Files
wace_rps/frontend/components/sales/ProposalCreateDialog.tsx
T
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

185 lines
7.7 KiB
TypeScript

"use client";
// 영업관리 > 구매요청서관리 — 품의서생성 확인 다이얼로그
// wace 1:1: createProposalFromPurchaseReg.do — 선택된 PURCHASE_REG 의 단가+공급업체 입력 품목만 필터해
// PURCHASE_REG_PROPOSAL row 신규 생성. 단가 또는 공급업체가 없는 품목은 제외 목록으로 표시.
import React, { useEffect, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { ClipboardCheck, X, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import { salesPurchaseRequestApi, ProposalTargetPart } from "@/lib/api/salesPurchaseRequest";
interface Props {
open: boolean;
onClose: () => void;
srmObjid: string; // 원본 구매요청서 OBJID
requestMngNo?: string;
onCreated: (proposalNo: string) => void;
}
export function ProposalCreateDialog({ open, onClose, srmObjid, requestMngNo, onCreated }: Props) {
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [targets, setTargets] = useState<ProposalTargetPart[]>([]);
const [excluded, setExcluded] = useState<ProposalTargetPart[]>([]);
useEffect(() => {
if (!open || !srmObjid) return;
setTargets([]); setExcluded([]);
setLoading(true);
(async () => {
try {
const data = await salesPurchaseRequestApi.getProposalTargets(srmObjid);
setTargets(data.targets ?? []);
setExcluded(data.excluded ?? []);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
} finally {
setLoading(false);
}
})();
}, [open, srmObjid]);
const handleCreate = async () => {
setSubmitting(true);
try {
const res = await salesPurchaseRequestApi.createProposal(srmObjid);
toast.success(`품의서가 생성되었습니다. (${res.proposal_no})`);
onCreated(res.proposal_no);
onClose();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "생성 실패");
} finally {
setSubmitting(false);
}
};
const sumTotal = targets.reduce((s, r) => s + (Number(r.total_price ?? 0) || Number(r.unit_price ?? 0) * Number(r.qty ?? 0)), 0);
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
{requestMngNo ? `${requestMngNo}` : ""}
.
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="py-10 text-center text-sm text-muted-foreground"> ...</div>
) : (
<>
<Section title={`품의서 생성 대상 (${targets.length}건)`} emptyMsg="대상 품목이 없습니다. 단가와 공급업체가 모두 입력되어야 합니다.">
{targets.length > 0 && (
<table className="w-full text-xs">
<thead className="bg-muted/30">
<tr>
<th className="px-2 py-1 text-left w-[140px]"></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-right w-[110px]"></th>
<th className="px-2 py-1 text-right w-[120px]"></th>
<th className="px-2 py-1 text-left w-[160px]"></th>
</tr>
</thead>
<tbody>
{targets.map((r) => (
<tr key={r.objid} className="border-t">
<td className="px-2 py-1">{r.part_no}</td>
<td className="px-2 py-1">{r.part_name}</td>
<td className="px-2 py-1 text-right">{fmt(r.qty)}</td>
<td className="px-2 py-1 text-right">{fmtMoney(r.unit_price)}</td>
<td className="px-2 py-1 text-right">
{fmtMoney(Number(r.total_price ?? 0) || Number(r.unit_price ?? 0) * Number(r.qty ?? 0))}
</td>
<td className="px-2 py-1">{r.vendor_name || r.vendor_pm}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="bg-muted/20 font-medium">
<td className="px-2 py-1" colSpan={4}></td>
<td className="px-2 py-1 text-right">{fmtMoney(sumTotal)}</td>
<td></td>
</tr>
</tfoot>
</table>
)}
</Section>
{excluded.length > 0 && (
<Section
title={
<span className="inline-flex items-center gap-1 text-amber-700">
<AlertTriangle className="h-3.5 w-3.5" /> ({excluded.length})
</span>
}
emptyMsg=""
>
<table className="w-full text-xs">
<thead className="bg-muted/30">
<tr>
<th className="px-2 py-1 text-left w-[140px]"></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-right w-[110px]"></th>
</tr>
</thead>
<tbody>
{excluded.map((r) => (
<tr key={r.objid} className="border-t">
<td className="px-2 py-1">{r.part_no}</td>
<td className="px-2 py-1">{r.part_name}</td>
<td className="px-2 py-1 text-right">{fmt(r.qty)}</td>
<td className="px-2 py-1 text-right">{fmtMoney(r.unit_price)}</td>
</tr>
))}
</tbody>
</table>
</Section>
)}
</>
)}
<DialogFooter className="mt-2">
<Button variant="outline" onClick={onClose} disabled={submitting}>
<X className="h-3.5 w-3.5 mr-1" />
</Button>
<Button onClick={handleCreate} disabled={submitting || loading || targets.length === 0}>
<ClipboardCheck className="h-3.5 w-3.5 mr-1" />
{submitting ? "생성 중..." : `품의서 생성 (${targets.length}건)`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Section({ title, emptyMsg, children }: { title: React.ReactNode; emptyMsg: string; children: React.ReactNode }) {
const hasChildren = React.Children.count(children) > 0;
return (
<div className="mt-3 border rounded">
<div className="border-b px-2 py-1 bg-muted/40 text-xs font-medium">{title}</div>
<div className="max-h-[280px] overflow-auto">
{hasChildren ? children : (
<div className="py-6 text-center text-xs text-muted-foreground">{emptyMsg}</div>
)}
</div>
</div>
);
}
// 수량: 자연수 1,234 / 금액: 1,234.00 (RPS 숫자 포맷 정책)
function fmt(n: any) {
const v = Math.floor(Number(n ?? 0));
return v.toLocaleString("ko-KR");
}
function fmtMoney(n: any) {
const v = Number(n ?? 0);
return v.toLocaleString("ko-KR", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}