diff --git a/backend-node/src/controllers/mbomController.ts b/backend-node/src/controllers/mbomController.ts index 787f9217..0af2f834 100644 --- a/backend-node/src/controllers/mbomController.ts +++ b/backend-node/src/controllers/mbomController.ts @@ -58,10 +58,12 @@ export async function searchAssignableEboms(req: AuthenticatedRequest, res: Resp try { const q = req.query as Record; const data = await svc.searchAssignableEboms({ + objid: String(q.objid ?? "").trim() || undefined, + product_cd: String(q.product_cd ?? "").trim() || undefined, search_part_no: String(q.search_part_no ?? "").trim() || undefined, search_part_name: String(q.search_part_name ?? "").trim() || undefined, - search_material: String(q.search_material ?? "").trim() || undefined, - search_supplier: String(q.search_supplier ?? "").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 }); @@ -71,6 +73,19 @@ export async function searchAssignableEboms(req: AuthenticatedRequest, res: Resp } } +// PR-B5 — E-BOM 미리보기 트리 (운영 mBomEbomSelectPopup.jsp iframe 대체) +export async function previewEbomTree(req: AuthenticatedRequest, res: Response) { + try { + const bomReportObjid = String(req.params.bomReportObjid ?? "").trim(); + if (!bomReportObjid) return res.status(400).json({ success: false, message: "bomReportObjid 누락" }); + const data = await svc.previewEbomTree(bomReportObjid); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("E-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 554c6008..7c02a939 100644 --- a/backend-node/src/routes/productionMbomRoutes.ts +++ b/backend-node/src/routes/productionMbomRoutes.ts @@ -17,6 +17,7 @@ router.post("/save", ctrl.save); // PR-B1 본 편집 저장 router.get("/history/:projectObjid", ctrl.getHistory); // PR-B4 변경이력 조회 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 할당 export default router; diff --git a/backend-node/src/services/mbomService.ts b/backend-node/src/services/mbomService.ts index 9e093e67..5bd95986 100644 --- a/backend-node/src/services/mbomService.ts +++ b/backend-node/src/services/mbomService.ts @@ -1241,10 +1241,12 @@ export async function save(payload: MbomSavePayload, sessionUserId: string): Pro // (M-BOM 할당은 PR-B5 v2 — 우선 E-BOM 만) export interface AssignableEbomFilter { + objid?: string; // 단건 정확매칭 (현재 할당 E-BOM 정보 조회용) + product_cd?: string; search_part_no?: string; search_part_name?: string; - search_material?: string; - search_supplier?: string; + search_from_date?: string; // YYYY-MM-DD + search_to_date?: string; limit?: number; } @@ -1268,6 +1270,14 @@ export async function searchAssignableEboms(filter: AssignableEbomFilter): Promi const conds: string[] = []; const params: any[] = []; let idx = 1; + if (filter.objid) { + conds.push(`T.OBJID::VARCHAR = $${idx++}`); + params.push(filter.objid); + } + if (filter.product_cd) { + conds.push(`T.PRODUCT_CD = $${idx++}`); + params.push(filter.product_cd); + } if (filter.search_part_no) { conds.push(`UPPER(T.PART_NO) LIKE '%' || UPPER($${idx++}) || '%'`); params.push(filter.search_part_no); @@ -1276,13 +1286,13 @@ export async function searchAssignableEboms(filter: AssignableEbomFilter): Promi conds.push(`UPPER(T.PART_NAME) LIKE '%' || UPPER($${idx++}) || '%'`); params.push(filter.search_part_name); } - if (filter.search_material) { - conds.push(`UPPER(PM.MATERIAL) LIKE '%' || UPPER($${idx++}) || '%'`); - params.push(filter.search_material); + if (filter.search_from_date) { + conds.push(`T.REGDATE >= TO_DATE($${idx++}, 'YYYY-MM-DD')`); + params.push(filter.search_from_date); } - if (filter.search_supplier) { - conds.push(`UPPER(PM.MAKER) LIKE '%' || UPPER($${idx++}) || '%'`); - params.push(filter.search_supplier); + if (filter.search_to_date) { + conds.push(`T.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) || 100)); const sql = ` @@ -1314,6 +1324,16 @@ export async function searchAssignableEboms(filter: AssignableEbomFilter): Promi return r.rows; } +// E-BOM 미리보기 트리 — bom_report_objid 만으로 EBOM_WORKING_TREE_SQL 호출. +// 운영판 mBomEbomSelectPopup.jsp 의 iframe 미리보기 대체. +export async function previewEbomTree(bomReportObjid: string): Promise { + if (!bomReportObjid) { + return { bom_data_type: "NONE", bom_report_objid: null, max_level: 1, rows: [] }; + } + const rows = await getEbomWorkingTree(bomReportObjid); + return finalize("ASSIGNED_EBOM", bomReportObjid, rows); +} + export type AssignSourceType = "EBOM" | "MBOM"; export async function assignBom( diff --git a/frontend/components/production/MbomAssignDialog.tsx b/frontend/components/production/MbomAssignDialog.tsx index 2d6fad4c..23378358 100644 --- a/frontend/components/production/MbomAssignDialog.tsx +++ b/frontend/components/production/MbomAssignDialog.tsx @@ -2,41 +2,75 @@ // 생산관리 > M-BOM 관리 — BOM 할당 다이얼로그 (PR-B5). // -// 운영판 mBomEbomSelectPopup.jsp + assignEbomToMbom.do 1:1. -// 프로젝트에 source E-BOM (part_bom_report) 을 지정 → project_mgmt.source_bom_type='EBOM'. -// 할당 후 M-BOM 본 다이얼로그 트리가 ASSIGNED_EBOM 분기로 자동 표시됨. +// 운영판 mBomEbomSelectPopup.jsp (324 lines) 1:1 재구성. +// 레이아웃 (상→하): +// 1) 헤더: "E-BOM 선택" 또는 "E-BOM 상세 및 변경" + 닫기/변경 토글 +// 2) 현재 할당된 E-BOM 정보 카드 (할당된 경우만, 2×3 table) +// 3) 리스트 섹션 (할당 안 됐거나 변경 토글 ON 일 때 표시): +// - 검색폼: 제품구분(select) + 품번 + 품명 + 등록일(범위) +// - 헤더: "E-BOM List (상태: Y)" + 우측 [조회][E-BOM 할당] 버튼 +// - 그리드 (제품구분/품번/품명/Ver/등록일/작성자) +// 4) 미리보기: 선택한 E-BOM 의 트리 (운영판 iframe → 직접 렌더) // -// (M-BOM 할당은 PR-B5 v2 예정 — 우선 E-BOM 만) +// (M-BOM 할당은 PR-B5 v2 — 우선 E-BOM 만) -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { - Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, + Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Loader2, Search, Link as LinkIcon } from "lucide-react"; +import { Loader2, Search, Link as LinkIcon, Eye, RefreshCw, Folder } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; -import { mbomApi, AssignableEbomRow } from "@/lib/api/mbom"; +import { mbomApi, AssignableEbomRow, MbomTreeResponse, MbomTreeRow } from "@/lib/api/mbom"; +import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; +import { apiClient } from "@/lib/api/client"; + +const PARENT_PRODUCT = "0000001"; // 제품구분 comm_code parent (운영 동일) interface Props { open: boolean; onOpenChange: (open: boolean) => void; projectObjid: string | null; - currentSourceEbomObjid?: string | null; // 이미 할당돼있으면 그 행 하이라이트 - onAssigned: () => void; // 할당 성공 후 부모가 트리 재조회 + currentEbomObjid?: string | null; // 이미 할당된 E-BOM (있으면 상단 카드 표시) + onAssigned: () => void; } export function MbomAssignDialog({ - open, onOpenChange, projectObjid, currentSourceEbomObjid, onAssigned, + open, onOpenChange, projectObjid, currentEbomObjid, onAssigned, }: Props) { + // 현재 할당된 E-BOM 의 상세 정보 (objid 로 검색해서 자체 로드) + const [currentEbom, setCurrentEbom] = useState(null); + // 검색 폼 const [filter, setFilter] = useState({ - search_part_no: "", search_part_name: "", search_material: "", + product_cd: "", search_part_no: "", search_part_name: "", + search_from_date: "", search_to_date: "", }); + const [productOpts, setProductOpts] = useState([]); + + // 리스트 / 선택 const [rows, setRows] = useState([]); const [loading, setLoading] = useState(false); - const [selectedObjid, setSelectedObjid] = useState(null); - const [assigning, setAssigning] = useState(false); + const [selectedRow, setSelectedRow] = useState(null); + + // 미리보기 트리 + const [preview, setPreview] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + + // 변경 모드 토글 — 할당된 경우 false 가 디폴트, 변경 버튼 클릭 시 true + const [changeMode, setChangeMode] = useState(false); + + const isAssigned = !!currentEbom; + const showList = !isAssigned || changeMode; + + // 제품구분 옵션 로드 + useEffect(() => { + if (!open) return; + apiClient.get(`/sales/codes/${PARENT_PRODUCT}`) + .then(r => setProductOpts(r.data?.data ?? [])) + .catch(() => { /* 옵션 실패 무시 */ }); + }, [open]); const search = (override?: Partial) => { const f = { ...filter, ...override }; @@ -47,144 +81,306 @@ export function MbomAssignDialog({ .finally(() => setLoading(false)); }; + // 다이얼로그 오픈/리셋 useEffect(() => { if (!open) { - setRows([]); setSelectedObjid(null); - setFilter({ search_part_no: "", search_part_name: "", search_material: "" }); + setRows([]); setSelectedRow(null); setPreview(null); + setChangeMode(false); setCurrentEbom(null); + setFilter({ product_cd: "", search_part_no: "", search_part_name: "", search_from_date: "", search_to_date: "" }); return; } - setSelectedObjid(currentSourceEbomObjid ?? null); - search(); + // 현재 할당된 E-BOM 상세 로드 + if (currentEbomObjid) { + mbomApi.searchAssignableEboms({ objid: currentEbomObjid, limit: 1 }) + .then(r => setCurrentEbom(r[0] ?? null)) + .catch(() => setCurrentEbom(null)); + } else { + // 할당 안 된 경우: 리스트 자동 조회 + search(); + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, currentSourceEbomObjid]); + }, [open, currentEbomObjid]); + + // 변경 모드 진입 시 리스트 자동 조회 + useEffect(() => { + if (changeMode) search(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [changeMode]); + + // 미리보기 — 선택 변경 시 트리 로드 + useEffect(() => { + if (!selectedRow?.objid) { setPreview(null); return; } + let alive = true; + setPreviewLoading(true); + mbomApi.previewEbomTree(selectedRow.objid) + .then(t => { if (alive) setPreview(t); }) + .catch((e: any) => toast.error(e?.response?.data?.message ?? e?.message ?? "미리보기 실패")) + .finally(() => { if (alive) setPreviewLoading(false); }); + return () => { alive = false; }; + }, [selectedRow?.objid]); const handleAssign = async () => { if (!projectObjid) return; - if (!selectedObjid) { toast.error("E-BOM 한 건을 선택해주세요"); return; } - if (selectedObjid === currentSourceEbomObjid) { + if (!selectedRow?.objid) { toast.error("E-BOM 한 건을 선택해주세요"); return; } + if (selectedRow.objid === currentEbom?.objid) { toast.info("현재 할당된 E-BOM 과 동일합니다"); return; } - setAssigning(true); + if (!window.confirm(isAssigned + ? `선택한 E-BOM(${selectedRow.part_no}) 으로 변경하시겠습니까?` + : `선택한 E-BOM(${selectedRow.part_no}) 을 할당하시겠습니까?`)) return; try { await mbomApi.assignBom({ project_obj_id: projectObjid, source_bom_type: "EBOM", - source_bom_obj_id: selectedObjid, + source_bom_obj_id: selectedRow.objid, }); - toast.success("E-BOM 이 할당되었습니다"); + toast.success(isAssigned ? "E-BOM 이 변경되었습니다" : "E-BOM 이 할당되었습니다"); onAssigned(); onOpenChange(false); } catch (e: any) { toast.error(e?.response?.data?.message ?? e?.message ?? "할당 실패"); - } finally { - setAssigning(false); } }; return ( - + - BOM 할당 — E-BOM 선택 - {currentSourceEbomObjid && ( - · 현재 할당: {currentSourceEbomObjid} + {isAssigned ? "E-BOM 상세 및 변경" : "E-BOM 선택"} + {isAssigned && !changeMode && ( + )} - {/* 검색 */} -
- 품번 - setFilter({ ...filter, search_part_no: e.target.value })} - onKeyDown={(e) => { if (e.key === "Enter") search(); }} - /> - 품명 - setFilter({ ...filter, search_part_name: e.target.value })} - onKeyDown={(e) => { if (e.key === "Enter") search(); }} - /> - 재료 - setFilter({ ...filter, search_material: e.target.value })} - onKeyDown={(e) => { if (e.key === "Enter") search(); }} - /> - -
- 총 {rows.length.toLocaleString()}건 (최대 100건) -
-
- - {/* 그리드 */}
- - - - - - - - - - - - - - - - {loading && rows.length === 0 ? ( - - ) : rows.length === 0 ? ( - - ) : rows.map(r => { - const isSel = selectedObjid === r.objid; - const isCurrent = currentSourceEbomObjid === r.objid; - return ( - setSelectedObjid(r.objid)}> - - - - - - - - - + {/* 1. 현재 할당된 E-BOM 정보 카드 */} + {isAssigned && currentEbom && ( +
+
+ 📋 현재 할당된 E-BOM 정보 +
+
선택제품구분품번품명재료메이커개정등록일작성자
조회된 E-BOM 이 없습니다.
- setSelectedObjid(r.objid)} /> - {r.product_name ?? ""}{r.part_no} - {isCurrent && (현재)} - {r.part_name}{r.material}{r.supplier}{r.revision ?? ""}{r.reg_date ?? ""}{r.writer_name ?? ""}
+ + + + + + - ); - })} - -
제품구분{currentEbom.product_name ?? ""}품번{currentEbom.part_no ?? ""}
-
+ + 품명 + {currentEbom.part_name ?? ""} + Version + {currentEbom.revision ?? ""} + + + 등록일 + {currentEbom.reg_date ?? ""} + 작성자 + {currentEbom.dept_name ?? ""} / {currentEbom.writer_name ?? ""} + + + + {!changeMode && ( +

+ 다른 E-BOM 으로 변경하려면 우상단 "E-BOM 변경" 버튼을 클릭하세요. +

+ )} + + )} - - - - + {/* 2. 리스트 섹션 (검색 + 그리드) */} + {showList && ( +
+ {/* 검색 폼 */} +
+ + setFilter({ ...filter, product_cd: v })} + /> + + setFilter({ ...filter, search_part_no: e.target.value })} + onKeyDown={(e) => { if (e.key === "Enter") search(); }} /> + + setFilter({ ...filter, search_part_name: e.target.value })} + onKeyDown={(e) => { if (e.key === "Enter") search(); }} /> + +
+ setFilter({ ...filter, search_from_date: e.target.value })} /> + ~ + setFilter({ ...filter, search_to_date: e.target.value })} /> +
+
+ + {/* 리스트 헤더 + 액션 버튼 (운영판: 버튼이 리스트 우상단) */} +
+

