Files
wace_rps/frontend/components/production/MbomDetailDialog.tsx
T
hjjeong 7a7f4f03b5 생산관리 M-BOM 본 편집(PR-B1) + 폴더 컬럼 + DataGrid 서버 페이지네이션 + bigint=varchar fix
PR-B1 본 편집/저장 (운영 saveMbom.do 1:1)
  · 매퍼 7종 1:1 (insert/updateMbomHeader, insert/updateMbomDetail,
    deleteMbomDetailByObjid, insertMbomHistory, updateProjectMbomStatus)
  · 신규 CREATE: createObjId + generateMbomNo(M-{partNo}-YYMMDD-NN) +
    child_objid 재매핑 + detail 일괄 insert + history(CREATE) + project_mgmt.mbom_status='Y'
  · 수정 UPDATE: 기존 mbom_header.objid UPSERT(insert/update/delete) + history(UPDATE)
  · POST /api/production/mbom/save (BEGIN/COMMIT/ROLLBACK 트랜잭션)
  · MbomDetailDialog: '본 편집' 토글 + 13개 셀 인라인 편집 + 저장/취소 가드

M-BOM 컬럼 폴더 아이콘
  · production/mbom/page.tsx: mbom_status 컬럼 → mbom_has(0/1) renderType=folder
  · onClick → MbomDetailDialog 오픈 (행 더블클릭도 그대로 유지)
  · 운영판 wace 견적/partMng 폴더 아이콘 패턴 1:1

DataGrid 서버 페이지네이션
  · props 신설: serverPaging/serverPage/serverPageSize/serverTotalItems
    + onPageChange/onPageSizeChange
  · 5메뉴 적용: production/mbom, development/change-list/ebom-regist/part-search/part-regist
  · pageSizeOptions=[10,15,20,50,100,200,500] 통일
  · 클라이언트 모드 하위호환 유지

bigint=varchar fix (mbom 트리 SQL 4종)
  · ATTACH_FILE_INFO 서브쿼리: P.OBJID(bigint) = F.TARGET_OBJID(varchar) → P.OBJID::varchar 캐스트
  · EBOM_WORKING_TREE_SQL INNER JOIN: P.OBJID = COALESCE(V.LAST_PART_OBJID,V.PART_NO) → ::varchar 캐스트
  · 사용자 보고: 폴더 클릭 시 'operator does not exist: bigint = character varying' 토스트

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:26:20 +09:00

438 lines
19 KiB
TypeScript

