Files
wace_rps/frontend/components/purchase/PurchaseOrderEnglishFormDialog.tsx
T
hjjeong 8258c2f0cf 구매관리 발주서 _outsourcing/_english 양식 + 양식 선택 모달
- 양식 선택 모달 신설 (운영판 wace 1:1: 일반/외주가공/영문/취소)
- 외주가공 발주서 다이얼로그 — 타이틀 변경, 좌 4필드, 그리드 WORK_ORDER_NO/업체명/제품명/부품명
- 영문 발주서 다이얼로그 — 영문 헤더, 2열 5행 필드(Shipment/Attn.to/Packing/Validity/Remarks),
  CURRENCY 컬럼, TOTAL, 서명영역(stamp_seal)
- proposal 발주서생성 → 양식 선택 모달, order 행클릭 → row.form_type 자동 분기
- listVendorOptions: client_mng.status 컬럼 부재로 빈배열 반환 → use_yn 사용 (RPS 함정)
- listUserOptions: name/position/phone/email alias 추가 (담당자 select 자동 채움)
- init API: USER_INFO 에서 안동윤/서동민 user_id lookup → sales_mng_user_id 자동 채움
2026-05-19 14:12:33 +09:00

587 lines
24 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, 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 { Plus, Trash2 } 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";
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 [deletedPartIds, setDeletedPartIds] = useState<string[]>([]);
const [checkedRowKeys, setCheckedRowKeys] = useState<string[]>([]);
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([]);
setDeletedPartIds([]);
setCheckedRowKeys([]);
(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 handleDownload = () => window.print();
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 handleAddRow = () => {
setParts((prev) => [...prev, {
rowKey: nextKey(),
objid: "", part_objid: "",
part_no: "", part_name: "", spec: "",
order_qty: "", qty: "",
unit: "0001400",
currency: DEFAULT_CURRENCY,
partner_price: "",
supply_unit_price: 0,
delivery_request_date: "",
}]);
};
const handleDeleteSelectedRows = () => {
if (checkedRowKeys.length === 0) {
toast.info("Select rows to delete");
return;
}
setParts((prev) => {
const remaining: PartRow[] = [];
const deletedObjids: string[] = [];
for (const r of prev) {
if (checkedRowKeys.includes(r.rowKey)) {
if (r.objid) deletedObjids.push(r.objid);
} else {
remaining.push(r);
}
}
if (deletedObjids.length > 0) {
setDeletedPartIds((d) => Array.from(new Set([...d, ...deletedObjids])));
}
return remaining;
});
setCheckedRowKeys([]);
};
const toggleRowCheck = (rowKey: string, checked: boolean) => {
setCheckedRowKeys((prev) =>
checked ? [...prev, rowKey] : prev.filter((k) => k !== rowKey));
};
const toggleAllCheck = (checked: boolean) => {
setCheckedRowKeys(checked ? parts.map((p) => p.rowKey) : []);
};
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: deletedPartIds,
};
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 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" variant="outline" className="h-8 gap-1 px-3 text-xs"
onClick={handleAddRow}>
<Plus className="h-3.5 w-3.5" /> Add Row
</Button>
<Button size="sm" variant="outline" className="h-8 gap-1 px-3 text-xs"
onClick={handleDeleteSelectedRows}>
<Trash2 className="h-3.5 w-3.5" /> Delete
</Button>
<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 && (
<Button size="sm" className="h-8 px-5 text-[13px]"
style={{ background: "#dfeffc", color: "#000" }}
onClick={handleDownload}>
Download PO
</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="w-8 border border-black px-1 py-1.5">
<input type="checkbox"
checked={parts.length > 0 && checkedRowKeys.length === parts.length}
onChange={(e) => toggleAllCheck(e.target.checked)}
disabled={isReadOnly} />
</th>
<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={9} className="text-center py-8 border border-black text-gray-500">
No items use "Add Row" to start
</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">
<input type="checkbox"
checked={checkedRowKeys.includes(row.rowKey)}
onChange={(e) => toggleRowCheck(row.rowKey, e.target.checked)}
disabled={isReadOnly} />
</td>
<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>
</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>
);
}