b38f5957f2
구매관리 (wace 1:1) - backend: services/purchaseService.ts (7 list + 옵션 3종) + controllers/purchaseController.ts + routes/purchaseRoutes.ts (/api/purchase 마운트) - frontend: lib/api/purchase.ts + 7 page.tsx (list/quote-request/proposal/inbound/inbound-by-item/inbound-by-date/project-status) - 영업관리 4메뉴 DataGrid 패턴 통일 — pageSizeOptions=[10,15,20,50,100], emptyMessage, showColumnSettings/summaryStats/onRefresh/onDownload/showChart - 마스터단독 데이터(sales_request_master, project_mgmt+mbom_detail) 노출, detail/part 누락 테이블 의존은 빈 그리드 + UI 발주관리 (purchase/order/page.tsx) - EDataTable → DataGrid 교체 + logicstudio 6종 props + 날짜/숫자 pre-format M-BOM PR-B3 — 구매리스트 생성 (wace createPurchaseListFromMBom.do 1:1) - mbomService.createSalesRequest + controller + route POST /api/production/mbom/sales-request - 단건 체크 + 1:1 강제 + R-YYYYMMDD-NNN 채번 + sales_request_master 단건 INSERT - production/mbom/page.tsx 에 [구매리스트 생성] 버튼 M-BOM PR-B5 — BOM 할당 (mBomEbomSelectPopup.do) - mbomService.searchAssignableEboms/assignBom + controller + routes - MbomAssignDialog 신규, MbomDetailDialog 통합 생산관리 4메뉴 라우트 (생산계획&실적, 소요량) - prodPlanResultService/Controller + productionPlanResultRoutes (planResult/mbomReq) - mbomRequirementService + 4 page.tsx (prod-plan-result, prod-plan-result-equip, raw-material-requirement, semi-product-requirement) - lib/api/prodPlanResult.ts Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
191 lines
8.6 KiB
TypeScript
191 lines
8.6 KiB
TypeScript
"use client";
|
|
|
|
// 생산관리 > 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 분기로 자동 표시됨.
|
|
//
|
|
// (M-BOM 할당은 PR-B5 v2 예정 — 우선 E-BOM 만)
|
|
|
|
import React, { useEffect, 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, Link as LinkIcon } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { cn } from "@/lib/utils";
|
|
import { mbomApi, AssignableEbomRow } from "@/lib/api/mbom";
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
projectObjid: string | null;
|
|
currentSourceEbomObjid?: string | null; // 이미 할당돼있으면 그 행 하이라이트
|
|
onAssigned: () => void; // 할당 성공 후 부모가 트리 재조회
|
|
}
|
|
|
|
export function MbomAssignDialog({
|
|
open, onOpenChange, projectObjid, currentSourceEbomObjid, onAssigned,
|
|
}: Props) {
|
|
const [filter, setFilter] = useState({
|
|
search_part_no: "", search_part_name: "", search_material: "",
|
|
});
|
|
const [rows, setRows] = useState<AssignableEbomRow[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [selectedObjid, setSelectedObjid] = useState<string | null>(null);
|
|
const [assigning, setAssigning] = useState(false);
|
|
|
|
const search = (override?: Partial<typeof filter>) => {
|
|
const f = { ...filter, ...override };
|
|
setLoading(true);
|
|
mbomApi.searchAssignableEboms(f)
|
|
.then(setRows)
|
|
.catch((e: any) => toast.error(e?.response?.data?.message ?? e?.message ?? "E-BOM 조회 실패"))
|
|
.finally(() => setLoading(false));
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setRows([]); setSelectedObjid(null);
|
|
setFilter({ search_part_no: "", search_part_name: "", search_material: "" });
|
|
return;
|
|
}
|
|
setSelectedObjid(currentSourceEbomObjid ?? null);
|
|
search();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [open, currentSourceEbomObjid]);
|
|
|
|
const handleAssign = async () => {
|
|
if (!projectObjid) return;
|
|
if (!selectedObjid) { toast.error("E-BOM 한 건을 선택해주세요"); return; }
|
|
if (selectedObjid === currentSourceEbomObjid) {
|
|
toast.info("현재 할당된 E-BOM 과 동일합니다");
|
|
return;
|
|
}
|
|
setAssigning(true);
|
|
try {
|
|
await mbomApi.assignBom({
|
|
project_obj_id: projectObjid,
|
|
source_bom_type: "EBOM",
|
|
source_bom_obj_id: selectedObjid,
|
|
});
|
|
toast.success("E-BOM 이 할당되었습니다");
|
|
onAssigned();
|
|
onOpenChange(false);
|
|
} catch (e: any) {
|
|
toast.error(e?.response?.data?.message ?? e?.message ?? "할당 실패");
|
|
} finally {
|
|
setAssigning(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[85vh] flex flex-col p-0 overflow-hidden">
|
|
<DialogHeader className="bg-blue-600 px-4 py-3">
|
|
<DialogTitle className="text-white flex items-center gap-2">
|
|
<LinkIcon className="w-4 h-4" />
|
|
BOM 할당 — E-BOM 선택
|
|
{currentSourceEbomObjid && (
|
|
<span className="text-xs font-normal opacity-90">· 현재 할당: {currentSourceEbomObjid}</span>
|
|
)}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
{/* 검색 */}
|
|
<div className="flex items-center gap-2 border-b bg-muted/30 px-4 py-2">
|
|
<span className="text-xs text-muted-foreground">품번</span>
|
|
<Input
|
|
className="h-7 text-xs w-[150px]"
|
|
value={filter.search_part_no}
|
|
onChange={(e) => setFilter({ ...filter, search_part_no: e.target.value })}
|
|
onKeyDown={(e) => { if (e.key === "Enter") search(); }}
|
|
/>
|
|
<span className="text-xs text-muted-foreground">품명</span>
|
|
<Input
|
|
className="h-7 text-xs w-[180px]"
|
|
value={filter.search_part_name}
|
|
onChange={(e) => setFilter({ ...filter, search_part_name: e.target.value })}
|
|
onKeyDown={(e) => { if (e.key === "Enter") search(); }}
|
|
/>
|
|
<span className="text-xs text-muted-foreground">재료</span>
|
|
<Input
|
|
className="h-7 text-xs w-[120px]"
|
|
value={filter.search_material}
|
|
onChange={(e) => setFilter({ ...filter, search_material: e.target.value })}
|
|
onKeyDown={(e) => { if (e.key === "Enter") search(); }}
|
|
/>
|
|
<Button size="sm" onClick={() => search()} disabled={loading}>
|
|
{loading ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : <Search className="w-3 h-3 mr-1" />}
|
|
조회
|
|
</Button>
|
|
<div className="ml-auto text-xs text-muted-foreground">
|
|
총 {rows.length.toLocaleString()}건 (최대 100건)
|
|
</div>
|
|
</div>
|
|
|
|
{/* 그리드 */}
|
|
<div className="flex-1 min-h-0 overflow-auto">
|
|
<table className="text-xs border-collapse w-full">
|
|
<thead className="bg-yellow-100 dark:bg-yellow-900/30 sticky top-0">
|
|
<tr>
|
|
<th className="border px-2 py-1.5 w-[40px] text-center">선택</th>
|
|
<th className="border px-2 py-1.5 w-[90px] text-center">제품구분</th>
|
|
<th className="border px-2 py-1.5 w-[150px] text-left">품번</th>
|
|
<th className="border px-2 py-1.5 text-left">품명</th>
|
|
<th className="border px-2 py-1.5 w-[100px] text-left">재료</th>
|
|
<th className="border px-2 py-1.5 w-[100px] text-left">메이커</th>
|
|
<th className="border px-2 py-1.5 w-[80px] text-center">개정</th>
|
|
<th className="border px-2 py-1.5 w-[100px] text-center">등록일</th>
|
|
<th className="border px-2 py-1.5 w-[80px] text-center">작성자</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading && rows.length === 0 ? (
|
|
<tr><td colSpan={9} className="py-12 text-center"><Loader2 className="inline w-5 h-5 animate-spin" /></td></tr>
|
|
) : rows.length === 0 ? (
|
|
<tr><td colSpan={9} className="py-8 text-center text-muted-foreground">조회된 E-BOM 이 없습니다.</td></tr>
|
|
) : rows.map(r => {
|
|
const isSel = selectedObjid === r.objid;
|
|
const isCurrent = currentSourceEbomObjid === r.objid;
|
|
return (
|
|
<tr key={r.objid}
|
|
className={cn("cursor-pointer hover:bg-muted/30",
|
|
isSel && "bg-blue-100 dark:bg-blue-950/40",
|
|
!isSel && isCurrent && "bg-emerald-50 dark:bg-emerald-950/20")}
|
|
onClick={() => setSelectedObjid(r.objid)}>
|
|
<td className="border px-2 py-1 text-center">
|
|
<input type="radio" checked={isSel} onChange={() => setSelectedObjid(r.objid)} />
|
|
</td>
|
|
<td className="border px-2 py-1 text-center">{r.product_name ?? ""}</td>
|
|
<td className="border px-2 py-1 whitespace-nowrap">{r.part_no}
|
|
{isCurrent && <span className="ml-1 text-[10px] text-emerald-600">(현재)</span>}
|
|
</td>
|
|
<td className="border px-2 py-1">{r.part_name}</td>
|
|
<td className="border px-2 py-1">{r.material}</td>
|
|
<td className="border px-2 py-1">{r.supplier}</td>
|
|
<td className="border px-2 py-1 text-center">{r.revision ?? ""}</td>
|
|
<td className="border px-2 py-1 text-center tabular-nums">{r.reg_date ?? ""}</td>
|
|
<td className="border px-2 py-1 text-center">{r.writer_name ?? ""}</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<DialogFooter className="border-t bg-muted/20 px-4 py-3 sm:justify-end gap-2">
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>취소</Button>
|
|
<Button onClick={handleAssign} disabled={!selectedObjid || assigning}>
|
|
{assigning ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : <LinkIcon className="w-3 h-3 mr-1" />}
|
|
{currentSourceEbomObjid ? "할당 변경" : "할당"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|