생산관리 M-BOM PR-B5+ — BOM 복사 다이얼로그 (운영 structureBomCopyFormPopup 1:1)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, any>;
|
||||||
|
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)
|
// PR-B5 — BOM 할당 (운영 saveBomAssignment.do 1:1)
|
||||||
export async function assignBom(req: AuthenticatedRequest, res: Response) {
|
export async function assignBom(req: AuthenticatedRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -19,5 +19,8 @@ router.post("/sales-request", ctrl.createSalesRequest); // PR-B3 구매리스트
|
|||||||
router.get("/assignable-eboms", ctrl.searchAssignableEboms); // PR-B5 할당 가능 E-BOM 검색
|
router.get("/assignable-eboms", ctrl.searchAssignableEboms); // PR-B5 할당 가능 E-BOM 검색
|
||||||
router.get("/ebom-preview/:bomReportObjid", ctrl.previewEbomTree); // PR-B5 E-BOM 미리보기
|
router.get("/ebom-preview/:bomReportObjid", ctrl.previewEbomTree); // PR-B5 E-BOM 미리보기
|
||||||
router.post("/assign", ctrl.assignBom); // PR-B5 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;
|
export default router;
|
||||||
|
|||||||
@@ -1357,6 +1357,112 @@ export async function assignBom(
|
|||||||
return { success: true, source_bom_type: sourceBomType, source_obj_id: sourceBomObjId };
|
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<AssignableMbomRow[]> {
|
||||||
|
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<MbomTreeResult> {
|
||||||
|
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<LatestMbomByPartNoRow | null> {
|
||||||
|
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) ──────────────────────────────────
|
// ─── 변경이력 조회 (PR-B4) ──────────────────────────────────
|
||||||
//
|
//
|
||||||
// 매퍼 productionplanning.getMbomHistory (3448~3470) 1:1.
|
// 매퍼 productionplanning.getMbomHistory (3448~3470) 1:1.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ShoppingCart, Loader2 } from "lucide-react";
|
import { ShoppingCart, Loader2, Copy } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||||
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
|
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
|
||||||
@@ -22,6 +22,7 @@ import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
|||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { mbomApi, MbomListFilter, MbomRow } from "@/lib/api/mbom";
|
import { mbomApi, MbomListFilter, MbomRow } from "@/lib/api/mbom";
|
||||||
import { MbomDetailDialog } from "@/components/production/MbomDetailDialog";
|
import { MbomDetailDialog } from "@/components/production/MbomDetailDialog";
|
||||||
|
import { BomCopyDialog } from "@/components/production/BomCopyDialog";
|
||||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
|
||||||
const PARENT_CATEGORY = "0000167"; // 주문유형 comm_code parent_code_id
|
const PARENT_CATEGORY = "0000167"; // 주문유형 comm_code parent_code_id
|
||||||
@@ -78,6 +79,16 @@ export default function MbomMgmtPage() {
|
|||||||
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||||
const [creatingPurchaseList, setCreatingPurchaseList] = useState(false);
|
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<MbomListFilter>) => {
|
const fetchList = useCallback(async (override?: Partial<MbomListFilter>) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -224,6 +235,32 @@ export default function MbomMgmtPage() {
|
|||||||
}
|
}
|
||||||
}, [checkedIds, gridRows, confirm, fetchList]);
|
}, [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 (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
|
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -231,18 +268,30 @@ export default function MbomMgmtPage() {
|
|||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
onReset={handleReset}
|
onReset={handleReset}
|
||||||
actions={
|
actions={
|
||||||
<Button
|
<>
|
||||||
size="sm"
|
<Button
|
||||||
variant="default"
|
size="sm"
|
||||||
className="h-8 gap-1 px-2 text-xs"
|
variant="outline"
|
||||||
onClick={handleCreatePurchaseList}
|
className="h-8 gap-1 px-2 text-xs"
|
||||||
disabled={creatingPurchaseList || checkedIds.length === 0}
|
onClick={handleOpenBomCopy}
|
||||||
>
|
disabled={checkedIds.length === 0}
|
||||||
{creatingPurchaseList
|
>
|
||||||
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
<Copy className="h-3.5 w-3.5" />
|
||||||
: <ShoppingCart className="h-3.5 w-3.5" />}
|
BOM 복사
|
||||||
구매리스트 생성
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
className="h-8 gap-1 px-2 text-xs"
|
||||||
|
onClick={handleCreatePurchaseList}
|
||||||
|
disabled={creatingPurchaseList || checkedIds.length === 0}
|
||||||
|
>
|
||||||
|
{creatingPurchaseList
|
||||||
|
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
: <ShoppingCart className="h-3.5 w-3.5" />}
|
||||||
|
구매리스트 생성
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<CompactFilterBar
|
<CompactFilterBar
|
||||||
@@ -365,6 +414,23 @@ export default function MbomMgmtPage() {
|
|||||||
onSaved={fetchList}
|
onSaved={fetchList}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<BomCopyDialog
|
||||||
|
open={bomCopyOpen}
|
||||||
|
onOpenChange={(v) => {
|
||||||
|
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}
|
{ConfirmDialogComponent}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<AssignableEbomRow[]>([]);
|
||||||
|
const [mbomOpts, setMbomOpts] = useState<AssignableMbomRow[]>([]);
|
||||||
|
const [ebomLoading, setEbomLoading] = useState(false);
|
||||||
|
const [mbomLoading, setMbomLoading] = useState(false);
|
||||||
|
const [selectedType, setSelectedType] = useState<SourceType | null>(null);
|
||||||
|
const [selectedObjid, setSelectedObjid] = useState<string>("");
|
||||||
|
|
||||||
|
// 미리보기 트리
|
||||||
|
const [preview, setPreview] = useState<MbomTreeResponse | null>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-[1400px] w-[95vw] max-h-[92vh] overflow-hidden flex flex-col p-0">
|
||||||
|
<DialogHeader className="px-6 pt-5 pb-3 border-b">
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Copy className="h-4 w-4 text-blue-600" />
|
||||||
|
BOM 복사
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto px-6 py-4 space-y-4">
|
||||||
|
{/* 상단: 품번/품명 readonly + [저장] [닫기] (운영 1:1) */}
|
||||||
|
<div className="grid grid-cols-12 gap-3 items-end">
|
||||||
|
<div className="col-span-3">
|
||||||
|
<label className="text-xs text-muted-foreground">품번</label>
|
||||||
|
<Input value={partNo ?? ""} readOnly className="bg-muted h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-5">
|
||||||
|
<label className="text-xs text-muted-foreground">품명</label>
|
||||||
|
<Input value={partName ?? ""} readOnly className="bg-muted h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-4 flex gap-2 justify-end">
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||||
|
<XIcon className="h-4 w-4 mr-1" /> 닫기
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving || !selectedType || !selectedObjid}>
|
||||||
|
{saving ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Copy className="h-4 w-4 mr-1" />}
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 중단: E-BOM / M-BOM 셀렉트 (상호배타) */}
|
||||||
|
<div className="grid grid-cols-12 gap-3">
|
||||||
|
<div className="col-span-2 flex items-center text-sm font-medium">E-BOM 선택</div>
|
||||||
|
<div className="col-span-10">
|
||||||
|
<select
|
||||||
|
className="h-9 w-full border rounded px-2 text-sm bg-background disabled:bg-muted disabled:opacity-60"
|
||||||
|
disabled={ebomLoading || selectedType === "MBOM"}
|
||||||
|
value={selectedType === "EBOM" ? selectedObjid : ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
if (!v) { setSelectedType(null); setSelectedObjid(""); return; }
|
||||||
|
setSelectedType("EBOM");
|
||||||
|
setSelectedObjid(v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{ebomLoading ? "로딩…" : "선택"}</option>
|
||||||
|
{ebomOpts.map(o => (
|
||||||
|
<option key={o.objid} value={o.objid}>
|
||||||
|
{(o.part_no ?? "")} - {(o.part_name ?? "")}{o.revision ? ` (Rev.${o.revision})` : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 flex items-center text-sm font-medium">M-BOM 선택</div>
|
||||||
|
<div className="col-span-10">
|
||||||
|
<select
|
||||||
|
className="h-9 w-full border rounded px-2 text-sm bg-background disabled:bg-muted disabled:opacity-60"
|
||||||
|
disabled={mbomLoading || selectedType === "EBOM"}
|
||||||
|
value={selectedType === "MBOM" ? selectedObjid : ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
if (!v) { setSelectedType(null); setSelectedObjid(""); return; }
|
||||||
|
setSelectedType("MBOM");
|
||||||
|
setSelectedObjid(v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{mbomLoading ? "로딩…" : "선택"}</option>
|
||||||
|
{mbomOpts.map(o => (
|
||||||
|
<option key={o.objid} value={o.objid}>
|
||||||
|
{(o.mbom_no ?? "")}{o.part_name ? ` - ${o.part_name}` : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단: 미리보기 트리 + 도면 업로드 */}
|
||||||
|
<div className="border rounded">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b text-sm">
|
||||||
|
<span className="font-medium truncate">{previewTitle}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{previewLoading ? "로딩…" : preview ? `${preview.rows.length} 행 · 최대 ${preview.max_level} 레벨` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<PreviewTree preview={preview} loading={previewLoading} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 도면 다중 업로드 (운영판 fn_uploadDrawingFiles placeholder → RPS 실구현) */}
|
||||||
|
<div className="border rounded p-3">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm font-medium flex items-center gap-1">
|
||||||
|
<Folder className="h-4 w-4 text-amber-600" /> 도면 다중 업로드
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">.stp .step .dwg .dxf .pdf</span>
|
||||||
|
</div>
|
||||||
|
{projectObjid ? (
|
||||||
|
<AttachFileDropZone
|
||||||
|
targetObjid={projectObjid}
|
||||||
|
docType="MBOM_DRAWING"
|
||||||
|
docTypeName="M-BOM 도면"
|
||||||
|
accept=".stp,.step,.dwg,.dxf,.pdf"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-muted-foreground">프로젝트 선택 후 도면 업로드가 가능합니다.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 트리 미리보기 (간단 그리드) ───────────────────────────────
|
||||||
|
function PreviewTree({
|
||||||
|
preview, loading,
|
||||||
|
}: { preview: MbomTreeResponse | null; loading: boolean }) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin inline mr-1" /> 트리 로딩 중…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!preview || preview.rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center text-sm text-muted-foreground">표시할 BOM 데이터가 없습니다.</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const maxLevel = preview.max_level || 1;
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto max-h-[40vh]">
|
||||||
|
<table className="min-w-full text-xs">
|
||||||
|
<thead className="sticky top-0 bg-muted/80 border-b">
|
||||||
|
<tr>
|
||||||
|
<th className="px-2 py-1 text-center" style={{ width: 50 }}>#</th>
|
||||||
|
{Array.from({ length: maxLevel }).map((_, i) => (
|
||||||
|
<th key={i} className="px-1 py-1 text-center" style={{ width: 26 }}>{i + 1}</th>
|
||||||
|
))}
|
||||||
|
<th className="px-2 py-1 text-left">품번</th>
|
||||||
|
<th className="px-2 py-1 text-left">품명</th>
|
||||||
|
<th className="px-2 py-1 text-right" style={{ width: 80 }}>수량</th>
|
||||||
|
<th className="px-2 py-1 text-left">규격</th>
|
||||||
|
<th className="px-2 py-1 text-left">소재</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{preview.rows.map((r: MbomTreeRow, idx: number) => (
|
||||||
|
<tr key={`${r.objid ?? ""}-${idx}`} className={cn("border-b", levelBg(r.level))}>
|
||||||
|
<td className="px-2 py-1 text-center text-muted-foreground">{idx + 1}</td>
|
||||||
|
{Array.from({ length: maxLevel }).map((_, i) => (
|
||||||
|
<td key={i} className="px-1 py-1 text-center">
|
||||||
|
{Number(r.level) === i + 1 ? "*" : ""}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="px-2 py-1 font-mono">{r.part_no ?? ""}</td>
|
||||||
|
<td className="px-2 py-1">{r.part_name ?? ""}</td>
|
||||||
|
<td className="px-2 py-1 text-right">{r.item_qty ?? r.qty ?? ""}</td>
|
||||||
|
<td className="px-2 py-1">{r.spec ?? ""}</td>
|
||||||
|
<td className="px-2 py-1">{r.material ?? ""}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -267,6 +267,18 @@ export const mbomApi = {
|
|||||||
const res = await apiClient.post("/production/mbom/assign", payload);
|
const res = await apiClient.post("/production/mbom/assign", payload);
|
||||||
return res.data?.data as AssignBomResult;
|
return res.data?.data as AssignBomResult;
|
||||||
},
|
},
|
||||||
|
async searchAssignableMboms(filter: AssignableMbomFilter = {}): Promise<AssignableMbomRow[]> {
|
||||||
|
const res = await apiClient.get("/production/mbom/assignable-mboms", { params: filter });
|
||||||
|
return (res.data?.data ?? []) as AssignableMbomRow[];
|
||||||
|
},
|
||||||
|
async previewMbomTree(mbomHeaderObjid: string): Promise<MbomTreeResponse> {
|
||||||
|
const res = await apiClient.get(`/production/mbom/mbom-preview/${encodeURIComponent(mbomHeaderObjid)}`);
|
||||||
|
return res.data?.data as MbomTreeResponse;
|
||||||
|
},
|
||||||
|
async getLatestMbomByPartNo(partNo: string): Promise<LatestMbomByPartNoRow | null> {
|
||||||
|
const res = await apiClient.get(`/production/mbom/latest-mbom-by-partno/${encodeURIComponent(partNo)}`);
|
||||||
|
return (res.data?.data ?? null) as LatestMbomByPartNoRow | null;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── BOM 할당 (PR-B5) ───────────────────────────────────────
|
// ─── BOM 할당 (PR-B5) ───────────────────────────────────────
|
||||||
@@ -310,6 +322,34 @@ export interface AssignBomResult {
|
|||||||
source_obj_id: string;
|
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) ───────────────────────────────────────
|
// ─── 변경이력 (PR-B4) ───────────────────────────────────────
|
||||||
|
|
||||||
export interface MbomHistoryRow {
|
export interface MbomHistoryRow {
|
||||||
|
|||||||
Reference in New Issue
Block a user