diff --git a/frontend/app/(main)/COMPANY_10/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_10/quality/item-inspection/page.tsx index 1059223c..05facbbc 100644 --- a/frontend/app/(main)/COMPANY_10/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_10/quality/item-inspection/page.tsx @@ -9,8 +9,9 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { - Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ChevronDown, + Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList, } from "lucide-react"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { cn } from "@/lib/utils"; @@ -20,19 +21,17 @@ import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { toast } from "sonner"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; -import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; const TABLE_NAME = "item_inspection_info"; +const ITEM_TABLE = "item_info"; +const INSPECTION_TABLE = "inspection_standard"; const GRID_COLUMNS = [ { key: "item_code", label: "품목코드" }, - { key: "item_name", label: "품목명" }, + { key: "item_name", label: "품명" }, { key: "inspection_type", label: "검사유형" }, - { key: "item_count", label: "항목수" }, { key: "is_active", label: "사용여부" }, ]; -const ITEM_TABLE = "item_info"; -const INSPECTION_TABLE = "inspection_standard"; const INSPECTION_TYPES = [ { key: "incoming_inspection", label: "수입검사", matchLabels: ["수입검사", "입고검사", "수입", "입고"] }, @@ -61,25 +60,29 @@ export default function ItemInspectionInfoPage() { const [totalCount, setTotalCount] = useState(0); const [searchFilters, setSearchFilters] = useState([]); const [checkedIds, setCheckedIds] = useState([]); - const [expandedItems, setExpandedItems] = useState>(new Set()); + // 우측 패널: 선택된 품목 + const [selectedItemCode, setSelectedItemCode] = useState(null); + const [selectedTypeTab, setSelectedTypeTab] = useState(""); + + // 모달 const [modalOpen, setModalOpen] = useState(false); const [editMode, setEditMode] = useState(false); const [form, setForm] = useState>({}); const [saving, setSaving] = useState(false); - /* FK 옵션 */ + // FK 옵션 const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]); const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; types: string[] }[]>([]); const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]); const [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]); const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]); - /* 검사유형별 검사항목 rows */ + // 검사유형별 검사항목 rows (모달용) const [inspectionRows, setInspectionRows] = useState>({}); const [collapsedTypes, setCollapsedTypes] = useState>({}); - /* 품목 선택 모달 */ + // 품목 선택 모달 const [itemModalOpen, setItemModalOpen] = useState(false); const [itemSearchKeyword, setItemSearchKeyword] = useState(""); const [filteredItems, setFilteredItems] = useState([]); @@ -110,7 +113,7 @@ export default function ItemInspectionInfoPage() { types: r.inspection_type ? r.inspection_type.split(",").filter(Boolean) : [], }))); - // 검사유형 카테고리 값 로드 (코드→라벨 매핑용) + // 검사유형 카테고리 try { const catRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_type/values`); const flatCats: { code: string; label: string }[] = []; @@ -119,7 +122,7 @@ export default function ItemInspectionInfoPage() { setInspTypeCatOptions(flatCats); } catch { /* skip */ } - // 검사방법 카테고리 값 로드 (코드→라벨 매핑용) + // 검사방법 카테고리 try { const methodRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_method/values`); const flatMethods: { code: string; label: string }[] = []; @@ -139,20 +142,12 @@ export default function ItemInspectionInfoPage() { }, []); /* ═══════════════════ 품목 선택 모달 ═══════════════════ */ - const openItemModal = () => { - setItemSearchKeyword(""); - setFilteredItems(itemOptions); - setItemModalOpen(true); - }; - + const openItemModal = () => { setItemSearchKeyword(""); setFilteredItems(itemOptions); setItemModalOpen(true); }; const handleItemSearch = () => { const kw = itemSearchKeyword.trim().toLowerCase(); if (!kw) { setFilteredItems(itemOptions); return; } - setFilteredItems(itemOptions.filter(o => - o.code.toLowerCase().includes(kw) || o.name.toLowerCase().includes(kw) - )); + setFilteredItems(itemOptions.filter(o => o.code.toLowerCase().includes(kw) || o.name.toLowerCase().includes(kw))); }; - const selectItem = (item: typeof itemOptions[0]) => { setForm(p => ({ ...p, item_code: item.code, item_name: item.name })); setItemModalOpen(false); @@ -196,24 +191,45 @@ export default function ItemInspectionInfoPage() { return Object.values(map); }, [data]); - // 검사기준 ID → 라벨 resolve + // 선택된 품목의 그룹 데이터 + const selectedGroup = useMemo(() => { + if (!selectedItemCode) return null; + return groupedData.find(g => g.item_code === selectedItemCode) || null; + }, [selectedItemCode, groupedData]); + + // 선택된 탭의 검사항목 행 + const selectedTabRows = useMemo(() => { + if (!selectedGroup || !selectedTypeTab) return []; + return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id); + }, [selectedGroup, selectedTypeTab]); + + // 검사기준 ID → 라벨 const resolveInspLabel = useCallback((id: string) => { - const opt = inspOptions.find(o => o.code === id); - return opt?.label || id || "-"; + return inspOptions.find(o => o.code === id)?.label || id || "-"; }, [inspOptions]); + // 검사방법 코드 → 라벨 + const resolveMethodLabel = useCallback((code: string) => { + return inspMethodCatOptions.find(o => o.code === code)?.label || code || "-"; + }, [inspMethodCatOptions]); + /* ═══════════════════ CRUD ═══════════════════ */ const openCreate = () => { setForm({}); setEditMode(false); setInspectionRows({}); setCollapsedTypes({}); setModalOpen(true); }; - const openEdit = async (row: any) => { + + const openEdit = async (itemCode?: string) => { + const code = itemCode || selectedItemCode; + if (!code) { toast.error("수정할 항목을 선택해주세요"); return; } + const group = groupedData.find(g => g.item_code === code); + if (!group) return; + const row = group.rows[0]; setForm({ ...row }); setEditMode(true); 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 }] }, + dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: code }] }, autoFilter: true, }); const allRows = res.data?.data?.data || res.data?.data?.rows || []; @@ -222,7 +238,6 @@ export default function ItemInspectionInfoPage() { 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))) @@ -243,38 +258,22 @@ export default function ItemInspectionInfoPage() { is_required: r.is_required === "true" || r.is_required === true, }); } - setInspectionRows(rowMap); setForm(p => ({ ...p, ...typeFlags })); - } catch { - setInspectionRows({}); - } + } catch { setInspectionRows({}); } setModalOpen(true); }; - /* ═══════════════════ 검사항목 행 관리 ═══════════════════ */ + /* ═══════════════════ 검사항목 행 관리 (모달) ═══════════════════ */ const addInspRow = (typeKey: string) => { setInspectionRows(prev => ({ ...prev, - [typeKey]: [...(prev[typeKey] || []), { - id: crypto.randomUUID(), - inspection_standard_id: "", - inspection_detail: "", - inspection_method: "", - apply_process: "", - acceptance_criteria: "", - is_required: false, - }], + [typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", acceptance_criteria: "", is_required: false }], })); }; - const removeInspRow = (typeKey: string, rowId: string) => { - setInspectionRows(prev => ({ - ...prev, - [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId), - })); + setInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) })); }; - const updateInspRow = (typeKey: string, rowId: string, field: string, value: any) => { setInspectionRows(prev => ({ ...prev, @@ -290,28 +289,19 @@ export default function ItemInspectionInfoPage() { }), })); }; - - /** 검사유형 키에 매칭되는 검사기준만 필터링 */ const getFilteredInspOptions = (typeKey: string) => { const typeDef = INSPECTION_TYPES.find(t => t.key === typeKey); if (!typeDef) return inspOptions; - // matchLabels와 카테고리 라벨을 비교하여 해당 카테고리 코드를 찾음 - const matchCodes = inspTypeCatOptions - .filter(cat => typeDef.matchLabels.some(ml => cat.label.includes(ml))) - .map(cat => cat.code); + const matchCodes = inspTypeCatOptions.filter(cat => typeDef.matchLabels.some(ml => cat.label.includes(ml))).map(cat => cat.code); if (matchCodes.length === 0) return inspOptions; return inspOptions.filter(opt => opt.types.some(t => matchCodes.includes(t))); }; - - const toggleCollapse = (typeKey: string) => { - setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); - }; + const toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); }; const handleSave = async () => { - if (!form.item_code) { toast.error("품목코드는 필수 입력이에요"); return; } + if (!form.item_code) { toast.error("품목코드는 필수예요"); return; } setSaving(true); try { - // 기존 행 삭제 (수정 모드) if (editMode) { const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, { page: 1, size: 500, @@ -320,54 +310,29 @@ export default function ItemInspectionInfoPage() { }); 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 })), - }); + 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 || "", - }); + rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, 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 || "", + id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, + 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 ? "품목검사정보를 수정했어요" : "품목검사정보를 등록했어요"); + for (const row of rows) { await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row); } + toast.success(editMode ? "수정했어요" : "등록했어요"); setModalOpen(false); fetchData(); } catch { toast.error("저장에 실패했어요"); } @@ -375,157 +340,231 @@ export default function ItemInspectionInfoPage() { }; const handleDelete = async () => { - if (checkedIds.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; } - const ok = await confirm("품목검사정보 삭제", { - description: `선택한 ${checkedIds.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`, - }); + if (!selectedItemCode) { toast.error("삭제할 품목을 선택해주세요"); return; } + const group = groupedData.find(g => g.item_code === selectedItemCode); + if (!group) return; + const ok = await confirm(`${selectedItemCode} 검사정보 삭제`, { description: "선택한 품목의 검사정보를 모두 삭제할까요?", variant: "destructive", confirmText: "삭제" }); if (!ok) return; try { - await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { - data: checkedIds.map(id => ({ id })), - }); - toast.success(`${checkedIds.length}건을 삭제했어요`); - setCheckedIds([]); + await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: group.rows.map((r: any) => ({ id: r.id })) }); + toast.success("삭제했어요"); + setSelectedItemCode(null); fetchData(); } catch { toast.error("삭제에 실패했어요"); } }; /* ═══════════════════ JSX ═══════════════════ */ return ( -
+
{ConfirmDialogComponent} -
-
- - - - - -
- } - /> -
-
- {loading ? ( -
- ) : groupedData.length === 0 ? ( -
- -

등록된 품목검사정보가 없어요

-
- ) : ( - - - - - 0 && checkedIds.length === data.length} onCheckedChange={(v) => setCheckedIds(v ? data.map(r => r.id) : [])} /> - {ts.visibleColumns.map((col) => ( - - {col.label} - - ))} - - - - {ts.groupData(groupedData).map((group) => { - if ((group as any)._isGroupSummary || (group as any)._isGroupHeader) return null; - const isExpanded = expandedItems.has(group.item_code); - const groupIds = group.rows.map((r: any) => r.id); - const allChecked = groupIds.every((id: string) => checkedIds.includes(id)); - const renderCell = (key: string) => { - switch (key) { - case "item_code": return {group.item_code}; - case "item_name": return {group.item_name}; - case "inspection_type": return ( - -
- {group.types.map((t: string) => {t})} -
-
- ); - case "item_count": return {group.rows.filter((r: any) => r.inspection_standard_id).length}; - case "is_active": return ( - - - {group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"} - - - ); - default: return {(group as any)[key] ?? ""}; - } - }; - return ( - - 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])} - > - - - - { e.stopPropagation(); setCheckedIds(prev => allChecked ? prev.filter(id => !groupIds.includes(id)) : [...new Set([...prev, ...groupIds])]); }}> - {}} /> - - {ts.visibleColumns.map((col) => renderCell(col.key))} - - {isExpanded && ( - - - - -
- - - 검사유형 - 검사기준 - 검사항목 - 검사방법 - 합격기준 - - - - {group.rows.filter((r: any) => r.inspection_standard_id).map((row: any) => ( - - {row.inspection_type} - {resolveInspLabel(row.inspection_standard_id)} - {row.inspection_item_name || "-"} - {row.inspection_method || "-"} - {row.pass_criteria || "-"} - - ))} - -
- - - )} - - ); - })} - - - )} -
전체 {groupedData.length}건 (품목 기준)
-
+ {/* 검색 필터 */} +
+
- {/* ═══════════════════ 등록/수정 모달 (품목선택 뷰 포함) ═══════════════════ */} + {/* 좌우 분할 패널 */} +
+ + {/* ═══════ 좌측: 품목 목록 ═══════ */} + +
+
+
+ + 품목 목록 + {groupedData.length}건 +
+
+ + + + +
+
+
+ {loading ? ( +
+ ) : groupedData.length === 0 ? ( +
+ +

등록된 품목검사정보가 없어요

+
+ ) : ( + + + + {ts.visibleColumns.map((col) => ( + + {col.label} + + ))} + + + + {groupedData.map((group) => ( + { + setSelectedItemCode(group.item_code); + setSelectedTypeTab(group.types[0] || ""); + }} + > + {ts.visibleColumns.map((col) => { + switch (col.key) { + case "item_code": return {group.item_code}; + case "item_name": return {group.item_name}; + case "inspection_type": return ( + +
+ {group.types.map((t: string) => ( + {t} + ))} +
+
+ ); + case "is_active": return ( + + + {group.is_active === "사용" || group.is_active === "true" ? "사용" : "미사용"} + + + ); + default: return {(group as any)[col.key] ?? ""}; + } + })} +
+ ))} +
+
+ )} +
+
+ 전체 {groupedData.length}건 (품목 기준) +
+
+
+ + + + {/* ═══════ 우측: 검사유형별 검사항목 ═══════ */} + +
+
+
+ 검사유형별 검사항목 +
+ {selectedGroup && ( +
+ + +
+ )} +
+ + {!selectedGroup ? ( +
+
+ +

좌측에서 품목을 선택해주세요

+
+
+ ) : ( +
+ {/* 검사유형 탭 */} +
+ {selectedGroup.types.map((type: string) => { + const count = selectedGroup.rows.filter((r: any) => r.inspection_type === type && r.inspection_standard_id).length; + return ( + + ); + })} +
+ + {/* 검사항목 상세 테이블 */} +
+ {selectedTypeTab && ( +
+
+ {selectedTypeTab} + 검사항목 상세 +
+
+ + + + 검사항목 + 검사기준 + 검사방법 + 적용공정 + 판단기준 + 합격기준 + 필수 + + + + {selectedTabRows.length === 0 ? ( + + 등록된 검사항목이 없어요 + + ) : selectedTabRows.map((row: any) => ( + + {row.inspection_item_name || "-"} + {resolveInspLabel(row.inspection_standard_id)} + {resolveMethodLabel(row.inspection_method)} + {row.apply_process || "-"} + + {row.judgment_criteria ? ( + {row.judgment_criteria} + ) : "-"} + + {row.pass_criteria || "-"} + + {row.is_required === "true" || row.is_required === true ? ( + 필수 + ) : "-"} + + + ))} + +
+
+
+ )} +
+
+ )} +
+
+
+
+ + {/* ═══════════════════ 등록/수정 모달 ═══════════════════ */} { if (!open) setItemModalOpen(false); setModalOpen(open); }}> {itemModalOpen ? ( @@ -535,16 +574,8 @@ export default function ItemInspectionInfoPage() { 품목코드 또는 품목명으로 검색
- setItemSearchKeyword(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") handleItemSearch(); }} - /> - + setItemSearchKeyword(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") handleItemSearch(); }} /> +
@@ -560,11 +591,7 @@ export default function ItemInspectionInfoPage() { {filteredItems.length === 0 ? ( 검색 결과가 없어요 ) : filteredItems.map((item) => ( - selectItem(item)} - > + selectItem(item)}> {item.code} {item.name} {item.item_type} @@ -574,9 +601,7 @@ export default function ItemInspectionInfoPage() {
- - - + ) : ( <> @@ -587,14 +612,14 @@ export default function ItemInspectionInfoPage() {
{/* 품목 정보 */}
-

📦 품목 정보

+

품목 정보

- +