"use client"; // 생산관리 > M-BOM 관리 — 단건 상세 + 트리 (read-only / 편집). // // 운영판 통합: // wace mBomHeaderPopup.jsp (헤더 메타) // + wace mBomPopupLeft.jsp (좌측 트리 — read-only/편집) // // 4분기 (운영판 mBomPopupLeft.do): // SAVED 저장된 mbom_header.status='Y' 의 트리 (생산정보 포함, 편집 시 UPDATE) // ASSIGNED_EBOM source_bom_type='EBOM' + source_ebom_objid → bom_part_qty 트리 (편집 시 신규 CREATE) // ASSIGNED_MBOM source_bom_type='MBOM' + source_mbom_objid → mbom_detail 구조만 // TEMPLATE Machine 이외 + 동일 part_no 의 mbom_header 템플릿 // NONE 빈 트리 // // PR-B1 — 셀 인라인 편집 + 저장 (운영 saveMbom.do 1:1). // 행 추가/삭제(mBomCenterBtnPopup) / BOM 복사 / 구매리스트 / 변경이력 — PR-B2~ 분리. import React, { useEffect, useMemo, useState } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Loader2, Folder, Pencil, Save, X, History, Plus, Trash2, Link as LinkIcon } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { mbomApi, MbomDetail, MbomTreeResponse, MbomBomDataType, MbomTreeRow, MbomSaveRow } from "@/lib/api/mbom"; import { MbomHistoryDialog } from "./MbomHistoryDialog"; import { MbomAddPartDialog, PickedPart } from "./MbomAddPartDialog"; import { MbomAssignDialog } from "./MbomAssignDialog"; interface Props { open: boolean; onOpenChange: (open: boolean) => void; projectObjid: string | null; onSaved?: () => void; } const BOM_DATA_TYPE_LABEL: Record = { SAVED: { text: "저장된 M-BOM", color: "bg-emerald-600" }, ASSIGNED_EBOM: { text: "할당된 E-BOM", color: "bg-sky-600" }, ASSIGNED_MBOM: { text: "할당된 M-BOM", color: "bg-indigo-600" }, TEMPLATE: { text: "M-BOM 템플릿", color: "bg-amber-600" }, NONE: { text: "BOM 없음", color: "bg-slate-500" }, }; // 편집 모드에서 사용자가 직접 수정 가능한 필드 (운영판 wace fieldMapping 기반) type EditableField = | "qty" | "supply_type" | "make_or_buy" | "raw_material_no" | "raw_material" | "raw_material_size" | "required_qty" | "order_qty" | "production_qty" | "processing_vendor" | "processing_deadline" | "grinding_deadline" | "remark"; export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }: Props) { const [detail, setDetail] = useState(null); const [tree, setTree] = useState(null); const [loading, setLoading] = useState(false); const [editMode, setEditMode] = useState(false); const [saving, setSaving] = useState(false); const [editableRows, setEditableRows] = useState([]); const [dirty, setDirty] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [addPartOpen, setAddPartOpen] = useState(false); const [assignOpen, setAssignOpen] = useState(false); const [selectedChildObjids, setSelectedChildObjids] = useState>(new Set()); const loadTree = (objid: string) => { setLoading(true); return Promise.all([ mbomApi.getDetail(objid), mbomApi.getTree(objid), ]) .then(([d, t]) => { setDetail(d); setTree(t); setEditableRows((t?.rows ?? []).map(r => ({ ...r }))); setDirty(false); }) .catch((e: any) => { toast.error(e?.response?.data?.message ?? e?.message ?? "M-BOM 조회 실패"); }) .finally(() => setLoading(false)); }; useEffect(() => { if (!open || !projectObjid) { setDetail(null); setTree(null); setEditableRows([]); setEditMode(false); setDirty(false); setSelectedChildObjids(new Set()); return; } void loadTree(projectObjid); }, [open, projectObjid]); // 편집 모드 off 시 선택 해제 useEffect(() => { if (!editMode) setSelectedChildObjids(new Set()); }, [editMode]); const maxLevel = Math.max(1, tree?.max_level ?? 1); const rows = editMode ? editableRows : (tree?.rows ?? []); const bomDataType: MbomBomDataType = tree?.bom_data_type ?? "NONE"; const meta = BOM_DATA_TYPE_LABEL[bomDataType]; const canEdit = bomDataType !== "NONE"; const levelHeaders = useMemo(() => { const h: number[] = []; for (let i = 1; i <= maxLevel; i++) h.push(i); return h; }, [maxLevel]); const updateRow = (idx: number, field: EditableField, value: any) => { setEditableRows(prev => { const next = [...prev]; next[idx] = { ...next[idx], [field]: value }; return next; }); setDirty(true); }; // 체크박스 토글 const toggleSelect = (childObjid: string | null | undefined) => { if (!childObjid) return; setSelectedChildObjids(prev => { const next = new Set(prev); if (next.has(childObjid)) next.delete(childObjid); else next.add(childObjid); return next; }); }; const toggleSelectAll = () => { if (selectedChildObjids.size === editableRows.length) setSelectedChildObjids(new Set()); else setSelectedChildObjids(new Set(editableRows.map(r => String(r.child_objid ?? r.objid)))); }; // BFS — 주어진 child_objid 의 모든 하위 노드 child_objid 집합 const getDescendants = (rootChildObjid: string): Set => { const result = new Set(); const queue = [rootChildObjid]; while (queue.length) { const parent = queue.shift()!; for (const r of editableRows) { const c = String(r.child_objid ?? ""); if (!c) continue; if (r.parent_objid === parent && !result.has(c)) { result.add(c); queue.push(c); } } } return result; }; // 행 추가 — PART 검색 다이얼로그에서 반환된 parts 를 editableRows 에 append. // 부모 = 선택된 행 1개 (없으면 root, 2개 이상이면 첫 번째). const handleAddParts = (parts: PickedPart[]) => { const firstSelected = selectedChildObjids.size > 0 ? Array.from(selectedChildObjids)[0] : null; const parentRow = firstSelected ? editableRows.find(r => String(r.child_objid) === firstSelected) : null; const parentChildObjid = parentRow ? String(parentRow.child_objid) : null; const parentLevel = parentRow ? Number(parentRow.level ?? 1) : 0; const base = Date.now(); const newRows: MbomTreeRow[] = parts.map((p, i) => ({ objid: "" as any, // 빈값 → 백엔드 createObjId parent_objid: parentChildObjid, child_objid: `temp-${base}-${i}` as any, // 클라이언트 임시 ID (백엔드 그대로 저장) seq: 999, level: parentLevel + 1, part_objid: p.objid as any, part_no: p.part_no, part_name: p.part_name, qty: 1, item_qty: 1, unit: p.unit, unit_title: p.unit, supply_type: null, make_or_buy: null, raw_material_no: null, raw_material: null, raw_material_size: null, required_qty: null, order_qty: null, production_qty: null, processing_vendor: null, processing_deadline: null, grinding_deadline: null, cu01_cnt: 0, cu02_cnt: 0, cu03_cnt: 0, remark: null, status: "ACTIVE", sub_part_cnt: 0, processing_vendor_name: null, } as any)); setEditableRows(prev => [...prev, ...newRows]); setDirty(true); toast.success(`${parts.length}개 행 추가${parentChildObjid ? ` (부모: ${parentRow?.part_no ?? ""})` : " (root)"}`); }; // 선택 삭제 — cascade. 선택된 child_objid + 하위 트리 전부 제거. const handleDeleteSelected = () => { if (selectedChildObjids.size === 0) return; const allToRemove = new Set(); for (const sel of selectedChildObjids) { allToRemove.add(sel); for (const d of getDescendants(sel)) allToRemove.add(d); } if (!window.confirm(`${allToRemove.size}개 행을 삭제합니다 (선택 ${selectedChildObjids.size} + 하위 ${allToRemove.size - selectedChildObjids.size}). 계속할까요?`)) return; setEditableRows(prev => prev.filter(r => !allToRemove.has(String(r.child_objid ?? r.objid)))); setSelectedChildObjids(new Set()); setDirty(true); }; const handleEditToggle = () => { if (editMode && dirty) { if (!window.confirm("편집 중인 변경사항이 사라집니다. 취소하시겠습니까?")) return; setEditableRows((tree?.rows ?? []).map(r => ({ ...r }))); setDirty(false); } setEditMode(!editMode); }; const handleSave = async () => { if (!projectObjid) return; if (rows.length === 0) { toast.error("저장할 트리가 비어있습니다"); return; } setSaving(true); try { const isUpdate = bomDataType === "SAVED"; const payload = { project_obj_id: projectObjid, is_update: isUpdate, rows: editableRows.map(r => ({ objid: r.objid, parent_objid: r.parent_objid, child_objid: r.child_objid, seq: r.seq, level: r.level, part_objid: r.part_objid, part_no: r.part_no, part_name: r.part_name, qty: r.qty, item_qty: r.item_qty, unit: r.unit, supply_type: r.supply_type, make_or_buy: r.make_or_buy, raw_material_no: r.raw_material_no, raw_material_spec: r.raw_material_spec, raw_material: r.raw_material, raw_material_size: (r as any).raw_material_size ?? r.size, processing_vendor: r.processing_vendor, processing_deadline: r.processing_deadline, grinding_deadline: r.grinding_deadline, required_qty: r.required_qty, order_qty: r.order_qty, production_qty: r.production_qty, remark: r.remark, })), }; const result = await mbomApi.save(payload); toast.success(`M-BOM ${result.mode === "CREATE" ? "생성" : "수정"} 완료 (${result.mbom_no})`); setEditMode(false); setDirty(false); await loadTree(projectObjid); onSaved?.(); } catch (e: any) { toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패"); } finally { setSaving(false); } }; return ( { if (!v && editMode && dirty) { if (!window.confirm("저장하지 않은 변경사항이 있습니다. 닫으시겠습니까?")) return; } onOpenChange(v); }}> M-BOM 관리 — {editMode ? "본 편집" : "단건 상세"} {meta.text} {editMode && dirty && ( 변경됨 )} {/* 헤더 메타 (운영판 mBomHeaderPopup.jsp 1:1) */} {detail && (
)}
총 {rows.length.toLocaleString()}행 · MAX_LEVEL = {maxLevel} {tree?.bom_report_objid && ( BOM_OBJID = {tree.bom_report_objid} )}
{!editMode && ( )} {!editMode && ( )} {canEdit && !editMode && ( )} {editMode && ( <> )}
{loading ? (
) : ( {editMode && ( )} {levelHeaders.map((i) => ( ))} {rows.length === 0 && ( )} {rows.map((r, idx) => { const lv = Number(r.level ?? 1); const cid = String(r.child_objid ?? r.objid ?? ""); const isSelected = editMode && cid && selectedChildObjids.has(cid); const isNew = editMode && String(cid).startsWith("temp-"); return ( {editMode && ( )} {levelHeaders.map((i) => ( ))} updateRow(idx, "qty", v)} /> updateRow(idx, "supply_type", v)} /> updateRow(idx, "make_or_buy", v)} /> updateRow(idx, "raw_material_no", v)} /> updateRow(idx, "raw_material", v)} /> updateRow(idx, "raw_material_size", v)} /> updateRow(idx, "required_qty", v)} /> updateRow(idx, "order_qty", v)} /> updateRow(idx, "production_qty", v)} /> updateRow(idx, "processing_vendor", v)} /> updateRow(idx, "processing_deadline", v)} /> updateRow(idx, "grinding_deadline", v)} /> updateRow(idx, "remark", v)} /> ); })}
0 && selectedChildObjids.size === editableRows.length} onChange={toggleSelectAll} /> {i}품번 품명 수량 항목수량 단위 자/사급 Make/Buy 소재품번 소재 규격 필요수량 주문수량 생산수량 가공업체 가공납기 연삭납기 3D 2D PDF 비고
{bomDataType === "NONE" ? "표시할 BOM이 없습니다." : "트리가 비어있습니다."}
toggleSelect(cid)} /> {i === lv ? "*" : ""} {r.part_no} {r.part_name} {fmtNum(r.item_qty)} {r.unit_title ?? r.unit ?? ""}
)}
{ if (projectObjid) void loadTree(projectObjid); onSaved?.(); }} /> String(r.child_objid) === Array.from(selectedChildObjids)[0])?.part_no ?? undefined : selectedChildObjids.size > 1 ? `${Array.from(selectedChildObjids).length}개 선택 — 첫 항목 부모` : "root (최상위)" } />
); } const SUPPLY_TYPE_OPTIONS = [ { value: "", label: "—" }, { value: "자급", label: "자급" }, { value: "사급", label: "사급" }, ]; const MAKE_OR_BUY_OPTIONS = [ { value: "", label: "—" }, { value: "Make", label: "Make" }, { value: "Buy", label: "Buy" }, ]; function EditableNumCell({ editable, value, onChange }: { editable: boolean; value: any; onChange: (v: any) => void }) { if (!editable) return {fmtNum(value)}; return ( onChange(e.target.value === "" ? null : e.target.value)} /> ); } function EditableTextCell({ editable, value, onChange }: { editable: boolean; value: any; onChange: (v: any) => void }) { if (!editable) return {value ?? ""}; return ( onChange(e.target.value)} /> ); } function EditableDateCell({ editable, value, onChange }: { editable: boolean; value: any; onChange: (v: any) => void }) { if (!editable) return {value ?? ""}; return ( onChange(e.target.value)} /> ); } function EditableSelectCell({ editable, value, options, onChange, }: { editable: boolean; value: any; options: { value: string; label: string }[]; onChange: (v: any) => void }) { if (!editable) return {value ?? ""}; return ( ); } function FolderCell({ n }: { n: any }) { const has = Number(n ?? 0) > 0; return ( ); } function MetaRow({ label, value, numeric }: { label: string; value: any; numeric?: boolean }) { return (
{label} {value != null && value !== "" ? value : "—"}
); } function fmtNum(v: any): string { if (v == null || v === "") return ""; const n = Number(v); if (!isFinite(n)) return String(v); return Number.isInteger(n) ? n.toLocaleString() : n.toLocaleString(undefined, { maximumFractionDigits: 4 }); } function paidLabel(v: string | null | undefined): string { if (v === "paid") return "유상"; if (v === "free") return "무상"; return v ?? ""; }