E-BOM List (상태: Y · 최대 100건 · 총 {rows.length}건)

+
+ + +
+
+ + {/* 그리드 */} +
+ + + + + + + + + + + + + + {loading && rows.length === 0 ? ( + + ) : rows.length === 0 ? ( + + ) : rows.map(r => { + const isSel = selectedRow?.objid === r.objid; + const isCurrent = currentEbom?.objid === r.objid; + return ( + setSelectedRow(r)}> + + + + + + + + + ); + })} + +
선택제품구분품번품명Ver등록일작성자
조회된 E-BOM 이 없습니다.
+ setSelectedRow(r)} /> + {r.product_name ?? ""} + {r.part_no} + {isCurrent && (현재)} + {r.part_name}{r.revision ?? ""}{r.reg_date ?? ""} + {r.dept_name && r.writer_name + ? `${r.dept_name}/${r.writer_name}` + : (r.writer_name ?? "")} +
+
+
+ )} + + {/* 3. 미리보기 트리 */} + {selectedRow && ( +
+
+ + E-BOM 미리보기 (읽기 전용) — {selectedRow.part_no} / {selectedRow.part_name} +
+
+ {previewLoading ? ( +
+ ) : ( + + )} +
+
+ )} +
); } + +// 미리보기 트리 — 운영판 iframe 대체. 간단한 read-only 표시. +function PreviewTree({ tree }: { tree: MbomTreeResponse | null }) { + const maxLevel = Math.max(1, tree?.max_level ?? 1); + const rows: MbomTreeRow[] = tree?.rows ?? []; + const levels = useMemo(() => Array.from({ length: maxLevel }, (_, i) => i + 1), [maxLevel]); + + if (rows.length === 0) { + return
미리보기 트리가 비어있습니다.
; + } + return ( + + + + {levels.map((lv) => ( + + ))} + + + + + + + + + + + {rows.map((r, idx) => { + const lv = Number(r.level ?? 1); + return ( + + {levels.map(i => ( + + ))} + + + + + + + + + ); + })} + +
{lv}품번품명수량단위3D2DPDF
+ {i === lv ? "*" : ""} + {r.part_no}{r.part_name}{r.qty ?? ""}{r.unit_title ?? r.unit ?? ""}
+ ); +} + +function FolderMini({ n }: { n: any }) { + const has = Number(n ?? 0) > 0; + return ( + + ); +} diff --git a/frontend/components/production/MbomDetailDialog.tsx b/frontend/components/production/MbomDetailDialog.tsx index 2c9b5ef3..6cfa62c3 100644 --- a/frontend/components/production/MbomDetailDialog.tsx +++ b/frontend/components/production/MbomDetailDialog.tsx @@ -471,10 +471,8 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }: open={assignOpen} onOpenChange={setAssignOpen} projectObjid={projectObjid} - currentSourceEbomObjid={ - (detail as any)?.source_bom_type === "EBOM" - ? (detail as any)?.source_ebom_objid - : null + currentEbomObjid={ + detail?.source_bom_type === "EBOM" ? detail.source_ebom_objid : null } onAssigned={() => { if (projectObjid) void loadTree(projectObjid); diff --git a/frontend/lib/api/mbom.ts b/frontend/lib/api/mbom.ts index 8a0ba50e..84c0a057 100644 --- a/frontend/lib/api/mbom.ts +++ b/frontend/lib/api/mbom.ts @@ -259,6 +259,10 @@ export const mbomApi = { const res = await apiClient.get("/production/mbom/assignable-eboms", { params: filter }); return (res.data?.data ?? []) as AssignableEbomRow[]; }, + async previewEbomTree(bomReportObjid: string): Promise { + const res = await apiClient.get(`/production/mbom/ebom-preview/${encodeURIComponent(bomReportObjid)}`); + return res.data?.data as MbomTreeResponse; + }, async assignBom(payload: AssignBomPayload): Promise { const res = await apiClient.post("/production/mbom/assign", payload); return res.data?.data as AssignBomResult; @@ -270,10 +274,12 @@ export const mbomApi = { // project_mgmt.source_bom_type='EBOM' + source_ebom_objid 만 우선 지원. export interface AssignableEbomFilter { + objid?: string; + product_cd?: string; search_part_no?: string; search_part_name?: string; - search_material?: string; - search_supplier?: string; + search_from_date?: string; + search_to_date?: string; limit?: number; }