구매관리 발주서 메일 발송 + 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:
@@ -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();
|
||||||
|
|||||||
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.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 & Description</th>
|
<th className="min-w-[260px] border border-black px-1 py-1.5">Commodity & 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user