"use client"; import React, { useState, useEffect, useCallback, useMemo } from "react"; import { ChevronRight, ChevronDown, Package, Layers, Box, AlertCircle, Expand, Shrink, Loader2, History, GitBranch, Check, } from "lucide-react"; import { cn } from "@/lib/utils"; import { entityJoinApi } from "@/lib/api/entityJoin"; import { Button } from "@/components/ui/button"; import { BomDetailEditModal } from "./BomDetailEditModal"; import { BomHistoryModal } from "./BomHistoryModal"; import { BomVersionModal } from "./BomVersionModal"; interface BomTreeNode { id: string; [key: string]: any; children: BomTreeNode[]; } interface BomHeaderInfo { id: string; [key: string]: any; } interface TreeColumnDef { key: string; title: string; width?: string; visible?: boolean; hidden?: boolean; isSourceDisplay?: boolean; } interface BomTreeComponentProps { component?: any; formData?: Record; tableName?: string; companyCode?: string; isDesignMode?: boolean; selectedRowsData?: any[]; [key: string]: any; } // 컬럼은 설정 패널에서만 추가 (하드코딩 금지) const EMPTY_COLUMNS: TreeColumnDef[] = []; const INDENT_PX = 16; export function BomTreeComponent({ component, formData, companyCode, isDesignMode = false, selectedRowsData, ...props }: BomTreeComponentProps) { const [headerInfo, setHeaderInfo] = useState(null); const [treeData, setTreeData] = useState([]); const [expandedNodes, setExpandedNodes] = useState>(new Set()); const [loading, setLoading] = useState(false); const [selectedNodeId, setSelectedNodeId] = useState(null); const [viewMode, setViewMode] = useState<"tree" | "level">("tree"); const [editModalOpen, setEditModalOpen] = useState(false); const [editTargetNode, setEditTargetNode] = useState(null); const [historyModalOpen, setHistoryModalOpen] = useState(false); const [versionModalOpen, setVersionModalOpen] = useState(false); const [colWidths, setColWidths] = useState>({}); const handleResizeStart = useCallback((colKey: string, e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); const startX = e.clientX; const th = (e.target as HTMLElement).closest("th"); const startWidth = th?.offsetWidth || 100; const onMove = (ev: MouseEvent) => { setColWidths((prev) => ({ ...prev, [colKey]: Math.max(40, startWidth + (ev.clientX - startX)) })); }; const onUp = () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); document.body.style.cursor = ""; document.body.style.userSelect = ""; }; document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }, []); const config = component?.componentConfig || {}; const overrides = component?.overrides || {}; const selectedBomId = useMemo(() => { if (selectedRowsData && selectedRowsData.length > 0) return selectedRowsData[0]?.id; if (formData?.id) return formData.id; return null; }, [formData, selectedRowsData]); const selectedHeaderData = useMemo(() => { const raw = selectedRowsData?.[0] || (formData?.id ? formData : null); if (!raw) return null; return { ...raw, item_name: raw.item_id_item_name || raw.item_name || "", item_code: raw.item_id_item_number || raw.item_code || "", item_type: raw.item_id_division || raw.item_id_type || raw.item_type || "", } as BomHeaderInfo; }, [formData, selectedRowsData]); const detailTable = overrides.detailTable || config.detailTable || "bom_detail"; const foreignKey = overrides.foreignKey || config.foreignKey || "bom_id"; const parentKey = overrides.parentKey || config.parentKey || "parent_detail_id"; const sourceFk = config.dataSource?.foreignKey || "child_item_id"; const historyTable = config.historyTable || "bom_history"; const versionTable = config.versionTable || "bom_version"; const displayColumns = useMemo(() => { const configured = config.columns as TreeColumnDef[] | undefined; if (configured && configured.length > 0) return configured.filter((c) => !c.hidden); return EMPTY_COLUMNS; }, [config.columns]); const features = config.features || {}; const showHistory = features.showHistory !== false; const showVersion = features.showVersion !== false; // ─── 데이터 로드 ─── // BOM 헤더 데이터로 가상 0레벨 루트 노드 생성 const buildVirtualRoot = useCallback((headerData: BomHeaderInfo | null, children: BomTreeNode[]): BomTreeNode | null => { if (!headerData) return null; return { id: `__root_${headerData.id}`, _isVirtualRoot: true, level: "0", child_item_name: headerData.item_name || "", child_item_code: headerData.item_code || headerData.bom_number || "", child_item_type: headerData.item_type || "", item_name: headerData.item_name || "", item_number: headerData.item_code || "", quantity: "-", base_qty: headerData.base_qty || "", unit: headerData.unit || "", revision: headerData.revision || "", loss_rate: "", process_type: "", remark: headerData.remark || "", children, }; }, []); const loadBomDetails = useCallback(async (bomId: string, headerData: BomHeaderInfo | null) => { if (!bomId) return; setLoading(true); try { const result = await entityJoinApi.getTableDataWithJoins(detailTable, { page: 1, size: 500, search: { [foreignKey]: bomId }, sortBy: "seq_no", sortOrder: "asc", enableEntityJoin: true, }); const rows = (result.data || []).map((row: Record) => { const mapped = { ...row }; for (const key of Object.keys(row)) { if (key.startsWith(`${sourceFk}_`)) { const shortKey = key.replace(`${sourceFk}_`, ""); const aliasKey = `child_${shortKey}`; if (!mapped[aliasKey]) mapped[aliasKey] = row[key]; if (!mapped[shortKey]) mapped[shortKey] = row[key]; } } mapped.child_item_name = row[`${sourceFk}_item_name`] || row.child_item_name || ""; mapped.child_item_code = row[`${sourceFk}_item_number`] || row.child_item_code || ""; mapped.child_item_type = row[`${sourceFk}_type`] || row[`${sourceFk}_division`] || row.child_item_type || ""; return mapped; }); const detailTree = buildTree(rows); // BOM 헤더를 가상 0레벨 루트로 삽입 const virtualRoot = buildVirtualRoot(headerData, detailTree); if (virtualRoot) { setTreeData([virtualRoot]); setExpandedNodes(new Set([virtualRoot.id])); } else { setTreeData(detailTree); const firstLevelIds = new Set(detailTree.map((n: BomTreeNode) => n.id)); setExpandedNodes(firstLevelIds); } } catch (error) { console.error("[BomTree] 데이터 로드 실패:", error); } finally { setLoading(false); } }, [detailTable, foreignKey, sourceFk, buildVirtualRoot]); const buildTree = (flatData: any[]): BomTreeNode[] => { const nodeMap = new Map(); const roots: BomTreeNode[] = []; flatData.forEach((item) => nodeMap.set(item.id, { ...item, children: [] })); flatData.forEach((item) => { const node = nodeMap.get(item.id)!; if (item[parentKey] && nodeMap.has(item[parentKey])) { nodeMap.get(item[parentKey])!.children.push(node); } else { roots.push(node); } }); return roots; }; useEffect(() => { if (selectedBomId) { setHeaderInfo(selectedHeaderData); loadBomDetails(selectedBomId, selectedHeaderData); } else { setHeaderInfo(null); setTreeData([]); } }, [selectedBomId, selectedHeaderData, loadBomDetails]); const toggleNode = useCallback((nodeId: string) => { setExpandedNodes((prev) => { const next = new Set(prev); if (next.has(nodeId)) next.delete(nodeId); else next.add(nodeId); return next; }); }, []); const expandAll = useCallback(() => { const allIds = new Set(); const collectIds = (nodes: BomTreeNode[]) => { nodes.forEach((n) => { allIds.add(n.id); if (n.children.length > 0) collectIds(n.children); }); }; collectIds(treeData); setExpandedNodes(allIds); }, [treeData]); const collapseAll = useCallback(() => setExpandedNodes(new Set()), []); // ─── 유틸 ─── const getItemTypeLabel = (type: string) => { const map: Record = { product: "제품", semi: "반제품", material: "원자재", part: "부품" }; return map[type] || type || "-"; }; const getItemTypeBadge = (type: string) => { const map: Record = { product: "bg-blue-50 text-blue-600 ring-blue-200", semi: "bg-amber-50 text-amber-600 ring-amber-200", material: "bg-emerald-50 text-emerald-600 ring-emerald-200", part: "bg-purple-50 text-purple-600 ring-purple-200", }; return map[type] || "bg-gray-50 text-gray-500 ring-gray-200"; }; const getItemIcon = (type: string) => { const map: Record = { product: Package, semi: Layers }; return map[type] || Box; }; const getItemIconColor = (type: string) => { const map: Record = { product: "text-blue-500", semi: "text-amber-500", material: "text-emerald-500", part: "text-purple-500", }; return map[type] || "text-gray-400"; }; // ─── 셀 렌더링 ─── const renderCellValue = (node: BomTreeNode, col: TreeColumnDef, depth: number) => { const value = node[col.key]; if (col.key === "child_item_type" || col.key === "item_type") { const label = getItemTypeLabel(String(value || "")); return ( {label} ); } if (col.key === "level") { const displayLevel = node._isVirtualRoot ? 0 : depth; return ( {displayLevel} ); } if (col.key === "child_item_code") { return {value || "-"}; } if (col.key === "child_item_name") { return {value || "-"}; } if (col.key === "quantity" || col.key === "base_qty") { return ( {value != null && value !== "" && value !== "0" ? value : "-"} ); } if (col.key === "loss_rate") { const num = Number(value); if (!num) return -; return {value}%; } if (col.key === "revision") { return ( {value != null && value !== "" && value !== "0" ? value : "-"} ); } if (col.key === "unit") { return {value || "-"}; } return {value ?? "-"}; }; // ─── 디자인 모드 ─── if (isDesignMode) { const configuredColumns = (config.columns || []).filter((c: TreeColumnDef) => !c.hidden); return (
BOM 트리 뷰 {detailTable} {config.dataSource?.sourceTable && ( {config.dataSource.sourceTable} )}
{configuredColumns.length === 0 ? (

컬럼 미설정

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

) : (
{configuredColumns.map((col: TreeColumnDef) => ( ))} {configuredColumns.map((col: TreeColumnDef, i: number) => ( ))} {configuredColumns.map((col: TreeColumnDef, i: number) => ( ))}
{col.title || col.key}
{col.key === "level" ? "0" : col.key.includes("type") ? ( 제품 ) : col.key.includes("quantity") || col.key.includes("qty") ? "30" : `예시${i + 1}`}
{col.key === "level" ? "1" : col.key.includes("type") ? ( 반제품 ) : col.key.includes("quantity") || col.key.includes("qty") ? "3" : `예시${i + 1}`}
)}
); } // ─── 미선택 상태 ─── if (!selectedBomId) { return (

