82a253ec6a
사용자 지적으로 재발견: 기존 PartFormDialog 는 임의로 "기본정보/크기·형상/분류·단위/Y-N
플래그" 섹션 분리 + 운영판에 없는 필드(두께/너비/높이/외경/내경/길이/수량/단위/후가공/
공급업체코드/제품OBJID) 추가 + Y/N 라디오로 작성되어 있었음. 운영판과 완전 다른 화면.
운영판 1:1 정정:
- 단일 4-col table 레이아웃 (라벨 / input / 라벨 / input)
- 필드 22개만 (운영판 그대로):
① 품번 | 품명
② 재료 | 열처리경도
③ 열처리방법 | 표면처리
④ 메이커 | 범주이름* (PART_TYPE, comm_code 0000062)
⑤ 규격 (colspan=3)
⑥ 계정구분*(comm_code 0900213) | 조달구분*(0=구매/1=생산/8=Phantom 하드)
⑦ 재고단위*(comm_code 0001399) | 관리단위*(동일)
⑧ 환산수량*(숫자) | LOT구분*(0=미사용/1=사용)
⑨ 사용여부*(0=미사용/1=사용) | 검사여부*(0=무검사/1=검사)
⑩ SET품여부*(0=부/1=여) | 의뢰여부*(0=부/1=여)
⑪ 개당길이 | 개당소요량
⑫ 비고 (colspan=3)
⑬ CAD Data : 3D / 2D(Drawing) / 2D(PDF) Drag&Drop placeholder
(실제 업로드 기능은 DEV-7 별 PR — UI 영역만 추가)
- LOT/USE_YN/QC_FG/SETITEM_FG/REQ_FG : Y/N 라디오 → 운영판 select 옵션 그대로 (4개씩 한글값)
- 필수 검증 11개 추가 (wace fn_save 1:1) : 범주이름·계정구분·조달구분·재고단위·관리단위·
환산수량·LOT구분·사용여부·검사여부·SET품여부·의뢰여부
- DialogTitle "품목 등록" / "품목 수정" (운영판 헤더 그대로) + 파란색 헤더 바
- 저장 / 닫기 버튼 중앙 배치 (운영판 plm_btn_wrap_center)
PartDetailDialog 도 동일하게 운영판 부적합 — 다음 단계에서 별도 재작성 예정.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
484 lines
19 KiB
TypeScript
484 lines
19 KiB
TypeScript
"use client";
|
|
|
|
// 개발관리 > PART 등록/수정 다이얼로그 — wace partMng/partMngFormPopUp.jsp 1:1
|
|
//
|
|
// 폼 필드 (운영판 그대로, 그 외 추가 없음):
|
|
// ① 품번 | 품명
|
|
// ② 재료 | 열처리경도
|
|
// ③ 열처리방법 | 표면처리
|
|
// ④ 메이커 | 범주이름* (PART_TYPE, comm_code 0000062)
|
|
// ⑤ 규격 (1행)
|
|
// ⑥ 계정구분* (ACCTFG, comm_code 0900213) | 조달구분* (ODRFG, 0/1/8 하드)
|
|
// ⑦ 재고단위* (UNIT_DC, comm_code 0001399) | 관리단위* (UNITMANG_DC, 동일)
|
|
// ⑧ 환산수량* (UNITCHNG_NB, 숫자) | LOT구분* (0=미사용, 1=사용)
|
|
// ⑨ 사용여부* (0=미사용, 1=사용) | 검사여부* (0=무검사, 1=검사)
|
|
// ⑩ SET품여부* (0=부, 1=여) | 의뢰여부* (0=부, 1=여)
|
|
// ⑪ 개당길이 | 개당소요량
|
|
// ⑫ 비고 (1행)
|
|
// ⑬ CAD Data: 3D / 2D(Drawing) / 2D(PDF) Drag&Drop — 별 PR(DEV-7) 도면업로드. 본 PR은 UI placeholder
|
|
//
|
|
// 신규: POST /api/development/part (운영 폼 22컬럼)
|
|
// 수정: PUT /api/development/part/:objid (wace updatePartDetail 21컬럼 1:1)
|
|
|
|
import React, { useCallback, useEffect, 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 { Label } from "@/components/ui/label";
|
|
import { Loader2, Save, Upload } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
|
|
import { devPartApi, PartCreateBody, PartUpdateBody, PartRow } from "@/lib/api/devPart";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
// comm_code group ids (vexplor_rps DB)
|
|
const GROUP_PART_TYPE = "0000062";
|
|
const GROUP_UNIT_DC = "0001399";
|
|
const GROUP_ACCTFG = "0900213";
|
|
|
|
// 운영 1:1 하드코딩 옵션
|
|
const OPT_ODRFG = [{ v: "0", t: "구매" }, { v: "1", t: "생산" }, { v: "8", t: "Phantom" }];
|
|
const OPT_LOT_FG = [{ v: "0", t: "미사용" }, { v: "1", t: "사용" }];
|
|
const OPT_USE_YN = [{ v: "0", t: "미사용" }, { v: "1", t: "사용" }];
|
|
const OPT_QC_FG = [{ v: "0", t: "무검사" }, { v: "1", t: "검사" }];
|
|
const OPT_YESNO = [{ v: "0", t: "부" }, { v: "1", t: "여" }];
|
|
|
|
interface FormState {
|
|
part_no: string;
|
|
part_name: string;
|
|
material: string;
|
|
heat_treatment_hardness: string;
|
|
heat_treatment_method: string;
|
|
surface_treatment: string;
|
|
maker: string;
|
|
part_type: string;
|
|
spec: string;
|
|
acctfg: string;
|
|
odrfg: string;
|
|
unit_dc: string;
|
|
unitmang_dc: string;
|
|
unitchng_nb: string;
|
|
lot_fg: string;
|
|
use_yn: string;
|
|
qc_fg: string;
|
|
setitem_fg: string;
|
|
req_fg: string;
|
|
unit_length: string;
|
|
unit_qty: string;
|
|
remark: string;
|
|
}
|
|
|
|
const EMPTY_FORM: FormState = {
|
|
part_no: "", part_name: "",
|
|
material: "", heat_treatment_hardness: "", heat_treatment_method: "", surface_treatment: "",
|
|
maker: "", part_type: "", spec: "",
|
|
acctfg: "", odrfg: "", unit_dc: "", unitmang_dc: "", unitchng_nb: "",
|
|
lot_fg: "", use_yn: "", qc_fg: "", setitem_fg: "", req_fg: "",
|
|
unit_length: "", unit_qty: "", remark: "",
|
|
};
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
mode: "create" | "edit";
|
|
editObjid?: string | null;
|
|
onSaved: () => void;
|
|
}
|
|
|
|
export function PartFormDialog({ open, onOpenChange, mode, editObjid, onSaved }: Props) {
|
|
const isEdit = mode === "edit";
|
|
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
|
const [loading, setLoading] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const setField = useCallback(
|
|
<K extends keyof FormState>(key: K, value: FormState[K]) =>
|
|
setForm((prev) => ({ ...prev, [key]: value })),
|
|
[]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
if (isEdit && editObjid) loadDetail(editObjid);
|
|
else setForm(EMPTY_FORM);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [open]);
|
|
|
|
const loadDetail = async (objid: string) => {
|
|
setLoading(true);
|
|
try {
|
|
const row = await devPartApi.detail(objid);
|
|
if (!row) {
|
|
toast.error("PART를 찾을 수 없습니다.");
|
|
onOpenChange(false);
|
|
return;
|
|
}
|
|
setForm(rowToForm(row));
|
|
} catch (e: any) {
|
|
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
|
|
onOpenChange(false);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// wace fn_save 1:1 — 모든 required 검증
|
|
const validate = (): string | null => {
|
|
if (!form.part_no.trim()) return "품번은 필수입니다.";
|
|
if (!form.part_name.trim()) return "품명은 필수입니다.";
|
|
if (!form.part_type) return "범주이름은 필수입니다.";
|
|
if (!form.acctfg) return "계정구분은 필수입니다.";
|
|
if (!form.odrfg) return "조달구분은 필수입니다.";
|
|
if (!form.unit_dc) return "재고단위는 필수입니다.";
|
|
if (!form.unitmang_dc) return "관리단위는 필수입니다.";
|
|
if (!form.unitchng_nb) return "환산수량은 필수입니다.";
|
|
if (!form.lot_fg) return "LOT구분은 필수입니다.";
|
|
if (!form.use_yn) return "사용여부는 필수입니다.";
|
|
if (!form.qc_fg) return "검사여부는 필수입니다.";
|
|
if (!form.setitem_fg) return "SET품여부는 필수입니다.";
|
|
if (!form.req_fg) return "의뢰여부는 필수입니다.";
|
|
return null;
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
const err = validate();
|
|
if (err) return toast.error(err);
|
|
|
|
setSaving(true);
|
|
try {
|
|
if (isEdit && editObjid) {
|
|
const body: PartUpdateBody = {
|
|
part_name: form.part_name,
|
|
material: form.material,
|
|
heat_treatment_hardness: form.heat_treatment_hardness,
|
|
heat_treatment_method: form.heat_treatment_method,
|
|
surface_treatment: form.surface_treatment,
|
|
maker: form.maker,
|
|
part_type: form.part_type,
|
|
acctfg: form.acctfg,
|
|
odrfg: form.odrfg,
|
|
spec: form.spec,
|
|
unit_dc: form.unit_dc,
|
|
unitmang_dc: form.unitmang_dc,
|
|
unitchng_nb: form.unitchng_nb,
|
|
lot_fg: form.lot_fg,
|
|
use_yn: form.use_yn,
|
|
qc_fg: form.qc_fg,
|
|
setitem_fg: form.setitem_fg,
|
|
req_fg: form.req_fg,
|
|
unit_length: form.unit_length,
|
|
unit_qty: form.unit_qty,
|
|
remark: form.remark,
|
|
};
|
|
await devPartApi.update(editObjid, body);
|
|
toast.success("PART가 수정되었습니다.");
|
|
} else {
|
|
const body: PartCreateBody = {
|
|
part_no: form.part_no,
|
|
part_name: form.part_name,
|
|
part_type: form.part_type,
|
|
material: form.material,
|
|
heat_treatment_hardness: form.heat_treatment_hardness,
|
|
heat_treatment_method: form.heat_treatment_method,
|
|
surface_treatment: form.surface_treatment,
|
|
maker: form.maker,
|
|
spec: form.spec,
|
|
acctfg: form.acctfg,
|
|
odrfg: form.odrfg,
|
|
unit_dc: form.unit_dc,
|
|
unitmang_dc: form.unitmang_dc,
|
|
unitchng_nb: form.unitchng_nb,
|
|
lot_fg: form.lot_fg,
|
|
use_yn: form.use_yn,
|
|
qc_fg: form.qc_fg,
|
|
setitem_fg: form.setitem_fg,
|
|
req_fg: form.req_fg,
|
|
unit_length: form.unit_length,
|
|
unit_qty: form.unit_qty,
|
|
remark: form.remark,
|
|
};
|
|
await devPartApi.create(body);
|
|
toast.success("PART가 등록되었습니다.");
|
|
}
|
|
onSaved();
|
|
onOpenChange(false);
|
|
} catch (e: any) {
|
|
toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const titleText = isEdit ? "품목 수정" : "품목 등록";
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[92vh] flex flex-col p-0 overflow-hidden">
|
|
<DialogHeader className="bg-blue-600 px-4 py-3">
|
|
<DialogTitle className="text-white">{titleText}</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
{loading ? (
|
|
<div className="flex h-64 items-center justify-center">
|
|
<Loader2 className="h-6 w-6 animate-spin" />
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 overflow-y-auto px-4 py-3">
|
|
<table className="w-full border-collapse text-sm">
|
|
<colgroup>
|
|
<col className="w-[110px]" />
|
|
<col />
|
|
<col className="w-[110px]" />
|
|
<col />
|
|
</colgroup>
|
|
<tbody>
|
|
{/* ① */}
|
|
<Tr>
|
|
<Th>품번</Th>
|
|
<Td><Input value={form.part_no} disabled={isEdit}
|
|
onChange={(e) => setField("part_no", e.target.value)} /></Td>
|
|
<Th>품명</Th>
|
|
<Td><Input value={form.part_name}
|
|
onChange={(e) => setField("part_name", e.target.value)} /></Td>
|
|
</Tr>
|
|
|
|
{/* ② */}
|
|
<Tr>
|
|
<Th>재료</Th>
|
|
<Td><Input value={form.material}
|
|
onChange={(e) => setField("material", e.target.value)} /></Td>
|
|
<Th>열처리경도</Th>
|
|
<Td><Input value={form.heat_treatment_hardness}
|
|
onChange={(e) => setField("heat_treatment_hardness", e.target.value)} /></Td>
|
|
</Tr>
|
|
|
|
{/* ③ */}
|
|
<Tr>
|
|
<Th>열처리방법</Th>
|
|
<Td><Input value={form.heat_treatment_method}
|
|
onChange={(e) => setField("heat_treatment_method", e.target.value)} /></Td>
|
|
<Th>표면처리</Th>
|
|
<Td><Input value={form.surface_treatment}
|
|
onChange={(e) => setField("surface_treatment", e.target.value)} /></Td>
|
|
</Tr>
|
|
|
|
{/* ④ */}
|
|
<Tr>
|
|
<Th>메이커</Th>
|
|
<Td><Input value={form.maker}
|
|
onChange={(e) => setField("maker", e.target.value)} /></Td>
|
|
<Th>범주이름</Th>
|
|
<Td>
|
|
<CommCodeSelect groupId={GROUP_PART_TYPE} withAll={false}
|
|
value={form.part_type}
|
|
onValueChange={(v) => setField("part_type", v)} />
|
|
</Td>
|
|
</Tr>
|
|
|
|
{/* ⑤ 규격 (colspan=3) */}
|
|
<Tr>
|
|
<Th>규격</Th>
|
|
<Td colSpan={3}><Input value={form.spec} maxLength={100}
|
|
onChange={(e) => setField("spec", e.target.value)} /></Td>
|
|
</Tr>
|
|
|
|
{/* ⑥ */}
|
|
<Tr>
|
|
<Th>계정구분<Req /></Th>
|
|
<Td>
|
|
<CommCodeSelect groupId={GROUP_ACCTFG} withAll={false}
|
|
value={form.acctfg}
|
|
onValueChange={(v) => setField("acctfg", v)} />
|
|
</Td>
|
|
<Th>조달구분<Req /></Th>
|
|
<Td><BasicSelect value={form.odrfg} options={OPT_ODRFG}
|
|
onChange={(v) => setField("odrfg", v)} /></Td>
|
|
</Tr>
|
|
|
|
{/* ⑦ */}
|
|
<Tr>
|
|
<Th>재고단위<Req /></Th>
|
|
<Td>
|
|
<CommCodeSelect groupId={GROUP_UNIT_DC} withAll={false}
|
|
value={form.unit_dc}
|
|
onValueChange={(v) => setField("unit_dc", v)} />
|
|
</Td>
|
|
<Th>관리단위<Req /></Th>
|
|
<Td>
|
|
<CommCodeSelect groupId={GROUP_UNIT_DC} withAll={false}
|
|
value={form.unitmang_dc}
|
|
onValueChange={(v) => setField("unitmang_dc", v)} />
|
|
</Td>
|
|
</Tr>
|
|
|
|
{/* ⑧ */}
|
|
<Tr>
|
|
<Th>환산수량<Req /></Th>
|
|
<Td><Input value={form.unitchng_nb} className="text-right"
|
|
inputMode="decimal"
|
|
onChange={(e) => setField("unitchng_nb", e.target.value.replace(/[^0-9.]/g, ""))} /></Td>
|
|
<Th>LOT구분<Req /></Th>
|
|
<Td><BasicSelect value={form.lot_fg} options={OPT_LOT_FG}
|
|
onChange={(v) => setField("lot_fg", v)} /></Td>
|
|
</Tr>
|
|
|
|
{/* ⑨ */}
|
|
<Tr>
|
|
<Th>사용여부<Req /></Th>
|
|
<Td><BasicSelect value={form.use_yn} options={OPT_USE_YN}
|
|
onChange={(v) => setField("use_yn", v)} /></Td>
|
|
<Th>검사여부<Req /></Th>
|
|
<Td><BasicSelect value={form.qc_fg} options={OPT_QC_FG}
|
|
onChange={(v) => setField("qc_fg", v)} /></Td>
|
|
</Tr>
|
|
|
|
{/* ⑩ */}
|
|
<Tr>
|
|
<Th>SET품여부<Req /></Th>
|
|
<Td><BasicSelect value={form.setitem_fg} options={OPT_YESNO}
|
|
onChange={(v) => setField("setitem_fg", v)} /></Td>
|
|
<Th>의뢰여부<Req /></Th>
|
|
<Td><BasicSelect value={form.req_fg} options={OPT_YESNO}
|
|
onChange={(v) => setField("req_fg", v)} /></Td>
|
|
</Tr>
|
|
|
|
{/* ⑪ */}
|
|
<Tr>
|
|
<Th>개당길이</Th>
|
|
<Td><Input value={form.unit_length} className="text-right"
|
|
inputMode="decimal" maxLength={20}
|
|
onChange={(e) => setField("unit_length", e.target.value.replace(/[^0-9.]/g, ""))} /></Td>
|
|
<Th>개당소요량</Th>
|
|
<Td><Input value={form.unit_qty} className="text-right"
|
|
inputMode="decimal" maxLength={20}
|
|
onChange={(e) => setField("unit_qty", e.target.value.replace(/[^0-9.]/g, ""))} /></Td>
|
|
</Tr>
|
|
|
|
{/* ⑫ 비고 (colspan=3) */}
|
|
<Tr>
|
|
<Th>비고</Th>
|
|
<Td colSpan={3}><Input value={form.remark}
|
|
onChange={(e) => setField("remark", e.target.value)} /></Td>
|
|
</Tr>
|
|
|
|
{/* ⑬ CAD Data — placeholder (DEV-7 도면업로드 별 PR) */}
|
|
<tr>
|
|
<th className="border bg-muted/30 px-3 py-2 text-left align-top font-medium" rowSpan={3}>
|
|
CAD Data
|
|
</th>
|
|
<th className="border bg-muted/30 px-3 py-2 text-left font-medium w-[110px]">3D</th>
|
|
<td className="border px-3 py-2" colSpan={2}>
|
|
<DropPlaceholder label="3D" />
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<th className="border bg-muted/30 px-3 py-2 text-left font-medium">2D(Drawing)</th>
|
|
<td className="border px-3 py-2" colSpan={2}>
|
|
<DropPlaceholder label="2D(Drawing)" />
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<th className="border bg-muted/30 px-3 py-2 text-left font-medium">2D(PDF)</th>
|
|
<td className="border px-3 py-2" colSpan={2}>
|
|
<DropPlaceholder label="2D(PDF)" />
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<div className="mt-2 text-[11px] text-muted-foreground">
|
|
CAD Data 첨부는 DEV-7 (도면업로드) 별 PR 에서 활성화됩니다.
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<DialogFooter className="border-t bg-muted/20 px-4 py-3 sm:justify-center">
|
|
<Button onClick={handleSave} disabled={saving || loading}>
|
|
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
|
<span className="ml-1">저장</span>
|
|
</Button>
|
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
|
닫기
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
// ─── 보조 컴포넌트 ──────────────────────────────────────────
|
|
|
|
function Tr({ children }: { children: React.ReactNode }) {
|
|
return <tr>{children}</tr>;
|
|
}
|
|
function Th({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<th className="border bg-muted/30 px-3 py-2 text-left align-middle font-medium w-[110px]">
|
|
{children}
|
|
</th>
|
|
);
|
|
}
|
|
function Td({ children, colSpan }: { children: React.ReactNode; colSpan?: number }) {
|
|
return <td className="border px-3 py-1.5" colSpan={colSpan}>{children}</td>;
|
|
}
|
|
function Req() {
|
|
return <span className="ml-1 text-red-500">*</span>;
|
|
}
|
|
function BasicSelect({
|
|
value, options, onChange,
|
|
}: {
|
|
value: string;
|
|
options: { v: string; t: string }[];
|
|
onChange: (v: string) => void;
|
|
}) {
|
|
return (
|
|
<select
|
|
className={cn("h-9 w-full rounded-md border bg-background px-2 text-sm")}
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
>
|
|
<option value="">선택</option>
|
|
{options.map((o) => <option key={o.v} value={o.v}>{o.t}</option>)}
|
|
</select>
|
|
);
|
|
}
|
|
function DropPlaceholder({ label }: { label: string }) {
|
|
return (
|
|
<div className="border-2 border-dashed border-muted-foreground/30 rounded text-muted-foreground py-6 text-center text-xs">
|
|
<Upload className="h-5 w-5 mx-auto mb-1" />
|
|
Drag & Drop Files Here ({label})
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── PartRow → FormState ────────────────────────────────────
|
|
|
|
function rowToForm(r: PartRow): FormState {
|
|
return {
|
|
part_no: r.part_no ?? "",
|
|
part_name: r.part_name ?? "",
|
|
material: r.material ?? "",
|
|
heat_treatment_hardness: r.heat_treatment_hardness ?? "",
|
|
heat_treatment_method: r.heat_treatment_method ?? "",
|
|
surface_treatment: r.surface_treatment ?? "",
|
|
maker: r.maker ?? "",
|
|
part_type: r.part_type ?? "",
|
|
spec: r.spec ?? "",
|
|
acctfg: r.acctfg ?? "",
|
|
odrfg: r.odrfg ?? "",
|
|
unit_dc: r.unit_dc ?? "",
|
|
unitmang_dc: r.unitmang_dc ?? "",
|
|
unitchng_nb: r.unitchng_nb != null ? String(r.unitchng_nb) : "",
|
|
lot_fg: r.lot_fg ?? "",
|
|
use_yn: r.use_yn ?? "",
|
|
qc_fg: r.qc_fg ?? "",
|
|
setitem_fg: r.setitem_fg ?? "",
|
|
req_fg: r.req_fg ?? "",
|
|
unit_length: r.unit_length ?? "",
|
|
unit_qty: r.unit_qty ?? "",
|
|
remark: r.remark ?? "",
|
|
};
|
|
}
|