"use client"; import React, { useState, useEffect, useCallback, useMemo } from "react"; import { GripVertical, Plus, X, Search, ChevronRight, ChevronDown, Package, } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from "@/components/ui/dialog"; import { entityJoinApi } from "@/lib/api/entityJoin"; import { apiClient } from "@/lib/api/client"; // ─── 타입 정의 ─── interface BomItemNode { tempId: string; id?: string; parent_detail_id: string | null; seq_no: number; level: number; children: BomItemNode[]; _isNew?: boolean; _isDeleted?: boolean; data: Record; } interface BomColumnConfig { key: string; title: string; width?: string; visible?: boolean; editable?: boolean; isSourceDisplay?: boolean; inputType?: string; } interface ItemInfo { id: string; item_number: string; item_name: string; type: string; unit: string; division: string; } interface BomItemEditorProps { component?: any; formData?: Record; companyCode?: string; isDesignMode?: boolean; selectedRowsData?: any[]; onChange?: (flatData: any[]) => void; bomId?: string; } // 임시 ID 생성 let tempIdCounter = 0; const generateTempId = () => `temp_${Date.now()}_${++tempIdCounter}`; // ─── 품목 검색 모달 ─── interface ItemSearchModalProps { open: boolean; onClose: () => void; onSelect: (item: ItemInfo) => void; companyCode?: string; } function ItemSearchModal({ open, onClose, onSelect, companyCode, }: ItemSearchModalProps) { const [searchText, setSearchText] = useState(""); const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const searchItems = useCallback( async (query: string) => { setLoading(true); try { const result = await entityJoinApi.getTableDataWithJoins("item_info", { page: 1, size: 50, search: query ? { item_number: query, item_name: query } : undefined, enableEntityJoin: true, companyCodeOverride: companyCode, }); setItems(result.data || []); } catch (error) { console.error("[BomItemEditor] 품목 검색 실패:", error); } finally { setLoading(false); } }, [companyCode], ); useEffect(() => { if (open) { setSearchText(""); searchItems(""); } }, [open, searchItems]); const handleSearch = () => { searchItems(searchText); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); handleSearch(); } }; return ( 품목 검색 하위 품목으로 추가할 품목을 선택하세요.
setSearchText(e.target.value)} onKeyDown={handleKeyDown} placeholder="품목코드 또는 품목명" className="h-8 text-xs sm:h-10 sm:text-sm" />
{loading ? (
검색 중...
) : items.length === 0 ? (
검색 결과가 없습니다.
) : ( {items.map((item) => ( { onSelect(item); onClose(); }} className="hover:bg-accent cursor-pointer border-t transition-colors" > ))}
품목코드 품목명 구분 단위
{item.item_number} {item.item_name} {item.type} {item.unit}
)}
); } // ─── 트리 노드 행 렌더링 (config.columns 기반 동적 셀) ─── interface TreeNodeRowProps { node: BomItemNode; depth: number; expanded: boolean; hasChildren: boolean; columns: BomColumnConfig[]; categoryOptionsMap: Record; mainTableName?: string; onToggle: () => void; onFieldChange: (tempId: string, field: string, value: string) => void; onDelete: (tempId: string) => void; onAddChild: (parentTempId: string) => void; } function TreeNodeRow({ node, depth, expanded, hasChildren, columns, categoryOptionsMap, mainTableName, onToggle, onFieldChange, onDelete, onAddChild, }: TreeNodeRowProps) { const indentPx = depth * 32; const visibleColumns = columns.filter((c) => c.visible !== false); const renderCell = (col: BomColumnConfig) => { const value = node.data[col.key] ?? ""; // 소스 표시 컬럼 (읽기 전용) if (col.isSourceDisplay) { return ( {value || "-"} ); } // 카테고리 타입: API에서 로드한 옵션으로 Select 렌더링 if (col.inputType === "category") { const categoryRef = mainTableName ? `${mainTableName}.${col.key}` : ""; const options = categoryOptionsMap[categoryRef] || []; return ( ); } // 편집 불가능 컬럼 if (col.editable === false) { return ( {value || "-"} ); } // 숫자 입력 if (col.inputType === "number" || col.inputType === "decimal") { return ( onFieldChange(node.tempId, col.key, e.target.value)} className="h-7 w-full min-w-[50px] text-center text-xs" placeholder={col.title} /> ); } // 기본 텍스트 입력 return ( onFieldChange(node.tempId, col.key, e.target.value)} className="h-7 w-full min-w-[50px] text-xs" placeholder={col.title} /> ); }; return (
0 && "ml-2 border-l-2 border-l-primary/20", )} style={{ marginLeft: `${indentPx}px` }} > {node.seq_no} {node.level > 0 && ( L{node.level} )} {/* config.columns 기반 동적 셀 렌더링 */} {visibleColumns.map((col) => (
{renderCell(col)}
))}
); } // ─── 메인 컴포넌트 ─── export function BomItemEditorComponent({ component, formData, companyCode, isDesignMode = false, selectedRowsData, onChange, bomId: propBomId, }: BomItemEditorProps) { const [treeData, setTreeData] = useState([]); const [expandedNodes, setExpandedNodes] = useState>(new Set()); const [loading, setLoading] = useState(false); const [itemSearchOpen, setItemSearchOpen] = useState(false); const [addTargetParentId, setAddTargetParentId] = useState(null); const [categoryOptionsMap, setCategoryOptionsMap] = useState>({}); // 설정값 추출 const cfg = useMemo(() => component?.componentConfig || {}, [component]); const mainTableName = cfg.mainTableName || "bom_detail"; const parentKeyColumn = cfg.parentKeyColumn || "parent_detail_id"; const columns: BomColumnConfig[] = useMemo(() => cfg.columns || [], [cfg.columns]); const visibleColumns = useMemo(() => columns.filter((c) => c.visible !== false), [columns]); const fkColumn = cfg.foreignKeyColumn || "bom_id"; // BOM ID 결정 const bomId = useMemo(() => { if (propBomId) return propBomId; if (formData?.id) return formData.id as string; if (selectedRowsData?.[0]?.id) return selectedRowsData[0].id as string; return null; }, [propBomId, formData, selectedRowsData]); // ─── 카테고리 옵션 로드 (리피터 방식) ─── useEffect(() => { const loadCategoryOptions = async () => { const categoryColumns = visibleColumns.filter((col) => col.inputType === "category"); if (categoryColumns.length === 0) return; for (const col of categoryColumns) { const categoryRef = `${mainTableName}.${col.key}`; if (categoryOptionsMap[categoryRef]) continue; try { const response = await apiClient.get(`/table-categories/${mainTableName}/${col.key}/values`); if (response.data?.success && response.data.data) { const options = response.data.data.map((item: any) => ({ value: item.valueCode || item.value_code, label: item.valueLabel || item.value_label || item.displayValue || item.display_value || item.label, })); setCategoryOptionsMap((prev) => ({ ...prev, [categoryRef]: options })); } } catch (error) { console.error(`카테고리 옵션 로드 실패 (${categoryRef}):`, error); } } }; if (!isDesignMode) { loadCategoryOptions(); } }, [visibleColumns, mainTableName, isDesignMode]); // ─── 데이터 로드 ─── const loadBomDetails = useCallback( async (id: string) => { if (!id) return; setLoading(true); try { const result = await entityJoinApi.getTableDataWithJoins(mainTableName, { page: 1, size: 500, search: { [fkColumn]: id }, sortBy: "seq_no", sortOrder: "asc", enableEntityJoin: true, }); const rows = result.data || []; const tree = buildTree(rows); setTreeData(tree); const firstLevelIds = new Set( tree.map((n) => n.tempId || n.id || ""), ); setExpandedNodes(firstLevelIds); } catch (error) { console.error("[BomItemEditor] 데이터 로드 실패:", error); } finally { setLoading(false); } }, [mainTableName, fkColumn], ); useEffect(() => { if (bomId && !isDesignMode) { loadBomDetails(bomId); } }, [bomId, isDesignMode, loadBomDetails]); // ─── 트리 빌드 (동적 데이터) ─── const buildTree = (flatData: any[]): BomItemNode[] => { const nodeMap = new Map(); const roots: BomItemNode[] = []; flatData.forEach((item) => { const tempId = item.id || generateTempId(); nodeMap.set(item.id || tempId, { tempId, id: item.id, parent_detail_id: item[parentKeyColumn] || null, seq_no: Number(item.seq_no) || 0, level: Number(item.level) || 0, children: [], data: { ...item }, }); }); flatData.forEach((item) => { const nodeId = item.id || ""; const node = nodeMap.get(nodeId); if (!node) return; const parentId = item[parentKeyColumn]; if (parentId && nodeMap.has(parentId)) { nodeMap.get(parentId)!.children.push(node); } else { roots.push(node); } }); const sortChildren = (nodes: BomItemNode[]) => { nodes.sort((a, b) => a.seq_no - b.seq_no); nodes.forEach((n) => sortChildren(n.children)); }; sortChildren(roots); return roots; }; // ─── 트리 -> 평면 변환 (onChange 콜백용) ─── const flattenTree = useCallback((nodes: BomItemNode[]): any[] => { const result: any[] = []; const traverse = ( items: BomItemNode[], parentId: string | null, level: number, ) => { items.forEach((node, idx) => { result.push({ ...node.data, id: node.id, tempId: node.tempId, [parentKeyColumn]: parentId, seq_no: String(idx + 1), level: String(level), _isNew: node._isNew, _targetTable: mainTableName, }); if (node.children.length > 0) { traverse(node.children, node.id || node.tempId, level + 1); } }); }; traverse(nodes, null, 0); return result; }, [parentKeyColumn, mainTableName]); // 트리 변경 시 부모에게 알림 const notifyChange = useCallback( (newTree: BomItemNode[]) => { setTreeData(newTree); onChange?.(flattenTree(newTree)); }, [onChange, flattenTree], ); // ─── 노드 조작 함수들 ─── // 트리에서 특정 노드 찾기 (재귀) const findAndUpdate = ( nodes: BomItemNode[], targetTempId: string, updater: (node: BomItemNode) => BomItemNode | null, ): BomItemNode[] => { const result: BomItemNode[] = []; for (const node of nodes) { if (node.tempId === targetTempId) { const updated = updater(node); if (updated) result.push(updated); } else { result.push({ ...node, children: findAndUpdate(node.children, targetTempId, updater), }); } } return result; }; // 필드 변경 (data Record 내부 업데이트) const handleFieldChange = useCallback( (tempId: string, field: string, value: string) => { const newTree = findAndUpdate(treeData, tempId, (node) => ({ ...node, data: { ...node.data, [field]: value }, })); notifyChange(newTree); }, [treeData, notifyChange], ); // 노드 삭제 const handleDelete = useCallback( (tempId: string) => { const newTree = findAndUpdate(treeData, tempId, () => null); notifyChange(newTree); }, [treeData, notifyChange], ); // 하위 품목 추가 시작 (모달 열기) const handleAddChild = useCallback((parentTempId: string) => { setAddTargetParentId(parentTempId); setItemSearchOpen(true); }, []); // 루트 품목 추가 시작 const handleAddRoot = useCallback(() => { setAddTargetParentId(null); setItemSearchOpen(true); }, []); // 품목 선택 후 추가 (동적 데이터) const handleItemSelect = useCallback( (item: ItemInfo) => { // 소스 테이블 데이터를 _display_ 접두사로 저장 (엔티티 조인 방식) const sourceData: Record = {}; const sourceTable = cfg.dataSource?.sourceTable; if (sourceTable) { const sourceFk = cfg.dataSource?.foreignKey || "child_item_id"; sourceData[sourceFk] = item.id; // 소스 표시 컬럼의 데이터 병합 Object.keys(item).forEach((key) => { sourceData[`_display_${key}`] = (item as any)[key]; sourceData[key] = (item as any)[key]; }); } const newNode: BomItemNode = { tempId: generateTempId(), parent_detail_id: null, seq_no: 0, level: 0, children: [], _isNew: true, data: { ...sourceData, quantity: "1", loss_rate: "0", remark: "", }, }; let newTree: BomItemNode[]; if (addTargetParentId === null) { newNode.seq_no = treeData.length + 1; newNode.level = 0; newTree = [...treeData, newNode]; } else { newTree = findAndUpdate(treeData, addTargetParentId, (parent) => { newNode.parent_detail_id = parent.id || parent.tempId; newNode.seq_no = parent.children.length + 1; newNode.level = parent.level + 1; return { ...parent, children: [...parent.children, newNode], }; }); setExpandedNodes((prev) => new Set([...prev, addTargetParentId])); } notifyChange(newTree); }, [addTargetParentId, treeData, notifyChange, cfg], ); // 펼침/접기 토글 const toggleExpand = useCallback((tempId: string) => { setExpandedNodes((prev) => { const next = new Set(prev); if (next.has(tempId)) next.delete(tempId); else next.add(tempId); return next; }); }, []); // ─── 재귀 렌더링 ─── const renderNodes = (nodes: BomItemNode[], depth: number) => { return nodes.map((node) => { const isExpanded = expandedNodes.has(node.tempId); return ( 0} columns={visibleColumns} categoryOptionsMap={categoryOptionsMap} mainTableName={mainTableName} onToggle={() => toggleExpand(node.tempId)} onFieldChange={handleFieldChange} onDelete={handleDelete} onAddChild={handleAddChild} /> {isExpanded && node.children.length > 0 && renderNodes(node.children, depth + 1)} ); }); }; // ─── 디자인 모드 미리보기 ─── if (isDesignMode) { const cfg = component?.componentConfig || {}; const hasConfig = cfg.mainTableName || cfg.dataSource?.sourceTable || (cfg.columns && cfg.columns.length > 0); if (!hasConfig) { return (

