- Implemented a new endpoint to initialize BOM versions, automatically creating the first version and updating related details. - Enhanced the BOM service to include logic for version name handling and duplication checks during version creation. - Updated the BOM controller to support the new initialization functionality, improving BOM management capabilities. - Improved the BOM version modal to allow users to specify version names during creation, enhancing user experience and flexibility.
1144 lines
45 KiB
TypeScript
1144 lines
45 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 { apiClient } from "@/lib/api/client";
|
|
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;
|
|
|
|
// 카테고리 라벨 캐시 (process_type 등)
|
|
const [categoryLabels, setCategoryLabels] = useState<Record<string, Record<string, string>>>({});
|
|
useEffect(() => {
|
|
const loadLabels = async () => {
|
|
try {
|
|
const res = await apiClient.get(`/table-categories/${detailTable}/process_type/values`);
|
|
const vals = res.data?.data || [];
|
|
if (vals.length > 0) {
|
|
const map: Record<string, string> = {};
|
|
vals.forEach((v: any) => { map[v.value_code] = v.value_label; });
|
|
setCategoryLabels((prev) => ({ ...prev, process_type: map }));
|
|
}
|
|
} catch { /* 무시 */ }
|
|
};
|
|
loadLabels();
|
|
}, [detailTable]);
|
|
|
|
// ─── 데이터 로드 ───
|
|
|
|
// 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 searchFilter: Record<string, any> = { [foreignKey]: bomId };
|
|
let versionId = headerData?.current_version_id;
|
|
|
|
// version_id가 없으면 서버에서 자동 초기화
|
|
if (!versionId) {
|
|
try {
|
|
const initRes = await apiClient.post(`/bom/${bomId}/initialize-version`);
|
|
if (initRes.data?.success && initRes.data.data?.versionId) {
|
|
versionId = initRes.data.data.versionId;
|
|
}
|
|
} catch { /* 무시 */ }
|
|
}
|
|
|
|
if (versionId) {
|
|
searchFilter.version_id = versionId;
|
|
}
|
|
|
|
// autoFilter 비활성화: BOM 전용 API로 company_code 관리하므로 autoFilter 불필요
|
|
const res = await apiClient.get(`/table-management/tables/${detailTable}/data-with-joins`, {
|
|
params: {
|
|
page: 1,
|
|
size: 500,
|
|
search: JSON.stringify(searchFilter),
|
|
sortBy: "seq_no",
|
|
sortOrder: "asc",
|
|
enableEntityJoin: true,
|
|
autoFilter: JSON.stringify({ enabled: false }),
|
|
},
|
|
});
|
|
|
|
const rawData = res.data?.data?.data || res.data?.data || [];
|
|
const rows = (Array.isArray(rawData) ? rawData : []).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);
|
|
|
|
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;
|
|
};
|
|
|
|
// BOM 전용 API로 최신 current_version_id 조회 (company_code 필터 무관)
|
|
const fetchCurrentVersionId = useCallback(async (bomId: string): Promise<string | null> => {
|
|
try {
|
|
const res = await apiClient.get(`/bom/${bomId}/versions`);
|
|
if (res.data?.success) {
|
|
if (res.data.currentVersionId) return res.data.currentVersionId;
|
|
const activeVersion = res.data.data?.find((v: any) => v.status === "active");
|
|
return activeVersion?.id || null;
|
|
}
|
|
} catch (e) {
|
|
console.error("[BomTree] active 버전 조회 실패:", e);
|
|
}
|
|
return null;
|
|
}, []);
|
|
|
|
// BOM 전용 헤더 API로 최신 데이터 조회 (autoFilter 영향 없음)
|
|
const fetchBomHeader = useCallback(async (bomId: string): Promise<BomHeaderInfo | null> => {
|
|
try {
|
|
const res = await apiClient.get(`/bom/${bomId}/header`);
|
|
if (res.data?.success && res.data.data) {
|
|
const raw = res.data.data;
|
|
return {
|
|
...raw,
|
|
id: raw.id,
|
|
item_name: raw.item_name || "",
|
|
item_code: raw.item_number || raw.item_code || "",
|
|
item_type: raw.item_type || raw.division || "",
|
|
} as BomHeaderInfo;
|
|
}
|
|
} catch (e) {
|
|
console.error("[BomTree] BOM 헤더 API 조회 실패:", e);
|
|
}
|
|
return null;
|
|
}, []);
|
|
|
|
// BOM 선택 시 전용 API로 헤더 + 디테일 로드
|
|
const loadingBomIdRef = React.useRef<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!selectedBomId) {
|
|
setHeaderInfo(null);
|
|
setTreeData([]);
|
|
loadingBomIdRef.current = null;
|
|
return;
|
|
}
|
|
|
|
// 현재 요청 ID로 stale 응답 필터링 (React StrictMode 호환)
|
|
const requestId = selectedBomId;
|
|
loadingBomIdRef.current = requestId;
|
|
|
|
const load = async () => {
|
|
let header = await fetchBomHeader(requestId);
|
|
if (!header && selectedHeaderData) {
|
|
header = { ...selectedHeaderData, id: requestId } as BomHeaderInfo;
|
|
const freshVersionId = await fetchCurrentVersionId(requestId);
|
|
if (freshVersionId) header.current_version_id = freshVersionId;
|
|
}
|
|
// stale 응답 무시: 다른 BOM이 선택됐거나 useEffect가 다시 실행된 경우
|
|
if (loadingBomIdRef.current !== requestId || !header) return;
|
|
|
|
setHeaderInfo(header);
|
|
loadBomDetails(requestId, header);
|
|
};
|
|
load();
|
|
}, [selectedBomId, selectedHeaderData, loadBomDetails, fetchBomHeader, fetchCurrentVersionId]);
|
|
|
|
// refreshTable 이벤트 수신 시 BOM 헤더 + 디테일 최신 데이터로 갱신
|
|
useEffect(() => {
|
|
const handleRefresh = async () => {
|
|
if (!selectedBomId) return;
|
|
try {
|
|
let header = await fetchBomHeader(selectedBomId);
|
|
if (!header && headerInfo) {
|
|
// API 실패 시 현재 headerInfo + 최신 version_id로 fallback
|
|
const freshVersionId = await fetchCurrentVersionId(selectedBomId);
|
|
header = { ...headerInfo, current_version_id: freshVersionId || headerInfo.current_version_id };
|
|
}
|
|
if (header) {
|
|
setHeaderInfo(header);
|
|
loadBomDetails(selectedBomId, header);
|
|
}
|
|
} catch (e) {
|
|
console.error("[BomTree] refreshTable 헤더 갱신 실패:", e);
|
|
}
|
|
};
|
|
|
|
window.addEventListener("refreshTable", handleRefresh);
|
|
return () => window.removeEventListener("refreshTable", handleRefresh);
|
|
}, [selectedBomId, loadBomDetails, fetchBomHeader, fetchCurrentVersionId, headerInfo]);
|
|
|
|
// EditModal 열릴 때 editData를 최신 headerInfo로 보정 (버전/마스터 데이터 stale 방지)
|
|
useEffect(() => {
|
|
const handler = (e: Event) => {
|
|
const detail = (e as CustomEvent).detail;
|
|
if (!detail?.editData || !headerInfo) return;
|
|
const editId = String(detail.editData.id || "");
|
|
const bomId = String(selectedBomId || "");
|
|
if (editId !== bomId) return;
|
|
|
|
console.log("[BomTree] openEditModal 가로채기 - editData 보정", {
|
|
oldVersion: detail.editData.version,
|
|
newVersion: headerInfo.version,
|
|
oldCurrentVersionId: detail.editData.current_version_id,
|
|
newCurrentVersionId: headerInfo.current_version_id,
|
|
});
|
|
|
|
// headerInfo의 모든 필드를 editData에 덮어쓰기 (최신 서버 데이터 보장)
|
|
Object.keys(headerInfo).forEach((key) => {
|
|
if ((headerInfo as any)[key] !== undefined && (headerInfo as any)[key] !== null) {
|
|
detail.editData[key] = (headerInfo as any)[key];
|
|
}
|
|
});
|
|
};
|
|
// capture: true → EditModal 리스너(bubble)보다 반드시 먼저 실행
|
|
window.addEventListener("openEditModal", handler, true);
|
|
return () => window.removeEventListener("openEditModal", handler, true);
|
|
}, [selectedBomId, headerInfo]);
|
|
|
|
// EditModal 저장 시 version 값을 현재 headerInfo 기준으로 보정
|
|
useEffect(() => {
|
|
const handler = (e: Event) => {
|
|
const detail = (e as CustomEvent).detail;
|
|
if (detail?.formData && detail.formData.id === selectedBomId && headerInfo) {
|
|
if (headerInfo.version && detail.formData.version !== headerInfo.version) {
|
|
console.log("[BomTree] formData.version 보정:", detail.formData.version, "→", headerInfo.version);
|
|
detail.formData.version = headerInfo.version;
|
|
}
|
|
if (headerInfo.revision && detail.formData.revision !== headerInfo.revision) {
|
|
detail.formData.revision = headerInfo.revision;
|
|
}
|
|
}
|
|
};
|
|
window.addEventListener("beforeFormSave", handler);
|
|
return () => window.removeEventListener("beforeFormSave", handler);
|
|
}, [selectedBomId, headerInfo]);
|
|
|
|
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 === "status") {
|
|
const statusMap: Record<string, string> = { active: "사용", inactive: "미사용", developing: "개발중" };
|
|
return <span>{statusMap[String(value)] || 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 === "process_type" && value) {
|
|
const label = categoryLabels.process_type?.[String(value)] || String(value);
|
|
return <span>{label}</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);
|
|
|
|
const previewSampleValue = (col: TreeColumnDef, rowIdx: number): React.ReactNode => {
|
|
if (col.key === "level") return rowIdx === 0 ? "0" : "1";
|
|
if (col.key.includes("type") || col.key.includes("division")) {
|
|
const badge = rowIdx === 0
|
|
? { bg: "bg-blue-50 text-blue-500 ring-blue-200", label: "제품" }
|
|
: { bg: "bg-amber-50 text-amber-500 ring-amber-200", label: "반제품" };
|
|
return (
|
|
<span className={cn("rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 ring-inset", badge.bg)}>
|
|
{badge.label}
|
|
</span>
|
|
);
|
|
}
|
|
if (col.key.includes("quantity") || col.key.includes("qty")) return rowIdx === 0 ? "30" : "3";
|
|
if (col.key.includes("unit")) return "EA";
|
|
if (col.key.includes("process")) {
|
|
const badge = rowIdx === 0
|
|
? { bg: "bg-emerald-50 text-emerald-600 ring-emerald-200", label: "제조" }
|
|
: { bg: "bg-purple-50 text-purple-600 ring-purple-200", label: "외주" };
|
|
return (
|
|
<span className={cn("rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 ring-inset", badge.bg)}>
|
|
{badge.label}
|
|
</span>
|
|
);
|
|
}
|
|
return `예시${rowIdx + 1}`;
|
|
};
|
|
|
|
return (
|
|
<div className="flex h-full flex-col bg-white">
|
|
{/* 헤더 (실제 화면과 동일 구조) */}
|
|
<div className="border-b px-5 py-3">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50">
|
|
<Package className="h-4 w-4 text-blue-500" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="text-sm font-semibold text-gray-400">BOM 상세정보</h3>
|
|
<span className="inline-flex items-center rounded-md bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-500 ring-1 ring-inset ring-blue-200">
|
|
제품
|
|
</span>
|
|
</div>
|
|
<div className="mt-0.5 flex gap-3 text-[10px] text-gray-300">
|
|
<span>품목코드 <b className="text-gray-400">SAMPLE-001</b></span>
|
|
<span>기준수량 <b className="text-gray-400">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">2</span>
|
|
<div className="ml-auto flex items-center gap-1">
|
|
{showHistory && (
|
|
<Button variant="outline" size="sm" disabled className="h-6 gap-1 px-2 text-[10px]">
|
|
<History className="h-3 w-3" />
|
|
이력
|
|
</Button>
|
|
)}
|
|
{showVersion && (
|
|
<Button variant="outline" size="sm" disabled 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">
|
|
<span className="h-6 bg-primary px-2 text-[10px] font-medium leading-6 text-primary-foreground">트리</span>
|
|
<span className="h-6 border-l bg-white px-2 text-[10px] font-medium leading-6 text-gray-500">레벨</span>
|
|
</div>
|
|
{features.showExpandAll !== false && (
|
|
<div className="flex gap-1">
|
|
<Button variant="ghost" size="sm" disabled className="h-6 gap-1 px-2 text-[10px] text-gray-400">
|
|
<Expand className="h-3 w-3" /> 정전개
|
|
</Button>
|
|
<Button variant="ghost" size="sm" disabled className="h-6 gap-1 px-2 text-[10px] text-gray-400">
|
|
<Shrink className="h-3 w-3" /> 역전개
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</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-auto">
|
|
<table className="w-full border-collapse text-xs">
|
|
<thead className="sticky top-0 z-10">
|
|
<tr className="border-b bg-gray-50">
|
|
<th className="px-2 py-2.5 text-center" style={{ width: "52px" }}></th>
|
|
{configuredColumns.map((col: TreeColumnDef) => {
|
|
const centered = ["quantity", "loss_rate", "base_qty", "revision", "seq_no", "unit", "level"].includes(col.key)
|
|
|| col.key.includes("qty") || col.key.includes("quantity");
|
|
return (
|
|
<th
|
|
key={col.key}
|
|
className={cn(
|
|
"px-3 py-2.5 text-[11px] font-semibold text-gray-500",
|
|
centered ? "text-center" : "text-left",
|
|
)}
|
|
>
|
|
{col.title || col.key}
|
|
</th>
|
|
);
|
|
})}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{/* 0레벨 루트 */}
|
|
<tr className="border-b hover:bg-gray-50/50">
|
|
<td className="px-2 py-2 text-center">
|
|
<ChevronDown className="inline h-3.5 w-3.5 text-gray-400" />
|
|
</td>
|
|
{configuredColumns.map((col: TreeColumnDef) => {
|
|
const centered = ["quantity", "loss_rate", "base_qty", "revision", "seq_no", "unit", "level"].includes(col.key)
|
|
|| col.key.includes("qty") || col.key.includes("quantity");
|
|
return (
|
|
<td key={col.key} className={cn("px-3 py-2 text-gray-600", centered && "text-center")}>
|
|
{previewSampleValue(col, 0)}
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
{/* 1레벨 자식 */}
|
|
<tr className="border-b hover:bg-gray-50/50">
|
|
<td className="px-2 py-2 text-center" style={{ paddingLeft: `${INDENT_PX + 8}px` }}>
|
|
<Layers className="inline h-3.5 w-3.5 text-gray-300" />
|
|
</td>
|
|
{configuredColumns.map((col: TreeColumnDef) => {
|
|
const centered = ["quantity", "loss_rate", "base_qty", "revision", "seq_no", "unit", "level"].includes(col.key)
|
|
|| col.key.includes("qty") || col.key.includes("quantity");
|
|
return (
|
|
<td key={col.key} className={cn("px-3 py-2 text-gray-400", centered && "text-center")}>
|
|
{previewSampleValue(col, 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]);
|
|
|
|
// 트리/레벨 뷰 전환 시 데이터 열 위치 고정을 위한 공통 접두 영역 너비
|
|
const prefixAreaWidth = useMemo(() => {
|
|
const treeIconWidth = Math.max(52, maxDepth * INDENT_PX + 44);
|
|
const levelColsWidth = (maxDepth + 1) * 30;
|
|
return Math.max(treeIconWidth, levelColsWidth);
|
|
}, [maxDepth]);
|
|
|
|
// ─── 메인 렌더링 ───
|
|
|
|
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: `${prefixAreaWidth + dataColumnsForLevelView.length * 90}px` }}
|
|
>
|
|
<colgroup>
|
|
{levelColumnsForView.map((lvl) => {
|
|
const eachWidth = Math.floor(prefixAreaWidth / levelColumnsForView.length);
|
|
return <col key={`lv-col-${lvl}`} style={{ width: `${eachWidth}px`, minWidth: `${eachWidth}px` }} />;
|
|
})}
|
|
{dataColumnsForLevelView.map((col) => (
|
|
<col key={`dc-col-${col.key}`} style={{ width: colWidths[col.key] ? `${colWidths[col.key]}px` : undefined }} />
|
|
))}
|
|
</colgroup>
|
|
<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"
|
|
>
|
|
{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;
|
|
|
|
const lvlDepthBg = isRoot
|
|
? "border-gray-200 bg-blue-50/50 font-medium hover:bg-blue-50/70"
|
|
: selectedNodeId === node.id
|
|
? "border-gray-100 bg-primary/5"
|
|
: depth === 1
|
|
? "border-gray-100 bg-white hover:bg-gray-50/60"
|
|
: depth === 2
|
|
? "border-gray-100 bg-gray-50/40 hover:bg-gray-100/50"
|
|
: depth >= 3
|
|
? "border-gray-100 bg-gray-100/40 hover:bg-gray-100/60"
|
|
: "border-gray-100 bg-white hover:bg-gray-50/60";
|
|
|
|
return (
|
|
<tr
|
|
key={node.id}
|
|
className={cn("cursor-pointer border-b transition-colors", lvlDepthBg)}
|
|
onClick={() => setSelectedNodeId(node.id)}
|
|
onDoubleClick={() => {
|
|
setEditTargetNode(node);
|
|
setEditModalOpen(true);
|
|
}}
|
|
>
|
|
{levelColumnsForView.map((lvl) => (
|
|
<td
|
|
key={`lv-${lvl}`}
|
|
className="py-2 text-center"
|
|
>
|
|
{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: `${prefixAreaWidth + displayColumns.length * 90}px` }}>
|
|
<colgroup>
|
|
<col style={{ width: `${prefixAreaWidth}px`, minWidth: `${prefixAreaWidth}px` }} />
|
|
{displayColumns.map((col) => (
|
|
<col key={`tc-col-${col.key}`} style={{ width: colWidths[col.key] ? `${colWidths[col.key]}px` : undefined }} />
|
|
))}
|
|
</colgroup>
|
|
<thead className="sticky top-0 z-10">
|
|
<tr className="border-b bg-gray-50">
|
|
<th className="px-2 py-2.5"></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);
|
|
|
|
const depthBg = isRoot
|
|
? "border-gray-200 bg-blue-50/50 font-medium hover:bg-blue-50/70"
|
|
: isSelected
|
|
? "border-gray-100 bg-primary/5"
|
|
: depth === 1
|
|
? "border-gray-100 bg-white hover:bg-gray-50/60"
|
|
: depth === 2
|
|
? "border-gray-100 bg-gray-50/40 hover:bg-gray-100/50"
|
|
: depth >= 3
|
|
? "border-gray-100 bg-gray-100/40 hover:bg-gray-100/60"
|
|
: "border-gray-100 bg-white hover:bg-gray-50/60";
|
|
|
|
const depthBarColor = isRoot
|
|
? "bg-blue-400"
|
|
: depth === 1 ? "bg-emerald-400"
|
|
: depth === 2 ? "bg-amber-400"
|
|
: depth >= 3 ? "bg-purple-400" : "bg-gray-300";
|
|
|
|
return (
|
|
<tr
|
|
key={node.id}
|
|
className={cn("group cursor-pointer border-b transition-colors", depthBg)}
|
|
onClick={() => {
|
|
setSelectedNodeId(node.id);
|
|
if (hasChildren) toggleNode(node.id);
|
|
}}
|
|
onDoubleClick={() => {
|
|
setEditTargetNode(node);
|
|
setEditModalOpen(true);
|
|
}}
|
|
>
|
|
<td className="relative px-1 py-2" style={{ paddingLeft: `${depth * INDENT_PX + 8}px` }}>
|
|
<div className={cn("absolute left-0 top-0 h-full w-[3px]", depthBarColor)} />
|
|
<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, headerInfo);
|
|
}}
|
|
/>
|
|
|
|
{showHistory && (
|
|
<BomHistoryModal
|
|
open={historyModalOpen}
|
|
onOpenChange={setHistoryModalOpen}
|
|
bomId={selectedBomId}
|
|
tableName={historyTable}
|
|
/>
|
|
)}
|
|
|
|
{showVersion && (
|
|
<BomVersionModal
|
|
open={versionModalOpen}
|
|
onOpenChange={setVersionModalOpen}
|
|
bomId={selectedBomId}
|
|
tableName={versionTable}
|
|
detailTable={detailTable}
|
|
onVersionLoaded={() => {
|
|
window.dispatchEvent(new CustomEvent("refreshTable"));
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default BomTreeComponent;
|