생산관리 M-BOM PR-B2 — 본 편집 다이얼로그 행 추가/삭제 (팝업 방식)

행 추가: MbomAddPartDialog 신설 (devPartApi.list 재사용)
  · 품번/품명 검색 + 체크박스 multi-select + 페이지네이션
  · 선택 행 1개면 그 자식으로 추가, 없으면 root (level=1)

편집 모드 확장 (MbomDetailDialog):
  · 체크박스 컬럼(맨 왼쪽) + 전체 선택 토글
  · 선택 행 파란 배경, 새 행 초록 배경 (temp- prefix 식별)
  · toolbar [+ 행 추가] / [선택 삭제] 버튼 추가
  · 선택 삭제 — cascade(선택 + 하위) + 확인창

backend save() UPDATE 분기:
  · client temp- child_objid → 서버 발급 createObjId 매핑
    (객체 ID 안정성 + DB 에 임시 ID 잔존 방지 + 충돌 회피)
  · parent_objid 가 temp- 참조면 신규 ID 로 remap

운영판은 좌(M-BOM)+중앙(<<,>>,변경)+우(소스 트리) 3분할이지만
좌측 19컬럼이 좁아져 가로스크롤 강제되는 약점. 팝업 방식이
트리 풀너비 확보 + 모바일 친화 → 사용자 결정으로 팝업 채택.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-14 16:59:29 +09:00
parent 8dd5f184ae
commit dee03f6024
3 changed files with 350 additions and 7 deletions
@@ -0,0 +1,193 @@
"use client";
// 생산관리 > M-BOM 관리 — 행 추가 시 PART 검색 다이얼로그 (PR-B2).
//
// 운영판 mBomPopupRight.jsp + mBomCenterBtnPopup.jsp 의 우측 패널/추가 흐름 단순화 버전.
// part_mng (개발관리 M2) 를 검색 → multi-select → 부모 컴포넌트로 반환.
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, Search } from "lucide-react";
import { toast } from "sonner";
import { devPartApi, PartRow } from "@/lib/api/devPart";
export interface PickedPart {
objid: string; // part_mng.objid (bigint, 문자열로)
part_no: string;
part_name: string;
unit: string | null;
}
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (parts: PickedPart[]) => void;
parentLabel?: string; // "선택된 부모: XYZ" 표시용
}
export function MbomAddPartDialog({ open, onOpenChange, onConfirm, parentLabel }: Props) {
const [filter, setFilter] = useState({ search_part_no: "", search_part_name: "" });
const [rows, setRows] = useState<PartRow[]>([]);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize] = useState(50);
const [total, setTotal] = useState(0);
const [checked, setChecked] = useState<Set<string>>(new Set());
const search = (override?: Partial<typeof filter> & { page?: number }) => {
const p = override?.page ?? 1;
const f = { ...filter, ...override, page: p, page_size: pageSize };
setLoading(true);
devPartApi.list(f)
.then(res => {
setRows(res.rows ?? []);
setTotal(res.total ?? 0);
setPage(p);
})
.catch((e: any) => toast.error(e?.response?.data?.message ?? e?.message ?? "PART 조회 실패"))
.finally(() => setLoading(false));
};
useEffect(() => {
if (!open) { setRows([]); setChecked(new Set()); setFilter({ search_part_no: "", search_part_name: "" }); return; }
search({ page: 1 });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const toggleAll = () => {
if (checked.size === rows.length) setChecked(new Set());
else setChecked(new Set(rows.map(r => String(r.objid))));
};
const toggleOne = (id: string) => {
setChecked(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
const handleConfirm = () => {
if (checked.size === 0) {
toast.error("추가할 PART 를 선택해주세요");
return;
}
const picked: PickedPart[] = rows
.filter(r => checked.has(String(r.objid)))
.map(r => ({
objid: String(r.objid),
part_no: r.part_no ?? "",
part_name: r.part_name ?? "",
unit: r.unit ?? null,
}));
onConfirm(picked);
onOpenChange(false);
};
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[900px] 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">
<Search className="w-4 h-4" />
PART M-BOM
{parentLabel && <span className="text-xs font-normal opacity-90">· : {parentLabel}</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({ page: 1 }); }}
/>
<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({ page: 1 }); }}
/>
<Button size="sm" onClick={() => search({ page: 1 })} 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">
{total.toLocaleString()} · {checked.size}
</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-[36px] text-center">
<input
type="checkbox"
checked={rows.length > 0 && checked.size === rows.length}
onChange={toggleAll}
/>
</th>
<th className="border px-2 py-1.5 w-[140px] 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-[60px] text-center"></th>
<th className="border px-2 py-1.5 w-[70px] text-center"></th>
</tr>
</thead>
<tbody>
{loading && rows.length === 0 ? (
<tr><td colSpan={7} className="py-12 text-center"><Loader2 className="inline w-5 h-5 animate-spin" /></td></tr>
) : rows.length === 0 ? (
<tr><td colSpan={7} className="py-8 text-center text-muted-foreground"> PART .</td></tr>
) : rows.map(r => {
const id = String(r.objid);
const isChecked = checked.has(id);
return (
<tr key={id} className={`hover:bg-muted/30 cursor-pointer ${isChecked ? "bg-blue-50" : ""}`}
onClick={() => toggleOne(id)}>
<td className="border px-2 py-1 text-center">
<input type="checkbox" checked={isChecked} onChange={() => toggleOne(id)} onClick={e => e.stopPropagation()} />
</td>
<td className="border px-2 py-1 whitespace-nowrap">{r.part_no}</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.spec ?? ""}</td>
<td className="border px-2 py-1 text-center">{r.unit_title ?? r.unit ?? ""}</td>
<td className="border px-2 py-1 text-center">{r.revision ?? ""}</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* 페이지네이션 */}
{total > pageSize && (
<div className="flex items-center justify-center gap-1 border-t px-3 py-2 text-xs">
<Button size="sm" variant="ghost" disabled={page <= 1} onClick={() => search({ page: page - 1 })}></Button>
<span className="px-2">{page} / {totalPages}</span>
<Button size="sm" variant="ghost" disabled={page >= totalPages} onClick={() => search({ page: page + 1 })}></Button>
</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={handleConfirm} disabled={checked.size === 0}>
({checked.size})
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -22,11 +22,12 @@ import {
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Loader2, Folder, Pencil, Save, X, History } from "lucide-react";
import { Loader2, Folder, Pencil, Save, X, History, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { mbomApi, MbomDetail, MbomTreeResponse, MbomBomDataType, MbomTreeRow, MbomSaveRow } from "@/lib/api/mbom";
import { MbomHistoryDialog } from "./MbomHistoryDialog";
import { MbomAddPartDialog, PickedPart } from "./MbomAddPartDialog";
interface Props {
open: boolean;
@@ -60,6 +61,8 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }:
const [editableRows, setEditableRows] = useState<MbomTreeRow[]>([]);
const [dirty, setDirty] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [addPartOpen, setAddPartOpen] = useState(false);
const [selectedChildObjids, setSelectedChildObjids] = useState<Set<string>>(new Set());
const loadTree = (objid: string) => {
setLoading(true);
@@ -83,11 +86,17 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }:
if (!open || !projectObjid) {
setDetail(null); setTree(null); setEditableRows([]);
setEditMode(false); setDirty(false);
setSelectedChildObjids(new Set());
return;
}
void loadTree(projectObjid);
}, [open, projectObjid]);
// 편집 모드 off 시 선택 해제
useEffect(() => {
if (!editMode) setSelectedChildObjids(new Set());
}, [editMode]);
const maxLevel = Math.max(1, tree?.max_level ?? 1);
const rows = editMode ? editableRows : (tree?.rows ?? []);
const bomDataType: MbomBomDataType = tree?.bom_data_type ?? "NONE";
@@ -109,6 +118,89 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }:
setDirty(true);
};
// 체크박스 토글
const toggleSelect = (childObjid: string | null | undefined) => {
if (!childObjid) return;
setSelectedChildObjids(prev => {
const next = new Set(prev);
if (next.has(childObjid)) next.delete(childObjid);
else next.add(childObjid);
return next;
});
};
const toggleSelectAll = () => {
if (selectedChildObjids.size === editableRows.length) setSelectedChildObjids(new Set());
else setSelectedChildObjids(new Set(editableRows.map(r => String(r.child_objid ?? r.objid))));
};
// BFS — 주어진 child_objid 의 모든 하위 노드 child_objid 집합
const getDescendants = (rootChildObjid: string): Set<string> => {
const result = new Set<string>();
const queue = [rootChildObjid];
while (queue.length) {
const parent = queue.shift()!;
for (const r of editableRows) {
const c = String(r.child_objid ?? "");
if (!c) continue;
if (r.parent_objid === parent && !result.has(c)) {
result.add(c);
queue.push(c);
}
}
}
return result;
};
// 행 추가 — PART 검색 다이얼로그에서 반환된 parts 를 editableRows 에 append.
// 부모 = 선택된 행 1개 (없으면 root, 2개 이상이면 첫 번째).
const handleAddParts = (parts: PickedPart[]) => {
const firstSelected = selectedChildObjids.size > 0 ? Array.from(selectedChildObjids)[0] : null;
const parentRow = firstSelected ? editableRows.find(r => String(r.child_objid) === firstSelected) : null;
const parentChildObjid = parentRow ? String(parentRow.child_objid) : null;
const parentLevel = parentRow ? Number(parentRow.level ?? 1) : 0;
const base = Date.now();
const newRows: MbomTreeRow[] = parts.map((p, i) => ({
objid: "" as any, // 빈값 → 백엔드 createObjId
parent_objid: parentChildObjid,
child_objid: `temp-${base}-${i}` as any, // 클라이언트 임시 ID (백엔드 그대로 저장)
seq: 999,
level: parentLevel + 1,
part_objid: p.objid as any,
part_no: p.part_no,
part_name: p.part_name,
qty: 1,
item_qty: 1,
unit: p.unit,
unit_title: p.unit,
supply_type: null, make_or_buy: null,
raw_material_no: null, raw_material: null, raw_material_size: null,
required_qty: null, order_qty: null, production_qty: null,
processing_vendor: null, processing_deadline: null, grinding_deadline: null,
cu01_cnt: 0, cu02_cnt: 0, cu03_cnt: 0,
remark: null,
status: "ACTIVE",
sub_part_cnt: 0,
processing_vendor_name: null,
} as any));
setEditableRows(prev => [...prev, ...newRows]);
setDirty(true);
toast.success(`${parts.length}개 행 추가${parentChildObjid ? ` (부모: ${parentRow?.part_no ?? ""})` : " (root)"}`);
};
// 선택 삭제 — cascade. 선택된 child_objid + 하위 트리 전부 제거.
const handleDeleteSelected = () => {
if (selectedChildObjids.size === 0) return;
const allToRemove = new Set<string>();
for (const sel of selectedChildObjids) {
allToRemove.add(sel);
for (const d of getDescendants(sel)) allToRemove.add(d);
}
if (!window.confirm(`${allToRemove.size}개 행을 삭제합니다 (선택 ${selectedChildObjids.size} + 하위 ${allToRemove.size - selectedChildObjids.size}). 계속할까요?`)) return;
setEditableRows(prev => prev.filter(r => !allToRemove.has(String(r.child_objid ?? r.objid))));
setSelectedChildObjids(new Set());
setDirty(true);
};
const handleEditToggle = () => {
if (editMode && dirty) {
if (!window.confirm("편집 중인 변경사항이 사라집니다. 취소하시겠습니까?")) return;
@@ -228,6 +320,12 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }:
)}
{editMode && (
<>
<Button size="sm" variant="outline" onClick={() => setAddPartOpen(true)} disabled={saving}>
<Plus className="w-3 h-3 mr-1" />
</Button>
<Button size="sm" variant="outline" onClick={handleDeleteSelected} disabled={saving || selectedChildObjids.size === 0}>
<Trash2 className="w-3 h-3 mr-1" /> {selectedChildObjids.size > 0 && `(${selectedChildObjids.size})`}
</Button>
<Button size="sm" variant="outline" onClick={handleEditToggle} disabled={saving}>
<X className="w-3 h-3 mr-1" />
</Button>
@@ -249,6 +347,13 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }:
<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>
{editMode && (
<th className="border px-1 py-1 w-[32px] text-center">
<input type="checkbox"
checked={editableRows.length > 0 && selectedChildObjids.size === editableRows.length}
onChange={toggleSelectAll} />
</th>
)}
{levelHeaders.map((i) => (
<th key={`l${i}`} className="border px-2 py-1 w-[36px] text-center font-bold">{i}</th>
))}
@@ -277,15 +382,27 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }:
<tbody>
{rows.length === 0 && (
<tr>
<td colSpan={levelHeaders.length + 19} className="py-8 text-center text-muted-foreground">
<td colSpan={levelHeaders.length + 19 + (editMode ? 1 : 0)} className="py-8 text-center text-muted-foreground">
{bomDataType === "NONE" ? "표시할 BOM이 없습니다." : "트리가 비어있습니다."}
</td>
</tr>
)}
{rows.map((r, idx) => {
const lv = Number(r.level ?? 1);
const cid = String(r.child_objid ?? r.objid ?? "");
const isSelected = editMode && cid && selectedChildObjids.has(cid);
const isNew = editMode && String(cid).startsWith("temp-");
return (
<tr key={`${r.objid}_${idx}`} className="hover:bg-muted/30">
<tr key={`${r.objid}_${cid}_${idx}`} className={cn("hover:bg-muted/30",
isSelected && "bg-blue-50 dark:bg-blue-950/30",
isNew && "bg-emerald-50 dark:bg-emerald-950/20")}>
{editMode && (
<td className="border px-1 py-0.5 text-center">
<input type="checkbox"
checked={!!isSelected}
onChange={() => toggleSelect(cid)} />
</td>
)}
{levelHeaders.map((i) => (
<td key={`lc${i}`} className={cn("border px-1 py-0.5 text-center", i === lv && "font-bold")}>
{i === lv ? "*" : ""}
@@ -341,6 +458,19 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }:
onOpenChange={setHistoryOpen}
projectObjid={projectObjid}
/>
<MbomAddPartDialog
open={addPartOpen}
onOpenChange={setAddPartOpen}
onConfirm={handleAddParts}
parentLabel={
selectedChildObjids.size === 1
? editableRows.find(r => String(r.child_objid) === Array.from(selectedChildObjids)[0])?.part_no ?? undefined
: selectedChildObjids.size > 1
? `${Array.from(selectedChildObjids).length}개 선택 — 첫 항목 부모`
: "root (최상위)"
}
/>
</Dialog>
);
}