diff --git a/backend-node/src/services/mbomService.ts b/backend-node/src/services/mbomService.ts index 340fcc55..c8113900 100644 --- a/backend-node/src/services/mbomService.ts +++ b/backend-node/src/services/mbomService.ts @@ -1106,20 +1106,40 @@ export async function save(payload: MbomSavePayload, sessionUserId: string): Pro const existIds = new Set(existRes.rows.map((r: any) => r.objid)); const incomingIds = new Set(); + // 신규 행의 client temp- child_objid → 서버 발급 createObjId 매핑 + // (객체 ID 안정성 + DB 에 temp- 잔존 방지) + const tempChildMap = new Map(); + for (const row of payload.rows ?? []) { + if (!s(row.objid)) { + const newId = createObjId(); + const tempChild = s(row.child_objid); + if (tempChild) tempChildMap.set(tempChild, newId); + // 임시로 row 자체에 새 objid 부여 — 이어지는 루프에서 다시 읽지 않도록 + (row as any).__newObjid = newId; + } + } + // UPSERT for (const row of payload.rows ?? []) { let objid = s(row.objid) ?? ""; - if (!objid) objid = createObjId(); - let childObjid = s(row.child_objid) ?? objid; + const isNew = !objid; + if (isNew) objid = (row as any).__newObjid as string; + // 새 행이면 child_objid 도 새 ID 로 통일, 기존 행이면 그대로 + let childObjid = isNew ? objid : (s(row.child_objid) ?? objid); + // parent_objid: temp- 참조면 신규 발급 ID 로 remap, 아니면 그대로 + const rawParent = s(row.parent_objid); + const parentObjid = rawParent && tempChildMap.has(rawParent) + ? tempChildMap.get(rawParent)! + : rawParent; incomingIds.add(objid); if (existIds.has(objid)) { - await client.query(DETAIL_UPDATE_SQL, detailUpdateParams({ ...row, child_objid: childObjid }, objid, userId)); + await client.query(DETAIL_UPDATE_SQL, detailUpdateParams({ ...row, parent_objid: parentObjid }, objid, userId)); updated++; } else { await client.query( DETAIL_INSERT_SQL, - detailInsertParams(row, objid, mbomHeaderObjid, childObjid, s(row.parent_objid), userId), + detailInsertParams(row, objid, mbomHeaderObjid, childObjid, parentObjid, userId), ); inserted++; } diff --git a/frontend/components/production/MbomAddPartDialog.tsx b/frontend/components/production/MbomAddPartDialog.tsx new file mode 100644 index 00000000..3c343ec7 --- /dev/null +++ b/frontend/components/production/MbomAddPartDialog.tsx @@ -0,0 +1,193 @@ +"use client"; + +// 생산관리 > M-BOM 관리 — 행 추가 시 PART 검색 다이얼로그 (PR-B2). +// +// 운영판 mBomPopupRight.jsp + mBomCenterBtnPopup.jsp 의 우측 패널/추가 흐름 단순화 버전. +// part_mng (개발관리 M2) 를 검색 → multi-select → 부모 컴포넌트로 반환. + +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, Search } from "lucide-react"; +import { toast } from "sonner"; +import { devPartApi, PartRow } from "@/lib/api/devPart"; + +export interface PickedPart { + objid: string; // part_mng.objid (bigint, 문자열로) + part_no: string; + part_name: string; + unit: string | null; +} + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: (parts: PickedPart[]) => void; + parentLabel?: string; // "선택된 부모: XYZ" 표시용 +} + +export function MbomAddPartDialog({ open, onOpenChange, onConfirm, parentLabel }: Props) { + const [filter, setFilter] = useState({ search_part_no: "", search_part_name: "" }); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [page, setPage] = useState(1); + const [pageSize] = useState(50); + const [total, setTotal] = useState(0); + const [checked, setChecked] = useState>(new Set()); + + const search = (override?: Partial & { page?: number }) => { + const p = override?.page ?? 1; + const f = { ...filter, ...override, page: p, page_size: pageSize }; + setLoading(true); + devPartApi.list(f) + .then(res => { + setRows(res.rows ?? []); + setTotal(res.total ?? 0); + setPage(p); + }) + .catch((e: any) => toast.error(e?.response?.data?.message ?? e?.message ?? "PART 조회 실패")) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + if (!open) { setRows([]); setChecked(new Set()); setFilter({ search_part_no: "", search_part_name: "" }); return; } + search({ page: 1 }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const toggleAll = () => { + if (checked.size === rows.length) setChecked(new Set()); + else setChecked(new Set(rows.map(r => String(r.objid)))); + }; + const toggleOne = (id: string) => { + setChecked(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + }; + + const handleConfirm = () => { + if (checked.size === 0) { + toast.error("추가할 PART 를 선택해주세요"); + return; + } + const picked: PickedPart[] = rows + .filter(r => checked.has(String(r.objid))) + .map(r => ({ + objid: String(r.objid), + part_no: r.part_no ?? "", + part_name: r.part_name ?? "", + unit: r.unit ?? null, + })); + onConfirm(picked); + onOpenChange(false); + }; + + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + + return ( + + + + + + PART 검색 — M-BOM 행 추가 + {parentLabel && · 부모: {parentLabel}} + + + + {/* 검색 */} +
+ 품번 + setFilter({ ...filter, search_part_no: e.target.value })} + onKeyDown={(e) => { if (e.key === "Enter") search({ page: 1 }); }} + /> + 품명 + setFilter({ ...filter, search_part_name: e.target.value })} + onKeyDown={(e) => { if (e.key === "Enter") search({ page: 1 }); }} + /> + +
+ 총 {total.toLocaleString()}건 · 선택 {checked.size}건 +
+
+ + {/* 그리드 */} +
+ + + + + + + + + + + + + + {loading && rows.length === 0 ? ( + + ) : rows.length === 0 ? ( + + ) : rows.map(r => { + const id = String(r.objid); + const isChecked = checked.has(id); + return ( + toggleOne(id)}> + + + + + + + + + ); + })} + +
+ 0 && checked.size === rows.length} + onChange={toggleAll} + /> + 품번품명재료규격단위개정
조회된 PART 가 없습니다.
+ toggleOne(id)} onClick={e => e.stopPropagation()} /> + {r.part_no}{r.part_name}{r.material ?? ""}{r.spec ?? ""}{r.unit_title ?? r.unit ?? ""}{r.revision ?? ""}
+
+ + {/* 페이지네이션 */} + {total > pageSize && ( +
+ + {page} / {totalPages} + +
+ )} + + + + + +
+
+ ); +} diff --git a/frontend/components/production/MbomDetailDialog.tsx b/frontend/components/production/MbomDetailDialog.tsx index bd0f430a..2b0a56f9 100644 --- a/frontend/components/production/MbomDetailDialog.tsx +++ b/frontend/components/production/MbomDetailDialog.tsx @@ -22,11 +22,12 @@ import { } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Loader2, Folder, Pencil, Save, X, History } from "lucide-react"; +import { Loader2, Folder, Pencil, Save, X, History, Plus, Trash2 } 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"; interface Props { open: boolean; @@ -60,6 +61,8 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }: const [editableRows, setEditableRows] = useState([]); const [dirty, setDirty] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); + const [addPartOpen, setAddPartOpen] = useState(false); + const [selectedChildObjids, setSelectedChildObjids] = useState>(new Set()); const loadTree = (objid: string) => { setLoading(true); @@ -83,11 +86,17 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }: 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"; @@ -109,6 +118,89 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }: 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; @@ -228,6 +320,12 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }: )} {editMode && ( <> + + @@ -249,6 +347,13 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }: + {editMode && ( + + )} {levelHeaders.map((i) => ( ))} @@ -277,15 +382,27 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }: {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) => (
+ 0 && selectedChildObjids.size === editableRows.length} + onChange={toggleSelectAll} /> + {i}
+ {bomDataType === "NONE" ? "표시할 BOM이 없습니다." : "트리가 비어있습니다."}
+ toggleSelect(cid)} /> + {i === lv ? "*" : ""} @@ -341,6 +458,19 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }: onOpenChange={setHistoryOpen} projectObjid={projectObjid} /> + + String(r.child_objid) === Array.from(selectedChildObjids)[0])?.part_no ?? undefined + : selectedChildObjids.size > 1 + ? `${Array.from(selectedChildObjids).length}개 선택 — 첫 항목 부모` + : "root (최상위)" + } + /> ); }