BOM 하위 품목 편집기

설정 패널에서 테이블과 컬럼을 지정하세요

); } const visibleColumns = (cfg.columns || []).filter((c: any) => c.visible !== false); const DUMMY_DATA: Record = { item_name: ["본체 조립", "프레임", "커버", "전장 조립", "PCB 보드"], item_number: ["ASM-001", "PRT-010", "PRT-011", "ASM-002", "PRT-020"], specification: ["100×50", "200mm", "ABS", "50×30", "4-Layer"], material: ["AL6061", "SUS304", "ABS", "FR-4", "구리"], stock_unit: ["EA", "EA", "EA", "EA", "EA"], quantity: ["1", "2", "1", "1", "3"], loss_rate: ["0", "5", "3", "0", "2"], unit: ["EA", "EA", "EA", "EA", "EA"], remark: ["", "외주", "", "", ""], seq_no: ["1", "2", "3", "4", "5"], }; const DUMMY_DEPTHS = [0, 1, 1, 0, 1]; const getDummyValue = (col: any, rowIdx: number): string => { const vals = DUMMY_DATA[col.key]; if (vals) return vals[rowIdx % vals.length]; return ""; }; return (

하위 품목 구성

{/* 설정 요약 뱃지 */}
{cfg.mainTableName && ( 저장: {cfg.mainTableName} )} {cfg.dataSource?.sourceTable && ( 소스: {cfg.dataSource.sourceTable} )} {cfg.parentKeyColumn && ( 트리: {cfg.parentKeyColumn} )}
{/* 테이블 형태 미리보기 - config.columns 순서 그대로 */}
{visibleColumns.length === 0 ? (

컬럼 탭에서 표시할 컬럼을 선택하세요

) : ( {visibleColumns.map((col: any) => ( ))} {DUMMY_DEPTHS.map((depth, rowIdx) => ( {visibleColumns.map((col: any) => ( ))} ))}
# {col.title} 액션
{depth === 0 ? ( ) : ( )}
{rowIdx + 1} {col.isSourceDisplay ? ( {getDummyValue(col, rowIdx) || col.title} ) : col.editable !== false ? (
{getDummyValue(col, rowIdx)}
) : ( {getDummyValue(col, rowIdx)} )}
)}
); } // ─── 메인 렌더링 ─── return (
{/* 헤더 */}

하위 품목 구성

{/* 트리 목록 */}
{loading ? (
로딩 중...
) : treeData.length === 0 ? (

하위 품목이 없습니다.

"품목추가" 버튼을 눌러 추가하세요.

) : ( renderNodes(treeData, 0) )}
{/* 품목 검색 모달 */} setItemSearchOpen(false)} onSelect={handleItemSelect} companyCode={companyCode} />
); } export default BomItemEditorComponent;