From 6368258797fbae1fd860f240694b17368c90e7fa Mon Sep 17 00:00:00 2001 From: hjjeong Date: Fri, 15 May 2026 10:53:35 +0900 Subject: [PATCH] =?UTF-8?q?=EC=83=9D=EC=82=B0=EA=B4=80=EB=A6=AC=20M-BOM=20?= =?UTF-8?q?PR-B5+=20=E2=80=94=20BOM=20=EB=B3=B5=EC=82=AC=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=B4=EC=96=BC=EB=A1=9C=EA=B7=B8=20(=EC=9A=B4=EC=98=81=20st?= =?UTF-8?q?ructureBomCopyFormPopup=201:1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5개 메뉴 1:1 검증(Agent 5병렬) 후 발견된 mbom 메인 그리드 [BOM 복사] 누락 보완. 저장 매퍼는 PR-B5 saveBomAssignment 재사용 — 백엔드는 EBOM/MBOM 분기 이미 지원, 이번에 MBOM UI 진입점도 첫 노출. 신규 컴포넌트: - BomCopyDialog.tsx — 품번/품명 readonly + E-BOM/M-BOM 셀렉트 상호배타 + 트리 미리보기 + AttachFileDropZone (docType=MBOM_DRAWING, accept=.stp,.step,.dwg,.dxf,.pdf) - 메인 page.tsx [BOM 복사] 버튼 추가 + 체크 단건 검증 + Machine(0000928) 외 동일 partNo 최신 M-BOM 자동 추천 신규 백엔드 (mbomService/Controller/Routes): - searchAssignableMboms — 매퍼 productionplanning.getMbomListForSelect2 (4007~4014) 1:1 - previewMbomTree — getStructureOnly + finalize("ASSIGNED_MBOM") - getLatestMbomByPartNo — 매퍼 getLatestMbomByPartNo (3426~3445) 1:1, Machine 외 자동 검색 - 라우트: GET /assignable-mboms, GET /mbom-preview/:objid, GET /latest-mbom-by-partno/:partNo 도면 업로드 차이: 운영 fn_uploadDrawingFiles 는 placeholder("구현 예정"). RPS 는 공용 AttachFileDropZone 재사용해 실구현 (target_objid=projectObjid). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/controllers/mbomController.ts | 44 +++ .../src/routes/productionMbomRoutes.ts | 3 + backend-node/src/services/mbomService.ts | 106 ++++++ .../COMPANY_16/production/mbom/page.tsx | 92 ++++- .../components/production/BomCopyDialog.tsx | 353 ++++++++++++++++++ frontend/lib/api/mbom.ts | 40 ++ 6 files changed, 625 insertions(+), 13 deletions(-) create mode 100644 frontend/components/production/BomCopyDialog.tsx diff --git a/backend-node/src/controllers/mbomController.ts b/backend-node/src/controllers/mbomController.ts index 0af2f834..245b786a 100644 --- a/backend-node/src/controllers/mbomController.ts +++ b/backend-node/src/controllers/mbomController.ts @@ -86,6 +86,50 @@ export async function previewEbomTree(req: AuthenticatedRequest, res: Response) } } +// PR-B5+ — 할당 가능한 M-BOM 검색 (운영 BOM 복사 다이얼로그 M-BOM 셀렉트 옵션) +export async function searchAssignableMboms(req: AuthenticatedRequest, res: Response) { + try { + const q = req.query as Record; + const data = await svc.searchAssignableMboms({ + search_part_no: String(q.search_part_no ?? "").trim() || undefined, + search_part_name: String(q.search_part_name ?? "").trim() || undefined, + search_from_date: String(q.search_from_date ?? "").trim() || undefined, + search_to_date: String(q.search_to_date ?? "").trim() || undefined, + limit: q.limit ? Number(q.limit) : undefined, + }); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("할당 가능 M-BOM 조회 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// PR-B5+ — M-BOM 미리보기 트리 (운영 BOM 복사 다이얼로그 M-BOM 선택 시) +export async function previewMbomTree(req: AuthenticatedRequest, res: Response) { + try { + const mbomHeaderObjid = String(req.params.mbomHeaderObjid ?? "").trim(); + if (!mbomHeaderObjid) return res.status(400).json({ success: false, message: "mbomHeaderObjid 누락" }); + const data = await svc.previewMbomTree(mbomHeaderObjid); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("M-BOM 미리보기 조회 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// PR-B5+ — 동일 partNo 의 최신 M-BOM 조회 (운영 getLatestMbomByPartNo, Machine 외 자동 추천) +export async function getLatestMbomByPartNo(req: AuthenticatedRequest, res: Response) { + try { + const partNo = String(req.params.partNo ?? "").trim(); + if (!partNo) return res.status(400).json({ success: false, message: "partNo 누락" }); + const data = await svc.getLatestMbomByPartNo(partNo); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("최신 M-BOM 조회 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + // PR-B5 — BOM 할당 (운영 saveBomAssignment.do 1:1) export async function assignBom(req: AuthenticatedRequest, res: Response) { try { diff --git a/backend-node/src/routes/productionMbomRoutes.ts b/backend-node/src/routes/productionMbomRoutes.ts index 7c02a939..62e90798 100644 --- a/backend-node/src/routes/productionMbomRoutes.ts +++ b/backend-node/src/routes/productionMbomRoutes.ts @@ -19,5 +19,8 @@ router.post("/sales-request", ctrl.createSalesRequest); // PR-B3 구매리스트 router.get("/assignable-eboms", ctrl.searchAssignableEboms); // PR-B5 할당 가능 E-BOM 검색 router.get("/ebom-preview/:bomReportObjid", ctrl.previewEbomTree); // PR-B5 E-BOM 미리보기 router.post("/assign", ctrl.assignBom); // PR-B5 BOM 할당 +router.get("/assignable-mboms", ctrl.searchAssignableMboms); // PR-B5+ BOM 복사 - M-BOM 셀렉트 옵션 +router.get("/mbom-preview/:mbomHeaderObjid", ctrl.previewMbomTree); // PR-B5+ BOM 복사 - M-BOM 미리보기 +router.get("/latest-mbom-by-partno/:partNo", ctrl.getLatestMbomByPartNo); // PR-B5+ Machine 외 자동 추천 export default router; diff --git a/backend-node/src/services/mbomService.ts b/backend-node/src/services/mbomService.ts index 5bd95986..0b369c5b 100644 --- a/backend-node/src/services/mbomService.ts +++ b/backend-node/src/services/mbomService.ts @@ -1357,6 +1357,112 @@ export async function assignBom( return { success: true, source_bom_type: sourceBomType, source_obj_id: sourceBomObjId }; } +// ─── BOM 복사 다이얼로그 보조 (PR-B5+) ───────────────────────── +// +// 운영판 partMng/structureBomCopyFormPopup.jsp 진입점 보조 함수. +// 저장은 위 assignBom 재사용 (sourceBomType='EBOM'|'MBOM'). + +export interface AssignableMbomFilter { + search_part_no?: string; + search_part_name?: string; + search_from_date?: string; + search_to_date?: string; + limit?: number; +} + +export interface AssignableMbomRow { + objid: string; + mbom_no: string | null; + part_no: string | null; + part_name: string | null; + reg_date: string | null; +} + +// 매퍼 productionplanning.getMbomListForSelect2 (4007~4014) 1:1 + 검색 필터 확장. +export async function searchAssignableMboms(filter: AssignableMbomFilter = {}): Promise { + const pool = getPool(); + const conds: string[] = []; + const params: any[] = []; + let idx = 1; + if (filter.search_part_no) { + conds.push(`UPPER(MH.PART_NO) LIKE '%' || UPPER($${idx++}) || '%'`); + params.push(filter.search_part_no); + } + if (filter.search_part_name) { + conds.push(`UPPER(MH.PART_NAME) LIKE '%' || UPPER($${idx++}) || '%'`); + params.push(filter.search_part_name); + } + if (filter.search_from_date) { + conds.push(`MH.REGDATE >= TO_DATE($${idx++}, 'YYYY-MM-DD')`); + params.push(filter.search_from_date); + } + if (filter.search_to_date) { + conds.push(`MH.REGDATE < TO_DATE($${idx++}, 'YYYY-MM-DD') + INTERVAL '1 day'`); + params.push(filter.search_to_date); + } + const limit = Math.min(500, Math.max(1, Number(filter.limit) || 200)); + const sql = ` + SELECT + MH.OBJID::VARCHAR AS objid, + COALESCE(MH.MBOM_NO, '') AS mbom_no, + COALESCE(MH.PART_NO, '') AS part_no, + COALESCE(MH.PART_NAME, '') AS part_name, + TO_CHAR(MH.REGDATE, 'YYYY-MM-DD') AS reg_date + FROM MBOM_HEADER MH + WHERE MH.STATUS = 'Y' + ${conds.length ? "AND " + conds.join(" AND ") : ""} + ORDER BY MH.REGDATE DESC, MH.MBOM_NO + LIMIT ${limit} + `; + const r = await pool.query(sql, params); + return r.rows; +} + +// M-BOM 미리보기 트리 — mbom_header_objid 만으로 STRUCTURE_ONLY_SQL 호출. +// 운영판 structureBomCopyFormPopup 의 BOM 트리 미리보기 (M-BOM 선택 시). +export async function previewMbomTree(mbomHeaderObjid: string): Promise { + if (!mbomHeaderObjid) { + return { bom_data_type: "NONE", bom_report_objid: null, max_level: 1, rows: [] }; + } + const rows = await getStructureOnly(mbomHeaderObjid); + return finalize("ASSIGNED_MBOM", mbomHeaderObjid, rows); +} + +export interface LatestMbomByPartNoRow { + template_header_objid: string; + mbom_no: string | null; + project_objid: string | null; + part_no: string | null; + part_name: string | null; + save_date: string | null; +} + +// 매퍼 productionplanning.getLatestMbomByPartNo (3426~3445) 1:1. +// Machine(0000928) 이외 제품 + 동일 partNo + STATUS='Y' 최신 1건. +export async function getLatestMbomByPartNo(partNo: string): Promise { + if (!partNo || !partNo.trim()) return null; + const pool = getPool(); + const sql = ` + SELECT + MH.OBJID::VARCHAR AS template_header_objid, + MH.MBOM_NO AS mbom_no, + MH.PROJECT_OBJID::VARCHAR AS project_objid, + MH.PART_NO AS part_no, + MH.PART_NAME AS part_name, + TO_CHAR(MH.REGDATE, 'YYYY-MM-DD') AS save_date + FROM MBOM_HEADER MH + INNER JOIN PROJECT_MGMT PM ON MH.PROJECT_OBJID = PM.OBJID + INNER JOIN CONTRACT_MGMT CM ON PM.CONTRACT_OBJID = CM.OBJID + WHERE MH.PART_NO = $1 + AND MH.STATUS = 'Y' + AND CM.PRODUCT != '0000928' + ORDER BY MH.REGDATE DESC + LIMIT 1 + `; + const r = await pool.query(sql, [partNo.trim()]); + return r.rows[0] ?? null; +} + // ─── 변경이력 조회 (PR-B4) ────────────────────────────────── // // 매퍼 productionplanning.getMbomHistory (3448~3470) 1:1. diff --git a/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx b/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx index 0ae507d4..e6300a4d 100644 --- a/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx +++ b/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx @@ -11,7 +11,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; -import { ShoppingCart, Loader2 } from "lucide-react"; +import { ShoppingCart, Loader2, Copy } from "lucide-react"; import { toast } from "sonner"; import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; @@ -22,6 +22,7 @@ import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { apiClient } from "@/lib/api/client"; import { mbomApi, MbomListFilter, MbomRow } from "@/lib/api/mbom"; import { MbomDetailDialog } from "@/components/production/MbomDetailDialog"; +import { BomCopyDialog } from "@/components/production/BomCopyDialog"; import { exportToExcel } from "@/lib/utils/excelExport"; const PARENT_CATEGORY = "0000167"; // 주문유형 comm_code parent_code_id @@ -78,6 +79,16 @@ export default function MbomMgmtPage() { const [checkedIds, setCheckedIds] = useState([]); const [creatingPurchaseList, setCreatingPurchaseList] = useState(false); + // PR-B5+: BOM 복사 — 단건 체크 + 다이얼로그 (wace fn_openBomCopyPopup 1:1) + const [bomCopyOpen, setBomCopyOpen] = useState(false); + const [bomCopyTarget, setBomCopyTarget] = useState<{ + projectObjid: string; + partNo: string; + partName: string; + productCode: string; + hasMbom: boolean; + } | null>(null); + const fetchList = useCallback(async (override?: Partial) => { setLoading(true); try { @@ -224,6 +235,32 @@ export default function MbomMgmtPage() { } }, [checkedIds, gridRows, confirm, fetchList]); + // PR-B5+ — BOM 복사 (wace fn_openBomCopyPopup 1:1) + // 검증: 단건 체크 + objid 확인. M-BOM 이미 있으면 다이얼로그 내부에서 "초기화" 경고. + const handleOpenBomCopy = useCallback(() => { + if (checkedIds.length === 0) { + toast.info("BOM 을 복사할 프로젝트를 선택해주세요."); + return; + } + if (checkedIds.length > 1) { + toast.info("한 번에 하나의 프로젝트만 선택해주세요."); + return; + } + const row = gridRows.find((r: any) => r.id === checkedIds[0]) as any; + if (!row?.objid) { + toast.error("프로젝트 OBJID를 찾을 수 없습니다."); + return; + } + setBomCopyTarget({ + projectObjid: String(row.objid), + partNo: String(row.part_no ?? ""), + partName: String(row.part_name ?? ""), + productCode: String(row.product ?? ""), + hasMbom: !!(row.mbom_header_objid && String(row.mbom_status ?? "") === "Y"), + }); + setBomCopyOpen(true); + }, [checkedIds, gridRows]); + return (
- {creatingPurchaseList - ? - : } - 구매리스트 생성 - + <> + + + } /> + { + setBomCopyOpen(v); + if (!v) setBomCopyTarget(null); + }} + projectObjid={bomCopyTarget?.projectObjid ?? null} + partNo={bomCopyTarget?.partNo ?? null} + partName={bomCopyTarget?.partName ?? null} + productCode={bomCopyTarget?.productCode ?? null} + hasMbom={bomCopyTarget?.hasMbom ?? false} + onSaved={() => { + setCheckedIds([]); + fetchList(); + }} + /> + {ConfirmDialogComponent}
); diff --git a/frontend/components/production/BomCopyDialog.tsx b/frontend/components/production/BomCopyDialog.tsx new file mode 100644 index 00000000..a18d9295 --- /dev/null +++ b/frontend/components/production/BomCopyDialog.tsx @@ -0,0 +1,353 @@ +"use client"; + +// 생산관리 > M-BOM 관리 — BOM 복사 다이얼로그 (PR-B5+). +// +// 운영판 partMng/structureBomCopyFormPopup.jsp (774 lines) 1:1 재구성. +// 진입: 메인 그리드 [BOM 복사] 버튼 (체크박스 1개 선택). +// 저장: /production/mbom/assign (saveBomAssignment) — MbomAssignDialog 와 동일 매퍼. +// 차이점: E-BOM/M-BOM 셀렉트 두 개 (상호배타) + 트리 미리보기 + 도면 다중 업로드. +// +// 운영판 fn_uploadDrawingFiles 는 placeholder ("구현 예정") — RPS 는 공용 AttachFileDropZone +// 재사용으로 실구현 (target_objid=projectObjid, docType="MBOM_DRAWING"). + +import React, { useEffect, useMemo, useState } from "react"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Loader2, Copy, Folder, X as XIcon } from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { + mbomApi, + AssignableEbomRow, + AssignableMbomRow, + MbomTreeResponse, + MbomTreeRow, + LatestMbomByPartNoRow, +} from "@/lib/api/mbom"; +import { AttachFileDropZone } from "@/components/common/AttachFileDropZone"; + +type SourceType = "EBOM" | "MBOM"; + +const MACHINE_PRODUCT_CD = "0000928"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + projectObjid: string | null; + partNo: string | null; + partName: string | null; + productCode?: string | null; // contract_mgmt.product — Machine 외 자동 추천 분기용 + hasMbom?: boolean; // 이미 저장된 M-BOM 이 있으면 "초기화" 경고 + onSaved: () => void; +} + +export function BomCopyDialog({ + open, onOpenChange, projectObjid, partNo, partName, productCode, hasMbom, onSaved, +}: Props) { + // 셀렉트 옵션 + 선택 상태 + const [ebomOpts, setEbomOpts] = useState([]); + const [mbomOpts, setMbomOpts] = useState([]); + const [ebomLoading, setEbomLoading] = useState(false); + const [mbomLoading, setMbomLoading] = useState(false); + const [selectedType, setSelectedType] = useState(null); + const [selectedObjid, setSelectedObjid] = useState(""); + + // 미리보기 트리 + const [preview, setPreview] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + + // 저장 진행 + 자동 추천 안내 1회 가드 + const [saving, setSaving] = useState(false); + const autoCheckedRef = React.useRef(false); + + // ── 오픈 시 초기화 + 옵션 로드 + Machine 외 자동 추천 ── + useEffect(() => { + if (!open) { + // 닫힐 때 상태 초기화 + setSelectedType(null); + setSelectedObjid(""); + setPreview(null); + autoCheckedRef.current = false; + return; + } + // E-BOM 옵션 + setEbomLoading(true); + mbomApi.searchAssignableEboms({ limit: 500 }) + .then(setEbomOpts) + .catch((e: any) => toast.error(e?.response?.data?.message ?? e?.message ?? "E-BOM 옵션 조회 실패")) + .finally(() => setEbomLoading(false)); + // M-BOM 옵션 + setMbomLoading(true); + mbomApi.searchAssignableMboms({ limit: 500 }) + .then(setMbomOpts) + .catch((e: any) => toast.error(e?.response?.data?.message ?? e?.message ?? "M-BOM 옵션 조회 실패")) + .finally(() => setMbomLoading(false)); + }, [open]); + + // Machine 이외 제품 + partNo 있으면 자동 추천 (운영 fn_checkExistingMbom) + useEffect(() => { + if (!open) return; + if (autoCheckedRef.current) return; + if (!partNo || !partNo.trim()) return; + const isMachine = (productCode ?? "").trim() === MACHINE_PRODUCT_CD; + if (isMachine) return; + autoCheckedRef.current = true; + mbomApi.getLatestMbomByPartNo(partNo) + .then((row) => { + if (!row) return; + const ok = window.confirm( + `동일 품번(${partNo})의 M-BOM이 이미 존재합니다.\n` + + `M-BOM 품번: ${row.mbom_no ?? ""}\n저장일: ${row.save_date ?? ""}\n\n` + + `기존 M-BOM 을 자동으로 불러오시겠습니까?` + ); + if (ok) { + setSelectedType("MBOM"); + setSelectedObjid(String(row.template_header_objid)); + } + }) + .catch(() => { /* 실패 시 조용히 무시 — 자동 추천은 부가기능 */ }); + }, [open, partNo, productCode]); + + // ── 선택 변경 시 트리 미리보기 ──────────────────────────── + useEffect(() => { + if (!selectedType || !selectedObjid) { + setPreview(null); + return; + } + setPreviewLoading(true); + const fetcher = selectedType === "EBOM" + ? mbomApi.previewEbomTree(selectedObjid) + : mbomApi.previewMbomTree(selectedObjid); + fetcher + .then(setPreview) + .catch((e: any) => toast.error(e?.response?.data?.message ?? e?.message ?? "트리 미리보기 실패")) + .finally(() => setPreviewLoading(false)); + }, [selectedType, selectedObjid]); + + const previewTitle = useMemo(() => { + if (!selectedType || !selectedObjid) return "BOM 데이터를 조회하려면 E-BOM 또는 M-BOM 을 선택하세요."; + if (selectedType === "EBOM") { + const r = ebomOpts.find(o => String(o.objid) === String(selectedObjid)); + return r ? `${r.part_no ?? ""} - ${r.part_name ?? ""}${r.revision ? ` (Rev.${r.revision})` : ""}` : ""; + } + const r = mbomOpts.find(o => String(o.objid) === String(selectedObjid)); + return r ? `${r.mbom_no ?? ""}${r.part_name ? ` - ${r.part_name}` : ""}` : ""; + }, [selectedType, selectedObjid, ebomOpts, mbomOpts]); + + // ── 저장 (운영 fn_saveBomCopy → saveBomAssignment.do) ───── + const handleSave = async () => { + if (!projectObjid) { toast.error("프로젝트가 선택되지 않았습니다."); return; } + if (!partNo || !partName) { toast.error("품번과 품명이 비어 있습니다."); return; } + if (!selectedType || !selectedObjid) { toast.error("복사할 BOM 을 선택하세요."); return; } + if (!preview || preview.rows.length === 0) { toast.error("복사할 BOM 데이터가 없습니다."); return; } + + if (hasMbom) { + const ok = window.confirm("저장된 M-BOM 이 초기화 됩니다.\n계속하시겠습니까?"); + if (!ok) return; + } + const ok = window.confirm( + `선택한 ${selectedType} 을(를) M-BOM 기준으로 할당하시겠습니까?\n` + + `실제 M-BOM 생성은 M-BOM 상세 팝업에서 저장 시 이루어집니다.` + ); + if (!ok) return; + + try { + setSaving(true); + await mbomApi.assignBom({ + project_obj_id: projectObjid, + source_bom_type: selectedType, + source_bom_obj_id: selectedObjid, + }); + toast.success("BOM 할당 정보가 저장되었습니다."); + onSaved(); + onOpenChange(false); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "BOM 할당 실패"); + } finally { + setSaving(false); + } + }; + + return ( + + + + + + BOM 복사 + + + +
+ {/* 상단: 품번/품명 readonly + [저장] [닫기] (운영 1:1) */} +
+
+ + +
+
+ + +
+
+ + +
+
+ + {/* 중단: E-BOM / M-BOM 셀렉트 (상호배타) */} +
+
E-BOM 선택
+
+ +
+
M-BOM 선택
+
+ +
+
+ + {/* 하단: 미리보기 트리 + 도면 업로드 */} +
+
+ {previewTitle} + + {previewLoading ? "로딩…" : preview ? `${preview.rows.length} 행 · 최대 ${preview.max_level} 레벨` : ""} + +
+ +
+ + {/* 도면 다중 업로드 (운영판 fn_uploadDrawingFiles placeholder → RPS 실구현) */} +
+
+ + 도면 다중 업로드 + + .stp .step .dwg .dxf .pdf +
+ {projectObjid ? ( + + ) : ( +
프로젝트 선택 후 도면 업로드가 가능합니다.
+ )} +
+
+
+
+ ); +} + +// ─── 트리 미리보기 (간단 그리드) ─────────────────────────────── +function PreviewTree({ + preview, loading, +}: { preview: MbomTreeResponse | null; loading: boolean }) { + if (loading) { + return ( +
+ 트리 로딩 중… +
+ ); + } + if (!preview || preview.rows.length === 0) { + return ( +
표시할 BOM 데이터가 없습니다.
+ ); + } + const maxLevel = preview.max_level || 1; + return ( +
+ + + + + {Array.from({ length: maxLevel }).map((_, i) => ( + + ))} + + + + + + + + + {preview.rows.map((r: MbomTreeRow, idx: number) => ( + + + {Array.from({ length: maxLevel }).map((_, i) => ( + + ))} + + + + + + + ))} + +
#{i + 1}품번품명수량규격소재
{idx + 1} + {Number(r.level) === i + 1 ? "*" : ""} + {r.part_no ?? ""}{r.part_name ?? ""}{r.item_qty ?? r.qty ?? ""}{r.spec ?? ""}{r.material ?? ""}
+
+ ); +} + +function levelBg(level: number | string | null | undefined): string { + const n = Number(level) || 0; + switch (n) { + case 1: return "bg-white"; + case 2: return "bg-blue-50"; + case 3: return "bg-amber-50"; + case 4: return "bg-emerald-50"; + case 5: return "bg-rose-50"; + default: return ""; + } +} diff --git a/frontend/lib/api/mbom.ts b/frontend/lib/api/mbom.ts index 84c0a057..b96aa538 100644 --- a/frontend/lib/api/mbom.ts +++ b/frontend/lib/api/mbom.ts @@ -267,6 +267,18 @@ export const mbomApi = { const res = await apiClient.post("/production/mbom/assign", payload); return res.data?.data as AssignBomResult; }, + async searchAssignableMboms(filter: AssignableMbomFilter = {}): Promise { + const res = await apiClient.get("/production/mbom/assignable-mboms", { params: filter }); + return (res.data?.data ?? []) as AssignableMbomRow[]; + }, + async previewMbomTree(mbomHeaderObjid: string): Promise { + const res = await apiClient.get(`/production/mbom/mbom-preview/${encodeURIComponent(mbomHeaderObjid)}`); + return res.data?.data as MbomTreeResponse; + }, + async getLatestMbomByPartNo(partNo: string): Promise { + const res = await apiClient.get(`/production/mbom/latest-mbom-by-partno/${encodeURIComponent(partNo)}`); + return (res.data?.data ?? null) as LatestMbomByPartNoRow | null; + }, }; // ─── BOM 할당 (PR-B5) ─────────────────────────────────────── @@ -310,6 +322,34 @@ export interface AssignBomResult { source_obj_id: string; } +// ─── BOM 복사 보조 (PR-B5+) ───────────────────────────────── +// 운영판 partMng/structureBomCopyFormPopup.jsp 의 셀렉트/자동검색 보조 API. + +export interface AssignableMbomFilter { + search_part_no?: string; + search_part_name?: string; + search_from_date?: string; + search_to_date?: string; + limit?: number; +} + +export interface AssignableMbomRow { + objid: string; + mbom_no: string | null; + part_no: string | null; + part_name: string | null; + reg_date: string | null; +} + +export interface LatestMbomByPartNoRow { + template_header_objid: string; + mbom_no: string | null; + project_objid: string | null; + part_no: string | null; + part_name: string | null; + save_date: string | null; +} + // ─── 변경이력 (PR-B4) ─────────────────────────────────────── export interface MbomHistoryRow {