ea6606da0c
backend (M1+M2):
- devPartService: listTemp/listRelease/getByObjid/create/update/deploy/removeMany
- partMngBaseSimple SELECT + 추가 15컬럼(acctfg/odrfg/unit_dc/unitmang_dc/lot_fg 등) 라벨/CASE
- deploy 트랜잭션 3단계 (isLastInit → part_mng_history INSERT → partMngDeploy + EO_NO 채번)
- EO_NO 분기: is_longd='1'→EOB{yy}-{seq} / else EO{yy}-{seq}
- objidUtil: wace CommonUtils.createObjId() 1:1 (bigint objid 채번)
- DDL: 9 신규 테이블 + part_mng 15컬럼 ALTER (운영판 1:1 추출)
frontend (M1+M2):
- part-regist (M1) / part-search (M2): 23셀 그리드 + 검색폼 + 액션
- PartFormDialog: 등록/수정 통합 (mode prop, 4 섹션)
- PartDetailDialog: 읽기 전용 + "수정" dispatch
- AdminPageRenderer dynamic 임포트 2건 + menu_info URL spec 정렬
본 PR 제외 (별 PR): 도면 다중 업로드, ERP 업로드, Excel Import, BOM_PART_QTY R/W
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
500 lines
19 KiB
TypeScript
500 lines
19 KiB
TypeScript
"use client";
|
|
|
|
// 개발관리 > PART 등록/수정 통합 다이얼로그.
|
|
// wace partMngFormPopUp.jsp + partMngDetailPopUp.jsp 1:1 (mode 분기).
|
|
//
|
|
// 신규: POST /api/development/part (38 컬럼)
|
|
// 수정: PUT /api/development/part/:objid (21 컬럼만 — wace updatePartDetail 1:1)
|
|
//
|
|
// 그룹:
|
|
// ① 기본정보 (필수 ★: part_no, part_name, part_type)
|
|
// ② 크기/형상
|
|
// ③ 분류/단위 (comm_code SmartSelect)
|
|
// ④ Y/N 플래그 (radio '1'/'0')
|
|
|
|
import React, { useCallback, 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 { Label } from "@/components/ui/label";
|
|
import { Loader2, Save } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
|
|
import { devPartApi, PartCreateBody, PartUpdateBody, PartRow } from "@/lib/api/devPart";
|
|
|
|
// comm_code 그룹 ID (vexplor_rps DB 실재 그룹)
|
|
const GROUP_PART_TYPE = "0000062"; // PART TYPE (조립품/부품/구매품)
|
|
const GROUP_UNIT = "0001399"; // 단위 (m/Set/EA/식/BAG/kg/...)
|
|
const GROUP_ACCTFG = "0900213"; // 파트_계정구분 (원자재/제품/...)
|
|
|
|
// ODRFG: spec '0=구매/1=생산/8=Phantom' — 하드코딩
|
|
const ODRFG_OPTIONS = [
|
|
{ code: "0", label: "구매" },
|
|
{ code: "1", label: "생산" },
|
|
{ code: "8", label: "Phantom" },
|
|
];
|
|
|
|
interface FormState {
|
|
// 기본
|
|
part_no: string;
|
|
part_name: string;
|
|
part_type: string;
|
|
unit: string;
|
|
qty: string;
|
|
spec: string;
|
|
material: string;
|
|
remark: string;
|
|
maker: string;
|
|
// 크기/형상
|
|
thickness: string;
|
|
width: string;
|
|
height: string;
|
|
out_diameter: string;
|
|
in_diameter: string;
|
|
length: string;
|
|
// 분류/단위
|
|
acctfg: string;
|
|
odrfg: string;
|
|
unit_dc: string;
|
|
unitmang_dc: string;
|
|
unitchng_nb: string;
|
|
unit_length: string;
|
|
unit_qty: string;
|
|
// 열처리/표면처리/후가공
|
|
heat_treatment_hardness: string;
|
|
heat_treatment_method: string;
|
|
surface_treatment: string;
|
|
post_processing: string;
|
|
// 부속 (신규 시만 표시)
|
|
product_mgmt_objid: string;
|
|
supply_code: string;
|
|
contract_objid: string;
|
|
// Y/N
|
|
lot_fg: string;
|
|
use_yn: string;
|
|
qc_fg: string;
|
|
setitem_fg: string;
|
|
req_fg: string;
|
|
}
|
|
|
|
const EMPTY_FORM: FormState = {
|
|
part_no: "", part_name: "", part_type: "", unit: "", qty: "", spec: "", material: "", remark: "", maker: "",
|
|
thickness: "", width: "", height: "", out_diameter: "", in_diameter: "", length: "",
|
|
acctfg: "", odrfg: "", unit_dc: "", unitmang_dc: "", unitchng_nb: "", unit_length: "", unit_qty: "",
|
|
heat_treatment_hardness: "", heat_treatment_method: "", surface_treatment: "", post_processing: "",
|
|
product_mgmt_objid: "", supply_code: "", contract_objid: "",
|
|
lot_fg: "1", use_yn: "1", qc_fg: "0", setitem_fg: "0", req_fg: "0",
|
|
};
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!form.part_no.trim()) return toast.error("품번은 필수입니다.");
|
|
if (!form.part_name.trim()) return toast.error("품명은 필수입니다.");
|
|
if (!isEdit && !form.part_type.trim()) return toast.error("범주(PART TYPE)는 필수입니다.");
|
|
|
|
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,
|
|
unit: form.unit,
|
|
qty: form.qty,
|
|
spec: form.spec,
|
|
material: form.material,
|
|
thickness: form.thickness,
|
|
width: form.width,
|
|
height: form.height,
|
|
out_diameter: form.out_diameter,
|
|
in_diameter: form.in_diameter,
|
|
length: form.length,
|
|
remark: form.remark,
|
|
product_mgmt_objid: form.product_mgmt_objid,
|
|
supply_code: form.supply_code,
|
|
maker: form.maker,
|
|
contract_objid: form.contract_objid,
|
|
post_processing: form.post_processing,
|
|
heat_treatment_hardness: form.heat_treatment_hardness,
|
|
heat_treatment_method: form.heat_treatment_method,
|
|
surface_treatment: form.surface_treatment,
|
|
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,
|
|
};
|
|
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 ? "PART 수정" : "PART 신규 등록";
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-5xl">
|
|
<DialogHeader>
|
|
<DialogTitle>{titleText}</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
{loading ? (
|
|
<div className="flex h-64 items-center justify-center">
|
|
<Loader2 className="h-6 w-6 animate-spin" />
|
|
</div>
|
|
) : (
|
|
<div className="max-h-[70vh] space-y-4 overflow-y-auto px-1 py-2">
|
|
{/* ① 기본정보 */}
|
|
<Section title="기본정보">
|
|
<Row>
|
|
<Field label="품번" required>
|
|
<Input value={form.part_no} disabled={isEdit}
|
|
onChange={(e) => setField("part_no", e.target.value)} />
|
|
</Field>
|
|
<Field label="품명" required>
|
|
<Input value={form.part_name}
|
|
onChange={(e) => setField("part_name", e.target.value)} />
|
|
</Field>
|
|
<Field label="범주(PART TYPE)" required>
|
|
<CommCodeSelect groupId={GROUP_PART_TYPE} withAll={false}
|
|
value={form.part_type}
|
|
onValueChange={(v) => setField("part_type", v)} />
|
|
</Field>
|
|
</Row>
|
|
<Row>
|
|
<Field label="단위">
|
|
<CommCodeSelect groupId={GROUP_UNIT} withAll={false}
|
|
value={form.unit}
|
|
onValueChange={(v) => setField("unit", v)} />
|
|
</Field>
|
|
<Field label="수량">
|
|
<Input value={form.qty} className="text-right"
|
|
onChange={(e) => setField("qty", e.target.value)} />
|
|
</Field>
|
|
<Field label="규격">
|
|
<Input value={form.spec}
|
|
onChange={(e) => setField("spec", e.target.value)} />
|
|
</Field>
|
|
</Row>
|
|
<Row>
|
|
<Field label="재료">
|
|
<Input value={form.material}
|
|
onChange={(e) => setField("material", e.target.value)} />
|
|
</Field>
|
|
<Field label="메이커">
|
|
<Input value={form.maker}
|
|
onChange={(e) => setField("maker", e.target.value)} />
|
|
</Field>
|
|
<Field label="비고">
|
|
<Input value={form.remark}
|
|
onChange={(e) => setField("remark", e.target.value)} />
|
|
</Field>
|
|
</Row>
|
|
</Section>
|
|
|
|
{/* ② 크기/형상 */}
|
|
<Section title="크기 / 형상">
|
|
<Row>
|
|
<Field label="두께"><Input value={form.thickness} className="text-right"
|
|
onChange={(e) => setField("thickness", e.target.value)} /></Field>
|
|
<Field label="너비(W)"><Input value={form.width} className="text-right"
|
|
onChange={(e) => setField("width", e.target.value)} /></Field>
|
|
<Field label="높이(H)"><Input value={form.height} className="text-right"
|
|
onChange={(e) => setField("height", e.target.value)} /></Field>
|
|
</Row>
|
|
<Row>
|
|
<Field label="외경"><Input value={form.out_diameter} className="text-right"
|
|
onChange={(e) => setField("out_diameter", e.target.value)} /></Field>
|
|
<Field label="내경"><Input value={form.in_diameter} className="text-right"
|
|
onChange={(e) => setField("in_diameter", e.target.value)} /></Field>
|
|
<Field label="길이(L)"><Input value={form.length} className="text-right"
|
|
onChange={(e) => setField("length", e.target.value)} /></Field>
|
|
</Row>
|
|
</Section>
|
|
|
|
{/* ③ 분류 / 단위 */}
|
|
<Section title="분류 / 단위">
|
|
<Row>
|
|
<Field label="계정구분">
|
|
<CommCodeSelect groupId={GROUP_ACCTFG} withAll={false}
|
|
value={form.acctfg}
|
|
onValueChange={(v) => setField("acctfg", v)} />
|
|
</Field>
|
|
<Field label="조달구분">
|
|
<select className="h-9 w-full rounded-md border bg-background px-2 text-sm"
|
|
value={form.odrfg}
|
|
onChange={(e) => setField("odrfg", e.target.value)}>
|
|
<option value="">선택</option>
|
|
{ODRFG_OPTIONS.map((o) =>
|
|
<option key={o.code} value={o.code}>{o.label}</option>)}
|
|
</select>
|
|
</Field>
|
|
<Field label="환산수량(UNITCHNG_NB)">
|
|
<Input value={form.unitchng_nb} className="text-right"
|
|
onChange={(e) => setField("unitchng_nb", e.target.value)} />
|
|
</Field>
|
|
</Row>
|
|
<Row>
|
|
<Field label="재고단위">
|
|
<CommCodeSelect groupId={GROUP_UNIT} withAll={false}
|
|
value={form.unit_dc}
|
|
onValueChange={(v) => setField("unit_dc", v)} />
|
|
</Field>
|
|
<Field label="관리단위">
|
|
<CommCodeSelect groupId={GROUP_UNIT} withAll={false}
|
|
value={form.unitmang_dc}
|
|
onValueChange={(v) => setField("unitmang_dc", v)} />
|
|
</Field>
|
|
<Field label="개당길이 / 개당수량">
|
|
<div className="flex items-center gap-1">
|
|
<Input value={form.unit_length} className="text-right"
|
|
placeholder="개당길이"
|
|
onChange={(e) => setField("unit_length", e.target.value)} />
|
|
<span className="text-xs text-muted-foreground">/</span>
|
|
<Input value={form.unit_qty} className="text-right"
|
|
placeholder="개당수량"
|
|
onChange={(e) => setField("unit_qty", e.target.value)} />
|
|
</div>
|
|
</Field>
|
|
</Row>
|
|
<Row>
|
|
<Field label="열처리경도">
|
|
<Input value={form.heat_treatment_hardness}
|
|
onChange={(e) => setField("heat_treatment_hardness", e.target.value)} />
|
|
</Field>
|
|
<Field label="열처리방법">
|
|
<Input value={form.heat_treatment_method}
|
|
onChange={(e) => setField("heat_treatment_method", e.target.value)} />
|
|
</Field>
|
|
<Field label="표면처리">
|
|
<Input value={form.surface_treatment}
|
|
onChange={(e) => setField("surface_treatment", e.target.value)} />
|
|
</Field>
|
|
</Row>
|
|
<Row>
|
|
<Field label="후가공">
|
|
<Input value={form.post_processing}
|
|
onChange={(e) => setField("post_processing", e.target.value)} />
|
|
</Field>
|
|
{!isEdit && (
|
|
<Field label="공급업체 코드">
|
|
<Input value={form.supply_code}
|
|
onChange={(e) => setField("supply_code", e.target.value)}
|
|
placeholder="admin_supply_mng.objid" />
|
|
</Field>
|
|
)}
|
|
{!isEdit && (
|
|
<Field label="제품 OBJID">
|
|
<Input value={form.product_mgmt_objid}
|
|
onChange={(e) => setField("product_mgmt_objid", e.target.value)}
|
|
placeholder="product_mgmt.objid" />
|
|
</Field>
|
|
)}
|
|
</Row>
|
|
</Section>
|
|
|
|
{/* ④ Y/N 플래그 */}
|
|
<Section title="Y/N 플래그">
|
|
<Row>
|
|
<Field label="LOT구분"><YNRadio value={form.lot_fg} onChange={(v) => setField("lot_fg", v)} /></Field>
|
|
<Field label="사용여부"><YNRadio value={form.use_yn} onChange={(v) => setField("use_yn", v)} /></Field>
|
|
<Field label="검사여부"><YNRadio value={form.qc_fg} onChange={(v) => setField("qc_fg", v)} /></Field>
|
|
</Row>
|
|
<Row>
|
|
<Field label="SET품여부"><YNRadio value={form.setitem_fg} onChange={(v) => setField("setitem_fg", v)} /></Field>
|
|
<Field label="의뢰여부"><YNRadio value={form.req_fg} onChange={(v) => setField("req_fg", v)} /></Field>
|
|
<Field label="" />
|
|
</Row>
|
|
</Section>
|
|
</div>
|
|
)}
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
|
취소
|
|
</Button>
|
|
<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">{isEdit ? "수정" : "등록"}</span>
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
// ─── 보조 컴포넌트 ──────────────────────────────────────────
|
|
|
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="rounded-md border bg-card p-3">
|
|
<div className="mb-2 text-sm font-semibold text-foreground">{title}</div>
|
|
<div className="space-y-2">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Row({ children }: { children: React.ReactNode }) {
|
|
return <div className="grid grid-cols-3 gap-3">{children}</div>;
|
|
}
|
|
|
|
function Field({ label, required, children }: { label: string; required?: boolean; children?: React.ReactNode }) {
|
|
return (
|
|
<div>
|
|
{label && (
|
|
<Label className="mb-1 block text-xs text-muted-foreground">
|
|
{label}
|
|
{required && <span className="ml-1 text-red-500">*</span>}
|
|
</Label>
|
|
)}
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function YNRadio({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
|
return (
|
|
<div className="flex h-9 items-center gap-3 rounded-md border bg-background px-3 text-sm">
|
|
<label className="flex items-center gap-1">
|
|
<input type="radio" checked={value === "1"} onChange={() => onChange("1")} />
|
|
<span>예</span>
|
|
</label>
|
|
<label className="flex items-center gap-1">
|
|
<input type="radio" checked={value === "0"} onChange={() => onChange("0")} />
|
|
<span>아니오</span>
|
|
</label>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── PartRow → FormState 매핑 ──────────────────────────────
|
|
|
|
function rowToForm(r: PartRow): FormState {
|
|
return {
|
|
part_no: r.part_no ?? "",
|
|
part_name: r.part_name ?? "",
|
|
part_type: r.part_type ?? "",
|
|
unit: r.unit ?? "",
|
|
qty: r.qty ?? "",
|
|
spec: r.spec ?? "",
|
|
material: r.material ?? "",
|
|
remark: r.remark ?? "",
|
|
maker: r.maker ?? "",
|
|
thickness: r.thickness ?? "",
|
|
width: r.width ?? "",
|
|
height: r.height ?? "",
|
|
out_diameter: r.out_diameter ?? "",
|
|
in_diameter: r.in_diameter ?? "",
|
|
length: r.length ?? "",
|
|
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) : "",
|
|
unit_length: r.unit_length ?? "",
|
|
unit_qty: r.unit_qty ?? "",
|
|
heat_treatment_hardness: r.heat_treatment_hardness ?? "",
|
|
heat_treatment_method: r.heat_treatment_method ?? "",
|
|
surface_treatment: r.surface_treatment ?? "",
|
|
post_processing: r.post_processing ?? "",
|
|
product_mgmt_objid: r.product_mgmt_objid ?? "",
|
|
supply_code: r.supply_code ?? "",
|
|
contract_objid: r.contract_objid ?? "",
|
|
lot_fg: r.lot_fg ?? "1",
|
|
use_yn: r.use_yn ?? "1",
|
|
qc_fg: r.qc_fg ?? "0",
|
|
setitem_fg: r.setitem_fg ?? "0",
|
|
req_fg: r.req_fg ?? "0",
|
|
};
|
|
}
|