Files
wace_rps/frontend/components/sales/PurchaseRequestFormDialog.tsx
hjjeong 0fe71298d2 공용 NumberInput + 숫자 포맷 정책 적용 (수량 1,234 / 금액 1,234.00)
- NumberInput 공용 컴포넌트: blur 시 콤마+소수점 자릿수 강제,
  focus 시 raw 숫자로 전환되어 자유 편집, 잘못된 입력은 이전 값 유지.
- 다이얼로그 수량/단가 input → NumberInput 으로 교체.
- 백엔드 정규화 — M-BOM/detail/proposal-targets:
  qty=FLOOR()::INTEGER, unit_price/partner_price/total_price=NUMERIC(18,2)
  (운영 sales_request_part 는 정수 String 이지만 M-BOM production_qty
   NUMERIC(15,4) 가 흘러들어와 '4.0000' 노출되던 문제 차단).
- ProposalCreateDialog fmt: Math.floor 후 ko-KR toLocaleString.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:52:50 +09:00

394 lines
16 KiB
TypeScript

"use client";
// 영업관리 > 구매요청서관리 — 구매요청서작성 다이얼로그
// wace 1:1: salesRequestFormPopUp.jsp
// - 프로젝트 선택 → purchaseOrderAdminSupplyInfo: 주문유형/제품구분/국내외/고객사/유무상 자동 채움
// - 행추가: 품번 SmartSelect (해당 프로젝트 M-BOM 품목) → 선택 시 품명/공급업체/단가 자동 셋팅
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Plus, Trash2, Save, X } from "lucide-react";
import { toast } from "sonner";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { CustomerSelect } from "@/components/common/CustomerSelect";
import { DateInput } from "@/components/common/DateInput";
import { NumberInput } from "@/components/common/NumberInput";
import { purchaseApi, OptionItem } from "@/lib/api/purchase";
import {
salesPurchaseRequestApi,
MbomPartItem,
PurchaseRequestPartInput,
} from "@/lib/api/salesPurchaseRequest";
interface Props {
open: boolean;
onClose: () => void;
onSaved: () => void;
/** 수정 모드 시 기존 SRM OBJID — 미지정이면 신규 등록 */
srmObjid?: string;
}
interface FormState {
objid?: string; // 수정 모드 시 기존 OBJID
request_mng_no?: string; // 수정 모드 표시용
project_no: string; // PROJECT_MGMT.OBJID
mbom_header_objid: string;
purchase_type: string;
order_type: string;
product_name: string;
area_cd: string;
customer_objid: string;
paid_type: string;
delivery_request_date: string;
}
interface PartRow extends PurchaseRequestPartInput {
rowKey: string;
part_no?: string;
}
const EMPTY_FORM: FormState = {
project_no: "", mbom_header_objid: "",
purchase_type: "", order_type: "", product_name: "",
area_cd: "", customer_objid: "", paid_type: "",
delivery_request_date: "",
};
let _rk = 0;
const nextKey = () => `r${++_rk}_${Date.now()}`;
export function PurchaseRequestFormDialog({ open, onClose, onSaved, srmObjid }: Props) {
const isEdit = !!srmObjid;
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [parts, setParts] = useState<PartRow[]>([]);
const [saving, setSaving] = useState(false);
const [loadingProject, setLoadingProject] = useState(false);
const [projectOpts, setProjectOpts] = useState<SmartSelectOption[]>([]);
const [supplierOpts, setSupplierOpts] = useState<SmartSelectOption[]>([]);
// 선택된 프로젝트의 M-BOM 품목 풀 (행추가 시 품번 셀렉트 옵션)
const [mbomItems, setMbomItems] = useState<MbomPartItem[]>([]);
// 모달 열릴 때 옵션 로드 + (수정모드면) 상세 로드, (신규면) 폼 초기화
useEffect(() => {
if (!open) return;
setForm(EMPTY_FORM);
setParts([]);
setMbomItems([]);
(async () => {
try {
const [proj, vendors] = await Promise.all([
purchaseApi.listProjects(),
salesPurchaseRequestApi.listVendors(), // client_mng 기반 (M-BOM vendor / partner 매칭용)
]);
setProjectOpts(proj.map(toSmart));
setSupplierOpts(vendors.map((v) => ({ code: v.code, label: v.label })));
if (srmObjid) {
const detail = await salesPurchaseRequestApi.getDetail(srmObjid);
const h = detail.header ?? {};
const projectObjid = String(h.project_no ?? "");
// 수정 모드 → M-BOM 풀도 함께 로드 (품번 셀렉트 옵션)
const items = projectObjid
? await salesPurchaseRequestApi.listMbomParts(projectObjid)
: [];
setMbomItems(items ?? []);
setForm({
objid: String(h.objid ?? ""),
request_mng_no: h.request_mng_no ?? "",
project_no: projectObjid,
mbom_header_objid: String(h.mbom_header_objid ?? items?.[0]?.mbom_header_objid ?? ""),
purchase_type: h.purchase_type ?? "",
order_type: h.order_type ?? h.category_cd ?? "",
product_name: h.product_name ?? "",
area_cd: h.area_cd ?? "",
customer_objid: h.customer_objid ?? "",
paid_type: h.paid_type ?? "",
delivery_request_date: normalizeDate(h.delivery_request_date),
});
setParts((detail.parts ?? []).map((p: any) => ({
rowKey: nextKey(),
objid: p.objid,
part_objid: p.part_objid,
part_no: p.part_no,
part_name: p.part_name,
qty: String(Math.floor(Number(p.qty ?? 0))),
partner_objid: p.partner_objid ?? "",
partner_price: p.partner_price != null && Number(p.partner_price) > 0
? String(Number(p.partner_price))
: (p.unit_price != null && Number(p.unit_price) > 0 ? String(Number(p.unit_price)) : ""),
})));
}
} catch (e: any) {
toast.error(`옵션 로드 실패: ${e?.message ?? ""}`);
}
})();
}, [open, srmObjid]);
// 프로젝트 선택 → 자동채움 (주문유형/제품구분/국내외/고객사/유무상) + M-BOM 품번 풀 갱신
const onProjectChange = useCallback(async (newProjectObjid: string) => {
setForm((f) => ({ ...f, project_no: newProjectObjid }));
setParts([]);
setMbomItems([]);
if (!newProjectObjid) {
setForm((f) => ({
...f, project_no: "",
mbom_header_objid: "", order_type: "", product_name: "",
area_cd: "", customer_objid: "", paid_type: "",
}));
return;
}
setLoadingProject(true);
try {
const [info, items] = await Promise.all([
salesPurchaseRequestApi.getProjectAutoFill(newProjectObjid),
salesPurchaseRequestApi.listMbomParts(newProjectObjid),
]);
setMbomItems(items ?? []);
setForm((f) => ({
...f,
project_no: newProjectObjid,
mbom_header_objid: info?.mbom_header_objid ?? (items?.[0]?.mbom_header_objid ?? ""),
order_type: info?.category_cd ?? "",
product_name: info?.product ?? "",
area_cd: info?.area_cd ?? "",
customer_objid: info?.customer_objid ?? "",
paid_type: info?.paid_type ?? "",
}));
if (!items || items.length === 0) {
toast.info("선택한 프로젝트에 M-BOM 품목이 없습니다. 품번 선택지가 비어 있습니다.");
}
} catch (e: any) {
toast.error(`프로젝트 정보 조회 실패: ${e?.message ?? ""}`);
} finally {
setLoadingProject(false);
}
}, []);
// M-BOM 품목 → 품번 셀렉트 옵션
const partOpts: SmartSelectOption[] = useMemo(
() => mbomItems.map((it) => ({ code: it.part_objid, label: it.part_no || it.part_objid })),
[mbomItems],
);
const addRow = () => {
setParts((p) => [
...p,
{ rowKey: nextKey(), part_objid: "", part_no: "", part_name: "", qty: "1", partner_objid: "", partner_price: "" },
]);
};
const deleteRow = (rowKey: string) => setParts((p) => p.filter((r) => r.rowKey !== rowKey));
const updateRow = (rowKey: string, patch: Partial<PartRow>) =>
setParts((p) => p.map((r) => (r.rowKey === rowKey ? { ...r, ...patch } : r)));
// 품번 선택 → M-BOM 메타데이터로 품명/공급업체/단가/수량 자동 셋팅
const onPartSelect = (rowKey: string, partObjid: string) => {
const hit = mbomItems.find((it) => it.part_objid === partObjid);
if (!hit) {
updateRow(rowKey, { part_objid: partObjid, part_no: "", part_name: "" });
return;
}
updateRow(rowKey, {
part_objid: hit.part_objid,
part_no: hit.part_no,
part_name: hit.part_name,
qty: String(Math.max(1, Math.floor(Number(hit.qty ?? 0)))),
partner_objid: hit.vendor_objid || "",
partner_price: Number(hit.unit_price ?? 0) > 0 ? String(Number(hit.unit_price)) : "",
});
};
const canSave = useMemo(() => {
if (!form.project_no) return false;
if (!form.purchase_type) return false;
if (parts.length === 0) return false;
return parts.every((r) => r.part_objid && Number(r.qty || 0) > 0);
}, [form, parts]);
const handleSave = async () => {
if (!canSave) {
if (!form.project_no) return toast.error("프로젝트번호를 선택해주세요.");
if (!form.purchase_type) return toast.error("구매유형을 선택해주세요.");
if (parts.length === 0) return toast.error("품목이 1건 이상 필요합니다.");
return toast.error("품번/수량(0 초과)을 모두 입력해주세요.");
}
setSaving(true);
try {
const { request_mng_no, ...rest } = form; // request_mng_no 는 표시 전용, 백엔드 미전송
void request_mng_no;
const payload = {
...rest,
parts: parts.map(({ rowKey, part_no, ...partRest }) => partRest), // eslint-disable-line @typescript-eslint/no-unused-vars
};
const res = await salesPurchaseRequestApi.save(payload);
toast.success(`${isEdit ? "수정" : "저장"}되었습니다. (${res.request_mng_no ?? form.request_mng_no ?? res.objid})`);
onSaved();
onClose();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패");
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-5xl">
<DialogHeader>
<DialogTitle>{isEdit ? `구매요청서 수정${form.request_mng_no ? `${form.request_mng_no}` : ""}` : "구매요청서 작성"}</DialogTitle>
<DialogDescription> //// . .</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-4 gap-3 text-sm">
<Field label="구매유형 *">
<CommCodeSelect groupId="0001814" value={form.purchase_type}
onValueChange={(v) => setForm({ ...form, purchase_type: v })} withAll={false} />
</Field>
<Field label="프로젝트번호 *">
<SmartSelect options={projectOpts} value={form.project_no}
onValueChange={onProjectChange}
placeholder={loadingProject ? "프로젝트 정보 조회 중..." : "선택"} />
</Field>
<Field label="주문유형">
<CommCodeSelect groupId="0000167" value={form.order_type}
onValueChange={(v) => setForm({ ...form, order_type: v })} withAll={false} />
</Field>
<Field label="제품구분">
<CommCodeSelect groupId="0000001" value={form.product_name}
onValueChange={(v) => setForm({ ...form, product_name: v })} withAll={false} />
</Field>
<Field label="국내/해외">
<CommCodeSelect groupId="0001219" value={form.area_cd}
onValueChange={(v) => setForm({ ...form, area_cd: v })} withAll={false} />
</Field>
<Field label="고객사">
<CustomerSelect value={form.customer_objid}
onValueChange={(v) => setForm({ ...form, customer_objid: v })} />
</Field>
<Field label="유/무상">
<SmartSelect
options={[{ code: "paid", label: "유상" }, { code: "free", label: "무상" }]}
value={form.paid_type}
onValueChange={(v) => setForm({ ...form, paid_type: v })} />
</Field>
<Field label="입고요청일">
<DateInput value={form.delivery_request_date}
onChange={(v) => setForm({ ...form, delivery_request_date: v })} />
</Field>
</div>
<div className="mt-3 border rounded">
<div className="flex items-center justify-between border-b px-2 py-1 bg-muted/40 text-xs">
<div className="font-medium">
({parts.length})
{form.project_no ? (
<span className="ml-2 text-muted-foreground"> {partOpts.length}</span>
) : null}
</div>
<div className="flex gap-1">
<Button size="sm" variant="outline" className="h-7 gap-1 px-2 text-xs"
onClick={addRow}
disabled={!form.project_no}>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="max-h-[360px] overflow-auto">
<table className="w-full text-xs">
<thead className="bg-muted/30 sticky top-0">
<tr>
<th className="px-2 py-1 text-left w-[200px]"></th>
<th className="px-2 py-1 text-left"></th>
<th className="px-2 py-1 text-right w-[90px]"></th>
<th className="px-2 py-1 text-left w-[180px]"></th>
<th className="px-2 py-1 text-right w-[110px]"></th>
<th className="px-2 py-1 w-[36px]"></th>
</tr>
</thead>
<tbody>
{parts.length === 0 ? (
<tr>
<td colSpan={6} className="px-2 py-6 text-center text-muted-foreground">
{form.project_no
? "[행추가] 버튼을 눌러 품번을 선택해주세요."
: "먼저 프로젝트번호를 선택해주세요."}
</td>
</tr>
) : parts.map((r) => (
<tr key={r.rowKey} className="border-t">
<td className="px-2 py-1">
<SmartSelect options={partOpts} value={r.part_objid}
onValueChange={(v) => onPartSelect(r.rowKey, v)}
placeholder={partOpts.length === 0 ? "M-BOM 품목 없음" : "선택"} />
</td>
<td className="px-2 py-1">{r.part_name || ""}</td>
<td className="px-2 py-1">
<NumberInput value={r.qty} decimals={0} min={0} className="h-7"
onChange={(v) => updateRow(r.rowKey, { qty: v === "" ? "" : String(v) })} />
</td>
<td className="px-2 py-1">
<SmartSelect options={supplierOpts} value={r.partner_objid ?? ""}
onValueChange={(v) => updateRow(r.rowKey, { partner_objid: v })} />
</td>
<td className="px-2 py-1">
<NumberInput value={r.partner_price} decimals={2} min={0} className="h-7"
onChange={(v) => updateRow(r.rowKey, { partner_price: v === "" ? "" : String(v) })} />
</td>
<td className="px-2 py-1 text-center">
<Button size="icon" variant="ghost" className="h-6 w-6"
onClick={() => deleteRow(r.rowKey)}>
<Trash2 className="h-3.5 w-3.5 text-red-600" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<DialogFooter className="mt-2">
<Button variant="outline" onClick={onClose} disabled={saving}>
<X className="h-3.5 w-3.5 mr-1" />
</Button>
<Button onClick={handleSave} disabled={saving || !canSave}>
<Save className="h-3.5 w-3.5 mr-1" /> {saving ? "저장 중..." : "저장"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1">
<Label className="text-xs">{label}</Label>
{children}
</div>
);
}
function toSmart(o: OptionItem): SmartSelectOption {
return { code: o.code, label: o.label };
}
// 운영 데이터에 'YYYY.MM.DD' 또는 'YYYY/MM/DD' 가 섞여 있을 수 있어 DateInput 입력 형식으로 정규화
function normalizeDate(v: any): string {
if (!v) return "";
const s = String(v).trim();
if (!s) return "";
const m = s.match(/^(\d{4})[.\-/](\d{1,2})[.\-/](\d{1,2})/);
if (!m) return "";
const yyyy = m[1];
const mm = m[2].padStart(2, "0");
const dd = m[3].padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}