feat: Enhance item management and inspection functionalities

- Implemented automatic numbering preview for item registration and copying, improving user experience during data entry.
- Updated subcontractor management to filter items based on outsourcing codes, ensuring relevant items are displayed.
- Enhanced BOM management to support keyword-based searching and improved data handling for item inspections, including grouping by item code.

These changes aim to streamline item management processes and improve overall functionality across various modules.
This commit is contained in:
kjs
2026-04-06 12:19:42 +09:00
parent 68e7d5763d
commit b974139abe
5 changed files with 376 additions and 110 deletions
@@ -183,12 +183,28 @@ export default function ItemInfoPage() {
fetchItems();
}, [fetchItems]);
// 채번 미리보기 로드
const loadNumberingPreview = async () => {
try {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${TABLE_NAME}/item_number`);
const rule = ruleRes.data?.data;
if (rule?.ruleId) {
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { formData: {} });
return previewRes.data?.data?.generatedCode || "";
}
} catch { /* 채번 규칙 없으면 무시 */ }
return "";
};
// 등록 모달 열기
const openRegisterModal = () => {
const openRegisterModal = async () => {
setFormData({});
setIsEditMode(false);
setEditId(null);
setIsModalOpen(true);
// 채번 컬럼 자동 로드
const code = await loadNumberingPreview();
if (code) setFormData(prev => ({ ...prev, item_number: code }));
};
// 수정 모달 열기
@@ -200,11 +216,13 @@ export default function ItemInfoPage() {
};
// 복사 모달 열기
const openCopyModal = (item: any) => {
const openCopyModal = async (item: any) => {
const { id, item_number, created_date, updated_date, writer, ...rest } = item;
setFormData(rest);
setIsEditMode(false);
setEditId(null);
const code = await loadNumberingPreview();
if (code) setFormData(prev => ({ ...prev, item_number: code }));
setIsModalOpen(true);
};
@@ -407,13 +407,18 @@ export default function SubcontractorManagementPage() {
const filters: any[] = [];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 50,
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const allItems = res.data?.data?.data || res.data?.data?.rows || [];
const existingItemIds = new Set(priceItems.map((p: any) => p.item_id || p.item_number));
setItemSearchResults(allItems.filter((item: any) => !existingItemIds.has(item.item_number) && !existingItemIds.has(item.id)));
const OUTSOURCING_CODE = "CAT_MMDJB7R4_TO3T";
setItemSearchResults(allItems.filter((item: any) => {
if (existingItemIds.has(item.item_number) || existingItemIds.has(item.id)) return false;
const div = item.division || "";
return div.includes(OUTSOURCING_CODE) || div.includes("외주");
}));
} catch { /* skip */ } finally { setItemSearchLoading(false); }
};
@@ -382,7 +382,7 @@ export default function BomManagementPage() {
size: 1000,
dataFilter: {
enabled: true,
filters: [{ columnName: "id", operator: "in", value: itemIds.join(",") }],
filters: [{ columnName: "id", operator: "in", value: itemIds }],
},
});
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
@@ -645,46 +645,32 @@ export default function BomManagementPage() {
};
// ─── 품목 검색 ───────────────────────────────
const searchItems = async () => {
if (!itemSearchKeyword.trim()) return;
const searchItems = async (keyword?: string) => {
const kw = (keyword ?? itemSearchKeyword).trim();
setItemSearchLoading(true);
try {
const filters: any[] = [];
if (kw) {
filters.push({ columnName: "item_name", operator: "contains", value: kw });
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1,
size: 50,
dataFilter: {
enabled: true,
filters: [
{
columnName: "item_number",
operator: "contains",
value: itemSearchKeyword.trim(),
},
],
},
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
let rows = res.data?.data?.data || res.data?.data?.rows || [];
// 품명으로도 검색
if (rows.length === 0) {
// 키워드가 있고 품명으로 못 찾으면 품목코드로 재시도
if (kw && rows.length === 0) {
const res2 = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1,
size: 50,
dataFilter: {
enabled: true,
filters: [
{
columnName: "item_name",
operator: "contains",
value: itemSearchKeyword.trim(),
},
],
},
page: 1, size: 50,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "contains", value: kw }] },
autoFilter: true,
});
setItemSearchResults(res2.data?.data?.data || res2.data?.data?.rows || []);
} else {
setItemSearchResults(rows);
rows = res2.data?.data?.data || res2.data?.data?.rows || [];
}
setItemSearchResults(rows);
} catch {
toast.error("품목 검색에 실패했어요");
} finally {
@@ -1309,7 +1295,7 @@ export default function BomManagementPage() {
{/* ─── BOM 등록/수정 모달 ─────────────────── */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[85vh] overflow-y-auto" onPointerDownOutside={(e) => { if (showItemSearchModal) e.preventDefault(); }}>
<DialogHeader>
<DialogTitle>{isEditMode ? "BOM 수정" : "BOM 등록"}</DialogTitle>
<DialogDescription>{isEditMode ? "BOM 정보를 수정해요" : "새로운 BOM을 등록해요"}</DialogDescription>
@@ -1329,7 +1315,9 @@ export default function BomManagementPage() {
readOnly
onClick={() => {
setItemSearchTarget("master");
setItemSearchKeyword("");
setShowItemSearchModal(true);
searchItems("");
}}
/>
<Button
@@ -1338,7 +1326,9 @@ export default function BomManagementPage() {
className="h-9 px-3"
onClick={() => {
setItemSearchTarget("master");
setItemSearchKeyword("");
setShowItemSearchModal(true);
searchItems("");
}}
>
<Search className="w-3.5 h-3.5" />
@@ -1437,7 +1427,9 @@ export default function BomManagementPage() {
variant="outline"
onClick={() => {
setItemSearchTarget("detail");
setItemSearchKeyword("");
setShowItemSearchModal(true);
searchItems("");
}}
>
<Plus className="w-3.5 h-3.5 mr-1" />
@@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -9,10 +9,11 @@ import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
ClipboardList, Pencil, Search, X, Package,
ClipboardList, Pencil, Search, X, Package, ChevronDown,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
Settings2,
} from "lucide-react";
@@ -115,6 +116,7 @@ export default function PurchaseOrderPage() {
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
// 테이블 설정
const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG);
@@ -288,6 +290,22 @@ export default function PurchaseOrderPage() {
useEffect(() => { fetchOrders(); }, [fetchOrders]);
// purchase_no 기준 그룹핑
const orderGroups = useMemo(() => {
const map: Record<string, { master: any; details: any[] }> = {};
for (const row of orders) {
const key = row.purchase_no || row.id;
if (!map[key]) {
map[key] = {
master: { purchase_no: row.purchase_no, order_date: row.order_date, supplier_name: row.supplier_name, status: row.status, memo: row.memo },
details: [],
};
}
map[key].details.push(row);
}
return map;
}, [orders]);
const getCategoryLabel = (col: string, code: string) => {
if (!code) return "";
const found = categoryOptions[col]?.find((o) => o.code === code);
@@ -712,33 +730,115 @@ export default function PurchaseOrderPage() {
</div>
</div>
{/* 데이터 테이블 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-card">
<EDataTable
columns={ts.visibleColumns.map((col): EDataTableColumn => ({
key: col.key,
label: col.label,
align: ["order_qty", "received_qty", "remain_qty", "unit_price", "amount"].includes(col.key) ? "right" : undefined,
formatNumber: ["order_qty", "received_qty", "remain_qty", "unit_price", "amount"].includes(col.key),
render: col.key === "status"
? (val: any, row: any) => row.status ? (
<span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[row.status] || "")}>
{row.status}
</span>
) : null
: undefined,
}))}
data={ts.groupData(orders)}
loading={loading}
emptyMessage="등록된 발주가 없어요"
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
onRowDoubleClick={(row) => openEditModal(row.purchase_no)}
showPagination
draggableColumns={false}
columnOrderKey="c16-purchase-order"
/>
{/* 데이터 테이블 — 아코디언 그룹핑 */}
<div className="flex-1 overflow-auto border rounded-lg bg-card">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : Object.keys(orderGroups).length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Package className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
(() => {
const MASTER_KEYS = new Set(["purchase_no", "order_date", "supplier_name", "status", "memo"]);
const detailCols = ts.visibleColumns.filter(c => !MASTER_KEYS.has(c.key));
const masterCols = ts.visibleColumns.filter(c => MASTER_KEYS.has(c.key));
const numCols = new Set(["order_qty", "received_qty", "remain_qty", "unit_price", "amount"]);
const renderDetailCell = (row: any, key: string) => {
const val = row[key];
if (key === "status") return val ? <span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[val] || "")}>{val}</span> : "-";
if (numCols.has(key)) return <span className="font-mono">{val ? Number(val).toLocaleString() : "0"}</span>;
return val || "-";
};
return (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-8" />
<TableHead className="w-10" />
{ts.isVisible("purchase_no") && <TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{ts.isVisible("order_date") && <TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{ts.isVisible("supplier_name") && <TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground text-center"></TableHead>
{detailCols.map(col => (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground", numCols.has(col.key) && "text-right")}>
{col.label}{col.key === "order_qty" || col.key === "amount" ? " 합계" : ""}
</TableHead>
))}
{ts.isVisible("status") && <TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground text-center"></TableHead>}
{ts.isVisible("memo") && <TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{Object.entries(orderGroups).map(([purchaseNo, group]) => {
const isExpanded = expandedOrders.has(purchaseNo);
const detailIds = group.details.map(d => d.id);
const allChecked = detailIds.length > 0 && detailIds.every(id => checkedIds.includes(id));
const someChecked = detailIds.some(id => checkedIds.includes(id));
const m = group.master;
const totalQty = group.details.reduce((s, d) => s + (Number(d.order_qty) || 0), 0);
const totalAmt = group.details.reduce((s, d) => s + (Number(d.amount) || 0), 0);
return (
<React.Fragment key={purchaseNo}>
<TableRow
style={{ borderTop: "2px solid hsl(var(--border))" }}
className={cn("cursor-pointer border-l-[3px] border-l-transparent font-semibold", allChecked && "border-l-primary bg-primary/5")}
onClick={() => setExpandedOrders(prev => { const next = new Set(prev); if (next.has(purchaseNo)) next.delete(purchaseNo); else next.add(purchaseNo); return next; })}
onDoubleClick={() => openEditModal(purchaseNo)}
>
<TableCell className="text-center p-2">
<ChevronDown className={cn("w-4 h-4 transition-transform text-muted-foreground", !isExpanded && "-rotate-90")} />
</TableCell>
<TableCell className="text-center p-2" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !detailIds.includes(id)) : [...new Set([...prev, ...detailIds])]); }}>
<Checkbox checked={allChecked} data-state={someChecked && !allChecked ? "indeterminate" : undefined} onCheckedChange={() => {}} />
</TableCell>
{ts.isVisible("purchase_no") && <TableCell className="text-sm font-semibold text-primary">{purchaseNo}</TableCell>}
{ts.isVisible("order_date") && <TableCell className="text-sm">{m.order_date ? new Date(m.order_date).toLocaleDateString("ko-KR") : "-"}</TableCell>}
{ts.isVisible("supplier_name") && <TableCell className="text-sm">{m.supplier_name || "-"}</TableCell>}
<TableCell className="text-sm text-center"><Badge variant="secondary" className="text-[10px]">{group.details.length}</Badge></TableCell>
{detailCols.map(col => (
<TableCell key={col.key} className={cn("text-sm", numCols.has(col.key) && "text-right font-mono")}>
{col.key === "order_qty" ? totalQty.toLocaleString()
: col.key === "amount" ? totalAmt.toLocaleString()
: ""}
</TableCell>
))}
{ts.isVisible("status") && <TableCell className="text-center">{m.status && <span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[m.status] || "")}>{m.status}</span>}</TableCell>}
{ts.isVisible("memo") && <TableCell className="text-sm text-muted-foreground max-w-[120px] truncate">{m.memo || ""}</TableCell>}
</TableRow>
{isExpanded && group.details.map((row) => (
<TableRow key={row.id} className="bg-muted/30 text-xs">
<TableCell />
<TableCell className="text-center" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id]); }}>
<Checkbox checked={checkedIds.includes(row.id)} onCheckedChange={() => {}} />
</TableCell>
{ts.isVisible("purchase_no") && <TableCell />}
{ts.isVisible("order_date") && <TableCell />}
{ts.isVisible("supplier_name") && <TableCell />}
<TableCell />
{detailCols.map(col => (
<TableCell key={col.key} className={cn(numCols.has(col.key) && "text-right")}>
{renderDetailCell(row, col.key)}
</TableCell>
))}
{ts.isVisible("status") && <TableCell />}
{ts.isVisible("memo") && <TableCell />}
</TableRow>
))}
</React.Fragment>
);
})}
</TableBody>
</Table>
);
})()
)}
<div className="px-4 py-2 border-t text-xs text-muted-foreground">
{Object.keys(orderGroups).length} ( ) / {orders.length} ( )
</div>
</div>
{/* 발주 등록/수정 모달 */}
@@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -27,19 +27,15 @@ const TABLE_NAME = "item_inspection_info";
const GRID_COLUMNS = [
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품목명" },
{ key: "inspection_standard_id", label: "검사기준ID" },
{ key: "inspection_standard_name", label: "검사기준명" },
{ key: "inspection_level", label: "검사수준" },
{ key: "sampling_method", label: "샘플링방법" },
{ key: "inspection_type", label: "검사유형" },
{ key: "is_active", label: "사용여부" },
];
const ITEM_TABLE = "item_info";
const INSPECTION_TABLE = "inspection_standard";
const INSPECTION_TYPES = [
{ key: "incoming_inspection", label: "입검사", matchLabels: ["입검사", "입검사", "입", "입"] },
{ key: "outgoing_inspection", label: "출검사", matchLabels: ["출검사", "출검사", "출", "출"] },
{ key: "inventory_inspection", label: "재고검사", matchLabels: ["재고검사", "재고"] },
{ key: "incoming_inspection", label: "입검사", matchLabels: ["입검사", "입검사", "입", "입"] },
{ key: "outgoing_inspection", label: "출검사", matchLabels: ["출검사", "출검사", "출", "출"] },
{ key: "process_inspection", label: "공정검사", matchLabels: ["공정검사", "공정"] },
{ key: "final_inspection", label: "최종검사", matchLabels: ["최종검사", "최종", "완제품검사"] },
] as const;
@@ -64,6 +60,7 @@ export default function ItemInspectionInfoPage() {
const [totalCount, setTotalCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const [modalOpen, setModalOpen] = useState(false);
const [editMode, setEditMode] = useState(false);
@@ -172,15 +169,73 @@ export default function ItemInspectionInfoPage() {
useEffect(() => { fetchData(); }, [fetchData]);
// item_code별 그룹핑
const groupedData = useMemo(() => {
const map: Record<string, { item_code: string; item_name: string; is_active: string; types: string[]; rows: any[] }> = {};
for (const row of data) {
const key = row.item_code || row.id;
if (!map[key]) {
map[key] = { item_code: row.item_code, item_name: row.item_name, is_active: row.is_active || "", types: [], rows: [] };
}
map[key].rows.push(row);
if (row.inspection_type && !map[key].types.includes(row.inspection_type)) {
map[key].types.push(row.inspection_type);
}
}
return Object.values(map);
}, [data]);
// 검사기준 ID → 라벨 resolve
const resolveInspLabel = useCallback((id: string) => {
const opt = inspOptions.find(o => o.code === id);
return opt?.label || id || "-";
}, [inspOptions]);
/* ═══════════════════ CRUD ═══════════════════ */
const openCreate = () => { setForm({}); setEditMode(false); setInspectionRows({}); setCollapsedTypes({}); setModalOpen(true); };
const openEdit = (row: any) => {
const openEdit = async (row: any) => {
setForm({ ...row });
setEditMode(true);
// 저장된 검사항목 rows 복원
const saved = row.inspection_items || {};
setInspectionRows(saved);
setCollapsedTypes({});
// 같은 item_code의 모든 검사항목 행을 조회하여 유형별로 분류
try {
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: row.item_code }] },
autoFilter: true,
});
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
const rowMap: Record<string, InspectionRow[]> = {};
const typeFlags: Record<string, boolean> = {};
for (const r of allRows) {
const inspType = r.inspection_type || "";
// 카테고리 코드/라벨로 INSPECTION_TYPES 키 매칭
const matched = INSPECTION_TYPES.find(t =>
t.matchLabels.some(ml => inspType.includes(ml)) ||
inspTypeCatOptions.some(cat => inspType.includes(cat.code) && t.matchLabels.some(ml => cat.label.includes(ml)))
);
const typeKey = matched?.key || "";
if (!typeKey) continue;
typeFlags[typeKey] = true;
if (!rowMap[typeKey]) rowMap[typeKey] = [];
rowMap[typeKey].push({
id: r.id,
inspection_standard_id: r.inspection_standard_id || "",
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
inspection_method: r.inspection_method || "",
apply_process: "",
acceptance_criteria: r.pass_criteria || "",
is_required: r.is_required === "true" || r.is_required === true,
});
}
setInspectionRows(rowMap);
setForm(p => ({ ...p, ...typeFlags }));
} catch {
setInspectionRows({});
}
setModalOpen(true);
};
@@ -239,18 +294,65 @@ export default function ItemInspectionInfoPage() {
const handleSave = async () => {
if (!form.item_code) { toast.error("품목코드는 필수 입력이에요"); return; }
const saveData = { ...form, inspection_items: inspectionRows };
setSaving(true);
try {
// 기존 행 삭제 (수정 모드)
if (editMode) {
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
originalData: { id: form.id }, updatedData: saveData,
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: form.item_code }] },
autoFilter: true,
});
toast.success("품목검사정보를 수정했어요");
} else {
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { id: crypto.randomUUID(), ...saveData });
toast.success("품목검사정보를 등록했어요");
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
if (existing.length > 0) {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: existing.map((r: any) => ({ id: r.id })),
});
}
}
// 검사유형별 항목을 개별 행으로 INSERT
const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]);
const rows: any[] = [];
for (const t of enabledTypes) {
const typeLabel = t.label;
const typeRows = inspectionRows[t.key] || [];
if (typeRows.length === 0) {
// 유형만 체크하고 항목 없는 경우에도 1행 생성
rows.push({
id: crypto.randomUUID(),
item_code: form.item_code,
item_name: form.item_name,
inspection_type: typeLabel,
is_active: form.is_active || "사용",
manager_id: form.manager_id || "",
memo: form.remarks || "",
});
} else {
for (const r of typeRows) {
rows.push({
id: crypto.randomUUID(),
item_code: form.item_code,
item_name: form.item_name,
inspection_type: typeLabel,
inspection_standard_id: r.inspection_standard_id || "",
inspection_item_name: r.inspection_detail || "",
inspection_method: r.inspection_method || "",
pass_criteria: r.acceptance_criteria || "",
is_required: r.is_required ? "true" : "false",
is_active: form.is_active || "사용",
manager_id: form.manager_id || "",
memo: form.remarks || "",
});
}
}
}
for (const row of rows) {
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row);
}
toast.success(editMode ? "품목검사정보를 수정했어요" : "품목검사정보를 등록했어요");
setModalOpen(false);
fetchData();
} catch { toast.error("저장에 실패했어요"); }
@@ -291,8 +393,10 @@ export default function ItemInspectionInfoPage() {
<Button size="sm" onClick={openCreate}><Plus className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="outline" onClick={() => {
const sel = data.find(r => checkedIds.includes(r.id));
if (sel) openEdit(sel);
else toast.error("수정할 항목을 선택해주세요");
if (sel) {
const group = groupedData.find(g => g.item_code === sel.item_code);
openEdit(group?.rows[0] || sel);
} else toast.error("수정할 항목을 선택해주세요");
}}><Pencil className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="destructive" onClick={handleDelete}><Trash2 className="w-4 h-4 mr-1" /></Button>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
@@ -302,30 +406,77 @@ export default function ItemInspectionInfoPage() {
}
/>
</div>
<div className="p-3">
<EDataTable
columns={ts.visibleColumns.map((col): EDataTableColumn => ({
key: col.key,
label: col.label,
render: col.key === "is_active"
? (val: any, row: any) => (
<Badge variant={row.is_active ? "default" : "secondary"} className="text-xs">
{row.is_active ? "사용" : "미사용"}
</Badge>
)
: undefined,
}))}
data={ts.groupData(data)}
loading={loading}
emptyMessage="등록된 품목검사정보가 없어요"
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
onRowDoubleClick={(row) => openEdit(row)}
showPagination
draggableColumns={false}
columnOrderKey="c16-item-inspection"
/>
<div className="p-3 overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : groupedData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-10" />
<TableHead className="w-10"><Checkbox checked={groupedData.length > 0 && checkedIds.length === data.length} onCheckedChange={(v) => setCheckedIds(v ? data.map(r => r.id) : [])} /></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groupedData.map((group) => {
const isExpanded = expandedItems.has(group.item_code);
const groupIds = group.rows.map(r => r.id);
const allChecked = groupIds.every(id => checkedIds.includes(id));
return (
<React.Fragment key={group.item_code}>
<TableRow
className={cn("cursor-pointer border-l-2 border-l-transparent", allChecked && "border-l-primary bg-primary/5")}
onClick={() => setExpandedItems(prev => { const next = new Set(prev); if (next.has(group.item_code)) next.delete(group.item_code); else next.add(group.item_code); return next; })}
onDoubleClick={() => openEdit(group.rows[0])}
>
<TableCell className="text-center p-2">
<ChevronDown className={cn("w-4 h-4 transition-transform text-muted-foreground", !isExpanded && "-rotate-90")} />
</TableCell>
<TableCell className="text-center p-2" onClick={(e) => { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !groupIds.includes(id)) : [...new Set([...prev, ...groupIds])]); }}>
<Checkbox checked={allChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="text-sm font-medium text-primary">{group.item_code}</TableCell>
<TableCell className="text-sm">{group.item_name}</TableCell>
<TableCell>
<div className="flex gap-1 flex-wrap">
{group.types.map(t => <Badge key={t} variant="secondary" className="text-[10px]">{t}</Badge>)}
</div>
</TableCell>
<TableCell className="text-sm text-center">{group.rows.filter(r => r.inspection_standard_id).length}</TableCell>
<TableCell>
<Badge variant={group.is_active === "사용" || group.is_active === "true" ? "default" : "secondary"} className="text-[10px]">
{group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"}
</Badge>
</TableCell>
</TableRow>
{isExpanded && group.rows.filter(r => r.inspection_standard_id).map((row, i) => (
<TableRow key={row.id} className="bg-muted/30 text-xs">
<TableCell />
<TableCell />
<TableCell className="pl-6 text-muted-foreground">{row.inspection_type}</TableCell>
<TableCell>{resolveInspLabel(row.inspection_standard_id)}</TableCell>
<TableCell>{row.inspection_item_name || "-"}</TableCell>
<TableCell>{row.inspection_method || "-"}</TableCell>
<TableCell>{row.pass_criteria || "-"}</TableCell>
</TableRow>
))}
</React.Fragment>
);
})}
</TableBody>
</Table>
)}
<div className="mt-2 text-xs text-muted-foreground"> {groupedData.length} ( )</div>
</div>
</div>