feat: Add BOM management features and enhance BOM tree component

- 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.
This commit is contained in:
DDD1542
2026-02-25 14:50:51 +09:00
parent ed9e36c213
commit 18cf5e3269
9 changed files with 1311 additions and 21 deletions

View File

@@ -0,0 +1,212 @@
"use client";
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Loader2 } from "lucide-react";
import apiClient from "@/lib/api/client";
interface BomDetailEditModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
node: Record<string, any> | null;
isRootNode?: boolean;
tableName: string;
onSaved?: () => void;
}
export function BomDetailEditModal({
open,
onOpenChange,
node,
isRootNode = false,
tableName,
onSaved,
}: BomDetailEditModalProps) {
const [formData, setFormData] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
useEffect(() => {
if (node && open) {
if (isRootNode) {
setFormData({
base_qty: node.base_qty || "",
unit: node.unit || "",
remark: node.remark || "",
});
} else {
setFormData({
quantity: node.quantity || "",
unit: node.unit || node.detail_unit || "",
process_type: node.process_type || "",
base_qty: node.base_qty || "",
loss_rate: node.loss_rate || "",
remark: node.remark || "",
});
}
}
}, [node, open, isRootNode]);
const handleChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSave = async () => {
if (!node) return;
setSaving(true);
try {
const targetTable = isRootNode ? "bom" : tableName;
const realId = isRootNode ? node.id?.replace("__root_", "") : node.id;
await apiClient.put(`/table-management/tables/${targetTable}/${realId}`, formData);
onSaved?.();
onOpenChange(false);
} catch (error) {
console.error("[BomDetailEdit] 저장 실패:", error);
} finally {
setSaving(false);
}
};
if (!node) return null;
const itemCode = isRootNode
? node.child_item_code || node.item_code || node.bom_number || "-"
: node.child_item_code || "-";
const itemName = isRootNode
? node.child_item_name || node.item_name || "-"
: node.child_item_name || "-";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{isRootNode ? "BOM 헤더 수정" : "품목 수정"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{isRootNode
? "BOM 기본 정보를 수정합니다"
: "선택한 품목의 BOM 구성 정보를 수정합니다"}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input value={itemCode} disabled className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" />
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input value={itemName} disabled className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs sm:text-sm">
{isRootNode ? "기준수량" : "구성수량"} *
</Label>
<Input
type="number"
value={isRootNode ? formData.base_qty : formData.quantity}
onChange={(e) => handleChange(isRootNode ? "base_qty" : "quantity", e.target.value)}
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={formData.unit}
onChange={(e) => handleChange("unit", e.target.value)}
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
{!isRootNode && (
<>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={formData.process_type}
onChange={(e) => handleChange("process_type", e.target.value)}
placeholder="예: 조립공정"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"> (%)</Label>
<Input
type="number"
value={formData.loss_rate}
onChange={(e) => handleChange("loss_rate", e.target.value)}
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={node.child_specification || node.specification || "-"}
disabled
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={node.child_material || node.material || "-"}
disabled
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
</>
)}
<div>
<Label className="text-xs sm:text-sm"></Label>
<Textarea
value={formData.remark}
onChange={(e) => handleChange("remark", e.target.value)}
placeholder="비고 사항을 입력하세요"
className="mt-1 min-h-[60px] text-xs sm:text-sm"
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSave}
disabled={saving}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,147 @@
"use client";
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import apiClient from "@/lib/api/client";
interface BomHistoryItem {
id: string;
revision: string;
version: string;
change_type: string;
change_description: string;
changed_by: string;
changed_date: string;
}
interface BomHistoryModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
bomId: string | null;
tableName?: string;
}
const CHANGE_TYPE_STYLE: Record<string, string> = {
"등록": "bg-blue-50 text-blue-600 ring-blue-200",
"수정": "bg-amber-50 text-amber-600 ring-amber-200",
"추가": "bg-emerald-50 text-emerald-600 ring-emerald-200",
"삭제": "bg-red-50 text-red-600 ring-red-200",
};
export function BomHistoryModal({ open, onOpenChange, bomId, tableName = "bom_history" }: BomHistoryModalProps) {
const [history, setHistory] = useState<BomHistoryItem[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (open && bomId) {
loadHistory();
}
}, [open, bomId]);
const loadHistory = async () => {
if (!bomId) return;
setLoading(true);
try {
const res = await apiClient.get(`/bom/${bomId}/history`, { params: { tableName } });
if (res.data?.success) {
setHistory(res.data.data || []);
}
} catch (error) {
console.error("[BomHistory] 로드 실패:", error);
} finally {
setLoading(false);
}
};
const formatDate = (dateStr: string) => {
if (!dateStr) return "-";
try {
return new Date(dateStr).toLocaleString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return dateStr;
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[650px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
BOM
</DialogDescription>
</DialogHeader>
<div className="max-h-[400px] overflow-auto">
{loading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
</div>
) : history.length === 0 ? (
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-gray-400"> </p>
</div>
) : (
<table className="w-full text-xs">
<thead className="sticky top-0 bg-gray-50">
<tr className="border-b">
<th className="px-3 py-2.5 text-center text-[11px] font-semibold text-gray-500" style={{ width: "50px" }}></th>
<th className="px-3 py-2.5 text-center text-[11px] font-semibold text-gray-500" style={{ width: "50px" }}></th>
<th className="px-3 py-2.5 text-center text-[11px] font-semibold text-gray-500" style={{ width: "70px" }}></th>
<th className="px-3 py-2.5 text-left text-[11px] font-semibold text-gray-500"></th>
<th className="px-3 py-2.5 text-center text-[11px] font-semibold text-gray-500" style={{ width: "70px" }}></th>
<th className="px-3 py-2.5 text-center text-[11px] font-semibold text-gray-500" style={{ width: "130px" }}></th>
</tr>
</thead>
<tbody>
{history.map((item, idx) => (
<tr key={item.id} className={cn("border-b border-gray-100", idx % 2 === 0 ? "bg-white" : "bg-gray-50/30")}>
<td className="px-3 py-2.5 text-center tabular-nums">{item.revision || "-"}</td>
<td className="px-3 py-2.5 text-center tabular-nums">{item.version || "-"}</td>
<td className="px-3 py-2.5 text-center">
<span className={cn(
"inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 ring-inset",
CHANGE_TYPE_STYLE[item.change_type] || "bg-gray-50 text-gray-500 ring-gray-200",
)}>
{item.change_type}
</span>
</td>
<td className="px-3 py-2.5 text-gray-700">{item.change_description || "-"}</td>
<td className="px-3 py-2.5 text-center text-gray-600">{item.changed_by || "-"}</td>
<td className="px-3 py-2.5 text-center text-gray-400">{formatDate(item.changed_date)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -11,10 +11,16 @@ import {
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;
@@ -48,6 +54,7 @@ interface BomTreeComponentProps {
// 컬럼은 설정 패널에서만 추가 (하드코딩 금지)
const EMPTY_COLUMNS: TreeColumnDef[] = [];
const INDENT_PX = 16;
export function BomTreeComponent({
component,
@@ -63,6 +70,35 @@ export function BomTreeComponent({
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 || {};
@@ -87,6 +123,8 @@ export function BomTreeComponent({
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;
@@ -95,6 +133,8 @@ export function BomTreeComponent({
}, [config.columns]);
const features = config.features || {};
const showHistory = features.showHistory !== false;
const showVersion = features.showVersion !== false;
// ─── 데이터 로드 ───
@@ -267,9 +307,10 @@ export function BomTreeComponent({
}
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">
{value ?? depth}
{displayLevel}
</span>
);
}
@@ -415,6 +456,33 @@ export function BomTreeComponent({
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 (
@@ -463,20 +531,69 @@ export function BomTreeComponent({
<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">
{flattenedRows.length}
{allFlattenedRows.length}
</span>
{features.showExpandAll !== false && (
<div className="ml-auto flex gap-1">
<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" />
<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>
<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" />
)}
{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>
{/* 테이블 */}
@@ -496,23 +613,122 @@ export function BomTreeComponent({
<Box className="h-8 w-8 text-gray-200" />
<p className="text-xs text-gray-400"> </p>
</div>
) : (
<table className="w-full border-collapse text-xs">
) : 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">
<th className="w-10 px-2 py-2.5"></th>
{displayColumns.map((col) => {
const centered = ["quantity", "loss_rate", "level", "base_qty", "revision", "seq_no"].includes(col.key);
{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(
"px-3 py-2.5 text-[11px] font-semibold text-gray-500",
"relative select-none whitespace-nowrap px-3 py-2.5 text-[11px] font-semibold text-gray-500",
centered ? "text-center" : "text-left",
)}
style={{ width: col.width || "auto" }}
style={w ? { width: `${w}px` } : undefined}
>
{col.title}
<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>
);
})}
@@ -544,8 +760,12 @@ export function BomTreeComponent({
setSelectedNodeId(node.id);
if (hasChildren) toggleNode(node.id);
}}
onDoubleClick={() => {
setEditTargetNode(node);
setEditModalOpen(true);
}}
>
<td className="px-1 py-2" style={{ paddingLeft: `${depth * 20 + 8}px` }}>
<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 ? (
@@ -573,7 +793,7 @@ export function BomTreeComponent({
<td
key={col.key}
className={cn(
"px-3 py-2",
"overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2",
centered ? "text-center" : "text-left",
)}
>
@@ -588,6 +808,40 @@ export function BomTreeComponent({
</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>
);
}

View File

@@ -0,0 +1,221 @@
"use client";
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Loader2, Plus, Trash2, Download } from "lucide-react";
import { cn } from "@/lib/utils";
import apiClient from "@/lib/api/client";
interface BomVersion {
id: string;
version_name: string;
revision: number;
status: string;
created_by: string;
created_date: string;
}
interface BomVersionModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
bomId: string | null;
tableName?: string;
detailTable?: string;
onVersionLoaded?: () => void;
}
const STATUS_STYLE: Record<string, { label: string; className: string }> = {
developing: { label: "개발중", className: "bg-red-50 text-red-600 ring-red-200" },
active: { label: "사용", className: "bg-emerald-50 text-emerald-600 ring-emerald-200" },
inactive: { label: "사용중지", className: "bg-gray-100 text-gray-500 ring-gray-200" },
};
export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_version", detailTable = "bom_detail", onVersionLoaded }: BomVersionModalProps) {
const [versions, setVersions] = useState<BomVersion[]>([]);
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const [actionId, setActionId] = useState<string | null>(null);
useEffect(() => {
if (open && bomId) loadVersions();
}, [open, bomId]);
const loadVersions = async () => {
if (!bomId) return;
setLoading(true);
try {
const res = await apiClient.get(`/bom/${bomId}/versions`, { params: { tableName } });
if (res.data?.success) setVersions(res.data.data || []);
} catch (error) {
console.error("[BomVersion] 로드 실패:", error);
} finally {
setLoading(false);
}
};
const handleCreateVersion = async () => {
if (!bomId) return;
setCreating(true);
try {
const res = await apiClient.post(`/bom/${bomId}/versions`, { tableName, detailTable });
if (res.data?.success) loadVersions();
} catch (error) {
console.error("[BomVersion] 생성 실패:", error);
} finally {
setCreating(false);
}
};
const handleLoadVersion = async (versionId: string) => {
if (!bomId) return;
setActionId(versionId);
try {
const res = await apiClient.post(`/bom/${bomId}/versions/${versionId}/load`, { tableName, detailTable });
if (res.data?.success) {
onVersionLoaded?.();
onOpenChange(false);
}
} catch (error) {
console.error("[BomVersion] 불러오기 실패:", error);
} finally {
setActionId(null);
}
};
const handleDeleteVersion = async (versionId: string) => {
if (!bomId || !confirm("이 버전을 삭제하시겠습니까?")) return;
setActionId(versionId);
try {
const res = await apiClient.delete(`/bom/${bomId}/versions/${versionId}`, { params: { tableName } });
if (res.data?.success) loadVersions();
} catch (error) {
console.error("[BomVersion] 삭제 실패:", error);
} finally {
setActionId(null);
}
};
const formatDate = (dateStr: string) => {
if (!dateStr) return "-";
try {
return new Date(dateStr).toLocaleDateString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
} catch {
return dateStr;
}
};
const getStatus = (status: string) => STATUS_STYLE[status] || STATUS_STYLE.inactive;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[550px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
BOM . .
</DialogDescription>
</DialogHeader>
<div className="max-h-[400px] space-y-2 overflow-auto">
{loading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
</div>
) : versions.length === 0 ? (
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-gray-400"> </p>
</div>
) : (
versions.map((ver) => {
const st = getStatus(ver.status);
const isActing = actionId === ver.id;
return (
<div
key={ver.id}
className={cn(
"flex items-center gap-3 rounded-lg border p-3 transition-colors",
ver.status === "active" ? "border-emerald-200 bg-emerald-50/30" : "border-gray-200",
)}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-900">
Version {ver.version_name}
</span>
<span className={cn(
"inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 ring-inset",
st.className,
)}>
{st.label}
</span>
</div>
<div className="mt-0.5 flex gap-3 text-[11px] text-gray-400">
<span>: {ver.revision}</span>
<span>: {formatDate(ver.created_date)}</span>
{ver.created_by && <span>: {ver.created_by}</span>}
</div>
</div>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
onClick={() => handleLoadVersion(ver.id)}
disabled={isActing}
className="h-7 gap-1 px-2 text-[10px]"
>
{isActing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
</Button>
{ver.status !== "active" && (
<Button
variant="destructive"
size="sm"
onClick={() => handleDeleteVersion(ver.id)}
disabled={isActing}
className="h-7 gap-1 px-2 text-[10px]"
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</div>
);
})
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
onClick={handleCreateVersion}
disabled={creating}
className="h-8 flex-1 gap-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{creating ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Plus className="mr-1 h-4 w-4" />}
</Button>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}