feat: Enhance BOM management with tree editing and process integration

- Added new state management for tree editing, allowing users to modify BOM structures dynamically.
- Integrated process options fetching from the API to enhance BOM detail management.
- Implemented functionality for handling where-used queries to track BOM item usage across processes.
- Expanded BOM detail structure to include additional fields such as spec, writer, and updated date for better data representation.

These changes aim to improve the user experience in managing BOM data and facilitate better tracking of item usage within processes.
This commit is contained in:
kjs
2026-04-06 17:22:26 +09:00
parent cf9f53e4c5
commit 7daa394aec
@@ -39,6 +39,8 @@ import {
import {
ChevronRight,
ChevronDown,
ChevronsDown,
ChevronsUp,
Plus,
Trash2,
Search,
@@ -55,6 +57,8 @@ import {
Play,
Copy,
Settings2,
Save,
Package,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
@@ -134,6 +138,9 @@ interface BomDetail {
remark?: string;
level?: string;
seq_no?: string;
spec?: string;
writer?: string;
updated_date?: string;
[key: string]: any;
}
@@ -162,6 +169,11 @@ interface BomHistoryEntry {
interface TreeNode extends BomDetail {
children: TreeNode[];
expanded?: boolean;
_isNew?: boolean; // 신규 노드 (DB에 없음)
_isModified?: boolean; // 수정된 노드
_tempId?: string; // 임시 ID
_level?: number; // flattenTree에서 사용
_isVirtualRoot?: boolean; // 가상 루트(BOM 마스터) 노드
}
// ─── 트리 구성 헬퍼 ─────────────────────────────
@@ -287,6 +299,35 @@ export default function BomManagementPage() {
// 카테고리 옵션
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 트리 편집 state
const [editingTree, setEditingTree] = useState<TreeNode[]>([]);
const [treeHasChanges, setTreeHasChanges] = useState(false);
// 공정 마스터 목록
const [processOptions, setProcessOptions] = useState<{ code: string; label: string }[]>([]);
useEffect(() => {
apiClient.get("/process-info/processes?useYn=Y").then((res) => {
const list = res.data?.data || [];
setProcessOptions(list.map((p: any) => ({ code: p.process_code, label: `${p.process_name} (${p.process_code})` })));
}).catch(() => {});
}, []);
// 트리뷰 행 선택 / 수정 모달
const [selectedTreeNodeId, setSelectedTreeNodeId] = useState<string | null>(null);
const [editingNodeId, setEditingNodeId] = useState<string | null>(null);
const [treeEditModalOpen, setTreeEditModalOpen] = useState(false);
const [treeEditTarget, setTreeEditTarget] = useState<TreeNode | null>(null);
const [treeEditForm, setTreeEditForm] = useState<Record<string, any>>({});
// 역전개 (Where Used)
const [treeViewMode, setTreeViewMode] = useState<"forward" | "reverse">("forward");
const [whereUsedData, setWhereUsedData] = useState<any[]>([]);
const [whereUsedLoading, setWhereUsedLoading] = useState(false);
const [originalDetailIds, setOriginalDetailIds] = useState<Set<string>>(new Set());
const [addTargetParentId, setAddTargetParentId] = useState<string | null>(null);
const [treeItemSearchOpen, setTreeItemSearchOpen] = useState(false);
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
// ─── 데이터 로드 ──────────────────────────────
const fetchBomList = useCallback(async () => {
setLoading(true);
@@ -320,6 +361,7 @@ export default function BomManagementPage() {
const loadCategories = async () => {
try {
const columns = ["bom_type", "status"];
const detailColumns = ["process_type"];
const results: Record<string, { code: string; label: string }[]> = {};
for (const col of columns) {
@@ -341,6 +383,40 @@ export default function BomManagementPage() {
} catch {}
}
// bom_detail 테이블의 카테고리
for (const col of detailColumns) {
try {
const res = await apiClient.get(`/table-categories/bom_detail/${col}/values`);
if (res.data?.data?.length > 0) {
const flatten = (items: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const item of items) {
result.push({ code: item.value || item.code, label: item.label || item.value });
if (item.children?.length) result.push(...flatten(item.children));
}
return result;
};
results[col] = flatten(res.data.data);
}
} catch {}
}
// item_info의 division 카테고리
try {
const res = await apiClient.get(`/table-categories/item_info/division/values`);
if (res.data?.data?.length > 0) {
const flatten = (items: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const item of items) {
result.push({ code: item.valueCode || item.value || item.code, label: item.valueLabel || item.label || item.value });
if (item.children?.length) result.push(...flatten(item.children));
}
return result;
};
results["division"] = flatten(res.data.data);
}
} catch {}
setCategoryOptions(results);
} catch {}
};
@@ -402,6 +478,9 @@ export default function BomManagementPage() {
item_name: item?.item_name || "",
item_type: divisionLabel,
unit: d.unit || item?.unit || "",
spec: item?.size || item?.spec || "",
writer: d.writer || "",
updated_date: d.updated_at || d.updated_date || "",
};
});
} catch {}
@@ -410,6 +489,22 @@ export default function BomManagementPage() {
setBomDetails(details);
const tree = buildTree(details);
setTreeNodes(tree);
// 편집용 트리 동기화
setEditingTree(buildTree(details));
setOriginalDetailIds(new Set(details.map((d: any) => d.id)));
setTreeHasChanges(false);
// 기본 전개: 모든 노드 + 가상 루트 펼침
const allIds = new Set<string>();
if (header?.id) allIds.add(`__root_${header.id}`);
const collectIds = (nodes: TreeNode[]) => {
for (const n of nodes) {
allIds.add(n.id || "");
if (n.children.length > 0) collectIds(n.children);
}
};
collectIds(tree);
setExpandedNodes(allIds);
} catch (err: any) {
toast.error("BOM 상세 조회에 실패했어요");
} finally {
@@ -455,6 +550,10 @@ export default function BomManagementPage() {
setBomHeader(null);
setBomDetails([]);
setTreeNodes([]);
setEditingTree([]);
setTreeHasChanges(false);
setOriginalDetailIds(new Set());
setExpandedNodes(new Set());
setVersions([]);
setHistoryList([]);
}
@@ -473,6 +572,348 @@ export default function BomManagementPage() {
const flatTree = useMemo(() => flattenTree(treeNodes), [treeNodes]);
// ─── 트리 편집 헬퍼 ──────────────────────────
// 트리 노드 토글 (펼침/접음)
const toggleEditTreeNode = 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>();
if (bomHeader?.id) allIds.add(`__root_${bomHeader.id}`);
const collect = (nodes: TreeNode[]) => {
for (const n of nodes) {
allIds.add(n.id || n._tempId || "");
if (n.children.length > 0) collect(n.children);
}
};
collect(editingTree);
setExpandedNodes(allIds);
}, [editingTree, bomHeader]);
// 역전개 (Where Used) 조회 — 현재 BOM 품목이 어디에 사용되는지
const fetchWhereUsed = useCallback(async () => {
if (!bomHeader?.item_code && !bomHeader?.item_id) return;
setWhereUsedLoading(true);
try {
// bom_detail에서 child_item_id가 현재 품목인 행 조회
const itemId = bomHeader.item_id || bomHeader.id;
const res = await apiClient.post(`/table-management/tables/bom_detail/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "child_item_id", operator: "equals", value: itemId }] },
autoFilter: true,
});
const details = res.data?.data?.data || res.data?.data?.rows || [];
// 각 bom_id로 BOM 마스터 정보 조회
const bomIds = [...new Set(details.map((d: any) => d.bom_id).filter(Boolean))];
const results: any[] = [];
for (const bomId of bomIds) {
try {
const bomRes = await apiClient.post(`/table-management/tables/bom/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "id", operator: "equals", value: bomId }] },
autoFilter: true,
});
const bom = (bomRes.data?.data?.data || bomRes.data?.data?.rows || [])[0];
if (bom) {
const detail = details.find((d: any) => d.bom_id === bomId);
results.push({
bom_id: bomId,
item_code: bom.item_code || bom.item_number || "",
item_name: bom.item_name || "",
quantity: detail?.quantity || "1",
unit: detail?.unit || "",
process_type: detail?.process_type || "",
});
}
} catch {}
}
setWhereUsedData(results);
} catch {
setWhereUsedData([]);
} finally {
setWhereUsedLoading(false);
}
}, [bomHeader]);
// 전체 접음
const collapseAll = useCallback(() => {
// 루트는 항상 펼침 유지, 나머지만 접음
const rootOnly = new Set<string>();
if (bomHeader?.id) rootOnly.add(`__root_${bomHeader.id}`);
setExpandedNodes(rootOnly);
}, [bomHeader]);
// 편집 트리 평탄화 (useMemo) — 가상 루트(BOM 마스터) 포함
const editingFlatTree = useMemo(() => {
const result: TreeNode[] = [];
// 가상 루트 (BOM 마스터)
if (bomHeader) {
const rootId = `__root_${bomHeader.id}`;
result.push({
id: rootId,
bom_id: bomHeader.id,
_tempId: undefined,
_isNew: false,
_isModified: false,
_level: 0,
_isVirtualRoot: true,
item_number: bomHeader.item_code || bomHeader.item_number || "",
item_name: bomHeader.item_name || "",
quantity: bomHeader.base_qty || "1",
unit: bomHeader.unit || "",
process_type: "",
loss_rate: "",
remark: bomHeader.remark || "",
spec: "",
writer: "",
updated_date: "",
item_type: "",
children: editingTree,
} as any);
}
// 하위 노드 평탄화 (레벨 +1)
const flatten = (nodes: TreeNode[], level: number) => {
for (const node of nodes) {
const nodeKey = node.id || node._tempId || "";
result.push({ ...node, _level: level });
if (node.children.length > 0 && expandedNodes.has(nodeKey)) {
flatten(node.children, level + 1);
}
}
};
const rootId = bomHeader ? `__root_${bomHeader.id}` : "";
if (!bomHeader || expandedNodes.has(rootId)) {
flatten(editingTree, bomHeader ? 1 : 0);
}
return result;
}, [editingTree, expandedNodes, bomHeader]);
// 트리에서 노드를 찾아서 업데이트
const updateTreeNode = (tree: TreeNode[], nodeId: string, updater: (node: TreeNode) => TreeNode): TreeNode[] => {
return tree.map(node => {
if ((node.id || node._tempId) === nodeId) return updater(node);
if (node.children.length > 0) return { ...node, children: updateTreeNode(node.children, nodeId, updater) };
return node;
});
};
// 트리에서 노드 삭제 (하위 포함)
const removeTreeNode = (tree: TreeNode[], nodeId: string): TreeNode[] => {
return tree.filter(node => {
if ((node.id || node._tempId) === nodeId) return false;
if (node.children.length > 0) node.children = removeTreeNode(node.children, nodeId);
return true;
});
};
// 트리에 자식 노드 추가
const addChildToNode = (tree: TreeNode[], parentId: string | null, newNode: TreeNode): TreeNode[] => {
if (parentId === null) return [...tree, newNode];
return tree.map(node => {
const nodeKey = node.id || node._tempId;
if (nodeKey === parentId) {
return { ...node, children: [...node.children, newNode], expanded: true };
}
if (node.children.length > 0) return { ...node, children: addChildToNode(node.children, parentId, newNode) };
return node;
});
};
// 노드 찾기 헬퍼
const findNodeById = (nodes: TreeNode[], id: string): TreeNode | null => {
for (const node of nodes) {
if ((node.id || node._tempId) === id) return node;
const found = findNodeById(node.children, id);
if (found) return found;
}
return null;
};
// 인라인 필드 변경
const handleTreeFieldChange = (nodeId: string, field: string, value: string) => {
setEditingTree(prev => updateTreeNode(prev, nodeId, node => ({
...node, [field]: value, _isModified: !node._isNew ? true : node._isModified,
})));
setTreeHasChanges(true);
};
// 노드 삭제
const handleTreeDeleteNode = async (nodeId: string) => {
if (!await confirm("이 품목과 하위 품목을 삭제할까요?", { variant: "destructive" })) return;
setEditingTree(prev => removeTreeNode(prev, nodeId));
setTreeHasChanges(true);
};
// 하위 품목 추가 시작
const handleTreeAddChild = (parentId: string | null) => {
setAddTargetParentId(parentId);
setTreeItemSearchOpen(true);
searchItems("");
};
// 트리 품목 선택 완료 (트리에 추가)
const handleTreeItemSelect = (item: any) => {
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`;
const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null;
const newLevel = parentNode ? ((parentNode._level ?? parentNode.level ?? 0) as number) + 1 : 0;
const newNode: TreeNode = {
id: "",
_tempId: tempId,
_isNew: true,
bom_id: selectedBomId || "",
parent_detail_id: addTargetParentId,
child_item_id: item.id,
item_number: item.item_number || "",
item_name: item.item_name || "",
item_type: item.division || "",
level: String(newLevel),
quantity: "1",
unit: item.unit || "",
process_type: "",
loss_rate: "0",
remark: "",
children: [],
expanded: true,
};
setEditingTree(prev => addChildToNode(prev, addTargetParentId, newNode));
setExpandedNodes(prev => {
const next = new Set(prev);
next.add(tempId);
if (addTargetParentId) next.add(addTargetParentId);
return next;
});
setTreeHasChanges(true);
setTreeItemSearchOpen(false);
};
// ─── 트리 저장 ────────────────────────────────
const handleSaveTree = async () => {
if (!selectedBomId || !treeHasChanges) return;
setSaving(true);
try {
// 1. version_id 확보
let versionId = currentVersionId;
if (!versionId) {
const initRes = await apiClient.post(`/bom/${selectedBomId}/initialize-version`, {});
versionId = initRes.data?.data?.versionId;
}
// 2. 트리 평탄화
const flatNodes: any[] = [];
const flattenForSave = (nodes: TreeNode[], parentId: string | null, level: number) => {
nodes.forEach((node, idx) => {
const nodeKey = node.id || node._tempId || "";
flatNodes.push({
...node,
_nodeKey: nodeKey,
parent_detail_id: parentId,
level: String(level),
seq_no: String(idx + 1),
bom_id: selectedBomId,
version_id: versionId,
});
if (node.children.length > 0) flattenForSave(node.children, nodeKey, level + 1);
});
};
flattenForSave(editingTree, null, 0);
// 3. 분류: INSERT / UPDATE / DELETE
const currentIds = new Set(flatNodes.filter(n => n.id && !n._isNew).map(n => n.id));
const toDelete = [...originalDetailIds].filter(id => !currentIds.has(id));
const toInsert = flatNodes.filter(n => n._isNew);
const toUpdate = flatNodes.filter(n => n.id && !n._isNew && n._isModified);
// 4. DELETE
if (toDelete.length > 0) {
await apiClient.delete(`/table-management/tables/bom_detail/delete`, {
data: toDelete.map(id => ({ id })),
});
}
// 5. INSERT (순서 중요: 부모 먼저)
const tempToRealId: Record<string, string> = {};
for (const node of toInsert) {
let parentId = node.parent_detail_id;
if (parentId && tempToRealId[parentId]) parentId = tempToRealId[parentId];
const newId = crypto.randomUUID();
const res = await apiClient.post(`/table-management/tables/bom_detail/add`, {
id: newId,
bom_id: selectedBomId,
version_id: versionId,
parent_detail_id: parentId && !parentId.startsWith("temp_") ? parentId : null,
child_item_id: node.child_item_id,
level: node.level,
seq_no: node.seq_no,
quantity: node.quantity || "1",
unit: node.unit || "",
process_type: node.process_type || "",
loss_rate: node.loss_rate || "0",
remark: node.remark || "",
writer: user?.userId || "",
company_code: user?.company_code || "",
});
const realId = res.data?.data?.id || newId;
if (node._tempId) tempToRealId[node._tempId] = realId;
}
// 6. INSERT된 노드의 자식들 parent_detail_id 업데이트
for (const node of toInsert) {
if (!node._tempId) continue;
const childNodes = flatNodes.filter(n => n.parent_detail_id === node._tempId);
for (const child of childNodes) {
if (child.id && !child._isNew && tempToRealId[node._tempId]) {
await apiClient.put(`/table-management/tables/bom_detail/edit`, {
originalData: { id: child.id },
updatedData: { parent_detail_id: tempToRealId[node._tempId] },
});
}
}
}
// 7. UPDATE
for (const node of toUpdate) {
let parentId = node.parent_detail_id;
if (parentId && tempToRealId[parentId]) parentId = tempToRealId[parentId];
await apiClient.put(`/table-management/tables/bom_detail/edit`, {
originalData: { id: node.id },
updatedData: {
quantity: node.quantity,
unit: node.unit,
process_type: node.process_type,
loss_rate: node.loss_rate,
remark: node.remark,
level: node.level,
seq_no: node.seq_no,
parent_detail_id: parentId,
},
});
}
toast.success("BOM 트리가 저장되었어요");
setTreeHasChanges(false);
fetchBomDetail(selectedBomId);
} catch (err: any) {
toast.error(err?.response?.data?.message || "트리 저장에 실패했어요");
} finally {
setSaving(false);
}
};
// ─── BOM 삭제 ────────────────────────────────
const handleDeleteBom = async () => {
if (checkedIds.length === 0) {
@@ -1096,43 +1537,240 @@ export default function BomManagementPage() {
{/* 트리뷰 탭 */}
<TabsContent value="tree" className="flex-1 overflow-auto p-0 m-0">
{flatTree.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2 py-12">
{/* 상단 액션바 */}
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30">
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => handleTreeAddChild(null)}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
if (selectedTreeNodeId && !selectedTreeNodeId.startsWith("__root_")) {
handleTreeAddChild(selectedTreeNodeId);
}
}}
disabled={!selectedTreeNodeId || selectedTreeNodeId.startsWith("__root_")}
>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10"
onClick={() => {
if (selectedTreeNodeId && !selectedTreeNodeId.startsWith("__root_")) {
handleTreeDeleteNode(selectedTreeNodeId);
setSelectedTreeNodeId(null);
setEditingNodeId(null);
}
}}
disabled={!selectedTreeNodeId || selectedTreeNodeId.startsWith("__root_")}
>
<Trash2 className="w-3.5 h-3.5 mr-1" />
</Button>
<div className="w-px h-5 bg-border" />
<div className="flex overflow-hidden rounded-md border">
<button onClick={() => { setTreeViewMode("forward"); }} className={cn("h-7 px-2.5 text-[11px] font-medium transition-colors", treeViewMode === "forward" ? "bg-primary text-primary-foreground" : "bg-background text-muted-foreground hover:bg-muted")}>
</button>
<button onClick={() => { setTreeViewMode("reverse"); fetchWhereUsed(); }} className={cn("h-7 border-l px-2.5 text-[11px] font-medium transition-colors", treeViewMode === "reverse" ? "bg-primary text-primary-foreground" : "bg-background text-muted-foreground hover:bg-muted")}>
</button>
</div>
{treeViewMode === "forward" && (
<>
<Button size="sm" variant="ghost" onClick={expandAll}>
<ChevronsDown className="w-3.5 h-3.5 mr-1" />
</Button>
<Button size="sm" variant="ghost" onClick={collapseAll}>
<ChevronsUp className="w-3.5 h-3.5 mr-1" />
</Button>
</>
)}
</div>
<div className="flex items-center gap-2">
{treeHasChanges && <Badge variant="secondary" className="text-warning"></Badge>}
<Button size="sm" onClick={handleSaveTree} disabled={!treeHasChanges || saving}>
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin mr-1" /> : <Save className="w-3.5 h-3.5 mr-1" />}
</Button>
</div>
</div>
{/* 역전개 (Where Used) 뷰 */}
{treeViewMode === "reverse" ? (
<div className="flex-1 overflow-auto">
{whereUsedLoading ? (
<div className="flex h-40 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
</div>
) : (
<div className="p-4">
<div className="flex items-center gap-2 mb-4">
<Package className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold">{bomHeader?.item_name || ""}</span>
<span className="font-mono text-xs text-muted-foreground">({bomHeader?.item_code || ""})</span>
</div>
<div className="text-xs text-muted-foreground mb-3"> (Where Used)</div>
{whereUsedData.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground gap-2">
<Inbox className="w-8 h-8 text-muted-foreground/40" />
<p className="text-xs"> BOM이 </p>
</div>
) : (
<div className="space-y-2">
{whereUsedData.map((wu, idx) => (
<div key={idx} className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent/50 cursor-pointer" onClick={() => {
// 해당 BOM 선택
setSelectedBomId(wu.bom_id);
setTreeViewMode("forward");
}}>
<Package className="h-4 w-4 text-primary shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">{wu.item_name}</span>
<span className="font-mono text-[11px] text-muted-foreground">({wu.item_code})</span>
</div>
<div className="mt-0.5 flex gap-3 text-[11px] text-muted-foreground">
<span>: <b className="text-foreground">{wu.quantity}</b> {wu.unit}</span>
{wu.process_type && <span>: <b className="text-foreground">{wu.process_type}</b></span>}
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground/50 shrink-0" />
</div>
))}
</div>
)}
</div>
)}
</div>
) : editingFlatTree.length === 0 ? (
<div className="flex flex-col items-center justify-center h-40 text-muted-foreground gap-2">
<Inbox className="w-8 h-8 text-muted-foreground/40" />
<p className="text-xs">BOM </p>
<p className="text-xs"> </p>
<Button size="sm" variant="outline" onClick={() => handleTreeAddChild(null)}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[60px] text-center text-[11px] font-bold"></TableHead>
<TableHead className="text-[11px] font-bold"></TableHead>
<TableHead className="text-[11px] font-bold"></TableHead>
<TableHead className="w-[80px] text-center text-[11px] font-bold"></TableHead>
<TableHead className="w-[80px] text-right text-[11px] font-bold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{flatTree.map((node) => {
<table className="w-full border-collapse text-xs" style={{ minWidth: "1100px" }}>
<colgroup><col style={{ width: "60px" }} /><col style={{ width: "140px" }} /><col /><col style={{ width: "70px" }} /><col style={{ width: "50px" }} /><col style={{ width: "80px" }} /><col style={{ width: "80px" }} /><col style={{ width: "80px" }} /><col style={{ width: "80px" }} /><col style={{ width: "150px" }} /><col style={{ width: "80px" }} /></colgroup>
<thead className="sticky top-0 z-10">
<tr className="border-b bg-muted">
<th className="px-2 py-2.5 text-[11px] font-semibold text-muted-foreground text-center"></th>
<th className="px-3 py-2.5 text-[11px] font-semibold text-muted-foreground text-left"></th>
<th className="px-3 py-2.5 text-[11px] font-semibold text-muted-foreground text-left"></th>
<th className="px-3 py-2.5 text-[11px] font-semibold text-muted-foreground text-center"></th>
<th className="px-3 py-2.5 text-[11px] font-semibold text-muted-foreground text-center"></th>
<th className="px-3 py-2.5 text-[11px] font-semibold text-muted-foreground text-left"></th>
<th className="px-3 py-2.5 text-[11px] font-semibold text-muted-foreground text-left"></th>
<th className="px-3 py-2.5 text-[11px] font-semibold text-muted-foreground text-left"></th>
<th className="px-3 py-2.5 text-[11px] font-semibold text-muted-foreground text-left"></th>
<th className="px-3 py-2.5 text-[11px] font-semibold text-muted-foreground text-left"></th>
<th className="px-3 py-2.5 text-[11px] font-semibold text-muted-foreground text-center"></th>
</tr>
</thead>
<tbody>
{editingFlatTree.map((node) => {
const nodeKey = node.id || node._tempId || "";
const isVirtualRoot = (node as any)._isVirtualRoot === true;
const typeBadge = getItemTypeBadge(node.item_type);
const hasChildren = isVirtualRoot ? editingTree.length > 0 : node.children.length > 0;
const isExpanded = expandedNodes.has(nodeKey);
const isSelected = selectedTreeNodeId === nodeKey;
const isEditing = editingNodeId === nodeKey && !isVirtualRoot;
const level = node._level || 0;
const depthBarColor = isVirtualRoot ? "bg-primary/70"
: level === 1 ? "bg-emerald-400"
: level === 2 ? "bg-amber-400"
: level >= 3 ? "bg-purple-400" : "bg-muted/60";
const depthBg = isVirtualRoot
? "border-border bg-primary/10 font-medium hover:bg-primary/20"
: isSelected
? "border-border bg-primary/5"
: level === 1 ? "border-border bg-background hover:bg-muted/60"
: level >= 2 ? "border-border bg-muted/40 hover:bg-muted/50"
: "border-border bg-background hover:bg-muted/60";
return (
<TableRow key={node.id} className="hover:bg-accent/50">
<TableCell className="text-center text-[13px] font-mono">{node._level}</TableCell>
<TableCell className="text-[13px]">
<span className="font-mono">{node.item_number || "-"}</span>
</TableCell>
<TableCell className="text-[13px]">{node.item_name || "-"}</TableCell>
<TableCell className="text-center">
<Badge variant="secondary" className={cn("text-[10px]", typeBadge.className)}>{typeBadge.label}</Badge>
</TableCell>
<TableCell className="text-right text-[13px] font-mono font-semibold">
{node.quantity ? Number(node.quantity).toLocaleString() : "-"}
</TableCell>
</TableRow>
<tr
key={nodeKey}
className={cn("group cursor-pointer border-b transition-colors", depthBg)}
onClick={() => {
if (editingNodeId && editingNodeId !== nodeKey) setEditingNodeId(null);
setSelectedTreeNodeId(nodeKey);
if (hasChildren) toggleEditTreeNode(nodeKey);
}}
onDoubleClick={() => {
if (!isVirtualRoot) {
setTreeEditTarget(node);
setTreeEditForm({ quantity: node.quantity || "1", unit: node.unit || "", process_type: node.process_type || "", loss_rate: node.loss_rate || "0", spec: node.spec || "", material: node.material || "", remark: node.remark || "" });
setTreeEditModalOpen(true);
}
}}
>
{/* 레벨 + 좌측 색상바 + 토글 */}
<td className="relative px-1 py-2" style={{ paddingLeft: `${level * 16 + 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", isVirtualRoot ? "text-primary" : "text-muted-foreground/70")} />
: <ChevronRight className={cn("h-3.5 w-3.5", isVirtualRoot ? "text-primary" : "text-muted-foreground/70")} />
) : (
<span className="h-1 w-1 rounded-full bg-muted-foreground/40" />
)}
</span>
<span className="text-[11px]">{level}</span>
</div>
</td>
{/* 품번 */}
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2">
<span className={cn("font-mono text-[11px]", isVirtualRoot && "font-bold text-foreground")}>
{node.item_number || "-"}
</span>
</td>
{/* 품명 */}
<td className={cn("overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2", isVirtualRoot && "font-bold")}>
{node.item_name || "-"}
</td>
{/* 소요량 */}
<td className="px-3 py-2 text-center">{isVirtualRoot ? (bomHeader?.base_qty || "1") : (node.quantity || "-")}</td>
{/* 단위 */}
<td className="px-3 py-2 text-center">{isVirtualRoot ? "-" : (node.unit || "-")}</td>
{/* 공정구분 */}
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2">{isVirtualRoot ? "-" : (node.process_type || "-")}</td>
{/* 규격 */}
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2">{isVirtualRoot ? "-" : (node.spec || "-")}</td>
{/* 비고 */}
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2">{isVirtualRoot ? "-" : (node.remark || "-")}</td>
{/* 작성자 */}
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2 text-muted-foreground">{isVirtualRoot ? "-" : (node.writer || "-")}</td>
{/* 수정일시 */}
<td className="overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2 text-muted-foreground">
{isVirtualRoot ? "-" : (node.updated_date ? new Date(node.updated_date).toLocaleString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "-")}
</td>
{/* 관리품목 */}
<td className="px-2 py-2" style={{ minWidth: "150px" }}>
{isVirtualRoot ? <span className="text-center block">-</span> : (
node.item_type ? (
<div className="flex flex-row items-center gap-1 flex-nowrap">{node.item_type.split(",").map((t: string, i: number) => {
const trimmed = t.trim();
if (!trimmed) return null;
return <span key={i} className="shrink-0 rounded px-1 py-0.5 text-[9px] font-medium bg-muted text-muted-foreground">{trimmed}</span>;
})}</div>
) : "-"
)}
</td>
</tr>
);
})}
</TableBody>
</Table>
</tbody>
</table>
)}
</TabsContent>
@@ -1525,7 +2163,7 @@ export default function BomManagementPage() {
onChange={(e) => setItemSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchItems()}
/>
<Button size="sm" className="h-9" onClick={searchItems} disabled={itemSearchLoading}>
<Button size="sm" className="h-9" onClick={() => searchItems()} disabled={itemSearchLoading}>
{itemSearchLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
</Button>
</div>
@@ -1558,6 +2196,132 @@ export default function BomManagementPage() {
</DialogContent>
</Dialog>
{/* ─── 품목 수정 모달 (더블클릭) ─────────────────── */}
<Dialog open={treeEditModalOpen} onOpenChange={setTreeEditModalOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> BOM </DialogDescription>
</DialogHeader>
{treeEditTarget && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9 bg-muted/50" value={treeEditTarget.item_number || ""} readOnly />
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9 bg-muted/50" value={treeEditTarget.item_name || ""} readOnly />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label className="text-xs font-semibold text-muted-foreground"> <span className="text-destructive">*</span></Label>
<Input className="h-9" type="number" value={treeEditForm.quantity || ""} onChange={(e) => setTreeEditForm((p) => ({ ...p, quantity: e.target.value }))} />
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9 bg-muted/50" value={treeEditForm.unit || "-"} readOnly />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={treeEditForm.process_type || ""} onValueChange={(v) => setTreeEditForm((p) => ({ ...p, process_type: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="공정 선택" /></SelectTrigger>
<SelectContent>
{processOptions.map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold text-muted-foreground"> (%)</Label>
<Input className="h-9" type="number" value={treeEditForm.loss_rate || ""} onChange={(e) => setTreeEditForm((p) => ({ ...p, loss_rate: e.target.value }))} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9 bg-muted/50" value={treeEditForm.spec || "-"} readOnly />
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Input className="h-9 bg-muted/50" value={treeEditForm.material || "-"} readOnly />
</div>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<textarea className="flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm" value={treeEditForm.remark || ""} onChange={(e) => setTreeEditForm((p) => ({ ...p, remark: e.target.value }))} placeholder="비고 사항을 입력하세요" />
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setTreeEditModalOpen(false)}></Button>
<Button onClick={() => {
if (treeEditTarget) {
const nodeKey = treeEditTarget.id || treeEditTarget._tempId || "";
handleTreeFieldChange(nodeKey, "quantity", treeEditForm.quantity || "1");
handleTreeFieldChange(nodeKey, "process_type", treeEditForm.process_type || "");
handleTreeFieldChange(nodeKey, "loss_rate", treeEditForm.loss_rate || "0");
handleTreeFieldChange(nodeKey, "remark", treeEditForm.remark || "");
}
setTreeEditModalOpen(false);
}}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ─── 트리 품목 검색 모달 ─────────────────── */}
<Dialog open={treeItemSearchOpen} onOpenChange={setTreeItemSearchOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle> ( )</DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="flex gap-2 mb-3">
<Input
className="h-9 flex-1"
placeholder="품목코드 또는 품명 입력"
value={itemSearchKeyword}
onChange={(e) => setItemSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchItems()}
/>
<Button size="sm" className="h-9" onClick={() => searchItems()} disabled={itemSearchLoading}>
{itemSearchLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
</Button>
</div>
<div className="max-h-[300px] overflow-auto border rounded-lg">
{itemSearchResults.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground gap-1">
<Search className="w-6 h-6 text-muted-foreground/40" />
<p className="text-xs"> </p>
</div>
) : (
itemSearchResults.map((item) => {
const badge = getItemTypeBadge(item.division);
return (
<div
key={item.id}
className="flex items-center gap-3 px-3 py-2 border-b last:border-b-0 hover:bg-accent cursor-pointer transition-colors"
onClick={() => handleTreeItemSelect(item)}
>
<span className={cn("text-[10px] font-semibold px-1.5 py-0.5 rounded shrink-0", badge.className)}>
{badge.label}
</span>
<span className="font-mono text-[11px] text-muted-foreground">{item.item_number}</span>
<span className="text-xs">{item.item_name}</span>
<span className="ml-auto text-[11px] text-muted-foreground">{item.unit || ""}</span>
</div>
);
})
)}
</div>
</DialogContent>
</Dialog>
{/* ─── 새 버전 생성 다이얼로그 ────────────── */}
<Dialog open={showNewVersionDialog} onOpenChange={setShowNewVersionDialog}>
<DialogContent className="max-w-sm">