831 lines
24 KiB
TypeScript
831 lines
24 KiB
TypeScript
"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<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: (item: ItemInfo) => void;
|
|
companyCode?: string;
|
|
}
|
|
|
|
function ItemSearchModal({
|
|
open,
|
|
onClose,
|
|
onSelect,
|
|
companyCode,
|
|
}: ItemSearchModalProps) {
|
|
const [searchText, setSearchText] = useState("");
|
|
const [items, setItems] = useState<ItemInfo[]>([]);
|
|
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 (
|
|
<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="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={() => {
|
|
onSelect(item);
|
|
onClose();
|
|
}}
|
|
className="hover:bg-accent cursor-pointer border-t transition-colors"
|
|
>
|
|
<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>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
// ─── 트리 노드 행 렌더링 ───
|
|
|
|
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 (
|
|
<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",
|
|
)}
|
|
style={{ marginLeft: `${indentPx}px` }}
|
|
>
|
|
{/* 드래그 핸들 */}
|
|
<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>
|
|
|
|
{/* 품목코드 */}
|
|
<span className="w-24 shrink-0 truncate font-mono text-xs font-medium">
|
|
{node.child_item_code || "-"}
|
|
</span>
|
|
|
|
{/* 품목명 */}
|
|
<span className="min-w-[80px] flex-1 truncate text-xs">
|
|
{node.child_item_name || "-"}
|
|
</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>
|
|
)}
|
|
|
|
{/* 수량 */}
|
|
<Input
|
|
value={node.quantity}
|
|
onChange={(e) =>
|
|
onFieldChange(node.tempId, "quantity", e.target.value)
|
|
}
|
|
className="h-7 w-16 shrink-0 text-center text-xs"
|
|
placeholder="수량"
|
|
/>
|
|
|
|
{/* 품목구분 셀렉트 */}
|
|
<Select
|
|
value={node.child_item_type || ""}
|
|
onValueChange={(val) =>
|
|
onFieldChange(node.tempId, "child_item_type", val)
|
|
}
|
|
>
|
|
<SelectTrigger className="h-7 w-20 shrink-0 text-xs">
|
|
<SelectValue placeholder="구분" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="assembly">조립</SelectItem>
|
|
<SelectItem value="process">공정</SelectItem>
|
|
<SelectItem value="purchase">구매</SelectItem>
|
|
<SelectItem value="outsource">외주</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{/* 하위 추가 버튼 */}
|
|
<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,
|
|
);
|
|
|
|
// 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<string>(
|
|
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<string, BomItemNode>();
|
|
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 (
|
|
<React.Fragment key={node.tempId}>
|
|
<TreeNodeRow
|
|
node={node}
|
|
depth={depth}
|
|
expanded={isExpanded}
|
|
hasChildren={node.children.length > 0}
|
|
onToggle={() => toggleExpand(node.tempId)}
|
|
onFieldChange={handleFieldChange}
|
|
onDelete={handleDelete}
|
|
onAddChild={handleAddChild}
|
|
/>
|
|
{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);
|
|
|
|
const sourceColumns = (cfg.columns || []).filter((c: any) => c.isSourceDisplay);
|
|
const inputColumns = (cfg.columns || []).filter((c: any) => !c.isSourceDisplay);
|
|
|
|
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 dummyRows = [
|
|
{ depth: 0, code: "ASM-001", name: "본체 조립", type: "조립", qty: "1" },
|
|
{ depth: 1, code: "PRT-010", name: "프레임", type: "구매", qty: "2" },
|
|
{ depth: 1, code: "PRT-011", name: "커버", type: "구매", qty: "1" },
|
|
{ depth: 0, code: "ASM-002", name: "전장 조립", type: "조립", qty: "1" },
|
|
{ depth: 1, code: "PRT-020", name: "PCB 보드", type: "구매", qty: "3" },
|
|
];
|
|
|
|
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>
|
|
)}
|
|
{inputColumns.length > 0 && (
|
|
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600">
|
|
입력 {inputColumns.length}개
|
|
</span>
|
|
)}
|
|
{sourceColumns.length > 0 && (
|
|
<span className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] text-blue-600">
|
|
표시 {sourceColumns.length}개
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* 더미 트리 미리보기 */}
|
|
<div className="space-y-0.5 rounded-md border p-1.5">
|
|
{dummyRows.map((row, i) => (
|
|
<div
|
|
key={i}
|
|
className={cn(
|
|
"flex items-center gap-1.5 rounded px-1.5 py-1",
|
|
row.depth > 0 && "border-l-2 border-l-primary/20",
|
|
i === 0 && "bg-accent/30",
|
|
)}
|
|
style={{ marginLeft: `${row.depth * 20}px` }}
|
|
>
|
|
<GripVertical className="text-muted-foreground h-3 w-3 shrink-0 opacity-40" />
|
|
{row.depth === 0 ? (
|
|
<ChevronDown className="h-3 w-3 shrink-0 opacity-50" />
|
|
) : (
|
|
<span className="w-3" />
|
|
)}
|
|
<span className="text-muted-foreground w-4 text-center text-[10px]">
|
|
{i + 1}
|
|
</span>
|
|
<span className="w-16 shrink-0 truncate font-mono text-[10px] font-medium">
|
|
{row.code}
|
|
</span>
|
|
<span className="min-w-[50px] flex-1 truncate text-[10px]">
|
|
{row.name}
|
|
</span>
|
|
|
|
{/* 소스 표시 컬럼 미리보기 */}
|
|
{sourceColumns.slice(0, 2).map((col: any) => (
|
|
<span
|
|
key={col.key}
|
|
className="w-12 shrink-0 truncate text-center text-[10px] text-blue-500"
|
|
>
|
|
{col.title}
|
|
</span>
|
|
))}
|
|
|
|
{/* 입력 컬럼 미리보기 */}
|
|
{inputColumns.slice(0, 2).map((col: any) => (
|
|
<div
|
|
key={col.key}
|
|
className="h-5 w-12 shrink-0 rounded border bg-background text-center text-[10px] leading-5"
|
|
>
|
|
{col.key === "quantity" || col.title === "수량"
|
|
? row.qty
|
|
: ""}
|
|
</div>
|
|
))}
|
|
|
|
<div className="flex shrink-0 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>
|
|
</div>
|
|
))}
|
|
</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="space-y-1">
|
|
{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">
|
|
"품목추가" 버튼을 눌러 추가하세요.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
renderNodes(treeData, 0)
|
|
)}
|
|
</div>
|
|
|
|
{/* 품목 검색 모달 */}
|
|
<ItemSearchModal
|
|
open={itemSearchOpen}
|
|
onClose={() => setItemSearchOpen(false)}
|
|
onSelect={handleItemSelect}
|
|
companyCode={companyCode}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default BomItemEditorComponent;
|