ERP-node/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx

1133 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 { 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<string, any>;
}
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<string, any>;
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;
}
function ItemSearchModal({
open,
onClose,
onSelect,
companyCode,
}: ItemSearchModalProps) {
const [searchText, setSearchText] = useState("");
const [items, setItems] = useState<ItemInfo[]>([]);
const [selectedItems, setSelectedItems] = useState<Set<string>>(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 (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Input
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="품목코드 또는 품목명"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<Button
onClick={handleSearch}
size="sm"
className="h-8 sm:h-10"
>
<Search className="mr-1 h-4 w-4" />
</Button>
</div>
<div className="max-h-[300px] overflow-y-auto rounded-md border">
{loading ? (
<div className="flex items-center justify-center py-8">
<span className="text-muted-foreground text-sm"> ...</span>
</div>
) : items.length === 0 ? (
<div className="flex items-center justify-center py-8">
<span className="text-muted-foreground text-sm">
.
</span>
</div>
) : (
<table className="w-full text-xs sm:text-sm">
<thead className="bg-muted/50 sticky top-0">
<tr>
<th className="w-8 px-2 py-2 text-center">
<Checkbox
checked={selectedItems.size > 0 && selectedItems.size === items.length}
onCheckedChange={(checked) => {
if (checked) setSelectedItems(new Set(items.map((i) => i.id)));
else setSelectedItems(new Set());
}}
/>
</th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr
key={item.id}
onClick={() => {
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(
"cursor-pointer border-t transition-colors",
selectedItems.has(item.id) ? "bg-primary/10" : "hover:bg-accent",
)}
>
<td className="px-2 py-2 text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedItems.has(item.id)}
onCheckedChange={(checked) => {
setSelectedItems((prev) => {
const next = new Set(prev);
if (checked) next.add(item.id);
else next.delete(item.id);
return next;
});
}}
/>
</td>
<td className="px-3 py-2 font-mono">
{item.item_number}
</td>
<td className="px-3 py-2">{item.item_name}</td>
<td className="px-3 py-2">{item.type}</td>
<td className="px-3 py-2">{item.unit}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{selectedItems.size > 0 && (
<DialogFooter className="gap-2 sm:gap-0">
<span className="text-muted-foreground text-xs sm:text-sm">
{selectedItems.size}
</span>
<Button
onClick={() => {
const selected = items.filter((i) => selectedItems.has(i.id));
onSelect(selected);
onClose();
}}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
<Plus className="mr-1 h-4 w-4" />
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
}
// ─── 트리 노드 행 렌더링 (config.columns 기반 동적 셀) ───
interface TreeNodeRowProps {
node: BomItemNode;
depth: number;
expanded: boolean;
hasChildren: boolean;
columns: BomColumnConfig[];
categoryOptionsMap: Record<string, { value: string; label: string }[]>;
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 (
<span className="truncate text-xs" title={String(value)}>
{value || "-"}
</span>
);
}
// 카테고리 타입: API에서 로드한 옵션으로 Select 렌더링
if (col.inputType === "category") {
const categoryRef = mainTableName ? `${mainTableName}.${col.key}` : "";
const options = categoryOptionsMap[categoryRef] || [];
return (
<Select
value={String(value || "")}
onValueChange={(val) => onFieldChange(node.tempId, col.key, val)}
>
<SelectTrigger className="h-7 w-full min-w-[70px] text-xs">
<SelectValue placeholder={col.title} />
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
// 편집 불가능 컬럼
if (col.editable === false) {
return (
<span className="text-muted-foreground truncate text-xs">
{value || "-"}
</span>
);
}
// 숫자 입력
if (col.inputType === "number" || col.inputType === "decimal") {
return (
<Input
type="number"
value={String(value)}
onChange={(e) => onFieldChange(node.tempId, col.key, e.target.value)}
className="h-7 w-full min-w-[50px] text-center text-xs"
placeholder={col.title}
/>
);
}
// 기본 텍스트 입력
return (
<Input
value={String(value)}
onChange={(e) => onFieldChange(node.tempId, col.key, e.target.value)}
className="h-7 w-full min-w-[50px] text-xs"
placeholder={col.title}
/>
);
};
return (
<div
className={cn(
"group flex items-center gap-2 rounded-md border px-2 py-1.5",
"transition-colors hover:bg-accent/30",
depth > 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)}
>
<GripVertical className="text-muted-foreground h-4 w-4 shrink-0 cursor-grab" />
<button
onClick={onToggle}
className={cn(
"flex h-5 w-5 shrink-0 items-center justify-center rounded",
hasChildren
? "hover:bg-accent cursor-pointer"
: "cursor-default opacity-0",
)}
>
{hasChildren &&
(expanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
))}
</button>
<span className="text-muted-foreground w-6 shrink-0 text-center text-xs font-medium">
{node.seq_no}
</span>
{node.level > 0 && (
<span className="bg-primary/10 text-primary shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold">
L{node.level}
</span>
)}
{/* config.columns 기반 동적 셀 렌더링 */}
{visibleColumns.map((col) => (
<div
key={col.key}
className={cn(
"shrink-0",
col.isSourceDisplay ? "min-w-[60px] flex-1" : "min-w-[50px]",
)}
style={{ width: col.width && col.width !== "auto" ? col.width : undefined }}
>
{renderCell(col)}
</div>
))}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => onAddChild(node.tempId)}
title="하위 품목 추가"
>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10 h-7 w-7 shrink-0"
onClick={() => onDelete(node.tempId)}
title="삭제"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
);
}
// ─── 메인 컴포넌트 ───
export function BomItemEditorComponent({
component,
formData,
companyCode,
isDesignMode = false,
selectedRowsData,
onChange,
bomId: propBomId,
}: BomItemEditorProps) {
const [treeData, setTreeData] = useState<BomItemNode[]>([]);
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [itemSearchOpen, setItemSearchOpen] = useState(false);
const [addTargetParentId, setAddTargetParentId] = useState<string | null>(null);
const [categoryOptionsMap, setCategoryOptionsMap] = useState<Record<string, { value: string; label: string }[]>>({});
// 설정값 추출
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]);
// ─── 카테고리 옵션 로드 (리피터 방식) ───
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<boolean>((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,
}));
const result = await entityJoinApi.getTableDataWithJoins(mainTableName, {
page: 1,
size: 500,
search: { [fkColumn]: id },
sortBy: "seq_no",
sortOrder: "asc",
enableEntityJoin: true,
additionalJoinColumns: additionalJoinColumns.length > 0 ? additionalJoinColumns : undefined,
});
const rows = (result.data || []).map((row: Record<string, any>) => {
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<string>(
tree.map((n) => n.tempId || n.id || ""),
);
setExpandedNodes(firstLevelIds);
} catch (error) {
console.error("[BomItemEditor] 데이터 로드 실패:", error);
} finally {
setLoading(false);
}
},
[mainTableName, fkColumn, sourceFk, sourceTable, columns],
);
useEffect(() => {
if (bomId && !isDesignMode) {
loadBomDetails(bomId);
}
}, [bomId, isDesignMode, loadBomDetails]);
// ─── 트리 빌드 (동적 데이터) ───
const buildTree = (flatData: any[]): BomItemNode[] => {
const nodeMap = new Map<string, BomItemNode>();
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],
);
// ─── 노드 조작 함수들 ───
// 트리에서 특정 노드 찾기 (재귀)
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(
(selectedItemsList: ItemInfo[]) => {
let newTree = [...treeData];
for (const item of selectedItemsList) {
const sourceData: Record<string, any> = {};
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]));
}
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 [dragId, setDragId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(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) }));
notifyChange(reindex(result));
}
setDragId(null);
}, [dragId, treeData, notifyChange]);
// ─── 재귀 렌더링 ───
const renderNodes = (nodes: BomItemNode[], depth: number) => {
return nodes.map((node) => {
const isExpanded = expandedNodes.has(node.tempId);
return (
<React.Fragment key={node.tempId}>
<TreeNodeRow
node={node}
depth={depth}
expanded={isExpanded}
hasChildren={node.children.length > 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)}
</React.Fragment>
);
});
};
// ─── 디자인 모드 미리보기 ───
if (isDesignMode) {
const cfg = component?.componentConfig || {};
const hasConfig =
cfg.mainTableName || cfg.dataSource?.sourceTable || (cfg.columns && cfg.columns.length > 0);
if (!hasConfig) {
return (
<div className="rounded-md border border-dashed p-6 text-center">
<Package className="text-muted-foreground mx-auto mb-2 h-8 w-8" />
<p className="text-muted-foreground text-sm font-medium">
BOM
</p>
<p className="text-muted-foreground text-xs">
</p>
</div>
);
}
const visibleColumns = (cfg.columns || []).filter((c: any) => c.visible !== false);
const DUMMY_DATA: Record<string, string[]> = {
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 (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold"> </h4>
<Button size="sm" className="h-7 text-xs" disabled>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{/* 설정 요약 뱃지 */}
<div className="flex flex-wrap gap-1">
{cfg.mainTableName && (
<span className="rounded bg-orange-100 px-1.5 py-0.5 text-[10px] text-orange-700">
: {cfg.mainTableName}
</span>
)}
{cfg.dataSource?.sourceTable && (
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700">
: {cfg.dataSource.sourceTable}
</span>
)}
{cfg.parentKeyColumn && (
<span className="rounded bg-green-100 px-1.5 py-0.5 text-[10px] text-green-700">
: {cfg.parentKeyColumn}
</span>
)}
</div>
{/* 테이블 형태 미리보기 - config.columns 순서 그대로 */}
<div className="overflow-hidden rounded-md border">
{visibleColumns.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6">
<Package className="text-muted-foreground mb-1.5 h-6 w-6" />
<p className="text-muted-foreground text-xs">
</p>
</div>
) : (
<table className="w-full text-[10px]">
<thead className="bg-muted/60">
<tr>
<th className="w-6 px-1 py-1.5 text-center font-medium" />
<th className="w-5 px-0.5 py-1.5 text-center font-medium">#</th>
{visibleColumns.map((col: any) => (
<th
key={col.key}
className={cn(
"px-2 py-1.5 text-left font-medium",
col.isSourceDisplay && "text-blue-600",
)}
style={{ width: col.width && col.width !== "auto" ? col.width : undefined }}
>
{col.title}
</th>
))}
<th className="w-14 px-1 py-1.5 text-center font-medium"></th>
</tr>
</thead>
<tbody>
{DUMMY_DEPTHS.map((depth, rowIdx) => (
<tr
key={rowIdx}
className={cn(
"border-t transition-colors",
rowIdx === 0 && "bg-accent/20",
)}
>
<td className="px-1 py-1 text-center">
<div className="flex items-center justify-center gap-0.5" style={{ paddingLeft: `${depth * 10}px` }}>
{depth === 0 ? (
<ChevronDown className="h-3 w-3 opacity-50" />
) : (
<span className="text-primary/40 text-[10px]"></span>
)}
</div>
</td>
<td className="text-muted-foreground px-0.5 py-1 text-center">
{rowIdx + 1}
</td>
{visibleColumns.map((col: any) => (
<td key={col.key} className="px-1.5 py-0.5">
{col.isSourceDisplay ? (
<span className="truncate text-blue-600">
{getDummyValue(col, rowIdx) || col.title}
</span>
) : col.editable !== false ? (
<div className="h-5 rounded border bg-background px-1.5 text-[10px] leading-5">
{getDummyValue(col, rowIdx)}
</div>
) : (
<span className="text-muted-foreground">
{getDummyValue(col, rowIdx)}
</span>
)}
</td>
))}
<td className="px-1 py-1 text-center">
<div className="flex items-center justify-center gap-0.5">
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center rounded opacity-40">
<Plus className="h-3 w-3" />
</div>
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center rounded opacity-40">
<X className="h-3 w-3" />
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
// ─── 메인 렌더링 ───
return (
<div className="space-y-3">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold"> </h4>
<Button
onClick={handleAddRoot}
size="sm"
className="h-8 text-xs"
>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
{/* 트리 목록 */}
<div className="max-h-[400px] space-y-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-8">
<span className="text-muted-foreground text-sm"> ...</span>
</div>
) : treeData.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-md border border-dashed py-8">
<Package className="text-muted-foreground mb-2 h-8 w-8" />
<p className="text-muted-foreground text-sm">
.
</p>
<p className="text-muted-foreground text-xs">
&quot;&quot; .
</p>
</div>
) : (
renderNodes(treeData, 0)
)}
</div>
{/* 품목 검색 모달 */}
<ItemSearchModal
open={itemSearchOpen}
onClose={() => setItemSearchOpen(false)}
onSelect={handleItemSelect}
companyCode={companyCode}
/>
</div>
);
}
export default BomItemEditorComponent;