구매관리 발주서 메일 발송 + 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 운영판은 모두 주석 처리/부재. 발주서는 품의서에서 자동 채움된 품목 그대로 사용)
This commit is contained in:
hjjeong
2026-05-19 14:57:47 +09:00
parent 8258c2f0cf
commit 17b08c7a09
9 changed files with 789 additions and 267 deletions
@@ -6,6 +6,7 @@ import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth"; import { AuthenticatedRequest } from "../types/auth";
import * as svc from "../services/purchaseService"; import * as svc from "../services/purchaseService";
import * as formSvc from "../services/purchaseOrderFormService"; import * as formSvc from "../services/purchaseOrderFormService";
import * as mailSvc from "../services/purchaseOrderMailService";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
function parseFilter(q: Record<string, any>): svc.PurchaseListFilter { function parseFilter(q: Record<string, any>): svc.PurchaseListFilter {
@@ -107,6 +108,54 @@ export async function deletePurchaseOrderForm(req: AuthenticatedRequest, res: Re
} }
} }
// ─── 발주서 메일 발송 ─────────────────────────────────────────
/** GET /api/purchase/order-form/mail-info/:objid */
export async function getPurchaseOrderMailInfo(req: AuthenticatedRequest, res: Response) {
try {
const objid = String(req.params.objid ?? "").trim();
if (!objid) return res.status(400).json({ success: false, message: "objid required" });
const data = await mailSvc.getOrderMailInfo(objid);
if (!data) return res.status(404).json({ success: false, message: "발주서를 찾을 수 없어요" });
return res.json({ success: true, data });
} catch (e: any) {
logger.error("발주서 메일 정보 조회 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
/** GET /api/purchase/options/partner-managers/:partnerObjid */
export async function getPartnerManagers(req: AuthenticatedRequest, res: Response) {
try {
const partnerObjid = String(req.params.partnerObjid ?? "").trim();
if (!partnerObjid) return res.json({ success: true, data: [] });
const data = await mailSvc.getPartnerManagerList(partnerObjid);
return res.json({ success: true, data });
} catch (e: any) {
logger.error("공급업체 담당자 조회 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
/** POST /api/purchase/order-form/mail */
export async function sendPurchaseOrderMail(req: AuthenticatedRequest, res: Response) {
try {
const body = req.body as mailSvc.PurchaseOrderMailBody;
if (!body || !body.pomObjid) {
return res.status(400).json({ success: false, message: "pomObjid 가 필요해요" });
}
const userId = String(req.user?.userId ?? "");
const result = await mailSvc.sendOrderMail(userId, body);
if (!result.success) {
return res.status(400).json(result);
}
return res.json(result);
} catch (e: any) {
logger.error("발주서 메일 발송 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function getSuppliers(_req: AuthenticatedRequest, res: Response) { export async function getSuppliers(_req: AuthenticatedRequest, res: Response) {
try { try {
const data = await svc.listSupplierOptions(); const data = await svc.listSupplierOptions();
+8 -5
View File
@@ -20,16 +20,19 @@ router.get("/inbound-by-date", ctrl.getInboundByDate); // 입고일별 입
router.get("/project-status", ctrl.getProjectStatus); // 프로젝트별 발주/입고 현황 router.get("/project-status", ctrl.getProjectStatus); // 프로젝트별 발주/입고 현황
router.get("/order-list", ctrl.getPurchaseOrderList); // 발주서관리 (wace purchaseOrderMasterList_new 1:1) router.get("/order-list", ctrl.getPurchaseOrderList); // 발주서관리 (wace purchaseOrderMasterList_new 1:1)
// 발주서 폼 (general 양식, wace purchaseOrderFormPopup_general.do 1:1) // 발주서 폼 (general / outsourcing / english 양식, wace purchaseOrderFormPopup_*.do 1:1)
router.get ("/order-form/init", ctrl.getPurchaseOrderFormInit); // 품의서에서 자동 채움 router.get ("/order-form/init", ctrl.getPurchaseOrderFormInit); // 품의서에서 자동 채움
router.post ("/order-form/save", ctrl.savePurchaseOrderForm); // 마스터+파트 UPSERT router.post ("/order-form/save", ctrl.savePurchaseOrderForm); // 마스터+파트 UPSERT
router.get ("/order-form/:objid", ctrl.getPurchaseOrderForm); // 수정/조회 router.post ("/order-form/mail", ctrl.sendPurchaseOrderMail); // 메일 발송 (PDF 첨부)
router.delete("/order-form/:objid", ctrl.deletePurchaseOrderForm); // 삭제 cascade router.get ("/order-form/mail-info/:objid", ctrl.getPurchaseOrderMailInfo); // 메일 다이얼로그 자동 채움
router.get ("/order-form/:objid", ctrl.getPurchaseOrderForm); // 수정/조회
router.delete("/order-form/:objid", ctrl.deletePurchaseOrderForm); // 삭제 cascade
// 공통 옵션 // 공통 옵션
router.get("/options/suppliers", ctrl.getSuppliers); router.get("/options/suppliers", ctrl.getSuppliers);
router.get("/options/vendors", ctrl.getVendors); // wace client_mng 기반 router.get("/options/vendors", ctrl.getVendors); // wace client_mng 기반
router.get("/options/users", ctrl.getUsers); router.get("/options/users", ctrl.getUsers);
router.get("/options/projects", ctrl.getProjects); router.get("/options/projects", ctrl.getProjects);
router.get("/options/partner-managers/:partnerObjid", ctrl.getPartnerManagers); // 발주서 메일 담당자
export default router; export default router;
@@ -0,0 +1,158 @@
// ============================================================
// 구매관리 > 발주서관리 — 메일 발송 서비스
//
// wace ContractMgmtService.sendEstimateMailCustom 패턴(영업관리) 재사용:
// - 본문(contents): 다이얼로그 입력 그대로 (HTML 변환은 textToHtml)
// - 첨부: 프론트가 html2canvas + jsPDF 로 만든 base64 PDF 1장
// - SMTP 계정: PURCHASE (mailUtil SmtpAccountType)
// - mail_log title 에 [OBJID:nnn] 토큰 부착 — 그리드 LIKE 매칭 호환
// - 발송 성공 시 purchase_order_master.mail_send_yn='Y', mail_send_date=NOW()
// ============================================================
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import { sendMailUTF8 } from "../utils/mailUtil";
export interface PurchaseOrderMailBody {
pomObjid: string;
toEmails: string; // ; 또는 , 로 구분
ccEmails?: string;
subject: string;
contents: string;
/** 프론트 html2canvas + jsPDF base64 (data URL 또는 raw base64) */
pdfBase64: string;
}
/** 메일 다이얼로그 자동채움 — 공급업체/작성자/발주번호 */
export async function getOrderMailInfo(pomObjid: string) {
const pool = getPool();
const sql = `
SELECT
POM.OBJID AS pom_objid,
POM.PURCHASE_ORDER_NO AS purchase_order_no,
POM.PARTNER_OBJID AS partner_objid,
POM.MANAGER_EMAIL AS writer_email,
POM.MANAGER_NAME AS writer_name,
POM.FORM_TYPE AS form_type,
CM.CLIENT_NM AS partner_name,
CM.EMAIL AS partner_email
FROM PURCHASE_ORDER_MASTER POM
LEFT JOIN CLIENT_MNG CM
ON CM.OBJID = POM.PARTNER_OBJID
WHERE POM.OBJID = $1
LIMIT 1`;
const r = await pool.query(sql, [pomObjid]);
return r.rows[0] ?? null;
}
/** 공급업체 담당자 리스트 — RPS client_mng 는 단일 email 만 보관 (별도 contact 테이블 없음) */
export async function getPartnerManagerList(partnerObjid: string) {
const pool = getPool();
const r = await pool.query(
`SELECT CLIENT_NM AS name, EMAIL AS email, TEL_NO AS phone, '' AS department, 'Y' AS is_main
FROM CLIENT_MNG
WHERE OBJID = $1
AND COALESCE(EMAIL, '') <> ''`,
[partnerObjid],
);
return r.rows;
}
function decodeBase64Pdf(input: string): Buffer {
const m = /^data:application\/pdf;base64,(.*)$/i.exec(input);
const b64 = m ? m[1] : input;
return Buffer.from(b64, "base64");
}
function textToHtml(text: string): string {
return (text || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\n/g, "<br>");
}
export async function sendOrderMail(userId: string, body: PurchaseOrderMailBody) {
const info = await getOrderMailInfo(body.pomObjid);
if (!info) {
return { success: false, message: "발주서 정보를 찾을 수 없습니다." };
}
const splitEmails = (s: string | undefined) => (s || "")
.split(/[;,]/)
.map((e) => e.trim())
.filter((e) => e !== "");
const toEmails = splitEmails(body.toEmails);
if (toEmails.length === 0) {
return { success: false, message: "수신인 이메일이 없습니다." };
}
const ccEmails = splitEmails(body.ccEmails);
// 작성자 이메일 자동 cc
if (info.writer_email && !ccEmails.includes(info.writer_email)) {
ccEmails.push(info.writer_email);
}
if (!body.pdfBase64) {
return { success: false, message: "발주서 PDF 가 전달되지 않았습니다." };
}
const pdfBuf = decodeBase64Pdf(body.pdfBase64);
const safeNo = (info.purchase_order_no || "발주서").toString().replace(/[^\w가-힣\-_.]/g, "_");
const ts = new Date().toISOString().replace(/[-:.TZ]/g, "").substring(0, 14);
const attachment = {
filename: `${safeNo}_${ts}.pdf`,
content: pdfBuf,
contentType: "application/pdf",
};
const subject = body.subject.trim();
const subjectForLog = subject.includes("[OBJID:")
? subject
: `${subject} [OBJID:${body.pomObjid}]`;
const html = textToHtml(body.contents);
const result = await sendMailUTF8({
accountType: "PURCHASE",
fromUserId: userId,
toEmails,
ccEmails: ccEmails.length > 0 ? ccEmails : undefined,
subject,
subjectForLog,
html,
attachments: [attachment],
mailType: "PURCHASE_ORDER",
});
logger.info("발주서 메일 발송 완료", {
pomObjid: body.pomObjid,
mailLogObjid: result.objid,
sent: result.sent,
to: toEmails,
cc: ccEmails,
});
if (!result.sent) {
return {
success: false,
message: result.error || "메일 발송에 실패했습니다.",
objid: result.objid,
};
}
// 발송 성공 시 mail_send_yn / mail_send_date 갱신
try {
await getPool().query(
`UPDATE PURCHASE_ORDER_MASTER
SET MAIL_SEND_YN = 'Y',
MAIL_SEND_DATE = NOW()
WHERE OBJID = $1`,
[body.pomObjid],
);
} catch (e: any) {
logger.warn("mail_send_yn 갱신 실패", { error: e.message, pomObjid: body.pomObjid });
}
return { success: true, message: "발주서가 성공적으로 발송되었습니다.", objid: result.objid };
}
@@ -8,17 +8,19 @@
// - 그리드 8 visible: Item No. / Commodity & Description / Unit / Q'ty / Currency / Unit Price / Amount / Delivery // - 그리드 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) // - TOTAL 한 행 + 하단 "Look forward to your soonest delivery..." + 서명영역 (stamp_seal.png 65x65)
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Plus, Trash2 } from "lucide-react"; import { Download, Mail } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { CommCodeSelect } from "@/components/common/CommCodeSelect"; import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { DateInput } from "@/components/common/DateInput"; import { DateInput } from "@/components/common/DateInput";
import { NumberInput } from "@/components/common/NumberInput"; import { NumberInput } from "@/components/common/NumberInput";
import { purchaseApi, OptionItem } from "@/lib/api/purchase"; import { purchaseApi, OptionItem } from "@/lib/api/purchase";
import { generatePurchaseOrderPdf } from "@/lib/utils/purchaseOrderPdf";
import { PurchaseOrderMailDialog } from "./PurchaseOrderMailDialog";
interface Props { interface Props {
open: boolean; open: boolean;
@@ -106,8 +108,6 @@ export function PurchaseOrderEnglishFormDialog({
const isEdit = !!pomObjid; const isEdit = !!pomObjid;
const [master, setMaster] = useState<MasterState>(EMPTY_MASTER); const [master, setMaster] = useState<MasterState>(EMPTY_MASTER);
const [parts, setParts] = useState<PartRow[]>([]); const [parts, setParts] = useState<PartRow[]>([]);
const [deletedPartIds, setDeletedPartIds] = useState<string[]>([]);
const [checkedRowKeys, setCheckedRowKeys] = useState<string[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -118,8 +118,6 @@ export function PurchaseOrderEnglishFormDialog({
if (!open) return; if (!open) return;
setMaster({ ...EMPTY_MASTER }); setMaster({ ...EMPTY_MASTER });
setParts([]); setParts([]);
setDeletedPartIds([]);
setCheckedRowKeys([]);
(async () => { (async () => {
try { try {
@@ -204,7 +202,27 @@ export function PurchaseOrderEnglishFormDialog({
return a === "결재중" || a === "결재완료" || master.status === "cancel"; return a === "결재중" || a === "결재완료" || master.status === "cancel";
}, [master.appr_status, master.status]); }, [master.appr_status, master.status]);
const handleDownload = () => window.print(); 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>) => { const updateRow = (rowKey: string, patch: Partial<PartRow>) => {
setParts((prev) => prev.map((r) => { setParts((prev) => prev.map((r) => {
@@ -218,51 +236,6 @@ export function PurchaseOrderEnglishFormDialog({
})); }));
}; };
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 () => { const handleSave = async () => {
if (!master.partner_objid) { toast.warning("Select supplier (Messrs.)"); return; } if (!master.partner_objid) { toast.warning("Select supplier (Messrs.)"); return; }
if (parts.length === 0) { toast.warning("No items"); return; } if (parts.length === 0) { toast.warning("No items"); return; }
@@ -290,7 +263,7 @@ export function PurchaseOrderEnglishFormDialog({
supply_unit_price: toNum(p.supply_unit_price), supply_unit_price: toNum(p.supply_unit_price),
delivery_request_date: p.delivery_request_date, delivery_request_date: p.delivery_request_date,
})), })),
deletedPartObjids: deletedPartIds, deletedPartObjids: [],
}; };
const res = await purchaseApi.saveOrderForm(payload); const res = await purchaseApi.saveOrderForm(payload);
toast.success(`Saved (${res.purchase_order_no})`); toast.success(`Saved (${res.purchase_order_no})`);
@@ -308,7 +281,7 @@ export function PurchaseOrderEnglishFormDialog({
<DialogContent className="max-w-[1280px] w-[96vw] max-h-[94vh] overflow-hidden flex flex-col p-0 gap-0 bg-white"> <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> <DialogTitle className="sr-only">Purchase Order (English)</DialogTitle>
<DialogDescription className="sr-only">wace English Purchase Order PDF form</DialogDescription> <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 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="flex items-start gap-4">
<div className="w-[120px]"> <div className="w-[120px]">
@@ -420,29 +393,26 @@ export function PurchaseOrderEnglishFormDialog({
<div className="flex justify-end gap-2 my-3"> <div className="flex justify-end gap-2 my-3">
{!isReadOnly && ( {!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]" <Button size="sm" className="h-8 px-5 text-[13px]"
style={{ background: "#dfeffc", color: "#000" }} style={{ background: "#dfeffc", color: "#000" }}
onClick={handleDownload}> onClick={handleSave} disabled={saving || loading}>
Download PO {saving ? "Saving..." : "Save"}
</Button> </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]" <Button size="sm" variant="outline" className="h-8 px-5 text-[13px]"
style={{ background: "#dfeffc" }} style={{ background: "#dfeffc" }}
onClick={onClose} disabled={saving}> onClick={onClose} disabled={saving}>
@@ -455,12 +425,6 @@ export function PurchaseOrderEnglishFormDialog({
<table className="w-full text-[11px] border-collapse"> <table className="w-full text-[11px] border-collapse">
<thead> <thead>
<tr className="bg-[#f0f4fa]"> <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-[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="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">Unit</th>
@@ -474,20 +438,14 @@ export function PurchaseOrderEnglishFormDialog({
<tbody> <tbody>
{parts.length === 0 ? ( {parts.length === 0 ? (
<tr> <tr>
<td colSpan={9} className="text-center py-8 border border-black text-gray-500"> <td colSpan={8} className="text-center py-8 border border-black text-gray-500">
No items use "Add Row" to start Items are auto-filled from the proposal
</td> </td>
</tr> </tr>
) : parts.map((row) => { ) : parts.map((row) => {
const desc = row.spec ? `${row.part_name}/${row.spec}` : row.part_name; const desc = row.spec ? `${row.part_name}/${row.spec}` : row.part_name;
return ( return (
<tr key={row.rowKey} className="hover:bg-blue-50/30"> <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-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 text-left text-gray-700">{desc}</td>
<td className="border border-black px-1 py-0.5"> <td className="border border-black px-1 py-0.5">
@@ -562,6 +520,15 @@ export function PurchaseOrderEnglishFormDialog({
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
<PurchaseOrderMailDialog
open={mailOpen}
onOpenChange={setMailOpen}
pomObjid={master.objid || null}
formType="english"
onRequestPdf={handleRequestPdf}
onSent={() => setMailOpen(false)}
/>
</Dialog> </Dialog>
); );
} }
@@ -14,17 +14,19 @@
// - SUPPLY_UNIT_PRICE = ORDER_QTY × PARTNER_PRICE (행 자동 계산) // - SUPPLY_UNIT_PRICE = ORDER_QTY × PARTNER_PRICE (행 자동 계산)
// - TOTAL_SUPPLY_PRICE = Σ SUPPLY_UNIT_PRICE (마스터 자동 합산) // - TOTAL_SUPPLY_PRICE = Σ SUPPLY_UNIT_PRICE (마스터 자동 합산)
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Plus, Trash2 } from "lucide-react"; import { Download, Mail } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { CommCodeSelect } from "@/components/common/CommCodeSelect"; import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { DateInput } from "@/components/common/DateInput"; import { DateInput } from "@/components/common/DateInput";
import { NumberInput } from "@/components/common/NumberInput"; import { NumberInput } from "@/components/common/NumberInput";
import { purchaseApi, OptionItem } from "@/lib/api/purchase"; import { purchaseApi, OptionItem } from "@/lib/api/purchase";
import { generatePurchaseOrderPdf } from "@/lib/utils/purchaseOrderPdf";
import { PurchaseOrderMailDialog } from "./PurchaseOrderMailDialog";
interface Props { interface Props {
open: boolean; open: boolean;
@@ -121,8 +123,6 @@ export function PurchaseOrderGeneralFormDialog({
const isEdit = !!pomObjid; const isEdit = !!pomObjid;
const [master, setMaster] = useState<MasterState>(EMPTY_MASTER); const [master, setMaster] = useState<MasterState>(EMPTY_MASTER);
const [parts, setParts] = useState<PartRow[]>([]); const [parts, setParts] = useState<PartRow[]>([]);
const [deletedPartIds, setDeletedPartIds] = useState<string[]>([]);
const [checkedRowKeys, setCheckedRowKeys] = useState<string[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -137,8 +137,6 @@ export function PurchaseOrderGeneralFormDialog({
if (!open) return; if (!open) return;
setMaster(EMPTY_MASTER); setMaster(EMPTY_MASTER);
setParts([]); setParts([]);
setDeletedPartIds([]);
setCheckedRowKeys([]);
(async () => { (async () => {
try { try {
@@ -229,9 +227,28 @@ export function PurchaseOrderGeneralFormDialog({
return a === "결재중" || a === "결재완료" || master.status === "cancel"; return a === "결재중" || a === "결재완료" || master.status === "cancel";
}, [master.appr_status, master.status]); }, [master.appr_status, master.status]);
/** 발주서다운 — 읽기전용 모드용 PDF 저장 (간이판: 브라우저 인쇄 다이얼로그) */ const pdfContainerRef = useRef<HTMLDivElement>(null);
const handleDownload = () => { const [mailOpen, setMailOpen] = useState(false);
window.print(); const [generatingPdf, setGeneratingPdf] = useState(false);
/** PDF 다운로드 — html2canvas + jsPDF 로 발주서 양식 컨테이너 캡처 */
const handleDownload = async () => {
if (!pdfContainerRef.current) return;
setGeneratingPdf(true);
try {
const filename = `${master.purchase_order_no || "purchase_order"}.pdf`;
await generatePurchaseOrderPdf(pdfContainerRef.current, { download: true, filename });
} catch (e: any) {
toast.error("PDF 생성 실패: " + (e?.message ?? ""));
} finally {
setGeneratingPdf(false);
}
};
/** 메일 발송 — 다이얼로그가 onRequestPdf 콜백으로 PDF 생성 요청 */
const handleRequestPdf = async (): Promise<string> => {
if (!pdfContainerRef.current) throw new Error("발주서 컨테이너를 찾을 수 없습니다");
return generatePurchaseOrderPdf(pdfContainerRef.current);
}; };
// 담당자 select 변경 시 hidden 필드(name/position/phone/email) 자동 채움 // 담당자 select 변경 시 hidden 필드(name/position/phone/email) 자동 채움
@@ -259,51 +276,6 @@ export function PurchaseOrderGeneralFormDialog({
})); }));
}; };
const handleAddRow = () => {
setParts((prev) => [...prev, {
rowKey: nextKey(),
objid: "", part_objid: "",
part_no: "", part_name: "", spec: "",
order_qty: "", qty: "",
unit: "0001400",
part_delivery_place: "RPS",
partner_price: "",
supply_unit_price: 0,
remark: "", 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 () => { const handleSave = async () => {
if (!master.partner_objid) { toast.warning("수신업체를 선택하세요"); return; } if (!master.partner_objid) { toast.warning("수신업체를 선택하세요"); return; }
if (parts.length === 0) { toast.warning("발주 품목이 없습니다"); return; } if (parts.length === 0) { toast.warning("발주 품목이 없습니다"); return; }
@@ -332,7 +304,7 @@ export function PurchaseOrderGeneralFormDialog({
delivery_request_date: p.delivery_request_date, delivery_request_date: p.delivery_request_date,
currency: p.currency, currency: p.currency,
})), })),
deletedPartObjids: deletedPartIds, deletedPartObjids: [],
}; };
const res = await purchaseApi.saveOrderForm(payload); const res = await purchaseApi.saveOrderForm(payload);
toast.success(`저장 완료 (${res.purchase_order_no})`); toast.success(`저장 완료 (${res.purchase_order_no})`);
@@ -352,7 +324,7 @@ export function PurchaseOrderGeneralFormDialog({
{/* Radix UI 접근성 — 시각 노출 없이 타이틀/설명 제공 */} {/* Radix UI 접근성 — 시각 노출 없이 타이틀/설명 제공 */}
<DialogTitle className="sr-only"> ()</DialogTitle> <DialogTitle className="sr-only"> ()</DialogTitle>
<DialogDescription className="sr-only">wace PDF </DialogDescription> <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 ref={pdfContainerRef} className="flex-1 overflow-y-auto p-4 text-[12px]" style={{ fontFamily: "'Malgun Gothic', '맑은 고딕', sans-serif" }}>
{/* ── 상단 메인 박스 (PDF 양식) ── */} {/* ── 상단 메인 박스 (PDF 양식) ── */}
<div className="border-2 border-black"> <div className="border-2 border-black">
{/* 1행: 로고 + 타이틀 */} {/* 1행: 로고 + 타이틀 */}
@@ -494,29 +466,26 @@ export function PurchaseOrderGeneralFormDialog({
{/* 버튼 영역 (그리드 위, 우측 정렬) — wace _general.jsp 941-948 1:1 */} {/* 버튼 영역 (그리드 위, 우측 정렬) — wace _general.jsp 941-948 1:1 */}
<div className="flex justify-end gap-2 my-3"> <div className="flex justify-end gap-2 my-3">
{!isReadOnly && ( {!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]" <Button size="sm" className="h-8 px-5 text-[13px]"
style={{ background: "#dfeffc", color: "#000" }} style={{ background: "#dfeffc", color: "#000" }}
onClick={handleDownload}> onClick={handleSave} disabled={saving || loading}>
{saving ? "저장 중..." : "저장"}
</Button> </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" />
</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 ? "생성 중..." : "PDF 다운로드"}
</Button>
</>
)}
<Button size="sm" variant="outline" className="h-8 px-5 text-[13px]" <Button size="sm" variant="outline" className="h-8 px-5 text-[13px]"
style={{ background: "#dfeffc" }} style={{ background: "#dfeffc" }}
onClick={onClose} disabled={saving}> onClick={onClose} disabled={saving}>
@@ -529,12 +498,6 @@ export function PurchaseOrderGeneralFormDialog({
<table className="w-full text-[11px] border-collapse"> <table className="w-full text-[11px] border-collapse">
<thead> <thead>
<tr className="bg-[#f0f4fa]"> <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="w-10 border border-black px-1 py-1.5">No</th>
<th className="min-w-[140px] 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="min-w-[120px] border border-black px-1 py-1.5"></th> <th className="min-w-[120px] border border-black px-1 py-1.5"></th>
@@ -550,18 +513,12 @@ export function PurchaseOrderGeneralFormDialog({
<tbody> <tbody>
{parts.length === 0 ? ( {parts.length === 0 ? (
<tr> <tr>
<td colSpan={11} className="text-center py-8 border border-black text-gray-500"> <td colSpan={10} className="text-center py-8 border border-black text-gray-500">
"행 추가"
</td> </td>
</tr> </tr>
) : parts.map((row, i) => ( ) : parts.map((row, i) => (
<tr key={row.rowKey} className="hover:bg-blue-50/30"> <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-center text-gray-600">{i + 1}</td>
<td className="border border-black px-1 py-0.5"> <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" <Input className="h-7 text-[11px] px-1.5 border-0 focus-visible:ring-1"
@@ -644,6 +601,15 @@ export function PurchaseOrderGeneralFormDialog({
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
<PurchaseOrderMailDialog
open={mailOpen}
onOpenChange={setMailOpen}
pomObjid={master.objid || null}
formType="general"
onRequestPdf={handleRequestPdf}
onSent={() => setMailOpen(false)}
/>
</Dialog> </Dialog>
); );
} }
@@ -0,0 +1,305 @@
"use client";
// 구매관리 > 발주서관리 — 메일 발송 다이얼로그
// EstimateMailDialog 패턴 1:1 (영업관리 견적관리) 발주서용 단순화:
// - 공급업체(client_mng) 단일 email 자동 채움
// - 단일 PDF 첨부 (호출자가 base64 미리 생성해서 props 로 전달)
// - 발송 성공 시 purchase_order_master.mail_send_yn='Y'/mail_send_date=NOW()
import { useCallback, useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Loader2, Send } from "lucide-react";
import { toast } from "sonner";
import { purchaseApi, PartnerManager } from "@/lib/api/purchase";
export interface PurchaseOrderMailDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** purchase_order_master.objid */
pomObjid: string | null;
/** form_type ('general' | 'outsourcing' | 'english') — 본문 템플릿 분기 */
formType?: string;
/** 호출자가 미리 생성한 발주서 PDF base64 (data URL 또는 raw) — 빈값이면 발송 단계에서 onRequestPdf 콜백 사용 */
pdfBase64?: string;
/** pdfBase64 가 없을 때 다이얼로그가 호출하는 PDF 생성기 (다이얼로그 외부 컴포넌트에서 DOM 캡처) */
onRequestPdf?: () => Promise<string>;
onSent?: () => void;
}
export function PurchaseOrderMailDialog({
open,
onOpenChange,
pomObjid,
formType = "general",
pdfBase64,
onRequestPdf,
onSent,
}: PurchaseOrderMailDialogProps) {
const isEnglish = formType === "english";
const [loading, setLoading] = useState(false);
const [sending, setSending] = useState(false);
const [progress, setProgress] = useState("");
const [managers, setManagers] = useState<PartnerManager[]>([]);
const [checkedEmails, setCheckedEmails] = useState<Record<string, boolean>>({});
const [form, setForm] = useState({
toEmails: "",
ccEmails: "",
subject: "",
contents: "",
});
useEffect(() => {
if (!open || !pomObjid) return;
let cancelled = false;
(async () => {
setLoading(true);
setManagers([]);
setCheckedEmails({});
setForm({ toEmails: "", ccEmails: "", subject: "", contents: "" });
try {
const info = await purchaseApi.getOrderMailInfo(pomObjid);
if (cancelled || !info) return;
const partnerName = info.partner_name ?? "";
const orderNo = info.purchase_order_no ?? "";
const template = isEnglish
? `Dear ${partnerName},\n\n` +
`Please find attached our Purchase Order (Ref. No: ${orderNo}).\n\n` +
`Should you have any questions, please feel free to contact us.\n\n` +
`Best regards,\nRPS CO., LTD.\n`
: `안녕하세요.\n\n` +
`${partnerName} 귀하께 발주서를 송부드립니다.\n\n` +
`발주번호: ${orderNo}\n\n` +
`첨부된 발주서를 확인하신 후 문의사항이 있으시면 연락 주시기 바랍니다.\n\n` +
`감사합니다.\n`;
setForm({
toEmails: info.partner_email ?? "",
ccEmails: info.writer_email ?? "",
subject: isEnglish
? `[RPS] Purchase Order ${orderNo}`
: `[${partnerName}] ${orderNo} 발주서`,
contents: template,
});
if (info.partner_objid) {
try {
const mgrs = await purchaseApi.listPartnerManagers(info.partner_objid);
if (!cancelled) setManagers(mgrs);
} catch {/* skip */}
}
} catch (e: any) {
toast.error("발주서 정보를 불러올 수 없습니다: " + (e?.message ?? ""));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [open, pomObjid, isEnglish]);
function toggleManager(email: string, checked: boolean) {
setCheckedEmails((prev) => ({ ...prev, [email]: checked }));
setForm((prev) => {
const current = prev.toEmails.split(/[,;]/).map((e) => e.trim()).filter(Boolean);
if (checked && !current.includes(email)) {
return { ...prev, toEmails: current.concat(email).join(", ") };
}
if (!checked && current.includes(email)) {
return { ...prev, toEmails: current.filter((e) => e !== email).join(", ") };
}
return prev;
});
}
const handleSend = useCallback(async () => {
if (!pomObjid) return;
const toEmails = form.toEmails.trim();
const subject = form.subject.trim();
const contents = form.contents.trim();
if (toEmails === "") { toast.error(isEnglish ? "Recipient required" : "수신인을 입력해주세요"); return; }
if (subject === "") { toast.error(isEnglish ? "Subject required" : "제목을 입력해주세요"); return; }
if (contents === "") { toast.error(isEnglish ? "Body required" : "내용을 입력해주세요"); return; }
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const emails = toEmails.split(/[,;]/).map((e) => e.trim()).filter(Boolean);
for (const e of emails) {
if (!emailPattern.test(e)) {
toast.error(`올바른 이메일 형식이 아닙니다: ${e}`);
return;
}
}
if (!confirm(isEnglish ? "Send purchase order?" : "발주서를 발송하시겠습니까?")) return;
setSending(true);
setProgress(isEnglish ? "Preparing..." : "발송 준비 중...");
try {
let pdf = pdfBase64;
if (!pdf && onRequestPdf) {
setProgress(isEnglish ? "Generating PDF..." : "PDF 생성 중... (최대 30초)");
pdf = await onRequestPdf();
}
if (!pdf) {
toast.error(isEnglish ? "PDF not available" : "PDF 가 준비되지 않았습니다");
setSending(false);
setProgress("");
return;
}
setProgress(isEnglish ? "Sending mail..." : "메일 발송 중...");
const result = await purchaseApi.sendOrderMail({
pomObjid,
toEmails,
ccEmails: form.ccEmails.trim() || undefined,
subject,
contents,
pdfBase64: pdf,
});
if (!result.success) {
toast.error((isEnglish ? "Send failed: " : "발송 실패: ") + result.message);
} else {
toast.success(result.message || (isEnglish ? "Sent" : "발주서가 발송되었습니다"));
onOpenChange(false);
onSent?.();
}
} catch (e: any) {
toast.error((isEnglish ? "Send failed: " : "발송 실패: ") + (e?.response?.data?.message ?? e?.message ?? ""));
} finally {
setSending(false);
setProgress("");
}
}, [pomObjid, form, isEnglish, pdfBase64, onRequestPdf, onOpenChange, onSent]);
return (
<Dialog open={open} onOpenChange={(o) => { if (!sending) onOpenChange(o); }}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{isEnglish ? "Send Purchase Order Email" : "발주서 메일 발송"}</DialogTitle>
<DialogDescription>
{isEnglish
? "Attachment: Purchase Order PDF (1 page)"
: "PDF 첨부: 발주서 양식 1장"}
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 mr-2 animate-spin" />
{isEnglish ? "Loading..." : "정보를 불러오는 중..."}
</div>
) : (
<div className="space-y-3">
<div>
<Label className="text-xs">{isEnglish ? "Supplier contact" : "공급업체 담당자"}</Label>
<div className="border rounded-md p-2 bg-muted/30 max-h-[120px] overflow-y-auto text-sm">
{managers.length === 0 ? (
<div className="text-muted-foreground text-center py-2">
{isEnglish ? "No contacts. Enter recipient directly." : "등록된 담당자가 없습니다. 수신인을 직접 입력해주세요."}
</div>
) : (
managers.map((m, i) => {
const email = m.email ?? "";
const id = `pom_manager_${i}_${email}`;
return (
<label key={id} htmlFor={id} className="flex items-center gap-2 py-1 cursor-pointer">
<input
type="checkbox"
id={id}
checked={!!checkedEmails[email]}
disabled={email === ""}
onChange={(e) => toggleManager(email, e.target.checked)}
/>
<span>
{m.name}
{email && <span className="text-muted-foreground"> ({email})</span>}
</span>
</label>
);
})
)}
</div>
</div>
<div>
<Label className="text-xs">
{isEnglish ? "To" : "수신인"} <span className="text-red-500">*</span>
</Label>
<Input
value={form.toEmails}
onChange={(e) => setForm({ ...form, toEmails: e.target.value })}
placeholder="email1@example.com, email2@example.com"
/>
<p className="text-[11px] text-muted-foreground mt-1">
{isEnglish ? "Multiple addresses separated by , or ;" : "여러 개는 쉼표(,) 또는 세미콜론(;)으로 구분"}
</p>
</div>
<div>
<Label className="text-xs">{isEnglish ? "CC" : "참조"}</Label>
<Input
value={form.ccEmails}
onChange={(e) => setForm({ ...form, ccEmails: e.target.value })}
placeholder={isEnglish ? "Optional" : "참조 이메일 주소 (선택사항)"}
/>
<p className="text-[11px] text-muted-foreground mt-1">
{isEnglish ? "Writer email is auto-added as CC." : "작성자 이메일이 자동으로 참조에 추가됩니다."}
</p>
</div>
<div>
<Label className="text-xs">
{isEnglish ? "Subject" : "제목"} <span className="text-red-500">*</span>
</Label>
<Input
value={form.subject}
onChange={(e) => setForm({ ...form, subject: e.target.value })}
/>
</div>
<div>
<Label className="text-xs">
{isEnglish ? "Body" : "내용"} <span className="text-red-500">*</span>
</Label>
<Textarea
rows={8}
value={form.contents}
onChange={(e) => setForm({ ...form, contents: e.target.value })}
/>
</div>
{sending && progress && (
<div className="text-sm text-blue-600 dark:text-blue-400 flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
{progress}
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={sending}>
{isEnglish ? "Cancel" : "취소"}
</Button>
<Button onClick={handleSend} disabled={loading || sending}>
{sending ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Send className="w-4 h-4 mr-1" />}
{isEnglish ? "Send" : "발송"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -8,17 +8,19 @@
// - 그리드 컬럼: ☑ / No / 업체명 / 제품명 / 부품명 / 수량 / 단위 / 단가 / 합계 / 작업지시번호 / 부품품번 / 입고요청일 // - 그리드 컬럼: ☑ / No / 업체명 / 제품명 / 부품명 / 수량 / 단위 / 단가 / 합계 / 작업지시번호 / 부품품번 / 입고요청일
// - 푸터: 총공급가액(VAT별도) + 한글 보안문구 // - 푸터: 총공급가액(VAT별도) + 한글 보안문구
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Plus, Trash2 } from "lucide-react"; import { Download, Mail } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { CommCodeSelect } from "@/components/common/CommCodeSelect"; import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { DateInput } from "@/components/common/DateInput"; import { DateInput } from "@/components/common/DateInput";
import { NumberInput } from "@/components/common/NumberInput"; import { NumberInput } from "@/components/common/NumberInput";
import { purchaseApi, OptionItem } from "@/lib/api/purchase"; import { purchaseApi, OptionItem } from "@/lib/api/purchase";
import { generatePurchaseOrderPdf } from "@/lib/utils/purchaseOrderPdf";
import { PurchaseOrderMailDialog } from "./PurchaseOrderMailDialog";
interface Props { interface Props {
open: boolean; open: boolean;
@@ -108,8 +110,6 @@ export function PurchaseOrderOutsourcingFormDialog({
const isEdit = !!pomObjid; const isEdit = !!pomObjid;
const [master, setMaster] = useState<MasterState>(EMPTY_MASTER); const [master, setMaster] = useState<MasterState>(EMPTY_MASTER);
const [parts, setParts] = useState<PartRow[]>([]); const [parts, setParts] = useState<PartRow[]>([]);
const [deletedPartIds, setDeletedPartIds] = useState<string[]>([]);
const [checkedRowKeys, setCheckedRowKeys] = useState<string[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -129,8 +129,6 @@ export function PurchaseOrderOutsourcingFormDialog({
if (!open) return; if (!open) return;
setMaster({ ...EMPTY_MASTER }); setMaster({ ...EMPTY_MASTER });
setParts([]); setParts([]);
setDeletedPartIds([]);
setCheckedRowKeys([]);
(async () => { (async () => {
try { try {
@@ -217,7 +215,27 @@ export function PurchaseOrderOutsourcingFormDialog({
return a === "결재중" || a === "결재완료" || master.status === "cancel"; return a === "결재중" || a === "결재완료" || master.status === "cancel";
}, [master.appr_status, master.status]); }, [master.appr_status, master.status]);
const handleDownload = () => window.print(); 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_outsourcing"}.pdf`;
await generatePurchaseOrderPdf(pdfContainerRef.current, { download: true, filename });
} catch (e: any) {
toast.error("PDF 생성 실패: " + (e?.message ?? ""));
} finally {
setGeneratingPdf(false);
}
};
const handleRequestPdf = async (): Promise<string> => {
if (!pdfContainerRef.current) throw new Error("발주서 컨테이너를 찾을 수 없습니다");
return generatePurchaseOrderPdf(pdfContainerRef.current);
};
const onManagerChange = (slot: 1 | 2, userId: string) => { const onManagerChange = (slot: 1 | 2, userId: string) => {
const u = userOpts.find((o) => o.code === userId); const u = userOpts.find((o) => o.code === userId);
@@ -243,50 +261,6 @@ export function PurchaseOrderOutsourcingFormDialog({
})); }));
}; };
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 () => { const handleSave = async () => {
if (!master.partner_objid) { toast.warning("수신업체를 선택하세요"); return; } if (!master.partner_objid) { toast.warning("수신업체를 선택하세요"); return; }
if (parts.length === 0) { toast.warning("발주 품목이 없습니다"); return; } if (parts.length === 0) { toast.warning("발주 품목이 없습니다"); return; }
@@ -315,7 +289,7 @@ export function PurchaseOrderOutsourcingFormDialog({
work_order_no: p.work_order_no, work_order_no: p.work_order_no,
delivery_request_date: p.delivery_request_date, delivery_request_date: p.delivery_request_date,
})), })),
deletedPartObjids: deletedPartIds, deletedPartObjids: [],
}; };
const res = await purchaseApi.saveOrderForm(payload); const res = await purchaseApi.saveOrderForm(payload);
toast.success(`저장 완료 (${res.purchase_order_no})`); toast.success(`저장 완료 (${res.purchase_order_no})`);
@@ -333,7 +307,7 @@ export function PurchaseOrderOutsourcingFormDialog({
<DialogContent className="max-w-[1280px] w-[96vw] max-h-[94vh] overflow-hidden flex flex-col p-0 gap-0 bg-white"> <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> <DialogTitle className="sr-only"> </DialogTitle>
<DialogDescription className="sr-only">wace PDF </DialogDescription> <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 ref={pdfContainerRef} className="flex-1 overflow-y-auto p-4 text-[12px]" style={{ fontFamily: "'Malgun Gothic', '맑은 고딕', sans-serif" }}>
<div className="border-2 border-black"> <div className="border-2 border-black">
<div className="flex items-center border-b-2 border-black"> <div className="flex items-center border-b-2 border-black">
<div className="w-[160px] text-center py-2"> <div className="w-[160px] text-center py-2">
@@ -455,29 +429,26 @@ export function PurchaseOrderOutsourcingFormDialog({
<div className="flex justify-end gap-2 my-3"> <div className="flex justify-end gap-2 my-3">
{!isReadOnly && ( {!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]" <Button size="sm" className="h-8 px-5 text-[13px]"
style={{ background: "#dfeffc", color: "#000" }} style={{ background: "#dfeffc", color: "#000" }}
onClick={handleDownload}> onClick={handleSave} disabled={saving || loading}>
{saving ? "저장 중..." : "저장"}
</Button> </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" />
</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 ? "생성 중..." : "PDF 다운로드"}
</Button>
</>
)}
<Button size="sm" variant="outline" className="h-8 px-5 text-[13px]" <Button size="sm" variant="outline" className="h-8 px-5 text-[13px]"
style={{ background: "#dfeffc" }} style={{ background: "#dfeffc" }}
onClick={onClose} disabled={saving}> onClick={onClose} disabled={saving}>
@@ -489,12 +460,6 @@ export function PurchaseOrderOutsourcingFormDialog({
<table className="w-full text-[11px] border-collapse"> <table className="w-full text-[11px] border-collapse">
<thead> <thead>
<tr className="bg-[#f0f4fa]"> <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="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-[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-[160px] border border-black px-1 py-1.5"></th>
@@ -511,18 +476,12 @@ export function PurchaseOrderOutsourcingFormDialog({
<tbody> <tbody>
{parts.length === 0 ? ( {parts.length === 0 ? (
<tr> <tr>
<td colSpan={12} className="text-center py-8 border border-black text-gray-500"> <td colSpan={11} className="text-center py-8 border border-black text-gray-500">
"행 추가"
</td> </td>
</tr> </tr>
) : parts.map((row, i) => ( ) : parts.map((row, i) => (
<tr key={row.rowKey} className="hover:bg-blue-50/30"> <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-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 text-left text-gray-700">{partnerName}</td>
<td className="border border-black px-1 py-0.5"> <td className="border border-black px-1 py-0.5">
@@ -593,6 +552,15 @@ export function PurchaseOrderOutsourcingFormDialog({
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
<PurchaseOrderMailDialog
open={mailOpen}
onOpenChange={setMailOpen}
pomObjid={master.objid || null}
formType="outsourcing"
onRequestPdf={handleRequestPdf}
onSent={() => setMailOpen(false)}
/>
</Dialog> </Dialog>
); );
} }
+47
View File
@@ -73,6 +73,34 @@ export interface SaveOrderResult {
purchase_order_no: string; purchase_order_no: string;
} }
export interface OrderMailInfo {
pom_objid: string;
purchase_order_no: string;
partner_objid: string;
partner_name: string;
partner_email: string;
writer_email: string;
writer_name: string;
form_type: string;
}
export interface PartnerManager {
name: string;
email: string;
phone: string;
department: string;
is_main: string;
}
export interface SendOrderMailPayload {
pomObjid: string;
toEmails: string;
ccEmails?: string;
subject: string;
contents: string;
pdfBase64: string;
}
export const purchaseApi = { export const purchaseApi = {
// 그리드 7종 // 그리드 7종
listPurchaseRequest: (f: PurchaseListFilter = {}) => getList("purchase-request", f), listPurchaseRequest: (f: PurchaseListFilter = {}) => getList("purchase-request", f),
@@ -107,6 +135,25 @@ export const purchaseApi = {
await apiClient.delete(`/purchase/order-form/${encodeURIComponent(objid)}`); await apiClient.delete(`/purchase/order-form/${encodeURIComponent(objid)}`);
}, },
// ─── 발주서 메일 발송 ─────────────────────────────────────
/** 메일 다이얼로그 자동채움 — 공급업체 이메일/이름 + 작성자 이메일 + 발주번호 */
async getOrderMailInfo(pomObjid: string): Promise<OrderMailInfo | null> {
const r = await apiClient.get(`/purchase/order-form/mail-info/${encodeURIComponent(pomObjid)}`);
return (r.data?.data ?? null) as OrderMailInfo | null;
},
/** 공급업체 담당자 리스트 — RPS client_mng 단일 email */
async listPartnerManagers(partnerObjid: string): Promise<PartnerManager[]> {
const r = await apiClient.get(
`/purchase/options/partner-managers/${encodeURIComponent(partnerObjid)}`,
);
return (r.data?.data ?? []) as PartnerManager[];
},
/** PDF 첨부 + SMTP 발송. 성공 시 mail_send_yn='Y'/mail_send_date=NOW() 갱신. */
async sendOrderMail(payload: SendOrderMailPayload): Promise<{ success: boolean; message: string; objid?: string }> {
const r = await apiClient.post("/purchase/order-form/mail", payload);
return r.data as { success: boolean; message: string; objid?: string };
},
// 공통 옵션 // 공통 옵션
async listSuppliers(): Promise<OptionItem[]> { async listSuppliers(): Promise<OptionItem[]> {
const r = await apiClient.get("/purchase/options/suppliers"); const r = await apiClient.get("/purchase/options/suppliers");
+59
View File
@@ -0,0 +1,59 @@
// 발주서 다이얼로그 컨테이너 DOM → PDF base64 변환 헬퍼.
// 영업관리 estimate template2 (lib + sales/estimate/template2/pop/page.tsx 372-438) 패턴 단순화 버전.
// - 다이얼로그 안의 양식 컨테이너를 html2canvas-pro 로 캡처 (scale=2, 흰배경, input/textarea → 텍스트)
// - A4 비율로 jsPDF 에 그려 datauristring 반환 (메일 첨부) 또는 save (파일 다운로드)
export async function generatePurchaseOrderPdf(
container: HTMLElement,
opts?: { filename?: string; download?: boolean },
): Promise<string> {
const html2canvas = (await import("html2canvas-pro")).default;
const jspdfModule: any = await import("jspdf");
const jsPDF = jspdfModule.jsPDF ?? jspdfModule.default;
const canvas = await html2canvas(container, {
scale: 2,
useCORS: true,
backgroundColor: "#ffffff",
onclone: (doc) => {
// input/textarea/select → 텍스트로 교체 (편집 윤곽 제거)
doc.querySelectorAll("input, textarea").forEach((el) => {
const v = (el as HTMLInputElement | HTMLTextAreaElement).value ?? "";
const span = doc.createElement("span");
span.textContent = v;
(span.style as any).whiteSpace = "pre-wrap";
el.parentNode?.replaceChild(span, el);
});
// ShadCN Select 트리거 안의 표시 텍스트만 추출 — 화살표/아이콘은 제거
doc.querySelectorAll('[role="combobox"]').forEach((el) => {
const text = el.textContent ?? "";
const span = doc.createElement("span");
span.textContent = text.trim();
el.parentNode?.replaceChild(span, el);
});
},
});
const pdf = new jsPDF("p", "mm", "a4");
const imgData = canvas.toDataURL("image/jpeg", 0.85);
const imgWidth = 210;
const pageHeight = 297;
const imgHeight = (canvas.height * imgWidth) / canvas.width;
let heightLeft = imgHeight;
let position = 0;
pdf.addImage(imgData, "JPEG", 0, position, imgWidth, imgHeight, undefined, "FAST");
heightLeft -= pageHeight;
while (heightLeft > 0) {
position = heightLeft - imgHeight;
pdf.addPage();
pdf.addImage(imgData, "JPEG", 0, position, imgWidth, imgHeight, undefined, "FAST");
heightLeft -= pageHeight;
}
if (opts?.download) {
pdf.save(opts.filename ?? "purchase_order.pdf");
}
return pdf.output("datauristring") as string;
}