Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node
This commit is contained in:
@@ -0,0 +1,448 @@
|
||||
"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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user