449 lines
14 KiB
TypeScript
449 lines
14 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||
|
|
import { ChevronRight, ChevronDown, Package, Layers, Box, AlertCircle } from "lucide-react";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* BOM 트리 노드 데이터
|
||
|
|
*/
|
||
|
|
interface BomTreeNode {
|
||
|
|
id: string;
|
||
|
|
bom_id: string;
|
||
|
|
parent_detail_id: string | null;
|
||
|
|
seq_no: string;
|
||
|
|
level: string;
|
||
|
|
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: BomTreeNode[];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* BOM 헤더 정보
|
||
|
|
*/
|
||
|
|
interface BomHeaderInfo {
|
||
|
|
id: string;
|
||
|
|
bom_number: string;
|
||
|
|
item_code: string;
|
||
|
|
item_name: string;
|
||
|
|
item_type: string;
|
||
|
|
base_qty: string;
|
||
|
|
unit: string;
|
||
|
|
version: string;
|
||
|
|
revision: string;
|
||
|
|
status: string;
|
||
|
|
effective_date: string;
|
||
|
|
expired_date: string;
|
||
|
|
remark: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface BomTreeComponentProps {
|
||
|
|
component?: any;
|
||
|
|
formData?: Record<string, any>;
|
||
|
|
tableName?: string;
|
||
|
|
companyCode?: string;
|
||
|
|
isDesignMode?: boolean;
|
||
|
|
selectedRowsData?: any[];
|
||
|
|
[key: string]: any;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* BOM 트리 컴포넌트
|
||
|
|
* 좌측 패널에서 BOM 헤더 선택 시 계층 구조로 BOM 디테일을 표시
|
||
|
|
*/
|
||
|
|
export function BomTreeComponent({
|
||
|
|
component,
|
||
|
|
formData,
|
||
|
|
companyCode,
|
||
|
|
isDesignMode = false,
|
||
|
|
selectedRowsData,
|
||
|
|
...props
|
||
|
|
}: BomTreeComponentProps) {
|
||
|
|
const [headerInfo, setHeaderInfo] = useState<BomHeaderInfo | null>(null);
|
||
|
|
const [treeData, setTreeData] = useState<BomTreeNode[]>([]);
|
||
|
|
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||
|
|
const [loading, setLoading] = useState(false);
|
||
|
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||
|
|
|
||
|
|
const config = component?.componentConfig || {};
|
||
|
|
|
||
|
|
// 선택된 BOM 헤더에서 bom_id 추출
|
||
|
|
const selectedBomId = useMemo(() => {
|
||
|
|
// SplitPanel에서 좌측 선택 시 formData나 selectedRowsData로 전달됨
|
||
|
|
if (selectedRowsData && selectedRowsData.length > 0) {
|
||
|
|
return selectedRowsData[0]?.id;
|
||
|
|
}
|
||
|
|
if (formData?.id) return formData.id;
|
||
|
|
return null;
|
||
|
|
}, [formData, selectedRowsData]);
|
||
|
|
|
||
|
|
// 선택된 BOM 헤더 정보 추출
|
||
|
|
const selectedHeaderData = useMemo(() => {
|
||
|
|
if (selectedRowsData && selectedRowsData.length > 0) {
|
||
|
|
return selectedRowsData[0] as BomHeaderInfo;
|
||
|
|
}
|
||
|
|
if (formData?.id) return formData as unknown as BomHeaderInfo;
|
||
|
|
return null;
|
||
|
|
}, [formData, selectedRowsData]);
|
||
|
|
|
||
|
|
// BOM 디테일 데이터 로드
|
||
|
|
const loadBomDetails = useCallback(async (bomId: string) => {
|
||
|
|
if (!bomId) return;
|
||
|
|
setLoading(true);
|
||
|
|
try {
|
||
|
|
const result = await entityJoinApi.getTableDataWithJoins("bom_detail", {
|
||
|
|
page: 1,
|
||
|
|
size: 500,
|
||
|
|
search: { bom_id: bomId },
|
||
|
|
sortBy: "seq_no",
|
||
|
|
sortOrder: "asc",
|
||
|
|
enableEntityJoin: true,
|
||
|
|
});
|
||
|
|
|
||
|
|
const rows = result.data || [];
|
||
|
|
const tree = buildTree(rows);
|
||
|
|
setTreeData(tree);
|
||
|
|
const firstLevelIds = new Set<string>(tree.map((n: BomTreeNode) => n.id));
|
||
|
|
setExpandedNodes(firstLevelIds);
|
||
|
|
} catch (error) {
|
||
|
|
console.error("[BomTree] 데이터 로드 실패:", error);
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// 평면 데이터 -> 트리 구조 변환
|
||
|
|
const buildTree = (flatData: any[]): BomTreeNode[] => {
|
||
|
|
const nodeMap = new Map<string, BomTreeNode>();
|
||
|
|
const roots: BomTreeNode[] = [];
|
||
|
|
|
||
|
|
// 모든 노드를 맵에 등록
|
||
|
|
flatData.forEach((item) => {
|
||
|
|
nodeMap.set(item.id, { ...item, children: [] });
|
||
|
|
});
|
||
|
|
|
||
|
|
// 부모-자식 관계 설정
|
||
|
|
flatData.forEach((item) => {
|
||
|
|
const node = nodeMap.get(item.id)!;
|
||
|
|
if (item.parent_detail_id && nodeMap.has(item.parent_detail_id)) {
|
||
|
|
nodeMap.get(item.parent_detail_id)!.children.push(node);
|
||
|
|
} else {
|
||
|
|
roots.push(node);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
return roots;
|
||
|
|
};
|
||
|
|
|
||
|
|
// 선택된 BOM 변경 시 데이터 로드
|
||
|
|
useEffect(() => {
|
||
|
|
if (selectedBomId) {
|
||
|
|
setHeaderInfo(selectedHeaderData);
|
||
|
|
loadBomDetails(selectedBomId);
|
||
|
|
} else {
|
||
|
|
setHeaderInfo(null);
|
||
|
|
setTreeData([]);
|
||
|
|
}
|
||
|
|
}, [selectedBomId, selectedHeaderData, loadBomDetails]);
|
||
|
|
|
||
|
|
// 노드 펼치기/접기 토글
|
||
|
|
const toggleNode = useCallback((nodeId: string) => {
|
||
|
|
setExpandedNodes((prev) => {
|
||
|
|
const next = new Set(prev);
|
||
|
|
if (next.has(nodeId)) {
|
||
|
|
next.delete(nodeId);
|
||
|
|
} else {
|
||
|
|
next.add(nodeId);
|
||
|
|
}
|
||
|
|
return next;
|
||
|
|
});
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// 전체 펼치기
|
||
|
|
const expandAll = useCallback(() => {
|
||
|
|
const allIds = new Set<string>();
|
||
|
|
const collectIds = (nodes: BomTreeNode[]) => {
|
||
|
|
nodes.forEach((n) => {
|
||
|
|
allIds.add(n.id);
|
||
|
|
if (n.children.length > 0) collectIds(n.children);
|
||
|
|
});
|
||
|
|
};
|
||
|
|
collectIds(treeData);
|
||
|
|
setExpandedNodes(allIds);
|
||
|
|
}, [treeData]);
|
||
|
|
|
||
|
|
// 전체 접기
|
||
|
|
const collapseAll = useCallback(() => {
|
||
|
|
setExpandedNodes(new Set());
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// 품목 구분 라벨
|
||
|
|
const getItemTypeLabel = (type: string) => {
|
||
|
|
switch (type) {
|
||
|
|
case "product": return "제품";
|
||
|
|
case "semi": return "반제품";
|
||
|
|
case "material": return "원자재";
|
||
|
|
case "part": return "부품";
|
||
|
|
default: return type || "-";
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 품목 구분 아이콘 & 색상
|
||
|
|
const getItemTypeStyle = (type: string) => {
|
||
|
|
switch (type) {
|
||
|
|
case "product":
|
||
|
|
return { icon: Package, color: "text-blue-600", bg: "bg-blue-50" };
|
||
|
|
case "semi":
|
||
|
|
return { icon: Layers, color: "text-amber-600", bg: "bg-amber-50" };
|
||
|
|
case "material":
|
||
|
|
return { icon: Box, color: "text-emerald-600", bg: "bg-emerald-50" };
|
||
|
|
default:
|
||
|
|
return { icon: Box, color: "text-gray-500", bg: "bg-gray-50" };
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 디자인 모드 미리보기
|
||
|
|
if (isDesignMode) {
|
||
|
|
return (
|
||
|
|
<div className="flex h-full flex-col rounded-md border bg-white p-4">
|
||
|
|
<div className="mb-3 flex items-center gap-2">
|
||
|
|
<Layers className="h-4 w-4 text-primary" />
|
||
|
|
<span className="text-sm font-medium">BOM 트리 뷰</span>
|
||
|
|
</div>
|
||
|
|
<div className="flex-1 space-y-1 rounded border border-dashed border-gray-300 p-3">
|
||
|
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||
|
|
<ChevronDown className="h-3 w-3" />
|
||
|
|
<Package className="h-3 w-3 text-blue-500" />
|
||
|
|
<span>완제품 A (제품)</span>
|
||
|
|
<span className="ml-auto text-gray-400">수량: 1</span>
|
||
|
|
</div>
|
||
|
|
<div className="ml-5 flex items-center gap-2 text-xs text-gray-500">
|
||
|
|
<ChevronRight className="h-3 w-3" />
|
||
|
|
<Layers className="h-3 w-3 text-amber-500" />
|
||
|
|
<span>반제품 B (반제품)</span>
|
||
|
|
<span className="ml-auto text-gray-400">수량: 2</span>
|
||
|
|
</div>
|
||
|
|
<div className="ml-5 flex items-center gap-2 text-xs text-gray-500">
|
||
|
|
<span className="ml-3.5" />
|
||
|
|
<Box className="h-3 w-3 text-emerald-500" />
|
||
|
|
<span>원자재 C (원자재)</span>
|
||
|
|
<span className="ml-auto text-gray-400">수량: 5</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 선택 안 된 상태
|
||
|
|
if (!selectedBomId) {
|
||
|
|
return (
|
||
|
|
<div className="flex h-full items-center justify-center">
|
||
|
|
<div className="text-muted-foreground text-center text-sm">
|
||
|
|
<p className="mb-2">좌측에서 BOM을 선택하세요</p>
|
||
|
|
<p className="text-xs">선택한 BOM의 구성 정보가 트리로 표시됩니다</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="flex h-full flex-col">
|
||
|
|
{/* 헤더 정보 */}
|
||
|
|
{headerInfo && (
|
||
|
|
<div className="border-b bg-gray-50/80 px-4 py-3">
|
||
|
|
<div className="mb-2 flex items-center gap-2">
|
||
|
|
<Package className="h-4 w-4 text-primary" />
|
||
|
|
<h3 className="text-sm font-semibold">{headerInfo.item_name || "-"}</h3>
|
||
|
|
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
|
||
|
|
{headerInfo.bom_number || "-"}
|
||
|
|
</span>
|
||
|
|
<span className={cn(
|
||
|
|
"ml-1 rounded px-1.5 py-0.5 text-[10px] font-medium",
|
||
|
|
headerInfo.status === "active" ? "bg-emerald-100 text-emerald-700" : "bg-gray-100 text-gray-500"
|
||
|
|
)}>
|
||
|
|
{headerInfo.status === "active" ? "사용" : "미사용"}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||
|
|
<span>품목코드: <b className="text-foreground">{headerInfo.item_code || "-"}</b></span>
|
||
|
|
<span>구분: <b className="text-foreground">{getItemTypeLabel(headerInfo.item_type)}</b></span>
|
||
|
|
<span>기준수량: <b className="text-foreground">{headerInfo.base_qty || "1"} {headerInfo.unit || ""}</b></span>
|
||
|
|
<span>버전: <b className="text-foreground">v{headerInfo.version || "1.0"} (차수 {headerInfo.revision || "1"})</b></span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* 트리 툴바 */}
|
||
|
|
<div className="flex items-center gap-2 border-b px-4 py-2">
|
||
|
|
<span className="text-xs font-medium text-muted-foreground">BOM 구성</span>
|
||
|
|
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
|
||
|
|
{treeData.length}건
|
||
|
|
</span>
|
||
|
|
<div className="ml-auto flex gap-1">
|
||
|
|
<button
|
||
|
|
onClick={expandAll}
|
||
|
|
className="rounded px-2 py-0.5 text-[10px] text-muted-foreground hover:bg-gray-100"
|
||
|
|
>
|
||
|
|
전체 펼치기
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
onClick={collapseAll}
|
||
|
|
className="rounded px-2 py-0.5 text-[10px] text-muted-foreground hover:bg-gray-100"
|
||
|
|
>
|
||
|
|
전체 접기
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 트리 컨텐츠 */}
|
||
|
|
<div className="flex-1 overflow-auto px-2 py-2">
|
||
|
|
{loading ? (
|
||
|
|
<div className="flex h-32 items-center justify-center">
|
||
|
|
<div className="text-muted-foreground text-sm">로딩 중...</div>
|
||
|
|
</div>
|
||
|
|
) : treeData.length === 0 ? (
|
||
|
|
<div className="flex h-32 flex-col items-center justify-center gap-2">
|
||
|
|
<AlertCircle className="h-5 w-5 text-muted-foreground" />
|
||
|
|
<p className="text-xs text-muted-foreground">등록된 하위 품목이 없습니다</p>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="space-y-0.5">
|
||
|
|
{treeData.map((node) => (
|
||
|
|
<TreeNodeRow
|
||
|
|
key={node.id}
|
||
|
|
node={node}
|
||
|
|
depth={0}
|
||
|
|
expandedNodes={expandedNodes}
|
||
|
|
selectedNodeId={selectedNodeId}
|
||
|
|
onToggle={toggleNode}
|
||
|
|
onSelect={setSelectedNodeId}
|
||
|
|
getItemTypeLabel={getItemTypeLabel}
|
||
|
|
getItemTypeStyle={getItemTypeStyle}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 트리 노드 행 (재귀 렌더링)
|
||
|
|
*/
|
||
|
|
interface TreeNodeRowProps {
|
||
|
|
node: BomTreeNode;
|
||
|
|
depth: number;
|
||
|
|
expandedNodes: Set<string>;
|
||
|
|
selectedNodeId: string | null;
|
||
|
|
onToggle: (id: string) => void;
|
||
|
|
onSelect: (id: string) => void;
|
||
|
|
getItemTypeLabel: (type: string) => string;
|
||
|
|
getItemTypeStyle: (type: string) => { icon: any; color: string; bg: string };
|
||
|
|
}
|
||
|
|
|
||
|
|
function TreeNodeRow({
|
||
|
|
node,
|
||
|
|
depth,
|
||
|
|
expandedNodes,
|
||
|
|
selectedNodeId,
|
||
|
|
onToggle,
|
||
|
|
onSelect,
|
||
|
|
getItemTypeLabel,
|
||
|
|
getItemTypeStyle,
|
||
|
|
}: TreeNodeRowProps) {
|
||
|
|
const isExpanded = expandedNodes.has(node.id);
|
||
|
|
const hasChildren = node.children.length > 0;
|
||
|
|
const isSelected = selectedNodeId === node.id;
|
||
|
|
const style = getItemTypeStyle(node.child_item_type);
|
||
|
|
const ItemIcon = style.icon;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
"group flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1.5 transition-colors",
|
||
|
|
isSelected ? "bg-primary/10" : "hover:bg-gray-50"
|
||
|
|
)}
|
||
|
|
style={{ paddingLeft: `${depth * 20 + 8}px` }}
|
||
|
|
onClick={() => {
|
||
|
|
onSelect(node.id);
|
||
|
|
if (hasChildren) onToggle(node.id);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{/* 펼치기/접기 화살표 */}
|
||
|
|
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center">
|
||
|
|
{hasChildren ? (
|
||
|
|
isExpanded ? (
|
||
|
|
<ChevronDown className="h-3.5 w-3.5 text-gray-400" />
|
||
|
|
) : (
|
||
|
|
<ChevronRight className="h-3.5 w-3.5 text-gray-400" />
|
||
|
|
)
|
||
|
|
) : (
|
||
|
|
<span className="h-1 w-1 rounded-full bg-gray-300" />
|
||
|
|
)}
|
||
|
|
</span>
|
||
|
|
|
||
|
|
{/* 품목 타입 아이콘 */}
|
||
|
|
<span className={cn("flex h-5 w-5 flex-shrink-0 items-center justify-center rounded", style.bg)}>
|
||
|
|
<ItemIcon className={cn("h-3 w-3", style.color)} />
|
||
|
|
</span>
|
||
|
|
|
||
|
|
{/* 품목 정보 */}
|
||
|
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||
|
|
<span className="truncate text-xs font-medium text-foreground">
|
||
|
|
{node.child_item_name || "-"}
|
||
|
|
</span>
|
||
|
|
<span className="flex-shrink-0 text-[10px] text-muted-foreground">
|
||
|
|
{node.child_item_code || ""}
|
||
|
|
</span>
|
||
|
|
<span className={cn(
|
||
|
|
"flex-shrink-0 rounded px-1 py-0.5 text-[10px]",
|
||
|
|
style.bg, style.color
|
||
|
|
)}>
|
||
|
|
{getItemTypeLabel(node.child_item_type)}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 수량/단위 */}
|
||
|
|
<div className="flex flex-shrink-0 items-center gap-2 text-[11px]">
|
||
|
|
<span className="text-muted-foreground">
|
||
|
|
수량: <b className="text-foreground">{node.quantity || "0"}</b> {node.unit || ""}
|
||
|
|
</span>
|
||
|
|
{node.loss_rate && node.loss_rate !== "0" && (
|
||
|
|
<span className="text-amber-600">
|
||
|
|
로스: {node.loss_rate}%
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 하위 노드 재귀 렌더링 */}
|
||
|
|
{hasChildren && isExpanded && (
|
||
|
|
<div>
|
||
|
|
{node.children.map((child) => (
|
||
|
|
<TreeNodeRow
|
||
|
|
key={child.id}
|
||
|
|
node={child}
|
||
|
|
depth={depth + 1}
|
||
|
|
expandedNodes={expandedNodes}
|
||
|
|
selectedNodeId={selectedNodeId}
|
||
|
|
onToggle={onToggle}
|
||
|
|
onSelect={onSelect}
|
||
|
|
getItemTypeLabel={getItemTypeLabel}
|
||
|
|
getItemTypeStyle={getItemTypeStyle}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|