Files
wace_rps/frontend/components/purchase/PurchaseOrderEnglishFormDialog.tsx
T
hjjeong 17b08c7a09 구매관리 발주서 메일 발송 + PDF 다운로드 + 행추가/삭제 제거
- backend purchaseOrderMailService 신설 — getOrderMailInfo / getPartnerManagerList /
  sendOrderMail (SMTP PURCHASE, 발송 성공 시 mail_send_yn='Y'/mail_send_date 갱신)
- backend routes — GET /order-form/mail-info/:objid, POST /order-form/mail,
  GET /options/partner-managers/:partnerObjid
- frontend lib/utils/purchaseOrderPdf — html2canvas-pro + jsPDF (A4, scale=2,
  input/textarea → 텍스트 변환). download:true 면 파일 저장, 아니면 base64 반환
- PurchaseOrderMailDialog 신설 — EstimateMailDialog 패턴 단순화 (한글/영문 본문 분기,
  공급업체 단일 email 자동 채움)
- 3개 양식 다이얼로그 — 읽기전용 + 저장된 발주서일 때 "메일 발송" + "PDF 다운로드"
  버튼 노출. window.print 간이판 제거
- 3개 양식 다이얼로그 — "행 추가"/"선택 행 삭제" 버튼 + 그리드 체크박스 컬럼 제거
  (wace 운영판은 모두 주석 처리/부재. 발주서는 품의서에서 자동 채움된 품목 그대로 사용)
2026-05-19 14:57:47 +09:00

