생산관리 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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user