"use client";
// 생산관리 > M-BOM 관리 — 단건 상세 + 트리 (read-only / 편집).
//
// 운영판 통합:
// wace mBomHeaderPopup.jsp (헤더 메타)
// + wace mBomPopupLeft.jsp (좌측 트리 — read-only/편집)
//
// 4분기 (운영판 mBomPopupLeft.do):
// SAVED 저장된 mbom_header.status='Y' 의 트리 (생산정보 포함, 편집 시 UPDATE)
// ASSIGNED_EBOM source_bom_type='EBOM' + source_ebom_objid → bom_part_qty 트리 (편집 시 신규 CREATE)
// ASSIGNED_MBOM source_bom_type='MBOM' + source_mbom_objid → mbom_detail 구조만
// TEMPLATE Machine 이외 + 동일 part_no 의 mbom_header 템플릿
// NONE 빈 트리
//
// PR-B1 — 셀 인라인 편집 + 저장 (운영 saveMbom.do 1:1).
// 행 추가/삭제(mBomCenterBtnPopup) / BOM 복사 / 구매리스트 / 변경이력 — PR-B2~ 분리.
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, Folder, Pencil, Save, X } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { mbomApi, MbomDetail, MbomTreeResponse, MbomBomDataType, MbomTreeRow, MbomSaveRow } from "@/lib/api/mbom";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
projectObjid: string | null;
onSaved?: () => void;
}
const BOM_DATA_TYPE_LABEL: Record<MbomBomDataType, { text: string; color: string }> = {
SAVED: { text: "저장된 M-BOM", color: "bg-emerald-600" },
ASSIGNED_EBOM: { text: "할당된 E-BOM", color: "bg-sky-600" },
ASSIGNED_MBOM: { text: "할당된 M-BOM", color: "bg-indigo-600" },
TEMPLATE: { text: "M-BOM 템플릿", color: "bg-amber-600" },
NONE: { text: "BOM 없음", color: "bg-slate-500" },
};
// 편집 모드에서 사용자가 직접 수정 가능한 필드 (운영판 wace fieldMapping 기반)
type EditableField =
| "qty" | "supply_type" | "make_or_buy"
| "raw_material_no" | "raw_material" | "raw_material_size"
| "required_qty" | "order_qty" | "production_qty"
| "processing_vendor" | "processing_deadline" | "grinding_deadline"
| "remark";
export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }: Props) {
const [detail, setDetail] = useState<MbomDetail | null>(null);
const [tree, setTree] = useState<MbomTreeResponse | null>(null);
const [loading, setLoading] = useState(false);
const [editMode, setEditMode] = useState(false);
const [saving, setSaving] = useState(false);
const [editableRows, setEditableRows] = useState<MbomTreeRow[]>([]);
const [dirty, setDirty] = useState(false);
const loadTree = (objid: string) => {
setLoading(true);
return Promise.all([
mbomApi.getDetail(objid),
mbomApi.getTree(objid),
])
.then(([d, t]) => {
setDetail(d);
setTree(t);
setEditableRows((t?.rows ?? []).map(r => ({ ...r })));
setDirty(false);
})
.catch((e: any) => {
toast.error(e?.response?.data?.message ?? e?.message ?? "M-BOM 조회 실패");
})
.finally(() => setLoading(false));
};
useEffect(() => {
if (!open || !projectObjid) {
setDetail(null); setTree(null); setEditableRows([]);
setEditMode(false); setDirty(false);
return;
}
void loadTree(projectObjid);
}, [open, projectObjid]);
const maxLevel = Math.max(1, tree?.max_level ?? 1);
const rows = editMode ? editableRows : (tree?.rows ?? []);
const bomDataType: MbomBomDataType = tree?.bom_data_type ?? "NONE";
const meta = BOM_DATA_TYPE_LABEL[bomDataType];
const canEdit = bomDataType !== "NONE";
const levelHeaders = useMemo(() => {
const h: number[] = [];
for (let i = 1; i <= maxLevel; i++) h.push(i);
return h;
}, [maxLevel]);
const updateRow = (idx: number, field: EditableField, value: any) => {
setEditableRows(prev => {
const next = [...prev];
next[idx] = { ...next[idx], [field]: value };
return next;
});
setDirty(true);
};
const handleEditToggle = () => {
if (editMode && dirty) {
if (!window.confirm("편집 중인 변경사항이 사라집니다. 취소하시겠습니까?")) return;
setEditableRows((tree?.rows ?? []).map(r => ({ ...r })));
setDirty(false);
}
setEditMode(!editMode);
};
const handleSave = async () => {
if (!projectObjid) return;
if (rows.length === 0) {
toast.error("저장할 트리가 비어있습니다");
return;
}
setSaving(true);
try {
const isUpdate = bomDataType === "SAVED";
const payload = {
project_obj_id: projectObjid,
is_update: isUpdate,
rows: editableRows.map<MbomSaveRow>(r => ({
objid: r.objid,
parent_objid: r.parent_objid,
child_objid: r.child_objid,
seq: r.seq,
level: r.level,
part_objid: r.part_objid,
part_no: r.part_no,
part_name: r.part_name,
qty: r.qty,
item_qty: r.item_qty,
unit: r.unit,
supply_type: r.supply_type,
make_or_buy: r.make_or_buy,
raw_material_no: r.raw_material_no,
raw_material_spec: r.raw_material_spec,
raw_material: r.raw_material,
raw_material_size: (r as any).raw_material_size ?? r.size,
processing_vendor: r.processing_vendor,
processing_deadline: r.processing_deadline,
grinding_deadline: r.grinding_deadline,
required_qty: r.required_qty,
order_qty: r.order_qty,
production_qty: r.production_qty,
remark: r.remark,
})),
};
const result = await mbomApi.save(payload);
toast.success(`M-BOM ${result.mode === "CREATE" ? "생성" : "수정"} 완료 (${result.mbom_no})`);
setEditMode(false);
setDirty(false);
await loadTree(projectObjid);
onSaved?.();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패");
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={(v) => {
if (!v && editMode && dirty) {
if (!window.confirm("저장하지 않은 변경사항이 있습니다. 닫으시겠습니까?")) return;
}
onOpenChange(v);
}}>
<DialogContent className="max-w-[1600px] w-[97vw] max-h-[92vh] flex flex-col p-0 overflow-hidden">
<DialogHeader className="bg-blue-600 px-4 py-3">
<DialogTitle className="text-white flex items-center gap-3">
<span>M-BOM {editMode ? "본 편집" : "단건 상세"}</span>
<span className={cn("rounded px-2 py-0.5 text-xs font-semibold", meta.color)}>{meta.text}</span>
{editMode && dirty && (
<span className="rounded bg-orange-500 px-2 py-0.5 text-xs font-semibold"></span>
)}
</DialogTitle>
</DialogHeader>
{/* 헤더 메타 (운영판 mBomHeaderPopup.jsp 1:1) */}
{detail && (
<div className="grid grid-cols-4 gap-x-6 gap-y-1.5 border-b px-4 py-3 text-xs">
<MetaRow label="프로젝트번호" value={detail.project_no} />
<MetaRow label="주문유형" value={detail.category_name} />
<MetaRow label="제품구분" value={detail.product_name} />
<MetaRow label="국내/해외" value={detail.area_name} />
<MetaRow label="고객사" value={detail.customer_name} />
<MetaRow label="유/무상" value={paidLabel(detail.paid_type)} />
<MetaRow label="품번" value={detail.part_no} />
<MetaRow label="품명" value={detail.part_name} />
<MetaRow label="수주수량" value={fmtNum(detail.quantity)} numeric />
<MetaRow label="총생산수량" value={fmtNum(detail.total_prod_qty)} numeric />
<MetaRow label="요청납기" value={detail.req_del_date} />
<MetaRow label="접수일" value={detail.receipt_date} />
<MetaRow label="M-BOM 품번" value={detail.mbom_part_no || "—"} />
<MetaRow label="M-BOM 저장일" value={detail.mbom_regdate || "—"} />
</div>
)}
<div className="flex items-center justify-between border-b px-4 py-2">
<div className="text-xs text-muted-foreground">
{rows.length.toLocaleString()} · MAX_LEVEL = {maxLevel}
{tree?.bom_report_objid && (
<span className="ml-2 text-muted-foreground/70">BOM_OBJID = {tree.bom_report_objid}</span>
)}
</div>
<div className="flex items-center gap-2">
{canEdit && !editMode && (
<Button size="sm" variant="outline" onClick={handleEditToggle} disabled={loading}>
<Pencil className="w-3 h-3 mr-1" />
</Button>
)}
{editMode && (
<>
<Button size="sm" variant="outline" onClick={handleEditToggle} disabled={saving}>
<X className="w-3 h-3 mr-1" />
</Button>
<Button size="sm" onClick={handleSave} disabled={!dirty || saving}>
{saving ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : <Save className="w-3 h-3 mr-1" />}
({bomDataType === "SAVED" ? "수정" : "신규"})
</Button>
</>
)}
</div>
</div>
<div className="flex-1 min-h-0 overflow-auto">
{loading ? (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : (
<table className="text-xs border-collapse w-max min-w-full">
<thead className="bg-yellow-100 dark:bg-yellow-900/30 sticky top-0">
<tr>
{levelHeaders.map((i) => (
<th key={`l${i}`} className="border px-2 py-1 w-[36px] text-center font-bold">{i}</th>
))}
<th className="border px-2 py-1 min-w-[150px] text-left"></th>
<th className="border px-2 py-1 min-w-[180px] text-left"></th>
<th className="border px-2 py-1 min-w-[60px] text-right"></th>
<th className="border px-2 py-1 min-w-[70px] text-right"></th>
<th className="border px-2 py-1 min-w-[60px] text-center"></th>
<th className="border px-2 py-1 min-w-[70px] text-center">/</th>
<th className="border px-2 py-1 min-w-[80px] text-center">Make/Buy</th>
<th className="border px-2 py-1 min-w-[120px] text-left"></th>
<th className="border px-2 py-1 min-w-[120px] text-left"></th>
<th className="border px-2 py-1 min-w-[100px] text-left"></th>
<th className="border px-2 py-1 min-w-[80px] text-right"></th>
<th className="border px-2 py-1 min-w-[80px] text-right"></th>
<th className="border px-2 py-1 min-w-[80px] text-right"></th>
<th className="border px-2 py-1 min-w-[110px] text-left"></th>
<th className="border px-2 py-1 min-w-[100px] text-center"></th>
<th className="border px-2 py-1 min-w-[100px] text-center"></th>
<th className="border px-2 py-1 min-w-[40px] text-center">3D</th>
<th className="border px-2 py-1 min-w-[40px] text-center">2D</th>
<th className="border px-2 py-1 min-w-[40px] text-center">PDF</th>
<th className="border px-2 py-1 min-w-[120px] text-left"></th>
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td colSpan={levelHeaders.length + 19} className="py-8 text-center text-muted-foreground">
{bomDataType === "NONE" ? "표시할 BOM이 없습니다." : "트리가 비어있습니다."}
</td>
</tr>
)}
{rows.map((r, idx) => {
const lv = Number(r.level ?? 1);
return (
<tr key={`${r.objid}_${idx}`} className="hover:bg-muted/30">
{levelHeaders.map((i) => (
<td key={`lc${i}`} className={cn("border px-1 py-0.5 text-center", i === lv && "font-bold")}>
{i === lv ? "*" : ""}
</td>
))}
<td className="border px-2 py-0.5 whitespace-nowrap">{r.part_no}</td>
<td className="border px-2 py-0.5">{r.part_name}</td>
<EditableNumCell editable={editMode} value={r.qty} onChange={(v) => updateRow(idx, "qty", v)} />
<td className="border px-2 py-0.5 text-right">{fmtNum(r.item_qty)}</td>
<td className="border px-2 py-0.5 text-center">{r.unit_title ?? r.unit ?? ""}</td>
<EditableSelectCell editable={editMode} value={r.supply_type} options={SUPPLY_TYPE_OPTIONS}
onChange={(v) => updateRow(idx, "supply_type", v)} />
<EditableSelectCell editable={editMode} value={r.make_or_buy} options={MAKE_OR_BUY_OPTIONS}
onChange={(v) => updateRow(idx, "make_or_buy", v)} />
<EditableTextCell editable={editMode} value={r.raw_material_no}
onChange={(v) => updateRow(idx, "raw_material_no", v)} />
<EditableTextCell editable={editMode} value={r.raw_material}
onChange={(v) => updateRow(idx, "raw_material", v)} />
<EditableTextCell editable={editMode} value={(r as any).raw_material_size ?? r.size}
onChange={(v) => updateRow(idx, "raw_material_size", v)} />
<EditableNumCell editable={editMode} value={r.required_qty}
onChange={(v) => updateRow(idx, "required_qty", v)} />
<EditableNumCell editable={editMode} value={r.order_qty}
onChange={(v) => updateRow(idx, "order_qty", v)} />
<EditableNumCell editable={editMode} value={r.production_qty}
onChange={(v) => updateRow(idx, "production_qty", v)} />
<EditableTextCell editable={editMode} value={r.processing_vendor_name ?? r.processing_vendor}
onChange={(v) => updateRow(idx, "processing_vendor", v)} />
<EditableDateCell editable={editMode} value={r.processing_deadline}
onChange={(v) => updateRow(idx, "processing_deadline", v)} />
<EditableDateCell editable={editMode} value={r.grinding_deadline}
onChange={(v) => updateRow(idx, "grinding_deadline", v)} />
<td className="border px-2 py-0.5 text-center"><FolderCell n={r.cu01_cnt} /></td>
<td className="border px-2 py-0.5 text-center"><FolderCell n={r.cu02_cnt} /></td>
<td className="border px-2 py-0.5 text-center"><FolderCell n={r.cu03_cnt} /></td>
<EditableTextCell editable={editMode} value={r.remark}
onChange={(v) => updateRow(idx, "remark", v)} />
</tr>
);
})}
</tbody>
</table>
)}
</div>
<DialogFooter className="border-t bg-muted/20 px-4 py-3 sm:justify-center">
<Button variant="outline" onClick={() => onOpenChange(false)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
const SUPPLY_TYPE_OPTIONS = [
{ value: "", label: "—" },
{ value: "자급", label: "자급" },
{ value: "사급", label: "사급" },
];
const MAKE_OR_BUY_OPTIONS = [
{ value: "", label: "—" },
{ value: "Make", label: "Make" },
{ value: "Buy", label: "Buy" },
];
function EditableNumCell({ editable, value, onChange }: { editable: boolean; value: any; onChange: (v: any) => void }) {
if (!editable) return <td className="border px-2 py-0.5 text-right">{fmtNum(value)}</td>;
return (
<td className="border px-1 py-0.5 text-right">
<Input
type="number"
step="any"
className="h-6 px-1 py-0 text-xs text-right border-blue-300 focus-visible:ring-1"
value={value ?? ""}
onChange={(e) => onChange(e.target.value === "" ? null : e.target.value)}
/>
</td>
);
}
function EditableTextCell({ editable, value, onChange }: { editable: boolean; value: any; onChange: (v: any) => void }) {
if (!editable) return <td className="border px-2 py-0.5">{value ?? ""}</td>;
return (
<td className="border px-1 py-0.5">
<Input
type="text"
className="h-6 px-1 py-0 text-xs border-blue-300 focus-visible:ring-1"
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
/>
</td>
);
}
function EditableDateCell({ editable, value, onChange }: { editable: boolean; value: any; onChange: (v: any) => void }) {
if (!editable) return <td className="border px-2 py-0.5 text-center">{value ?? ""}</td>;
return (
<td className="border px-1 py-0.5">
<Input
type="date"
className="h-6 px-1 py-0 text-xs border-blue-300 focus-visible:ring-1"
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
/>
</td>
);
}
function EditableSelectCell({
editable, value, options, onChange,
}: { editable: boolean; value: any; options: { value: string; label: string }[]; onChange: (v: any) => void }) {
if (!editable) return <td className="border px-2 py-0.5 text-center">{value ?? ""}</td>;
return (
<td className="border px-1 py-0.5">
<select
className="h-6 px-1 py-0 text-xs w-full border border-blue-300 rounded"
value={value ?? ""}
onChange={(e) => onChange(e.target.value || null)}
>
{options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</td>
);
}
function FolderCell({ n }: { n: any }) {
const has = Number(n ?? 0) > 0;
return (
<span className="inline-flex items-center justify-center">
<Folder className={cn("w-4 h-4",
has ? "fill-[#1a73e8] text-[#1a73e8]" : "fill-white text-muted-foreground/40")} />
</span>
);
}
function MetaRow({ label, value, numeric }: { label: string; value: any; numeric?: boolean }) {
return (
<div className="flex items-baseline gap-2">
<span className="text-muted-foreground w-[80px] shrink-0">{label}</span>
<span className={cn("font-medium", numeric && "tabular-nums")}>
{value != null && value !== "" ? value : "—"}
</span>
</div>
);
}
function fmtNum(v: any): string {
if (v == null || v === "") return "";
const n = Number(v);
if (!isFinite(n)) return String(v);
return Number.isInteger(n)
? n.toLocaleString()
: n.toLocaleString(undefined, { maximumFractionDigits: 4 });
}
function paidLabel(v: string | null | undefined): string {
if (v === "paid") return "유상";
if (v === "free") return "무상";
return v ?? "";
}