"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; bom_id?: string; parent_detail_id: string | null; seq_no: number; level: number; child_item_id: string; child_item_code: string; child_item_name: string; child_item_type: string; quantity: string; unit: string; loss_rate: string; remark: string; children: BomItemNode[]; _isNew?: boolean; _isDeleted?: boolean; } 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}
)}
); } // ─── 트리 노드 행 렌더링 ─── interface TreeNodeRowProps { node: BomItemNode; depth: number; expanded: boolean; hasChildren: boolean; onToggle: () => void; onFieldChange: (tempId: string, field: string, value: string) => void; onDelete: (tempId: string) => void; onAddChild: (parentTempId: string) => void; } function TreeNodeRow({ node, depth, expanded, hasChildren, onToggle, onFieldChange, onDelete, onAddChild, }: TreeNodeRowProps) { const indentPx = depth * 32; return (
0 && "ml-2 border-l-2 border-l-primary/20", )} style={{ marginLeft: `${indentPx}px` }} > {/* 드래그 핸들 */} {/* 펼침/접기 */} {/* 순번 */} {node.seq_no} {/* 품목코드 */} {node.child_item_code || "-"} {/* 품목명 */} {node.child_item_name || "-"} {/* 레벨 뱃지 */} {node.level > 0 && ( L{node.level} )} {/* 수량 */} onFieldChange(node.tempId, "quantity", e.target.value) } className="h-7 w-16 shrink-0 text-center text-xs" placeholder="수량" /> {/* 품목구분 셀렉트 */} {/* 하위 추가 버튼 */} {/* 삭제 버튼 */}
); } // ─── 메인 컴포넌트 ─── 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, ); // 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]); // ─── 데이터 로드 ─── const loadBomDetails = useCallback( async (id: string) => { if (!id) return; setLoading(true); try { const result = await entityJoinApi.getTableDataWithJoins("bom_detail", { page: 1, size: 500, search: { bom_id: id }, sortBy: "seq_no", sortOrder: "asc", enableEntityJoin: true, }); const rows = result.data || []; const tree = buildTree(rows); setTreeData(tree); // 1레벨 기본 펼침 const firstLevelIds = new Set( tree.map((n) => n.tempId || n.id || ""), ); setExpandedNodes(firstLevelIds); } catch (error) { console.error("[BomItemEditor] 데이터 로드 실패:", error); } finally { setLoading(false); } }, [], ); 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, bom_id: item.bom_id, parent_detail_id: item.parent_detail_id || null, seq_no: Number(item.seq_no) || 0, level: Number(item.level) || 0, child_item_id: item.child_item_id || "", child_item_code: item.child_item_code || "", child_item_name: item.child_item_name || "", child_item_type: item.child_item_type || "", quantity: item.quantity || "1", unit: item.unit || "EA", loss_rate: item.loss_rate || "0", remark: item.remark || "", children: [], }); }); flatData.forEach((item) => { const nodeId = item.id || ""; const node = nodeMap.get(nodeId); if (!node) return; if (item.parent_detail_id && nodeMap.has(item.parent_detail_id)) { nodeMap.get(item.parent_detail_id)!.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({ id: node.id, tempId: node.tempId, bom_id: node.bom_id, parent_detail_id: parentId, seq_no: String(idx + 1), level: String(level), child_item_id: node.child_item_id, child_item_code: node.child_item_code, child_item_name: node.child_item_name, child_item_type: node.child_item_type, quantity: node.quantity, unit: node.unit, loss_rate: node.loss_rate, remark: node.remark, _isNew: node._isNew, _targetTable: "bom_detail", }); if (node.children.length > 0) { traverse(node.children, node.id || node.tempId, level + 1); } }); }; traverse(nodes, null, 0); return result; }, []); // 트리 변경 시 부모에게 알림 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; }; // 필드 변경 const handleFieldChange = useCallback( (tempId: string, field: string, value: string) => { const newTree = findAndUpdate(treeData, tempId, (node) => ({ ...node, [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) => { const newNode: BomItemNode = { tempId: generateTempId(), parent_detail_id: null, seq_no: 0, level: 0, child_item_id: item.id, child_item_code: item.item_number || "", child_item_name: item.item_name || "", child_item_type: item.type || "", quantity: "1", unit: item.unit || "EA", loss_rate: "0", remark: "", children: [], _isNew: true, }; 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], ); // 펼침/접기 토글 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} onToggle={() => toggleExpand(node.tempId)} onFieldChange={handleFieldChange} onDelete={handleDelete} onAddChild={handleAddChild} /> {isExpanded && node.children.length > 0 && renderNodes(node.children, depth + 1)} ); }); }; // ─── 디자인 모드 ─── if (isDesignMode) { return (

BOM 하위 품목 편집기

트리 구조로 하위 품목을 관리합니다

); } // ─── 메인 렌더링 ─── return (
{/* 헤더 */}

하위 품목 구성

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

하위 품목이 없습니다.

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

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