BOM을 선택해주세요

좌측 목록에서 BOM을 선택하면 구성이 표시됩니다

); } // ─── 트리 평탄화 ─── const flattenedRows = useMemo(() => { const rows: { node: BomTreeNode; depth: number }[] = []; const traverse = (nodes: BomTreeNode[], depth: number) => { for (const node of nodes) { rows.push({ node, depth }); if (node.children.length > 0 && expandedNodes.has(node.id)) { traverse(node.children, depth + 1); } } }; traverse(treeData, 0); return rows; }, [treeData, expandedNodes]); // 레벨 뷰용: 전체 노드 평탄화 (expand 상태 무관) const allFlattenedRows = useMemo(() => { const rows: { node: BomTreeNode; depth: number }[] = []; const traverse = (nodes: BomTreeNode[], depth: number) => { for (const node of nodes) { rows.push({ node, depth }); if (node.children.length > 0) traverse(node.children, depth + 1); } }; traverse(treeData, 0); return rows; }, [treeData]); const maxDepth = useMemo(() => { return allFlattenedRows.reduce((max, r) => Math.max(max, r.depth), 0); }, [allFlattenedRows]); const visibleRows = viewMode === "level" ? allFlattenedRows : flattenedRows; const levelColumnsForView = useMemo(() => { return Array.from({ length: maxDepth + 1 }, (_, i) => i); }, [maxDepth]); // 레벨 뷰에서 "level" 컬럼을 제외한 데이터 컬럼 const dataColumnsForLevelView = useMemo(() => { return displayColumns.filter((c) => c.key !== "level"); }, [displayColumns]); // ─── 메인 렌더링 ─── return (
{/* 헤더 정보 */} {features.showHeader !== false && headerInfo && (

{headerInfo.item_name || "-"}

{getItemTypeLabel(headerInfo.item_type)} {headerInfo.status === "active" ? "사용" : "미사용"}
품목코드 {headerInfo.item_code || "-"} 기준수량 {headerInfo.base_qty || "1"} 버전 v{headerInfo.version || "1"} (차수 {headerInfo.revision || "1"})
)} {/* 툴바 */}
BOM 구성 {allFlattenedRows.length}
{showHistory && ( )} {showVersion && ( )}
{features.showExpandAll !== false && (
)}
{/* 테이블 */}
{loading ? (
) : displayColumns.length === 0 ? (

표시할 컬럼이 설정되지 않았습니다

디자인 모드에서 컬럼을 추가하세요

) : treeData.length === 0 ? (

등록된 하위 품목이 없습니다

) : viewMode === "level" ? ( /* ═══ 레벨 뷰 ═══ */ {levelColumnsForView.map((lvl) => ( ))} {dataColumnsForLevelView.map((col) => { const centered = ["quantity", "loss_rate", "base_qty", "revision", "seq_no"].includes(col.key); const w = colWidths[col.key]; return ( ); })} {allFlattenedRows.map(({ node, depth }, rowIdx) => { const isRoot = !!node._isVirtualRoot; const displayDepth = isRoot ? 0 : depth; return ( setSelectedNodeId(node.id)} onDoubleClick={() => { setEditTargetNode(node); setEditModalOpen(true); }} > {levelColumnsForView.map((lvl) => ( ))} {dataColumnsForLevelView.map((col) => { const centered = ["quantity", "loss_rate", "base_qty", "revision", "seq_no"].includes(col.key); return ( ); })} ); })}
{lvl} {col.title}
handleResizeStart(col.key, e)} />
{displayDepth === lvl ? ( ) : null} {renderCellValue(node, col, depth)}
) : ( /* ═══ 트리 뷰 ═══ */ {displayColumns.map((col) => { const centered = ["quantity", "loss_rate", "level", "base_qty", "revision", "seq_no"].includes(col.key); const w = colWidths[col.key]; return ( ); })} {flattenedRows.map(({ node, depth }, rowIdx) => { const hasChildren = node.children.length > 0; const isExpanded = expandedNodes.has(node.id); const isSelected = selectedNodeId === node.id; const isRoot = !!node._isVirtualRoot; const itemType = node.child_item_type || node.item_type || ""; const ItemIcon = getItemIcon(itemType); return ( { setSelectedNodeId(node.id); if (hasChildren) toggleNode(node.id); }} onDoubleClick={() => { setEditTargetNode(node); setEditModalOpen(true); }} > {displayColumns.map((col) => { const centered = ["quantity", "loss_rate", "level", "base_qty", "revision", "seq_no"].includes(col.key); return ( ); })} ); })}
{col.title}
handleResizeStart(col.key, e)} />
{hasChildren ? ( isExpanded ? ( ) : ( ) ) : ( )}
{renderCellValue(node, col, depth)}
)}
{/* 품목 수정 모달 */} { if (selectedBomId) loadBomDetails(selectedBomId, selectedHeaderData); }} /> {showHistory && ( )} {showVersion && ( { if (selectedBomId) loadBomDetails(selectedBomId, selectedHeaderData); }} /> )}
); } export default BomTreeComponent;