dee03f6024
행 추가: 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>
194 lines
8.2 KiB
TypeScript
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>
|
|
);
|
|
}
|