"use client"; import React, { useState, useEffect, useCallback, useMemo, useRef } 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 { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } 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: (items: ItemInfo[]) => void; companyCode?: string; existingItemIds?: Set; } function ItemSearchModal({ open, onClose, onSelect, companyCode, existingItemIds, }: ItemSearchModalProps) { const [searchText, setSearchText] = useState(""); const [items, setItems] = useState([]); const [selectedItems, setSelectedItems] = useState>(new Set()); 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 || []) as ItemInfo[]); } catch (error) { console.error("[BomItemEditor] 품목 검색 실패:", error); } finally { setLoading(false); } }, [companyCode], ); useEffect(() => { if (open) { setSearchText(""); setSelectedItems(new Set()); 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) => { const alreadyAdded = existingItemIds?.has(item.id) || false; return ( { if (alreadyAdded) return; setSelectedItems((prev) => { const next = new Set(prev); if (next.has(item.id)) next.delete(item.id); else next.add(item.id); return next; }); }} className={cn( "border-t transition-colors", alreadyAdded ? "cursor-not-allowed opacity-40" : "cursor-pointer", !alreadyAdded && selectedItems.has(item.id) ? "bg-primary/10" : !alreadyAdded ? "hover:bg-accent" : "", )} > ); })}
0 && selectedItems.size === items.length} onCheckedChange={(checked) => { if (checked) setSelectedItems(new Set(items.map((i) => i.id))); else setSelectedItems(new Set()); }} /> 품목코드 품목명 구분 단위
e.stopPropagation()}> { if (alreadyAdded) return; setSelectedItems((prev) => { const next = new Set(prev); if (checked) next.add(item.id); else next.delete(item.id); return next; }); }} /> {item.item_number} {alreadyAdded && (추가됨)} {item.item_name} {item.type} {item.unit}
)}
{selectedItems.size > 0 && ( {selectedItems.size}개 선택됨 )}
); } // ─── 트리 노드 행 렌더링 (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; onDragStart: (e: React.DragEvent, tempId: string) => void; onDragOver: (e: React.DragEvent, tempId: string) => void; onDrop: (e: React.DragEvent, tempId: string) => void; isDragOver?: boolean; } function TreeNodeRow({ node, depth, expanded, hasChildren, columns, categoryOptionsMap, mainTableName, onToggle, onFieldChange, onDelete, onAddChild, onDragStart, onDragOver, onDrop, isDragOver, }: 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", isDragOver && "border-primary bg-primary/5 border-dashed", )} style={{ marginLeft: `${indentPx}px` }} draggable onDragStart={(e) => onDragStart(e, node.tempId)} onDragOver={(e) => onDragOver(e, node.tempId)} onDrop={(e) => onDrop(e, node.tempId)} > {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 && cfg.parentKeyColumn !== "id") ? 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]); // BOM 전용 API로 현재 current_version_id 조회 const fetchCurrentVersionId = useCallback(async (id: string): Promise => { try { const res = await apiClient.get(`/bom/${id}/versions`); if (res.data?.success) { // bom.current_version_id를 직접 반환 (불러오기와 사용확정 구분) if (res.data.currentVersionId) return res.data.currentVersionId; // fallback: active 상태 버전 const activeVersion = res.data.data?.find((v: any) => v.status === "active"); if (activeVersion) return activeVersion.id; } } catch (e) { console.error("[BomItemEditor] current_version_id 조회 실패:", e); } return null; }, []); // formData에서 가져오는 versionId (fallback용) const propsVersionId = (formData?.current_version_id as string) || (selectedRowsData?.[0]?.current_version_id as string) || null; // ─── 카테고리 옵션 로드 (리피터 방식) ─── 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}`; const alreadyLoaded = await new Promise((resolve) => { setCategoryOptionsMap((prev) => { resolve(!!prev[categoryRef]); return prev; }); }); if (alreadyLoaded) 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 sourceFk = cfg.dataSource?.foreignKey || "child_item_id"; const sourceTable = cfg.dataSource?.sourceTable || "item_info"; const loadBomDetails = useCallback( async (id: string) => { if (!id) return; setLoading(true); try { // isSourceDisplay 컬럼을 추가 조인 컬럼으로 요청 const displayCols = columns.filter((c) => c.isSourceDisplay); const additionalJoinColumns = displayCols.map((col) => ({ sourceTable, sourceColumn: sourceFk, joinAlias: `${sourceFk}_${col.key}`, referenceTable: sourceTable, })); // 서버에서 최신 current_version_id 조회 (항상 최신 보장) const freshVersionId = await fetchCurrentVersionId(id); const effectiveVersionId = freshVersionId || propsVersionId; const searchFilter: Record = { [fkColumn]: id }; if (effectiveVersionId) { searchFilter.version_id = effectiveVersionId; } // autoFilter 비활성화: BOM 전용 API로 company_code 관리 const res = await apiClient.get(`/table-management/tables/${mainTableName}/data-with-joins`, { params: { page: 1, size: 500, search: JSON.stringify(searchFilter), sortBy: "seq_no", sortOrder: "asc", enableEntityJoin: true, additionalJoinColumns: additionalJoinColumns.length > 0 ? JSON.stringify(additionalJoinColumns) : undefined, autoFilter: JSON.stringify({ enabled: false }), }, }); const rawData = res.data?.data?.data || res.data?.data || []; const rows = (Array.isArray(rawData) ? rawData : []).map((row: Record) => { const mapped = { ...row }; for (const key of Object.keys(row)) { if (key.startsWith(`${sourceFk}_`)) { const shortKey = key.replace(`${sourceFk}_`, ""); if (!mapped[shortKey]) mapped[shortKey] = row[key]; } } return mapped; }); 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, sourceFk, sourceTable, columns, fetchCurrentVersionId, propsVersionId], ); // formData.current_version_id가 변경될 때도 재로드 (버전 전환 시 반영) const formVersionRef = useRef(null); useEffect(() => { if (!bomId || isDesignMode) return; const currentFormVersion = formData?.current_version_id as string || null; // bomId가 바뀌거나, formData의 current_version_id가 바뀌면 재로드 if (formVersionRef.current !== currentFormVersion || !formVersionRef.current) { formVersionRef.current = currentFormVersion; loadBomDetails(bomId); } }, [bomId, isDesignMode, loadBomDetails, formData?.current_version_id]); // ─── 트리 빌드 (동적 데이터) ─── 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, [fkColumn]: bomId, seq_no: String(idx + 1), level: String(level), _isNew: node._isNew, _targetTable: mainTableName, _fkColumn: fkColumn, _deferSave: true, }); if (node.children.length > 0) { traverse(node.children, node.id || node.tempId, level + 1); } }); }; traverse(nodes, null, 0); return result; }, [parentKeyColumn, mainTableName, fkColumn, bomId]); // 트리 변경 시 부모에게 알림 const notifyChange = useCallback( (newTree: BomItemNode[]) => { setTreeData(newTree); onChange?.(flattenTree(newTree)); }, [onChange, flattenTree], ); // ─── DB 저장 (INSERT/UPDATE/DELETE 일괄) ─── const [saving, setSaving] = useState(false); const [hasChanges, setHasChanges] = useState(false); const originalDataRef = React.useRef>(new Set()); useEffect(() => { if (treeData.length > 0 && originalDataRef.current.size === 0) { const collectIds = (nodes: BomItemNode[]) => { nodes.forEach((n) => { if (n.id) originalDataRef.current.add(n.id); collectIds(n.children); }); }; collectIds(treeData); } }, [treeData]); const markChanged = useCallback(() => setHasChanges(true), []); const originalNotifyChange = notifyChange; const notifyChangeWithDirty = useCallback( (newTree: BomItemNode[]) => { originalNotifyChange(newTree); markChanged(); }, [originalNotifyChange, markChanged], ); const handleSaveAllRef = React.useRef<(() => Promise) | null>(null); // EditModal 저장 시 beforeFormSave 이벤트로 디테일 데이터도 함께 저장 useEffect(() => { if (isDesignMode || !bomId) return; const handler = (e: Event) => { const detail = (e as CustomEvent).detail; if (handleSaveAllRef.current) { const savePromise = handleSaveAllRef.current(); if (detail?.pendingPromises) { detail.pendingPromises.push(savePromise); } } }; window.addEventListener("beforeFormSave", handler); return () => window.removeEventListener("beforeFormSave", handler); }, [isDesignMode, bomId]); const handleSaveAll = useCallback(async () => { if (!bomId) return; setSaving(true); try { // version_id 확보: 없으면 서버에서 자동 초기화 let saveVersionId = await fetchCurrentVersionId(bomId) || propsVersionId; if (!saveVersionId) { try { const initRes = await apiClient.post(`/bom/${bomId}/initialize-version`); if (initRes.data?.success && initRes.data.data?.versionId) { saveVersionId = initRes.data.data.versionId; } } catch (e) { console.error("[BomItemEditor] 버전 초기화 실패:", e); } } const collectAll = (nodes: BomItemNode[], parentRealId: string | null, level: number): any[] => { const result: any[] = []; nodes.forEach((node, idx) => { result.push({ node, parentRealId, level, seqNo: idx + 1, }); if (node.children.length > 0) { result.push(...collectAll(node.children, node.id || node.tempId, level + 1)); } }); return result; }; const allNodes = collectAll(treeData, null, 0); const tempToReal: Record = {}; let savedCount = 0; for (const { node, parentRealId, level, seqNo } of allNodes) { const realParentId = parentRealId ? tempToReal[parentRealId] || parentRealId : null; if (node._isNew) { const raw: Record = { ...node.data, [fkColumn]: bomId, [parentKeyColumn]: realParentId, seq_no: String(seqNo), level: String(level), company_code: companyCode || undefined, version_id: saveVersionId || undefined, }; // bom_detail에 유효한 필드만 남기기 (item_info 조인 필드 제거) const payload: Record = {}; const validKeys = new Set([ fkColumn, parentKeyColumn, "seq_no", "level", "child_item_id", "quantity", "unit", "loss_rate", "remark", "process_type", "base_qty", "revision", "version_id", "company_code", "writer", ]); Object.keys(raw).forEach((k) => { if (validKeys.has(k)) payload[k] = raw[k]; }); const resp = await apiClient.post( `/table-management/tables/${mainTableName}/add`, payload, ); const newId = resp.data?.data?.id; if (newId) tempToReal[node.tempId] = newId; savedCount++; } else if (node.id) { const updatedData: Record = { id: node.id, [fkColumn]: bomId, [parentKeyColumn]: realParentId, seq_no: String(seqNo), level: String(level), }; ["quantity", "unit", "loss_rate", "remark", "process_type", "base_qty", "revision", "child_item_id", "version_id", "company_code"].forEach((k) => { if (node.data[k] !== undefined) updatedData[k] = node.data[k]; }); await apiClient.put( `/table-management/tables/${mainTableName}/edit`, { originalData: { id: node.id }, updatedData }, ); savedCount++; } } const currentIds = new Set(allNodes.filter((a) => a.node.id).map((a) => a.node.id)); for (const oldId of originalDataRef.current) { if (!currentIds.has(oldId)) { await apiClient.delete( `/table-management/tables/${mainTableName}/delete`, { data: [{ id: oldId }] }, ); savedCount++; } } originalDataRef.current = new Set(allNodes.filter((a) => a.node.id || tempToReal[a.node.tempId]).map((a) => a.node.id || tempToReal[a.node.tempId])); setHasChanges(false); if (bomId) loadBomDetails(bomId); window.dispatchEvent(new CustomEvent("refreshTable")); console.log(`[BomItemEditor] ${savedCount}건 저장 완료`); } catch (error) { console.error("[BomItemEditor] 저장 실패:", error); alert("저장 중 오류가 발생했습니다."); } finally { setSaving(false); } }, [bomId, treeData, fkColumn, parentKeyColumn, mainTableName, companyCode, sourceFk, loadBomDetails, fetchCurrentVersionId, propsVersionId]); useEffect(() => { handleSaveAllRef.current = handleSaveAll; }, [handleSaveAll]); // ─── 노드 조작 함수들 ─── // 트리에서 특정 노드 찾기 (재귀) 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 }, })); notifyChangeWithDirty(newTree); }, [treeData, notifyChangeWithDirty], ); // 노드 삭제 const handleDelete = useCallback( (tempId: string) => { const newTree = findAndUpdate(treeData, tempId, () => null); notifyChangeWithDirty(newTree); }, [treeData, notifyChangeWithDirty], ); // 하위 품목 추가 시작 (모달 열기) const handleAddChild = useCallback((parentTempId: string) => { setAddTargetParentId(parentTempId); setItemSearchOpen(true); }, []); // 이미 추가된 품목 ID 목록 (중복 방지용) const existingItemIds = useMemo(() => { const ids = new Set(); const collect = (nodes: BomItemNode[]) => { for (const n of nodes) { const fk = n.data[cfg.dataSource?.foreignKey || "child_item_id"]; if (fk) ids.add(fk); collect(n.children); } }; collect(treeData); return ids; }, [treeData, cfg]); // 루트 품목 추가 시작 const handleAddRoot = useCallback(() => { setAddTargetParentId(null); setItemSearchOpen(true); }, []); // 품목 선택 후 추가 (다중 선택 지원) const handleItemSelect = useCallback( (selectedItemsList: ItemInfo[]) => { let newTree = [...treeData]; for (const item of selectedItemsList) { 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: "", }, }; if (addTargetParentId === null) { newNode.seq_no = newTree.length + 1; newNode.level = 0; newTree = [...newTree, newNode]; } else { newTree = findAndUpdate(newTree, 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], }; }); } } if (addTargetParentId !== null) { setExpandedNodes((prev) => new Set([...prev, addTargetParentId])); } notifyChangeWithDirty(newTree); }, [addTargetParentId, treeData, notifyChangeWithDirty, 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 [dragId, setDragId] = useState(null); const [dragOverId, setDragOverId] = useState(null); // 트리에서 노드를 제거하고 반환 const removeNode = (nodes: BomItemNode[], tempId: string): { tree: BomItemNode[]; removed: BomItemNode | null } => { const result: BomItemNode[] = []; let removed: BomItemNode | null = null; for (const node of nodes) { if (node.tempId === tempId) { removed = node; } else { const childResult = removeNode(node.children, tempId); if (childResult.removed) removed = childResult.removed; result.push({ ...node, children: childResult.tree }); } } return { tree: result, removed }; }; // 노드가 대상의 자손인지 확인 (자기 자신의 하위로 드래그 방지) const isDescendant = (nodes: BomItemNode[], parentId: string, childId: string): boolean => { const find = (list: BomItemNode[]): BomItemNode | null => { for (const n of list) { if (n.tempId === parentId) return n; const found = find(n.children); if (found) return found; } return null; }; const parent = find(nodes); if (!parent) return false; const check = (children: BomItemNode[]): boolean => { for (const c of children) { if (c.tempId === childId) return true; if (check(c.children)) return true; } return false; }; return check(parent.children); }; const handleDragStart = useCallback((e: React.DragEvent, tempId: string) => { setDragId(tempId); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", tempId); }, []); const handleDragOver = useCallback((e: React.DragEvent, tempId: string) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; setDragOverId(tempId); }, []); const handleDrop = useCallback((e: React.DragEvent, targetTempId: string) => { e.preventDefault(); setDragOverId(null); if (!dragId || dragId === targetTempId) return; // 자기 자신의 하위로 드래그 방지 if (isDescendant(treeData, dragId, targetTempId)) return; const { tree: treeWithout, removed } = removeNode(treeData, dragId); if (!removed) return; // 대상 노드 바로 뒤에 같은 레벨로 삽입 const insertAfter = (nodes: BomItemNode[], afterId: string, node: BomItemNode): { result: BomItemNode[]; inserted: boolean } => { const result: BomItemNode[] = []; let inserted = false; for (const n of nodes) { result.push(n); if (n.tempId === afterId) { result.push({ ...node, level: n.level, parent_detail_id: n.parent_detail_id }); inserted = true; } else if (!inserted) { const childResult = insertAfter(n.children, afterId, node); if (childResult.inserted) { result[result.length - 1] = { ...n, children: childResult.result }; inserted = true; } } } return { result, inserted }; }; const { result, inserted } = insertAfter(treeWithout, targetTempId, removed); if (inserted) { const reindex = (nodes: BomItemNode[], depth = 0): BomItemNode[] => nodes.map((n, i) => ({ ...n, seq_no: i + 1, level: depth, children: reindex(n.children, depth + 1) })); notifyChangeWithDirty(reindex(result)); } setDragId(null); }, [dragId, treeData, notifyChangeWithDirty]); // ─── 재귀 렌더링 ─── 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} onDragStart={handleDragStart} onDragOver={handleDragOver} onDrop={handleDrop} isDragOver={dragOverId === node.tempId} /> {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 (
{/* 헤더 */}

하위 품목 구성 {hasChanges && (미저장)}

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

하위 품목이 없습니다.

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

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