Files
wace_rps/frontend/components/purchase/PurchaseOrderOutsourcingFormDialog.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

599 lines
26 KiB
TypeScript

"use client";
// 구매관리 > 발주서관리 — outsourcing 양식 등록/수정 다이얼로그
// wace 원본: purchaseOrder/purchaseOrderFormPopup_outsourcing.jsp
// - 타이틀: "외주가공 발주서"
// - 좌 박스 4필드: 발주번호 / 발주일자 / 수신업체 / 합계금액(VAT별도) — 결제방식 X
// - 우 박스: 일반발주서와 동일 (담당자1/2 + 회사정보 2줄 + 직인)
// - 그리드 컬럼: ☑ / No / 업체명 / 제품명 / 부품명 / 수량 / 단위 / 단가 / 합계 / 작업지시번호 / 부품품번 / 입고요청일
// - 푸터: 총공급가액(VAT별도) + 한글 보안문구
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;
product_name: string;
part_name: string;
spec: string;
order_qty: number | "";
qty: number | "";
unit: string;
partner_price: number | "";
supply_unit_price: number;
work_order_no: string;
delivery_request_date: string;
_src?: string;
}
interface MasterState {
objid: string;
purchase_order_no: string;
purchase_date: string;
partner_objid: string;
sales_mng_user_id: string;
manager_name: string;
manager_position: string;
manager_phone: string;
manager_email: string;
sales_mng_user_id2: string;
manager_name2: string;
manager_position2: string;
manager_phone2: string;
manager_email2: string;
title: string;
request_content: 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: "",
sales_mng_user_id: "", manager_name: "", manager_position: "",
manager_phone: "", manager_email: "",
sales_mng_user_id2: "", manager_name2: "", manager_position2: "",
manager_phone2: "", manager_email2: "",
title: "", request_content: "",
sales_request_objid: "", contract_mgmt_objid: "",
form_type: "outsourcing", status: "create",
appr_status: "",
};
const UNIT_GROUP_ID = "0001399";
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 fmt = (n: number) => n.toLocaleString("ko-KR");
interface UserOptionExt extends OptionItem {
name?: string;
position?: string;
phone?: string;
email?: string;
}
export function PurchaseOrderOutsourcingFormDialog({
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[]>([]);
const userOptsForSelect: SmartSelectOption[] = useMemo(
() => userOpts.map((u) => ({ code: u.code, label: u.label })),
[userOpts],
);
const partnerName = useMemo(() => {
const v = vendorOpts.find((o) => o.code === master.partner_objid);
return v?.label ?? "";
}, [vendorOpts, master.partner_objid]);
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 ?? ""),
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_mng_user_id2: String(m.sales_mng_user_id2 ?? ""),
manager_name2: String(m.manager_name2 ?? ""),
manager_position2: String(m.manager_position2 ?? ""),
manager_phone2: String(m.manager_phone2 ?? ""),
manager_email2: String(m.manager_email2 ?? ""),
title: String(m.title ?? ""),
request_content: String(m.request_content ?? ""),
sales_request_objid: String(m.sales_request_objid ?? m.proposal_objid ?? ""),
contract_mgmt_objid: String(m.contract_mgmt_objid ?? ""),
form_type: "outsourcing",
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 ?? ""),
product_name: String(p.product_name ?? p.part_name ?? ""),
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"),
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)),
work_order_no: String(p.work_order_no ?? ""),
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 onManagerChange = (slot: 1 | 2, userId: string) => {
const u = userOpts.find((o) => o.code === userId);
setMaster((prev) => ({
...prev,
[`sales_mng_user_id${slot === 2 ? "2" : ""}`]: userId,
[`manager_name${slot === 2 ? "2" : ""}`]: u?.name ?? u?.label ?? "",
[`manager_position${slot === 2 ? "2" : ""}`]: u?.position ?? "",
[`manager_phone${slot === 2 ? "2" : ""}`]: u?.phone ?? "",
[`manager_email${slot === 2 ? "2" : ""}`]: u?.email ?? "",
}));
};
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: "", product_name: "", part_name: "", spec: "",
order_qty: "", qty: "",
unit: "0001400",
partner_price: "",
supply_unit_price: 0,
work_order_no: "", delivery_request_date: "",
}]);
};
const handleDeleteSelectedRows = () => {
if (checkedRowKeys.length === 0) {
toast.info("삭제할 행을 선택하세요");
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("수신업체를 선택하세요"); return; }
if (parts.length === 0) { toast.warning("발주 품목이 없습니다"); return; }
setSaving(true);
try {
const payload = {
master: {
...master,
form_type: "outsourcing",
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,
product_name: p.product_name,
part_name: p.part_name,
spec: p.spec,
order_qty: toNum(p.order_qty),
qty: toNum(p.qty || p.order_qty),
unit: p.unit,
partner_price: toNum(p.partner_price),
supply_unit_price: toNum(p.supply_unit_price),
work_order_no: p.work_order_no,
delivery_request_date: p.delivery_request_date,
})),
deletedPartObjids: deletedPartIds,
};
const res = await purchaseApi.saveOrderForm(payload);
toast.success(`저장 완료 (${res.purchase_order_no})`);
onSaved?.(res);
onClose();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패");
} 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"> </DialogTitle>
<DialogDescription className="sr-only">wace PDF </DialogDescription>
<div className="flex-1 overflow-y-auto p-4 text-[12px]" style={{ fontFamily: "'Malgun Gothic', '맑은 고딕', sans-serif" }}>
<div className="border-2 border-black">
<div className="flex items-center border-b-2 border-black">
<div className="w-[160px] text-center py-2">
<img src="/images/rps-logo-on.png" alt="RPS Logo"
style={{ maxWidth: "140px", height: "auto" }} />
</div>
<div className="flex-1 py-3 text-center">
<div className="text-[16px] font-bold mb-1"> </div>
<div className="text-[28px] font-bold"> </div>
</div>
<div className="w-[160px]"></div>
</div>
<div className="flex">
<div className="w-[45%] p-3 border-r border-black">
<table className="w-full border-collapse">
<tbody>
<tr>
<td className="py-1.5 font-semibold whitespace-nowrap pr-3 w-[160px]">1. :</td>
<td className="py-1.5 font-bold">
<span>{master.purchase_order_no || <span className="text-gray-400"></span>}</span>
</td>
</tr>
<tr>
<td className="py-1.5 font-semibold whitespace-nowrap pr-3">2. :</td>
<td className="py-1.5">
<div className="w-[210px]">
<DateInput value={master.purchase_date}
onChange={(v) => setMaster({ ...master, purchase_date: v })}
disabled={isReadOnly} />
</div>
</td>
</tr>
<tr>
<td className="py-1.5 font-semibold whitespace-nowrap pr-3">3. :</td>
<td className="py-1.5">
<div className="w-[260px]">
<SmartSelect options={vendorOpts}
value={master.partner_objid}
onValueChange={(v) => setMaster({ ...master, partner_objid: v })}
disabled={isReadOnly} />
</div>
</td>
</tr>
<tr>
<td className="py-1.5 font-semibold whitespace-nowrap pr-3">4. (VAT별도) :</td>
<td className="py-1.5 font-bold">
{fmt(totalSupplyPrice)} <span className="font-normal"></span>
</td>
</tr>
</tbody>
</table>
</div>
<div className="flex-1 p-3">
<table className="w-full border-collapse border border-black text-[11px]">
<tbody>
<tr>
<td rowSpan={4} className="w-[60px] text-center font-bold border border-black align-middle bg-gray-100"
style={{ letterSpacing: "3px" }}>
<br/><br/>
</td>
<td className="border border-black px-2 py-1 text-center">
<div className="w-full max-w-[220px] mx-auto">
<SmartSelect options={userOptsForSelect}
value={master.sales_mng_user_id}
onValueChange={(v) => onManagerChange(1, v)}
disabled={isReadOnly} />
</div>
</td>
</tr>
<tr>
<td className="border border-black px-2 py-1 text-center relative">
<span>(<span>{master.manager_phone || "-"}</span> / <span>{master.manager_email || "-"}</span>)</span>
<img src="/images/rps-stamp-seal.png" alt="직인"
className="absolute"
style={{ right: 5, top: -10, width: 45, height: 45, opacity: 0.9 }}
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }} />
</td>
</tr>
<tr>
<td className="border border-black px-2 py-1 text-center">
<div className="w-full max-w-[220px] mx-auto">
<SmartSelect options={userOptsForSelect}
value={master.sales_mng_user_id2}
onValueChange={(v) => onManagerChange(2, v)}
disabled={isReadOnly} />
</div>
</td>
</tr>
<tr>
<td className="border border-black px-2 py-1 text-center">
<span>(<span>{master.manager_phone2 || "-"}</span> / <span>{master.manager_email2 || "-"}</span>)</span>
</td>
</tr>
<tr>
<td colSpan={2} className="border border-black border-b-0 px-2 py-1.5 text-center text-[14px] font-bold">
</td>
</tr>
<tr>
<td colSpan={2} className="border border-black border-t-0 px-2 py-1.5 text-center text-[11px] font-bold">
108( 402-4)
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
{isReadOnly && (
<div className="mt-2 text-[11px] text-amber-700 bg-amber-50 border border-amber-300 px-3 py-1.5 rounded">
/ .
{master.appr_status ? ` (결재상태: ${master.appr_status})` : ""}
{master.status === "cancel" ? " (취소됨)" : ""}
</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" />
</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" />
</Button>
<Button size="sm" className="h-8 px-5 text-[13px]"
style={{ background: "#dfeffc", color: "#000" }}
onClick={handleSave} disabled={saving || loading}>
{saving ? "저장 중..." : "저장"}
</Button>
</>
)}
{isReadOnly && (
<Button size="sm" className="h-8 px-5 text-[13px]"
style={{ background: "#dfeffc", color: "#000" }}
onClick={handleDownload}>
</Button>
)}
<Button size="sm" variant="outline" className="h-8 px-5 text-[13px]"
style={{ background: "#dfeffc" }}
onClick={onClose} disabled={saving}>
</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="w-10 border border-black px-1 py-1.5">No</th>
<th className="min-w-[130px] border border-black px-1 py-1.5"></th>
<th className="min-w-[160px] border border-black px-1 py-1.5"></th>
<th className="min-w-[140px] border border-black px-1 py-1.5"></th>
<th className="w-[60px] border border-black px-1 py-1.5"></th>
<th className="w-[70px] border border-black px-1 py-1.5"></th>
<th className="w-[90px] border border-black px-1 py-1.5"></th>
<th className="w-[100px] border border-black px-1 py-1.5"></th>
<th className="w-[110px] border border-black px-1 py-1.5"></th>
<th className="w-[100px] border border-black px-1 py-1.5"></th>
<th className="w-[110px] border border-black px-1 py-1.5"></th>
</tr>
</thead>
<tbody>
{parts.length === 0 ? (
<tr>
<td colSpan={12} className="text-center py-8 border border-black text-gray-500">
"행 추가"
</td>
</tr>
) : parts.map((row, i) => (
<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-600">{i + 1}</td>
<td className="border border-black px-1 py-0.5 text-left text-gray-700">{partnerName}</td>
<td className="border border-black px-1 py-0.5">
<Input className="h-7 text-[11px] px-1.5 border-0 focus-visible:ring-1"
value={row.product_name}
onChange={(e) => updateRow(row.rowKey, { product_name: e.target.value })}
disabled={isReadOnly} />
</td>
<td className="border border-black px-1 py-0.5 text-left text-gray-700">{row.part_name}</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={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.partner_price} decimals={0}
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">
{fmt(row.supply_unit_price)}
</td>
<td className="border border-black px-1 py-0.5">
<Input className="h-7 text-[11px] px-1.5 border-0 focus-visible:ring-1"
value={row.work_order_no}
onChange={(e) => updateRow(row.rowKey, { work_order_no: e.target.value })}
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">
<DateInput value={row.delivery_request_date}
onChange={(v) => updateRow(row.rowKey, { delivery_request_date: v })}
size="sm"
disabled={isReadOnly} />
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex justify-end mt-3">
<table className="border-collapse">
<tbody>
<tr>
<td className="bg-[#e8f0e1] border border-black px-6 py-2 text-[13px] font-bold whitespace-nowrap" style={{ letterSpacing: "2px" }}>
(VAT별도)
</td>
<td className="border border-black px-4 py-2 text-right font-bold text-[16px] min-w-[200px] tabular-nums">
{fmt(totalSupplyPrice)}
</td>
</tr>
</tbody>
</table>
</div>
<div className="text-right text-[11px] text-red-600 mt-3">
(CONFIDENTIAL) : (APPROVAL) .
</div>
</div>
</DialogContent>
</Dialog>
);
}