18cf5e3269
- Integrated BOM routes into the backend for managing BOM history and versions. - Enhanced the V2BomTreeConfigPanel to include options for history and version table management. - Updated the BomTreeComponent to support viewing BOM data in both tree and level formats, with modals for editing BOM details, viewing history, and managing versions. - Improved user interaction with new buttons for accessing BOM history and version management directly from the BOM tree view.
850 lines
33 KiB
TypeScript
850 lines
33 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
import {
|
|
ChevronRight,
|
|
ChevronDown,
|
|
Package,
|
|
Layers,
|
|
Box,
|
|
AlertCircle,
|
|
Expand,
|
|
Shrink,
|
|
Loader2,
|
|
History,
|
|
GitBranch,
|
|
Check,
|
|
} from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { entityJoinApi } from "@/lib/api/entityJoin";
|
|
import { Button } from "@/components/ui/button";
|
|
import { BomDetailEditModal } from "./BomDetailEditModal";
|
|
import { BomHistoryModal } from "./BomHistoryModal";
|
|
import { BomVersionModal } from "./BomVersionModal";
|
|
|
|
interface BomTreeNode {
|
|
id: string;
|
|
[key: string]: any;
|
|
children: BomTreeNode[];
|
|
}
|
|
|
|
interface BomHeaderInfo {
|
|
id: string;
|
|
[key: string]: any;
|
|
}
|
|
|
|
interface TreeColumnDef {
|
|
key: string;
|
|
title: string;
|
|
width?: string;
|
|
visible?: boolean;
|
|
hidden?: boolean;
|
|
isSourceDisplay?: boolean;
|
|
}
|
|
|
|
interface BomTreeComponentProps {
|
|
component?: any;
|
|
formData?: Record<string, any>;
|
|
tableName?: string;
|
|
companyCode?: string;
|
|
isDesignMode?: boolean;
|
|
selectedRowsData?: any[];
|
|
[key: string]: any;
|
|
}
|
|
|
|
// 컬럼은 설정 패널에서만 추가 (하드코딩 금지)
|
|
const EMPTY_COLUMNS: TreeColumnDef[] = [];
|
|
const INDENT_PX = 16;
|
|
|
|
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 [viewMode, setViewMode] = useState<"tree" | "level">("tree");
|
|
|
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
|
const [editTargetNode, setEditTargetNode] = useState<BomTreeNode | null>(null);
|
|
const [historyModalOpen, setHistoryModalOpen] = useState(false);
|
|
const [versionModalOpen, setVersionModalOpen] = useState(false);
|
|
const [colWidths, setColWidths] = useState<Record<string, number>>({});
|
|
|
|
const handleResizeStart = useCallback((colKey: string, e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const startX = e.clientX;
|
|
const th = (e.target as HTMLElement).closest("th");
|
|
const startWidth = th?.offsetWidth || 100;
|
|
const onMove = (ev: MouseEvent) => {
|
|
setColWidths((prev) => ({ ...prev, [colKey]: Math.max(40, startWidth + (ev.clientX - startX)) }));
|
|
};
|
|
const onUp = () => {
|
|
document.removeEventListener("mousemove", onMove);
|
|
document.removeEventListener("mouseup", onUp);
|
|
document.body.style.cursor = "";
|
|
document.body.style.userSelect = "";
|
|
};
|
|
document.body.style.cursor = "col-resize";
|
|
document.body.style.userSelect = "none";
|
|
document.addEventListener("mousemove", onMove);
|
|
document.addEventListener("mouseup", onUp);
|
|
}, []);
|
|
|
|
const config = component?.componentConfig || {};
|
|
const overrides = component?.overrides || {};
|
|
|
|
const selectedBomId = useMemo(() => {
|
|
if (selectedRowsData && selectedRowsData.length > 0) return selectedRowsData[0]?.id;
|
|
if (formData?.id) return formData.id;
|
|
return null;
|
|
}, [formData, selectedRowsData]);
|
|
|
|
const selectedHeaderData = useMemo(() => {
|
|
const raw = selectedRowsData?.[0] || (formData?.id ? formData : null);
|
|
if (!raw) return null;
|
|
return {
|
|
...raw,
|
|
item_name: raw.item_id_item_name || raw.item_name || "",
|
|
item_code: raw.item_id_item_number || raw.item_code || "",
|
|
item_type: raw.item_id_division || raw.item_id_type || raw.item_type || "",
|
|
} as BomHeaderInfo;
|
|
}, [formData, selectedRowsData]);
|
|
|
|
const detailTable = overrides.detailTable || config.detailTable || "bom_detail";
|
|
const foreignKey = overrides.foreignKey || config.foreignKey || "bom_id";
|
|
const parentKey = overrides.parentKey || config.parentKey || "parent_detail_id";
|
|
const sourceFk = config.dataSource?.foreignKey || "child_item_id";
|
|
const historyTable = config.historyTable || "bom_history";
|
|
const versionTable = config.versionTable || "bom_version";
|
|
|
|
const displayColumns = useMemo(() => {
|
|
const configured = config.columns as TreeColumnDef[] | undefined;
|
|
if (configured && configured.length > 0) return configured.filter((c) => !c.hidden);
|
|
return EMPTY_COLUMNS;
|
|
}, [config.columns]);
|
|
|
|
const features = config.features || {};
|
|
const showHistory = features.showHistory !== false;
|
|
const showVersion = features.showVersion !== false;
|
|
|
|
// ─── 데이터 로드 ───
|
|
|
|
// BOM 헤더 데이터로 가상 0레벨 루트 노드 생성
|
|
const buildVirtualRoot = useCallback((headerData: BomHeaderInfo | null, children: BomTreeNode[]): BomTreeNode | null => {
|
|
if (!headerData) return null;
|
|
return {
|
|
id: `__root_${headerData.id}`,
|
|
_isVirtualRoot: true,
|
|
level: "0",
|
|
child_item_name: headerData.item_name || "",
|
|
child_item_code: headerData.item_code || headerData.bom_number || "",
|
|
child_item_type: headerData.item_type || "",
|
|
item_name: headerData.item_name || "",
|
|
item_number: headerData.item_code || "",
|
|
quantity: "-",
|
|
base_qty: headerData.base_qty || "",
|
|
unit: headerData.unit || "",
|
|
revision: headerData.revision || "",
|
|
loss_rate: "",
|
|
process_type: "",
|
|
remark: headerData.remark || "",
|
|
children,
|
|
};
|
|
}, []);
|
|
|
|
const loadBomDetails = useCallback(async (bomId: string, headerData: BomHeaderInfo | null) => {
|
|
if (!bomId) return;
|
|
setLoading(true);
|
|
try {
|
|
const result = await entityJoinApi.getTableDataWithJoins(detailTable, {
|
|
page: 1,
|
|
size: 500,
|
|
search: { [foreignKey]: bomId },
|
|
sortBy: "seq_no",
|
|
sortOrder: "asc",
|
|
enableEntityJoin: true,
|
|
});
|
|
|
|
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}_`, "");
|
|
const aliasKey = `child_${shortKey}`;
|
|
if (!mapped[aliasKey]) mapped[aliasKey] = row[key];
|
|
if (!mapped[shortKey]) mapped[shortKey] = row[key];
|
|
}
|
|
}
|
|
mapped.child_item_name = row[`${sourceFk}_item_name`] || row.child_item_name || "";
|
|
mapped.child_item_code = row[`${sourceFk}_item_number`] || row.child_item_code || "";
|
|
mapped.child_item_type = row[`${sourceFk}_type`] || row[`${sourceFk}_division`] || row.child_item_type || "";
|
|
return mapped;
|
|
});
|
|
|
|
const detailTree = buildTree(rows);
|
|
|
|
// BOM 헤더를 가상 0레벨 루트로 삽입
|
|
const virtualRoot = buildVirtualRoot(headerData, detailTree);
|
|
if (virtualRoot) {
|
|
setTreeData([virtualRoot]);
|
|
setExpandedNodes(new Set([virtualRoot.id]));
|
|
} else {
|
|
setTreeData(detailTree);
|
|
const firstLevelIds = new Set<string>(detailTree.map((n: BomTreeNode) => n.id));
|
|
setExpandedNodes(firstLevelIds);
|
|
}
|
|
} catch (error) {
|
|
console.error("[BomTree] 데이터 로드 실패:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [detailTable, foreignKey, sourceFk, buildVirtualRoot]);
|
|
|
|
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[parentKey] && nodeMap.has(item[parentKey])) {
|
|
nodeMap.get(item[parentKey])!.children.push(node);
|
|
} else {
|
|
roots.push(node);
|
|
}
|
|
});
|
|
return roots;
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (selectedBomId) {
|
|
setHeaderInfo(selectedHeaderData);
|
|
loadBomDetails(selectedBomId, selectedHeaderData);
|
|
} 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) => {
|
|
const map: Record<string, string> = { product: "제품", semi: "반제품", material: "원자재", part: "부품" };
|
|
return map[type] || type || "-";
|
|
};
|
|
|
|
const getItemTypeBadge = (type: string) => {
|
|
const map: Record<string, string> = {
|
|
product: "bg-blue-50 text-blue-600 ring-blue-200",
|
|
semi: "bg-amber-50 text-amber-600 ring-amber-200",
|
|
material: "bg-emerald-50 text-emerald-600 ring-emerald-200",
|
|
part: "bg-purple-50 text-purple-600 ring-purple-200",
|
|
};
|
|
return map[type] || "bg-gray-50 text-gray-500 ring-gray-200";
|
|
};
|
|
|
|
const getItemIcon = (type: string) => {
|
|
const map: Record<string, any> = { product: Package, semi: Layers };
|
|
return map[type] || Box;
|
|
};
|
|
|
|
const getItemIconColor = (type: string) => {
|
|
const map: Record<string, string> = {
|
|
product: "text-blue-500",
|
|
semi: "text-amber-500",
|
|
material: "text-emerald-500",
|
|
part: "text-purple-500",
|
|
};
|
|
return map[type] || "text-gray-400";
|
|
};
|
|
|
|
// ─── 셀 렌더링 ───
|
|
|
|
const renderCellValue = (node: BomTreeNode, col: TreeColumnDef, depth: number) => {
|
|
const value = node[col.key];
|
|
|
|
if (col.key === "child_item_type" || col.key === "item_type") {
|
|
const label = getItemTypeLabel(String(value || ""));
|
|
return (
|
|
<span className={cn(
|
|
"inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 ring-inset",
|
|
getItemTypeBadge(String(value || "")),
|
|
)}>
|
|
{label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
if (col.key === "level") {
|
|
const displayLevel = node._isVirtualRoot ? 0 : depth;
|
|
return (
|
|
<span className="inline-flex h-5 w-5 items-center justify-center rounded bg-gray-100 text-[10px] font-medium text-gray-600">
|
|
{displayLevel}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
if (col.key === "child_item_code") {
|
|
return <span className="font-mono text-xs text-gray-700">{value || "-"}</span>;
|
|
}
|
|
|
|
if (col.key === "child_item_name") {
|
|
return <span className="font-medium text-gray-900">{value || "-"}</span>;
|
|
}
|
|
|
|
if (col.key === "quantity" || col.key === "base_qty") {
|
|
return (
|
|
<span className="font-medium tabular-nums text-gray-800">
|
|
{value != null && value !== "" && value !== "0" ? value : "-"}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
if (col.key === "loss_rate") {
|
|
const num = Number(value);
|
|
if (!num) return <span className="text-gray-300">-</span>;
|
|
return <span className="tabular-nums text-amber-600">{value}%</span>;
|
|
}
|
|
|
|
if (col.key === "revision") {
|
|
return (
|
|
<span className="tabular-nums text-gray-600">
|
|
{value != null && value !== "" && value !== "0" ? value : "-"}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
if (col.key === "unit") {
|
|
return <span className="text-gray-500">{value || "-"}</span>;
|
|
}
|
|
|
|
return <span className="text-gray-600">{value ?? "-"}</span>;
|
|
};
|
|
|
|
// ─── 디자인 모드 ───
|
|
|
|
if (isDesignMode) {
|
|
const configuredColumns = (config.columns || []).filter((c: TreeColumnDef) => !c.hidden);
|
|
|
|
return (
|
|
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-white shadow-sm">
|
|
<div className="flex items-center gap-2 border-b bg-gray-50/80 px-4 py-2.5">
|
|
<Package className="h-4 w-4 text-primary" />
|
|
<span className="text-sm font-semibold">BOM 트리 뷰</span>
|
|
<span className="rounded-md bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-500">{detailTable}</span>
|
|
{config.dataSource?.sourceTable && (
|
|
<span className="rounded-md bg-blue-50 px-1.5 py-0.5 text-[10px] text-blue-500">
|
|
{config.dataSource.sourceTable}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{configuredColumns.length === 0 ? (
|
|
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6">
|
|
<AlertCircle className="h-8 w-8 text-gray-200" />
|
|
<p className="text-sm font-medium text-gray-400">컬럼 미설정</p>
|
|
<p className="text-[11px] text-gray-300">설정 패널 > 컬럼 탭에서 표시할 컬럼을 선택하세요</p>
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 overflow-hidden">
|
|
<table className="w-full text-xs">
|
|
<thead>
|
|
<tr className="border-b bg-gray-50">
|
|
<th className="w-10 border-r border-gray-100 px-2 py-2"></th>
|
|
{configuredColumns.map((col: TreeColumnDef) => (
|
|
<th
|
|
key={col.key}
|
|
className="border-r border-gray-100 px-3 py-2 text-left text-[11px] font-semibold text-gray-600"
|
|
style={{ width: col.width }}
|
|
>
|
|
{col.title || col.key}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="text-gray-400">
|
|
<tr className="border-b bg-white">
|
|
<td className="border-r border-gray-50 px-2 py-2">
|
|
<ChevronDown className="h-3.5 w-3.5 text-gray-300" />
|
|
</td>
|
|
{configuredColumns.map((col: TreeColumnDef, i: number) => (
|
|
<td key={col.key} className="border-r border-gray-50 px-3 py-2">
|
|
{col.key === "level" ? "0" : col.key.includes("type") ? (
|
|
<span className="rounded-md bg-blue-50 px-1.5 py-0.5 text-[10px] text-blue-500 ring-1 ring-inset ring-blue-200">제품</span>
|
|
) : col.key.includes("quantity") || col.key.includes("qty") ? "30" : `예시${i + 1}`}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
<tr className="border-b bg-gray-50/30">
|
|
<td className="border-r border-gray-50 px-2 py-2 pl-7">
|
|
<span className="inline-block h-1 w-1 rounded-full bg-gray-300" />
|
|
</td>
|
|
{configuredColumns.map((col: TreeColumnDef, i: number) => (
|
|
<td key={col.key} className="border-r border-gray-50 px-3 py-2 text-gray-300">
|
|
{col.key === "level" ? "1" : col.key.includes("type") ? (
|
|
<span className="rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] text-amber-500 ring-1 ring-inset ring-amber-200">반제품</span>
|
|
) : col.key.includes("quantity") || col.key.includes("qty") ? "3" : `예시${i + 1}`}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 미선택 상태 ───
|
|
|
|
if (!selectedBomId) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center bg-gray-50/30">
|
|
<div className="text-center">
|
|
<Package className="mx-auto mb-3 h-10 w-10 text-gray-200" />
|
|
<p className="text-sm font-medium text-gray-400">BOM을 선택해주세요</p>
|
|
<p className="mt-1 text-xs text-gray-300">좌측 목록에서 BOM을 선택하면 구성이 표시됩니다</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 트리 평탄화 ───
|
|
|
|
const flattenedRows = useMemo(() => {
|
|
const rows: { node: BomTreeNode; depth: number }[] = [];
|
|
const traverse = (nodes: BomTreeNode[], depth: number) => {
|
|
for (const node of nodes) {
|
|
rows.push({ node, depth });
|
|
if (node.children.length > 0 && expandedNodes.has(node.id)) {
|
|
traverse(node.children, depth + 1);
|
|
}
|
|
}
|
|
};
|
|
traverse(treeData, 0);
|
|
return rows;
|
|
}, [treeData, expandedNodes]);
|
|
|
|
// 레벨 뷰용: 전체 노드 평탄화 (expand 상태 무관)
|
|
const allFlattenedRows = useMemo(() => {
|
|
const rows: { node: BomTreeNode; depth: number }[] = [];
|
|
const traverse = (nodes: BomTreeNode[], depth: number) => {
|
|
for (const node of nodes) {
|
|
rows.push({ node, depth });
|
|
if (node.children.length > 0) traverse(node.children, depth + 1);
|
|
}
|
|
};
|
|
traverse(treeData, 0);
|
|
return rows;
|
|
}, [treeData]);
|
|
|
|
const maxDepth = useMemo(() => {
|
|
return allFlattenedRows.reduce((max, r) => Math.max(max, r.depth), 0);
|
|
}, [allFlattenedRows]);
|
|
|
|
const visibleRows = viewMode === "level" ? allFlattenedRows : flattenedRows;
|
|
const levelColumnsForView = useMemo(() => {
|
|
return Array.from({ length: maxDepth + 1 }, (_, i) => i);
|
|
}, [maxDepth]);
|
|
|
|
// 레벨 뷰에서 "level" 컬럼을 제외한 데이터 컬럼
|
|
const dataColumnsForLevelView = useMemo(() => {
|
|
return displayColumns.filter((c) => c.key !== "level");
|
|
}, [displayColumns]);
|
|
|
|
// ─── 메인 렌더링 ───
|
|
|
|
return (
|
|
<div className="flex h-full flex-col bg-white">
|
|
{/* 헤더 정보 */}
|
|
{features.showHeader !== false && headerInfo && (
|
|
<div className="border-b px-5 py-3">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className={cn(
|
|
"flex h-8 w-8 items-center justify-center rounded-lg",
|
|
getItemTypeBadge(headerInfo.item_type).split(" ").slice(0, 1).join(" "),
|
|
)}>
|
|
<Package className={cn("h-4 w-4", getItemIconColor(headerInfo.item_type))} />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="truncate text-sm font-semibold text-gray-900">
|
|
{headerInfo.item_name || "-"}
|
|
</h3>
|
|
<span className={cn(
|
|
"inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 ring-inset",
|
|
getItemTypeBadge(headerInfo.item_type),
|
|
)}>
|
|
{getItemTypeLabel(headerInfo.item_type)}
|
|
</span>
|
|
<span className={cn(
|
|
"rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 ring-inset",
|
|
headerInfo.status === "active"
|
|
? "bg-emerald-50 text-emerald-600 ring-emerald-200"
|
|
: "bg-gray-50 text-gray-400 ring-gray-200",
|
|
)}>
|
|
{headerInfo.status === "active" ? "사용" : "미사용"}
|
|
</span>
|
|
</div>
|
|
<div className="mt-0.5 flex gap-3 text-[11px] text-gray-400">
|
|
<span>품목코드 <b className="text-gray-600">{headerInfo.item_code || "-"}</b></span>
|
|
<span>기준수량 <b className="text-gray-600">{headerInfo.base_qty || "1"}</b></span>
|
|
<span>버전 <b className="text-gray-600">v{headerInfo.version || "1"} (차수 {headerInfo.revision || "1"})</b></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 툴바 */}
|
|
<div className="flex items-center border-b bg-gray-50/50 px-5 py-1.5">
|
|
<span className="text-xs font-medium text-gray-500">BOM 구성</span>
|
|
<span className="ml-2 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold text-primary">
|
|
{allFlattenedRows.length}
|
|
</span>
|
|
<div className="ml-auto flex items-center gap-1">
|
|
{showHistory && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setHistoryModalOpen(true)}
|
|
className="h-6 gap-1 px-2 text-[10px]"
|
|
>
|
|
<History className="h-3 w-3" />
|
|
이력
|
|
</Button>
|
|
)}
|
|
{showVersion && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setVersionModalOpen(true)}
|
|
className="h-6 gap-1 px-2 text-[10px]"
|
|
>
|
|
<GitBranch className="h-3 w-3" />
|
|
버전
|
|
</Button>
|
|
)}
|
|
<div className="mx-1 h-4 w-px bg-gray-200" />
|
|
<div className="flex overflow-hidden rounded-md border">
|
|
<button
|
|
onClick={() => setViewMode("tree")}
|
|
className={cn(
|
|
"h-6 px-2 text-[10px] font-medium transition-colors",
|
|
viewMode === "tree"
|
|
? "bg-primary text-primary-foreground"
|
|
: "bg-white text-gray-500 hover:bg-gray-50",
|
|
)}
|
|
>
|
|
트리
|
|
</button>
|
|
<button
|
|
onClick={() => setViewMode("level")}
|
|
className={cn(
|
|
"h-6 border-l px-2 text-[10px] font-medium transition-colors",
|
|
viewMode === "level"
|
|
? "bg-primary text-primary-foreground"
|
|
: "bg-white text-gray-500 hover:bg-gray-50",
|
|
)}
|
|
>
|
|
레벨
|
|
</button>
|
|
</div>
|
|
{features.showExpandAll !== false && (
|
|
<div className={cn("flex gap-1", viewMode !== "tree" && "pointer-events-none invisible")}>
|
|
<Button variant="ghost" size="sm" onClick={expandAll} className="h-6 gap-1 px-2 text-[10px] text-gray-400 hover:text-gray-600">
|
|
<Expand className="h-3 w-3" />
|
|
정전개
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={collapseAll} className="h-6 gap-1 px-2 text-[10px] text-gray-400 hover:text-gray-600">
|
|
<Shrink className="h-3 w-3" />
|
|
역전개
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 테이블 */}
|
|
<div className="flex-1 overflow-auto">
|
|
{loading ? (
|
|
<div className="flex h-40 items-center justify-center">
|
|
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
|
</div>
|
|
) : displayColumns.length === 0 ? (
|
|
<div className="flex h-40 flex-col items-center justify-center gap-2">
|
|
<AlertCircle className="h-6 w-6 text-gray-200" />
|
|
<p className="text-xs text-gray-400">표시할 컬럼이 설정되지 않았습니다</p>
|
|
<p className="text-[10px] text-gray-300">디자인 모드에서 컬럼을 추가하세요</p>
|
|
</div>
|
|
) : treeData.length === 0 ? (
|
|
<div className="flex h-40 flex-col items-center justify-center gap-2">
|
|
<Box className="h-8 w-8 text-gray-200" />
|
|
<p className="text-xs text-gray-400">등록된 하위 품목이 없습니다</p>
|
|
</div>
|
|
) : viewMode === "level" ? (
|
|
/* ═══ 레벨 뷰 ═══ */
|
|
<table
|
|
className="w-full border-collapse text-xs"
|
|
style={{ minWidth: `${(maxDepth + 1) * 30 + dataColumnsForLevelView.length * 90}px` }}
|
|
>
|
|
<thead className="sticky top-0 z-10">
|
|
<tr className="border-b bg-gray-50">
|
|
{levelColumnsForView.map((lvl) => (
|
|
<th
|
|
key={`lv-${lvl}`}
|
|
className="whitespace-nowrap px-0.5 py-2.5 text-center text-[10px] font-semibold text-gray-500"
|
|
style={{ width: "30px", minWidth: "30px", maxWidth: "30px" }}
|
|
>
|
|
{lvl}
|
|
</th>
|
|
))}
|
|
{dataColumnsForLevelView.map((col) => {
|
|
const centered = ["quantity", "loss_rate", "base_qty", "revision", "seq_no"].includes(col.key);
|
|
const w = colWidths[col.key];
|
|
return (
|
|
<th
|
|
key={col.key}
|
|
className={cn(
|
|
"relative select-none whitespace-nowrap px-3 py-2.5 text-[11px] font-semibold text-gray-500",
|
|
centered ? "text-center" : "text-left",
|
|
)}
|
|
style={w ? { width: `${w}px` } : undefined}
|
|
>
|
|
<span className="truncate">{col.title}</span>
|
|
<div
|
|
className="absolute right-0 top-0 h-full w-1.5 cursor-col-resize hover:bg-primary/30"
|
|
onMouseDown={(e) => handleResizeStart(col.key, e)}
|
|
/>
|
|
</th>
|
|
);
|
|
})}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{allFlattenedRows.map(({ node, depth }, rowIdx) => {
|
|
const isRoot = !!node._isVirtualRoot;
|
|
const displayDepth = isRoot ? 0 : depth;
|
|
|
|
return (
|
|
<tr
|
|
key={node.id}
|
|
className={cn(
|
|
"cursor-pointer border-b transition-colors",
|
|
isRoot
|
|
? "border-gray-200 bg-blue-50/40 font-medium hover:bg-blue-50/60"
|
|
: selectedNodeId === node.id
|
|
? "border-gray-100 bg-primary/5"
|
|
: rowIdx % 2 === 0
|
|
? "border-gray-100 bg-white hover:bg-gray-50/80"
|
|
: "border-gray-100 bg-gray-50/30 hover:bg-gray-50/80",
|
|
)}
|
|
onClick={() => setSelectedNodeId(node.id)}
|
|
onDoubleClick={() => {
|
|
setEditTargetNode(node);
|
|
setEditModalOpen(true);
|
|
}}
|
|
>
|
|
{levelColumnsForView.map((lvl) => (
|
|
<td
|
|
key={`lv-${lvl}`}
|
|
className="py-2 text-center"
|
|
style={{ width: "30px", minWidth: "30px", maxWidth: "30px" }}
|
|
>
|
|
{displayDepth === lvl ? (
|
|
<Check className="mx-auto h-3.5 w-3.5 text-gray-700" />
|
|
) : null}
|
|
</td>
|
|
))}
|
|
{dataColumnsForLevelView.map((col) => {
|
|
const centered = ["quantity", "loss_rate", "base_qty", "revision", "seq_no"].includes(col.key);
|
|
return (
|
|
<td
|
|
key={col.key}
|
|
className={cn(
|
|
"overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2",
|
|
centered ? "text-center" : "text-left",
|
|
)}
|
|
>
|
|
{renderCellValue(node, col, depth)}
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
) : (
|
|
/* ═══ 트리 뷰 ═══ */
|
|
<table className="w-full border-collapse text-xs" style={{ minWidth: `${Math.max(52, maxDepth * INDENT_PX + 44) + displayColumns.length * 90}px` }}>
|
|
<thead className="sticky top-0 z-10">
|
|
<tr className="border-b bg-gray-50">
|
|
<th className="px-2 py-2.5" style={{ width: `${Math.max(52, maxDepth * INDENT_PX + 44)}px` }}></th>
|
|
{displayColumns.map((col) => {
|
|
const centered = ["quantity", "loss_rate", "level", "base_qty", "revision", "seq_no"].includes(col.key);
|
|
const w = colWidths[col.key];
|
|
return (
|
|
<th
|
|
key={col.key}
|
|
className={cn(
|
|
"relative select-none px-3 py-2.5 text-[11px] font-semibold text-gray-500",
|
|
centered ? "text-center" : "text-left",
|
|
)}
|
|
style={{ width: w ? `${w}px` : (col.width || "auto") }}
|
|
>
|
|
<span className="truncate">{col.title}</span>
|
|
<div
|
|
className="absolute right-0 top-0 h-full w-1.5 cursor-col-resize hover:bg-primary/30"
|
|
onMouseDown={(e) => handleResizeStart(col.key, e)}
|
|
/>
|
|
</th>
|
|
);
|
|
})}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{flattenedRows.map(({ node, depth }, rowIdx) => {
|
|
const hasChildren = node.children.length > 0;
|
|
const isExpanded = expandedNodes.has(node.id);
|
|
const isSelected = selectedNodeId === node.id;
|
|
const isRoot = !!node._isVirtualRoot;
|
|
const itemType = node.child_item_type || node.item_type || "";
|
|
const ItemIcon = getItemIcon(itemType);
|
|
|
|
return (
|
|
<tr
|
|
key={node.id}
|
|
className={cn(
|
|
"group cursor-pointer border-b transition-colors",
|
|
isRoot
|
|
? "border-gray-200 bg-blue-50/40 font-medium hover:bg-blue-50/60"
|
|
: isSelected
|
|
? "border-gray-100 bg-primary/5"
|
|
: rowIdx % 2 === 0
|
|
? "border-gray-100 bg-white hover:bg-gray-50/80"
|
|
: "border-gray-100 bg-gray-50/30 hover:bg-gray-50/80",
|
|
)}
|
|
onClick={() => {
|
|
setSelectedNodeId(node.id);
|
|
if (hasChildren) toggleNode(node.id);
|
|
}}
|
|
onDoubleClick={() => {
|
|
setEditTargetNode(node);
|
|
setEditModalOpen(true);
|
|
}}
|
|
>
|
|
<td className="px-1 py-2" style={{ paddingLeft: `${depth * INDENT_PX + 8}px` }}>
|
|
<div className="flex items-center gap-1">
|
|
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center">
|
|
{hasChildren ? (
|
|
isExpanded ? (
|
|
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform", isRoot ? "text-blue-500" : "text-gray-400")} />
|
|
) : (
|
|
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isRoot ? "text-blue-500" : "text-gray-400")} />
|
|
)
|
|
) : (
|
|
<span className="h-1 w-1 rounded-full bg-gray-300" />
|
|
)}
|
|
</span>
|
|
<span className={cn(
|
|
"flex h-5 w-5 items-center justify-center rounded",
|
|
getItemTypeBadge(itemType).split(" ").slice(0, 1).join(" "),
|
|
)}>
|
|
<ItemIcon className={cn(isRoot ? "h-3.5 w-3.5" : "h-3 w-3", getItemIconColor(itemType))} />
|
|
</span>
|
|
</div>
|
|
</td>
|
|
|
|
{displayColumns.map((col) => {
|
|
const centered = ["quantity", "loss_rate", "level", "base_qty", "revision", "seq_no"].includes(col.key);
|
|
return (
|
|
<td
|
|
key={col.key}
|
|
className={cn(
|
|
"overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2",
|
|
centered ? "text-center" : "text-left",
|
|
)}
|
|
>
|
|
{renderCellValue(node, col, depth)}
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
|
|
{/* 품목 수정 모달 */}
|
|
<BomDetailEditModal
|
|
open={editModalOpen}
|
|
onOpenChange={setEditModalOpen}
|
|
node={editTargetNode}
|
|
isRootNode={!!editTargetNode?._isVirtualRoot}
|
|
tableName={detailTable}
|
|
onSaved={() => {
|
|
if (selectedBomId) loadBomDetails(selectedBomId, selectedHeaderData);
|
|
}}
|
|
/>
|
|
|
|
{showHistory && (
|
|
<BomHistoryModal
|
|
open={historyModalOpen}
|
|
onOpenChange={setHistoryModalOpen}
|
|
bomId={selectedBomId}
|
|
tableName={historyTable}
|
|
/>
|
|
)}
|
|
|
|
{showVersion && (
|
|
<BomVersionModal
|
|
open={versionModalOpen}
|
|
onOpenChange={setVersionModalOpen}
|
|
bomId={selectedBomId}
|
|
tableName={versionTable}
|
|
detailTable={detailTable}
|
|
onVersionLoaded={() => {
|
|
if (selectedBomId) loadBomDetails(selectedBomId, selectedHeaderData);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default BomTreeComponent;
|