0fe71298d2
- 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>
394 lines
16 KiB
TypeScript
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}`;
|
|
}
|