From 5e9544f31da7f9e2ccf56936f8897a9b56970fdf Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 6 Apr 2026 18:09:00 +0900 Subject: [PATCH] Merge branch 'jskim-node' of https://g.wace.me/jskim/vexplor_dev into jskim-node --- .../(main)/COMPANY_16/production/bom/page.tsx | 210 ++++++++++++------ 1 file changed, 136 insertions(+), 74 deletions(-) diff --git a/frontend/app/(main)/COMPANY_16/production/bom/page.tsx b/frontend/app/(main)/COMPANY_16/production/bom/page.tsx index ec6c6339..0563f47b 100644 --- a/frontend/app/(main)/COMPANY_16/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_16/production/bom/page.tsx @@ -209,6 +209,17 @@ function flattenTree(nodes: TreeNode[], level = 0): (TreeNode & { _level: number } // ─── 타입 뱃지 색상 ──────────────────────────── +// BOM 이력 변경 유형 색상 +const CHANGE_TYPE_STYLE: Record = { + "등록": "bg-primary/10 text-primary ring-primary/20", + "수정": "bg-amber-50 text-amber-600 ring-amber-200", + "추가": "bg-emerald-50 text-emerald-600 ring-emerald-200", + "삭제": "bg-destructive/10 text-destructive ring-red-200", + "버전변경": "bg-blue-50 text-blue-600 ring-blue-200", + "excel_upload": "bg-blue-50 text-blue-600 ring-blue-200", +}; +const CHANGE_TYPE_OPTIONS = ["등록", "수정", "추가", "삭제", "버전변경", "기타"]; + function getItemTypeBadge(type?: string) { switch (type) { case "원자재": @@ -762,6 +773,37 @@ export default function BomManagementPage() { // 트리 품목 선택 완료 (트리에 추가) const handleTreeItemSelect = (item: any) => { + // 1. 자기 자신 추가 방지 (BOM 마스터 품목과 동일한 품목) + if (bomHeader && (item.id === bomHeader.item_id || item.item_number === bomHeader.item_code)) { + toast.error("자기 자신을 BOM에 추가할 수 없습니다"); + return; + } + + // 2. 같은 경로에 동일 품목 중복 방지 (조상 노드에 같은 품목이 있으면 순환) + const getAncestorItemIds = (tree: TreeNode[], targetId: string | null): Set => { + const ids = new Set(); + if (!targetId) return ids; + const collectPath = (nodes: TreeNode[], path: TreeNode[]): boolean => { + for (const n of nodes) { + const nk = n.id || n._tempId || ""; + if (nk === targetId) { + path.forEach(p => { if (p.child_item_id) ids.add(p.child_item_id); }); + if (n.child_item_id) ids.add(n.child_item_id); + return true; + } + if (n.children.length > 0 && collectPath(n.children, [...path, n])) return true; + } + return false; + }; + collectPath(tree, []); + return ids; + }; + const ancestorIds = getAncestorItemIds(editingTree, addTargetParentId); + if (ancestorIds.has(item.id)) { + toast.error("상위 경로에 이미 동일 품목이 있어 순환 참조가 발생합니다"); + return; + } + const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`; const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null; const newLevel = parentNode ? ((parentNode._level ?? parentNode.level ?? 0) as number) + 1 : 0; @@ -892,6 +934,8 @@ export default function BomManagementPage() { await apiClient.put(`/table-management/tables/bom_detail/edit`, { originalData: { id: node.id }, updatedData: { + bom_id: selectedBomId, + version_id: versionId, quantity: node.quantity, unit: node.unit, process_type: node.process_type, @@ -904,6 +948,36 @@ export default function BomManagementPage() { }); } + // 이력 자동 기록 (변경 내용 상세) + const historyParts: string[] = []; + // 추가된 품목 + for (const n of toInsert) { + historyParts.push(`${n.item_number || "품목"} 추가 (소요량: ${n.quantity || 1})`); + } + // 수정된 품목 (변경 전후 비교) + for (const n of toUpdate) { + const orig = bomDetails.find((d: any) => d.id === n.id); + if (orig) { + const changes: string[] = []; + if (String(orig.quantity || "") !== String(n.quantity || "")) changes.push(`소요량 ${orig.quantity || "-"} → ${n.quantity || "-"}`); + if (String(orig.process_type || "") !== String(n.process_type || "")) changes.push(`공정 변경`); + if (String(orig.loss_rate || "") !== String(n.loss_rate || "")) changes.push(`손실율 ${orig.loss_rate || "0"} → ${n.loss_rate || "0"}`); + if (String(orig.remark || "") !== String(n.remark || "")) changes.push(`비고 변경`); + if (changes.length > 0) historyParts.push(`${n.item_number || "품목"}: ${changes.join(", ")}`); + } + } + // 삭제된 품목 + for (const id of toDelete) { + const orig = bomDetails.find((d: any) => d.id === id); + historyParts.push(`${orig?.item_number || "품목"} 삭제`); + } + if (historyParts.length > 0) { + apiClient.post(`/bom/${selectedBomId}/history`, { + change_type: historyParts.some(p => p.includes("추가")) ? "추가" : historyParts.some(p => p.includes("삭제")) ? "삭제" : "수정", + change_description: historyParts.join("; "), + }).catch(() => {}); + } + toast.success("BOM 트리가 저장되었어요"); setTreeHasChanges(false); fetchBomDetail(selectedBomId); @@ -1165,6 +1239,7 @@ export default function BomManagementPage() { await apiClient.post(`/bom/${selectedBomId}/versions`, { versionName: newVersionName.trim() || undefined, }); + apiClient.post(`/bom/${selectedBomId}/history`, { change_type: "버전변경", change_description: `새 버전 생성: ${newVersionName.trim() || "자동"}` }).catch(() => {}); toast.success("새 버전이 생성되었어요"); setShowNewVersionDialog(false); setNewVersionName(""); @@ -1195,6 +1270,7 @@ export default function BomManagementPage() { if (!ok) return; try { await apiClient.post(`/bom/${selectedBomId}/versions/${versionId}/activate`); + apiClient.post(`/bom/${selectedBomId}/history`, { change_type: "버전변경", change_description: `버전 사용 확정` }).catch(() => {}); toast.success("버전이 사용 확정되었어요"); fetchVersions(selectedBomId); fetchBomDetail(selectedBomId); @@ -1491,48 +1567,6 @@ export default function BomManagementPage() { - {/* 트리뷰 토글 (트리뷰 탭에서만) */} - {rightTab === "tree" && ( -
-
- - -
-
-
- - -
-
- )}
{/* 트리뷰 탭 */} @@ -1865,10 +1899,6 @@ export default function BomManagementPage() { {historyList.length}건 이력 - {historyLoading ? (
@@ -1881,24 +1911,34 @@ export default function BomManagementPage() {
) : (
- {historyList.map((entry) => ( -
-
- - {entry.change_type} - - - {entry.changed_date ? new Date(entry.changed_date).toLocaleDateString("ko-KR") : "-"} - + {historyList.map((entry) => { + const typeStyle = CHANGE_TYPE_STYLE[entry.change_type] || "bg-muted text-muted-foreground ring-border"; + return ( +
+
+
+ + {entry.change_type} + + {(entry.revision || entry.version) && ( + + {entry.revision && `차수 ${entry.revision}`}{entry.revision && entry.version && " / "}{entry.version && `v${entry.version}`} + + )} +
+ + {entry.changed_date ? new Date(entry.changed_date).toLocaleString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) : "-"} + +
+

{entry.change_description || "-"}

+ {entry.changed_by && ( + + {entry.changed_by} + + )}
-

{entry.change_description || "-"}

- {entry.changed_by && ( - - 작성자: {entry.changed_by} - - )} -
- ))} + ); + })}
)}
@@ -2263,10 +2303,15 @@ export default function BomManagementPage() { @@ -2300,7 +2345,27 @@ export default function BomManagementPage() {

검색어를 입력해주세요

) : ( - itemSearchResults.map((item) => { + itemSearchResults.filter((item) => { + // 자기 자신 제외 + if (bomHeader && (item.id === bomHeader.item_id || item.item_number === bomHeader.item_code)) return false; + // 조상 경로 순환 참조 제외 + if (addTargetParentId) { + const collectAncestorIds = (nodes: TreeNode[], targetId: string, path: string[]): string[] | null => { + for (const n of nodes) { + const nk = n.id || n._tempId || ""; + if (nk === targetId) return [...path, n.child_item_id || ""]; + if (n.children.length > 0) { + const r = collectAncestorIds(n.children, targetId, [...path, n.child_item_id || ""]); + if (r) return r; + } + } + return null; + }; + const ancestors = collectAncestorIds(editingTree, addTargetParentId, []); + if (ancestors && ancestors.includes(item.id)) return false; + } + return true; + }).map((item) => { const badge = getItemTypeBadge(item.division); return (
setNewHistory((prev) => ({ ...prev, change_type: v }))}> - 설계 변경 - 버전 업데이트 - 자재 변경 - 수량 변경 - 공정 변경 - 기타 + {CHANGE_TYPE_OPTIONS.map((t) => ( + {t} + ))}