Files
wace_rps/frontend/components/production/MbomAddPartDialog.tsx
T
hjjeong dee03f6024 생산관리 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>
2026-05-14 16:59:29 +09:00

194 lines
8.2 KiB
TypeScript

"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>
);
}