554 lines
23 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
// 구매관리 > 발주서관리 — english 양식 등록/수정 다이얼로그
// wace 원본: purchaseOrder/purchaseOrderFormPopup_english.jsp
// - 헤더: 로고 110px + "R P S CO., LTD." + 영문 회사정보 (주소/Tel/Fax/E-mail/Purchasing Team Manager)
// - 타이틀: "Purchase Order" (밑줄) + 부제 "We are pleased to issue Purchase Order ..."
// - 좌+우 2열 5행 필드: Messrs./Shipment, Attn.to/Payment, Date/Packing, Ref.NO/Validity, (빈)/Remarks
// - 그리드 8 visible: Item No. / Commodity & Description / Unit / Q'ty / Currency / Unit Price / Amount / Delivery
// - TOTAL 한 행 + 하단 "Look forward to your soonest delivery..." + 서명영역 (stamp_seal.png 65x65)
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Download, Mail } from "lucide-react";
import { toast } from "sonner";
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { DateInput } from "@/components/common/DateInput";
import { NumberInput } from "@/components/common/NumberInput";
import { purchaseApi, OptionItem } from "@/lib/api/purchase";
import { generatePurchaseOrderPdf } from "@/lib/utils/purchaseOrderPdf";
import { PurchaseOrderMailDialog } from "./PurchaseOrderMailDialog";
interface Props {
open: boolean;
onClose: () => void;
onSaved?: (result: { objid: string; purchase_order_no: string }) => void;
pomObjid?: string;
proposalObjid?: string;
}
interface PartRow {
rowKey: string;
objid: string;
part_objid: string;
part_no: string;
part_name: string;
spec: string;
order_qty: number | "";
qty: number | "";
unit: string;
currency: string;
partner_price: number | "";
supply_unit_price: number;
delivery_request_date: string;
_src?: string;
}
interface MasterState {
objid: string;
purchase_order_no: string;
purchase_date: string;
partner_objid: string;
payment_terms: string;
shipment: string;
attn_to: string;
packing: string;
validity: string;
remark: string;
sales_mng_user_id: string;
manager_name: string;
manager_position: string;
manager_phone: string;
manager_email: string;
sales_request_objid: string;
contract_mgmt_objid: string;
form_type: string;
status: string;
appr_status: string;
}
const EMPTY_MASTER: MasterState = {
objid: "", purchase_order_no: "", purchase_date: "",
partner_objid: "", payment_terms: "",
shipment: "", attn_to: "", packing: "", validity: "", remark: "",
sales_mng_user_id: "", manager_name: "", manager_position: "",
manager_phone: "", manager_email: "",
sales_request_objid: "", contract_mgmt_objid: "",
form_type: "english", status: "create",
appr_status: "",
};
const UNIT_GROUP_ID = "0001399";
const CURRENCY_GROUP_ID = "0001533"; // wace currency_cd (0001534=USD default)
const DEFAULT_CURRENCY = "0001534";
let _rk = 0;
const nextKey = () => `r${++_rk}_${Date.now()}`;
const toNum = (v: any): number => {
if (v == null || v === "") return 0;
const n = Number(String(v).replace(/,/g, ""));
return Number.isFinite(n) ? n : 0;
};
const fmt2 = (n: number) => n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
interface UserOptionExt extends OptionItem {
name?: string;
position?: string;
phone?: string;
email?: string;
}
export function PurchaseOrderEnglishFormDialog({
open, onClose, onSaved, pomObjid, proposalObjid,
}: Props) {
const isEdit = !!pomObjid;
const [master, setMaster] = useState<MasterState>(EMPTY_MASTER);
const [parts, setParts] = useState<PartRow[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [vendorOpts, setVendorOpts] = useState<SmartSelectOption[]>([]);
const [userOpts, setUserOpts] = useState<UserOptionExt[]>([]);
useEffect(() => {
if (!open) return;
setMaster({ ...EMPTY_MASTER });
setParts([]);
(async () => {
try {
const [vs, us] = await Promise.all([
purchaseApi.listVendors(),
purchaseApi.listUsers(),
]);
setVendorOpts(vs.map((v) => ({ code: v.code, label: v.label })));
setUserOpts(us as UserOptionExt[]);
} catch {/* skip */}
})();
setLoading(true);
(async () => {
try {
if (isEdit && pomObjid) {
const r = await purchaseApi.getOrderForm(pomObjid);
applyServerData(r.master ?? {}, r.parts ?? []);
} else if (proposalObjid) {
const r = await purchaseApi.initOrderForm(proposalObjid);
applyServerData(r.master ?? {}, r.parts ?? []);
}
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "초기 로드 실패");
} finally {
setLoading(false);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, isEdit, pomObjid, proposalObjid]);
const applyServerData = (m: Record<string, any>, ps: Record<string, any>[]) => {
setMaster({
objid: String(m.objid ?? ""),
purchase_order_no: String(m.purchase_order_no ?? ""),
purchase_date: String(m.purchase_date ?? ""),
partner_objid: String(m.partner_objid ?? ""),
payment_terms: String(m.payment_terms ?? ""),
shipment: String(m.shipment ?? ""),
attn_to: String(m.attn_to ?? ""),
packing: String(m.packing ?? ""),
validity: String(m.validity ?? ""),
remark: String(m.remark ?? ""),
sales_mng_user_id: String(m.sales_mng_user_id ?? ""),
manager_name: String(m.manager_name ?? ""),
manager_position: String(m.manager_position ?? ""),
manager_phone: String(m.manager_phone ?? ""),
manager_email: String(m.manager_email ?? ""),
sales_request_objid: String(m.sales_request_objid ?? m.proposal_objid ?? ""),
contract_mgmt_objid: String(m.contract_mgmt_objid ?? ""),
form_type: "english",
status: String(m.status ?? "create"),
appr_status: String(m.appr_status ?? ""),
});
setParts(ps.map((p) => ({
rowKey: nextKey(),
objid: String(p.objid ?? ""),
part_objid: String(p.part_objid ?? ""),
part_no: String(p.part_no ?? ""),
part_name: String(p.part_name ?? ""),
spec: String(p.spec ?? ""),
order_qty: p.order_qty === "" || p.order_qty == null ? "" : Number(p.order_qty),
qty: p.qty === "" || p.qty == null
? (p.order_qty === "" || p.order_qty == null ? "" : Number(p.order_qty))
: Number(p.qty),
unit: String(p.unit || "0001400"),
currency: String(p.currency || DEFAULT_CURRENCY),
partner_price: p.partner_price === "" || p.partner_price == null ? "" : Number(p.partner_price),
supply_unit_price: toNum(p.supply_unit_price ?? toNum(p.order_qty) * toNum(p.partner_price)),
delivery_request_date: String(p.delivery_request_date ?? ""),
_src: p._src,
})));
};
const totalSupplyPrice = useMemo(
() => parts.reduce((sum, p) => sum + toNum(p.supply_unit_price), 0),
[parts],
);
const isReadOnly = useMemo(() => {
const a = master.appr_status;
return a === "결재중" || a === "결재완료" || master.status === "cancel";
}, [master.appr_status, master.status]);
const pdfContainerRef = useRef<HTMLDivElement>(null);
const [mailOpen, setMailOpen] = useState(false);
const [generatingPdf, setGeneratingPdf] = useState(false);
const handleDownload = async () => {
if (!pdfContainerRef.current) return;
setGeneratingPdf(true);
try {
const filename = `${master.purchase_order_no || "purchase_order_english"}.pdf`;
await generatePurchaseOrderPdf(pdfContainerRef.current, { download: true, filename });
} catch (e: any) {
toast.error("PDF generation failed: " + (e?.message ?? ""));
} finally {
setGeneratingPdf(false);
}
};
const handleRequestPdf = async (): Promise<string> => {
if (!pdfContainerRef.current) throw new Error("Purchase order container not found");
return generatePurchaseOrderPdf(pdfContainerRef.current);
};
const updateRow = (rowKey: string, patch: Partial<PartRow>) => {
setParts((prev) => prev.map((r) => {
if (r.rowKey !== rowKey) return r;
const merged: PartRow = { ...r, ...patch };
const q = toNum(merged.order_qty);
const u = toNum(merged.partner_price);
merged.qty = merged.order_qty;
merged.supply_unit_price = q * u;
return merged;
}));
};
const handleSave = async () => {
if (!master.partner_objid) { toast.warning("Select supplier (Messrs.)"); return; }
if (parts.length === 0) { toast.warning("No items"); return; }
setSaving(true);
try {
const payload = {
master: {
...master,
form_type: "english",
total_supply_price: String(totalSupplyPrice),
total_supply_unit_price: String(totalSupplyPrice),
total_price: String(totalSupplyPrice),
},
parts: parts.map((p) => ({
objid: p.objid,
part_objid: p.part_objid,
part_no: p.part_no,
part_name: p.part_name,
spec: p.spec,
order_qty: toNum(p.order_qty),
qty: toNum(p.qty || p.order_qty),
unit: p.unit,
currency: p.currency,
partner_price: toNum(p.partner_price),
supply_unit_price: toNum(p.supply_unit_price),
delivery_request_date: p.delivery_request_date,
})),
deletedPartObjids: [],
};
const res = await purchaseApi.saveOrderForm(payload);
toast.success(`Saved (${res.purchase_order_no})`);
onSaved?.(res);
onClose();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "Save failed");
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent className="max-w-[1280px] w-[96vw] max-h-[94vh] overflow-hidden flex flex-col p-0 gap-0 bg-white">
<DialogTitle className="sr-only">Purchase Order (English)</DialogTitle>
<DialogDescription className="sr-only">wace English Purchase Order PDF form</DialogDescription>
<div ref={pdfContainerRef} className="flex-1 overflow-y-auto p-6 text-[12px]" style={{ fontFamily: "Arial, 'Helvetica Neue', sans-serif" }}>
{/* 헤더 */}
<div className="flex items-start gap-4">
<div className="w-[120px]">
<img src="/images/rps-logo-on.png" alt="RPS Logo"
style={{ maxWidth: "110px", height: "auto" }} />
</div>
<div className="flex-1 text-[11px] leading-snug pt-1">
<div className="text-[14px] font-bold mb-1">R P S CO., LTD.</div>
<div>www.rps-korea.com</div>
<div>8, Gukjegwahak 10-ro, Yuseong-gu, Daejeon, Republic of Korea</div>
<div>Tel : +82-42-602-3300 / Fax : +82-42-672-3399 / E-mail : ady1225@rps-korea.com</div>
<div>Purchasing Team Manager, An-Dong-Yoon</div>
</div>
</div>
<div className="text-center text-[26px] font-bold mt-5 mb-2" style={{ textDecoration: "underline" }}>
Purchase Order
</div>
<p className="text-[11px] mb-2">
We are pleased to issue Purchase Order with the terms and condition described as below.
</p>
{/* 좌+우 2열 5행 정보 테이블 */}
<table className="w-full border-collapse text-[11px]">
<tbody>
<FieldRow
leftLabel="Messrs."
leftCell={
<SmartSelect options={vendorOpts}
value={master.partner_objid}
onValueChange={(v) => setMaster({ ...master, partner_objid: v })}
disabled={isReadOnly} />
}
rightLabel="Shipment"
rightBg="#ebf1de"
rightCell={
<Input className="h-7 text-[11px] px-1.5 border-0 bg-[#ebf1de] focus-visible:ring-1"
value={master.shipment}
onChange={(e) => setMaster({ ...master, shipment: e.target.value })}
disabled={isReadOnly} />
}
/>
<FieldRow
leftLabel="Attn. to"
leftCell={
<Input className="h-7 text-[11px] px-1.5 border-0 focus-visible:ring-1"
value={master.attn_to}
onChange={(e) => setMaster({ ...master, attn_to: e.target.value })}
disabled={isReadOnly} />
}
rightLabel="Payment"
rightCell={
<Input className="h-7 text-[11px] px-1.5 border-0 focus-visible:ring-1"
value={master.payment_terms}
onChange={(e) => setMaster({ ...master, payment_terms: e.target.value })}
disabled={isReadOnly} />
}
/>
<FieldRow
leftLabel="Date"
leftCell={
<DateInput value={master.purchase_date}
onChange={(v) => setMaster({ ...master, purchase_date: v })}
disabled={isReadOnly} />
}
rightLabel="Packing"
rightCell={
<Input className="h-7 text-[11px] px-1.5 border-0 focus-visible:ring-1"
placeholder="Export Standard"
value={master.packing}
onChange={(e) => setMaster({ ...master, packing: e.target.value })}
disabled={isReadOnly} />
}
/>
<FieldRow
leftLabel="Ref. NO"
leftCell={
<span className="font-bold">{master.purchase_order_no || <span className="text-gray-400 font-normal">auto</span>}</span>
}
rightLabel="Validity"
rightCell={
<Input className="h-7 text-[11px] px-1.5 border-0 focus-visible:ring-1"
value={master.validity}
onChange={(e) => setMaster({ ...master, validity: e.target.value })}
disabled={isReadOnly} />
}
/>
<FieldRow
leftLabel=""
leftCell={<span>&nbsp;</span>}
rightLabel="Remarks"
rightCell={
<Input className="h-7 text-[11px] px-1.5 border-0 focus-visible:ring-1"
value={master.remark}
onChange={(e) => setMaster({ ...master, remark: e.target.value })}
disabled={isReadOnly} />
}
/>
</tbody>
</table>
{isReadOnly && (
<div className="mt-3 text-[11px] text-amber-700 bg-amber-50 border border-amber-300 px-3 py-1.5 rounded">
This order is locked (approval in progress / completed or cancelled).
{master.appr_status ? ` (status: ${master.appr_status})` : ""}
{master.status === "cancel" ? " (cancelled)" : ""}
</div>
)}
<div className="flex justify-end gap-2 my-3">
{!isReadOnly && (
<Button size="sm" className="h-8 px-5 text-[13px]"
style={{ background: "#dfeffc", color: "#000" }}
onClick={handleSave} disabled={saving || loading}>
{saving ? "Saving..." : "Save"}
</Button>
)}
{isReadOnly && master.objid && (
<>
<Button size="sm" variant="outline" className="h-8 gap-1 px-3 text-xs"
onClick={() => setMailOpen(true)}>
<Mail className="h-3.5 w-3.5" /> Send Email
</Button>
<Button size="sm" className="h-8 gap-1 px-3 text-[13px]"
style={{ background: "#dfeffc", color: "#000" }}
onClick={handleDownload} disabled={generatingPdf}>
<Download className="h-3.5 w-3.5" />
{generatingPdf ? "Generating..." : "Download PDF"}
</Button>
</>
)}
<Button size="sm" variant="outline" className="h-8 px-5 text-[13px]"
style={{ background: "#dfeffc" }}
onClick={onClose} disabled={saving}>
Close
</Button>
</div>
{/* 그리드 */}
<div className="border border-black overflow-x-auto">
<table className="w-full text-[11px] border-collapse">
<thead>
<tr className="bg-[#f0f4fa]">
<th className="min-w-[120px] border border-black px-1 py-1.5">Item No.</th>
<th className="min-w-[260px] border border-black px-1 py-1.5">Commodity &amp; Description</th>
<th className="w-[60px] border border-black px-1 py-1.5">Unit</th>
<th className="w-[60px] border border-black px-1 py-1.5">Q&apos;ty</th>
<th className="w-[75px] border border-black px-1 py-1.5">Currency</th>
<th className="w-[100px] border border-black px-1 py-1.5">Unit Price</th>
<th className="w-[110px] border border-black px-1 py-1.5">Amount</th>
<th className="w-[110px] border border-black px-1 py-1.5">Delivery</th>
</tr>
</thead>
<tbody>
{parts.length === 0 ? (
<tr>
<td colSpan={8} className="text-center py-8 border border-black text-gray-500">
Items are auto-filled from the proposal
</td>
</tr>
) : parts.map((row) => {
const desc = row.spec ? `${row.part_name}/${row.spec}` : row.part_name;
return (
<tr key={row.rowKey} className="hover:bg-blue-50/30">
<td className="border border-black px-1 py-0.5 text-center text-gray-700">{row.part_no}</td>
<td className="border border-black px-1 py-0.5 text-left text-gray-700">{desc}</td>
<td className="border border-black px-1 py-0.5">
<CommCodeSelect groupId={UNIT_GROUP_ID}
value={row.unit}
onValueChange={(v) => updateRow(row.rowKey, { unit: v })}
withAll={false} className="h-7"
disabled={isReadOnly} />
</td>
<td className="border border-black px-1 py-0.5">
<NumberInput value={row.order_qty} decimals={0}
onChange={(v) => updateRow(row.rowKey, { order_qty: v })}
className="h-7 text-[11px]"
disabled={isReadOnly} />
</td>
<td className="border border-black px-1 py-0.5">
<CommCodeSelect groupId={CURRENCY_GROUP_ID}
value={row.currency}
onValueChange={(v) => updateRow(row.rowKey, { currency: v })}
withAll={false} className="h-7"
disabled={isReadOnly} />
</td>
<td className="border border-black px-1 py-0.5">
<NumberInput value={row.partner_price} decimals={2}
onChange={(v) => updateRow(row.rowKey, { partner_price: v })}
className="h-7 text-[11px]"
disabled={isReadOnly} />
</td>
<td className="border border-black px-1 py-0.5 text-right tabular-nums pr-2">
{fmt2(row.supply_unit_price)}
</td>
<td className="border border-black px-1 py-0.5">
<DateInput value={row.delivery_request_date}
onChange={(v) => updateRow(row.rowKey, { delivery_request_date: v })}
size="sm"
disabled={isReadOnly} />
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* TOTAL */}
<table className="w-full border-collapse mt-2">
<tbody>
<tr>
<td className="border border-black bg-[#d9e2f3] text-center text-[14px] font-bold py-1.5" style={{ width: "70%" }}>
TOTAL
</td>
<td className="border border-black text-right text-[14px] font-bold py-1.5 px-3 tabular-nums">
{fmt2(totalSupplyPrice)}
</td>
</tr>
</tbody>
</table>
<p className="text-[11px] italic mt-4">
Look forward to your soonest delivery with good condition.
</p>
{/* 서명 영역 */}
<div className="relative mt-8 min-h-[80px]">
<hr className="border-0 border-t border-black ml-auto" style={{ width: 250 }} />
<div className="text-right pr-[70px] text-[12px] mt-1">Signed by Dong-Heon Lee / President</div>
<div className="text-right pr-[70px] text-[12px] font-bold">RPS CO.,LTD</div>
<img src="/images/rps-stamp-seal.png" alt="Stamp"
className="absolute"
style={{ right: 0, bottom: 0, width: 65, height: 65, opacity: 0.85 }}
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }} />
</div>
</div>
</DialogContent>
<PurchaseOrderMailDialog
open={mailOpen}
onOpenChange={setMailOpen}
pomObjid={master.objid || null}
formType="english"
onRequestPdf={handleRequestPdf}
onSent={() => setMailOpen(false)}
/>
</Dialog>
);
}
interface FieldRowProps {
leftLabel: string;
leftCell: React.ReactNode;
rightLabel: string;
rightCell: React.ReactNode;
rightBg?: string;
}
function FieldRow({ leftLabel, leftCell, rightLabel, rightCell, rightBg }: FieldRowProps) {
return (
<tr>
<td className="border border-black bg-[#f6f6f6] font-semibold text-center w-[110px] px-2 py-1">{leftLabel || " "}</td>
<td className="border border-black px-2 py-1" style={{ width: "37%" }}>{leftCell}</td>
<td style={{ width: "1%", border: "none", padding: 0 }}></td>
<td className="border border-black bg-[#f6f6f6] font-semibold text-center w-[110px] px-2 py-1">{rightLabel}</td>
<td className="border border-black px-2 py-1" style={{ width: "37%", background: rightBg }}>{rightCell}</td>
</tr>
);
}