diff --git a/backend-node/src/controllers/bomController.ts b/backend-node/src/controllers/bomController.ts index 870833fc..7db45174 100644 --- a/backend-node/src/controllers/bomController.ts +++ b/backend-node/src/controllers/bomController.ts @@ -93,6 +93,19 @@ export async function loadBomVersion(req: Request, res: Response) { } } +export async function activateBomVersion(req: Request, res: Response) { + try { + const { bomId, versionId } = req.params; + const { tableName } = req.body || {}; + + const result = await bomService.activateBomVersion(bomId, versionId, tableName); + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("BOM 버전 사용 확정 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + export async function deleteBomVersion(req: Request, res: Response) { try { const { bomId, versionId } = req.params; diff --git a/backend-node/src/routes/bomRoutes.ts b/backend-node/src/routes/bomRoutes.ts index a6d4fa10..8da360ba 100644 --- a/backend-node/src/routes/bomRoutes.ts +++ b/backend-node/src/routes/bomRoutes.ts @@ -18,6 +18,7 @@ router.post("/:bomId/history", bomController.addBomHistory); router.get("/:bomId/versions", bomController.getBomVersions); router.post("/:bomId/versions", bomController.createBomVersion); router.post("/:bomId/versions/:versionId/load", bomController.loadBomVersion); +router.post("/:bomId/versions/:versionId/activate", bomController.activateBomVersion); router.delete("/:bomId/versions/:versionId", bomController.deleteBomVersion); export default router; diff --git a/backend-node/src/services/bomService.ts b/backend-node/src/services/bomService.ts index 687326df..fbfd836d 100644 --- a/backend-node/src/services/bomService.ts +++ b/backend-node/src/services/bomService.ts @@ -112,6 +112,9 @@ export async function createBomVersion( companyCode, ]); + // BOM 헤더의 version 필드도 업데이트 + await client.query(`UPDATE bom SET version = $1 WHERE id = $2`, [versionName, bomId]); + logger.info("BOM 버전 생성", { bomId, versionName, companyCode, vTable, dTable }); return result.rows[0]; }); @@ -140,6 +143,7 @@ export async function loadBomVersion( await client.query(`DELETE FROM ${snapshotDetailTable} WHERE bom_id = $1`, [bomId]); const b = snapshot.bom; + const loadedVersionName = verRow.rows[0].version_name; await client.query( `UPDATE bom SET base_qty = $1, unit = $2, revision = $3, remark = $4 WHERE id = $5`, [b.base_qty || null, b.unit || null, b.revision || null, b.remark || null, bomId], @@ -169,12 +173,50 @@ export async function loadBomVersion( } logger.info("BOM 버전 불러오기 완료", { bomId, versionId, vTable, snapshotDetailTable }); - return { restored: true, versionName: verRow.rows[0].version_name }; + return { restored: true, versionName: loadedVersionName }; + }); +} + +export async function activateBomVersion(bomId: string, versionId: string, tableName?: string) { + const table = safeTableName(tableName || "", "bom_version"); + + return transaction(async (client) => { + const verRow = await client.query( + `SELECT version_name FROM ${table} WHERE id = $1 AND bom_id = $2`, + [versionId, bomId], + ); + if (verRow.rows.length === 0) throw new Error("버전을 찾을 수 없습니다"); + + // 기존 active -> inactive + await client.query( + `UPDATE ${table} SET status = 'inactive' WHERE bom_id = $1 AND status = 'active'`, + [bomId], + ); + // 선택한 버전 -> active + await client.query( + `UPDATE ${table} SET status = 'active' WHERE id = $1`, + [versionId], + ); + // BOM 헤더 version도 갱신 + const versionName = verRow.rows[0].version_name; + await client.query( + `UPDATE bom SET version = $1 WHERE id = $2`, + [versionName, bomId], + ); + + logger.info("BOM 버전 사용 확정", { bomId, versionId, versionName }); + return { activated: true, versionName }; }); } export async function deleteBomVersion(bomId: string, versionId: string, tableName?: string) { const table = safeTableName(tableName || "", "bom_version"); + // active 상태 버전은 삭제 불가 + const checkSql = `SELECT status FROM ${table} WHERE id = $1 AND bom_id = $2`; + const checkResult = await query(checkSql, [versionId, bomId]); + if (checkResult.length > 0 && checkResult[0].status === "active") { + throw new Error("사용중인 버전은 삭제할 수 없습니다"); + } const sql = `DELETE FROM ${table} WHERE id = $1 AND bom_id = $2 RETURNING id`; const result = await query(sql, [versionId, bomId]); return result.length > 0; diff --git a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx index 88461200..6061f4cd 100644 --- a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx @@ -357,19 +357,92 @@ export function BomTreeComponent({ if (isDesignMode) { const configuredColumns = (config.columns || []).filter((c: TreeColumnDef) => !c.hidden); + const previewSampleValue = (col: TreeColumnDef, rowIdx: number): React.ReactNode => { + if (col.key === "level") return rowIdx === 0 ? "0" : "1"; + if (col.key.includes("type") || col.key.includes("division")) { + const badge = rowIdx === 0 + ? { bg: "bg-blue-50 text-blue-500 ring-blue-200", label: "제품" } + : { bg: "bg-amber-50 text-amber-500 ring-amber-200", label: "반제품" }; + return ( + + {badge.label} + + ); + } + if (col.key.includes("quantity") || col.key.includes("qty")) return rowIdx === 0 ? "30" : "3"; + if (col.key.includes("unit")) return "EA"; + if (col.key.includes("process")) { + const badge = rowIdx === 0 + ? { bg: "bg-emerald-50 text-emerald-600 ring-emerald-200", label: "제조" } + : { bg: "bg-purple-50 text-purple-600 ring-purple-200", label: "외주" }; + return ( + + {badge.label} + + ); + } + return `예시${rowIdx + 1}`; + }; + return ( -
-
- - BOM 트리 뷰 - {detailTable} - {config.dataSource?.sourceTable && ( - - {config.dataSource.sourceTable} - - )} +
+ {/* 헤더 (실제 화면과 동일 구조) */} +
+
+
+ +
+
+
+

BOM 상세정보

+ + 제품 + +
+
+ 품목코드 SAMPLE-001 + 기준수량 1 +
+
+
+ {/* 툴바 (실제 화면과 동일 구조) */} +
+ BOM 구성 + 2 +
+ {showHistory && ( + + )} + {showVersion && ( + + )} +
+
+ 트리 + 레벨 +
+ {features.showExpandAll !== false && ( +
+ + +
+ )} +
+
+ + {/* 테이블 */} {configuredColumns.length === 0 ? (
@@ -377,46 +450,58 @@ export function BomTreeComponent({

설정 패널 > 컬럼 탭에서 표시할 컬럼을 선택하세요

) : ( -
- - +
+
+ - - {configuredColumns.map((col: TreeColumnDef) => ( - - ))} + + {configuredColumns.map((col: TreeColumnDef) => { + const centered = ["quantity", "loss_rate", "base_qty", "revision", "seq_no", "unit", "level"].includes(col.key) + || col.key.includes("qty") || col.key.includes("quantity"); + return ( + + ); + })} - - - + {/* 0레벨 루트 */} + + - {configuredColumns.map((col: TreeColumnDef, i: number) => ( - - ))} + {configuredColumns.map((col: TreeColumnDef) => { + const centered = ["quantity", "loss_rate", "base_qty", "revision", "seq_no", "unit", "level"].includes(col.key) + || col.key.includes("qty") || col.key.includes("quantity"); + return ( + + ); + })} - - + - {configuredColumns.map((col: TreeColumnDef, i: number) => ( - - ))} + {configuredColumns.map((col: TreeColumnDef) => { + const centered = ["quantity", "loss_rate", "base_qty", "revision", "seq_no", "unit", "level"].includes(col.key) + || col.key.includes("qty") || col.key.includes("quantity"); + return ( + + ); + })}
- {col.title || col.key} - + {col.title || col.key} +
- +
+ - {col.key === "level" ? "0" : col.key.includes("type") ? ( - 제품 - ) : col.key.includes("quantity") || col.key.includes("qty") ? "30" : `예시${i + 1}`} - + {previewSampleValue(col, 0)} +
- + {/* 1레벨 자식 */} +
+ - {col.key === "level" ? "1" : col.key.includes("type") ? ( - 반제품 - ) : col.key.includes("quantity") || col.key.includes("qty") ? "3" : `예시${i + 1}`} - + {previewSampleValue(col, 1)} +
@@ -839,6 +924,7 @@ export function BomTreeComponent({ detailTable={detailTable} onVersionLoaded={() => { if (selectedBomId) loadBomDetails(selectedBomId, selectedHeaderData); + window.dispatchEvent(new CustomEvent("refreshTable")); }} /> )} diff --git a/frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx b/frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx index db7e5142..ccca8676 100644 --- a/frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx +++ b/frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx @@ -10,7 +10,7 @@ import { DialogFooter, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { Loader2, Plus, Trash2, Download } from "lucide-react"; +import { Loader2, Plus, Trash2, Download, ShieldCheck } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -81,7 +81,7 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve const res = await apiClient.post(`/bom/${bomId}/versions/${versionId}/load`, { tableName, detailTable }); if (res.data?.success) { onVersionLoaded?.(); - onOpenChange(false); + loadVersions(); } } catch (error) { console.error("[BomVersion] 불러오기 실패:", error); @@ -90,6 +90,22 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve } }; + const handleActivateVersion = async (versionId: string) => { + if (!bomId || !confirm("이 버전을 사용 확정하시겠습니까?\n기존 사용중 버전은 사용중지로 변경됩니다.")) return; + setActionId(versionId); + try { + const res = await apiClient.post(`/bom/${bomId}/versions/${versionId}/activate`, { tableName }); + if (res.data?.success) { + loadVersions(); + window.dispatchEvent(new CustomEvent("refreshTable")); + } + } catch (error) { + console.error("[BomVersion] 사용 확정 실패:", error); + } finally { + setActionId(null); + } + }; + const handleDeleteVersion = async (versionId: string) => { if (!bomId || !confirm("이 버전을 삭제하시겠습니까?")) return; setActionId(versionId); @@ -179,17 +195,33 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve {isActing ? : } 불러오기 - {ver.status !== "active" && ( - + {ver.status === "active" ? ( + + 사용중 + + ) : ( + <> + + + )}