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>
185 lines
7.7 KiB
TypeScript
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 });
|
|
}
|