PR-C G6 견적관리 SMTP 메일 발송 (wace sendEstimateMailCustom 1:1)
- nodemailer + pdf-lib로 실제 SMTP 발송. mail_log INSERT(is_send='N') → 발송 → 성공 시 UPDATE(is_send='Y'), 실패 시 UPDATE(error_log). SMTP_SEND_SWITCH='N'면 발송 스킵.
- SMTP 3계정(ERP/SALES/PURCHASE) host/user/pw 환경변수 분리. 견적서는 SALES. dev는 backend-node/.env, 운영은 deploy/onpremise + docker/prod + docker/deploy 3개 compose에 environment 매핑(호스트 .env에서 실값 주입).
- 다이얼로그(EstimateMailDialog): wace estimateMailFormPopup.jsp 1:1. 고객사 담당자 체크박스 + To/CC/제목/내용 자동채움(GET /sales/estimate/mail-info/:id + .../customer/:id/managers). hasBaseEst/hasAddEst 분기로 PDF 첨부 안내. 본문은 다이얼로그 plain text 입력 → <br> 변환.
- PDF 첨부: 메일 다이얼로그가 hidden iframe으로 최신 차수 template1/2 페이지를 렌더 → window.fn_generateAndUploadPdf(cb) 글로벌 → jsPDF.output('datauristring') base64 추출 → 한 요청에 전달. backend가 견적 PDF + estimate02 N건 pdf-lib로 합본 첨부.
- PDF 캡처 수신처 누락 픽스: CustomerSelect의 /sales/customers 옵션 fetch가 iframe에서 dataLoaded=true 뒤에 끝나 셀렉트 라벨이 빈 상태로 캡처되던 현상. fetchCustomers export + template1/2 setLoading(false) 직전 await + onclone에서 [role="combobox"] 라이브 DOM 텍스트 fallback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,3 +15,25 @@ DOCUMENT_DATA_SOURCE=memory
|
||||
# https://openweathermap.org/api 에서 무료 가입 후 발급
|
||||
OPENWEATHER_API_KEY=your_openweathermap_api_key_here
|
||||
|
||||
|
||||
# ==================== SMTP (견적서/발주서 등 메일 발송) ====================
|
||||
# wace Constants.Mail 1:1. 호스트는 ERP/SALES/PURCHASE 모두 동일.
|
||||
# 운영 발송 OFF: SMTP_SEND_SWITCH=N (mail_log INSERT만 수행, Transport.send 스킵)
|
||||
|
||||
SMTP_HOST=erp.rps-korea.com
|
||||
SMTP_PORT=25
|
||||
SMTP_TLS=false
|
||||
SMTP_SEND_SWITCH=Y
|
||||
|
||||
# 기본/ERP 계정
|
||||
SMTP_USER_ERP=erp@example.com
|
||||
SMTP_PW_ERP=your_erp_password
|
||||
|
||||
# 영업팀 (견적서 등) — accountType=SALES 사용 시
|
||||
SMTP_USER_SALES=sales@example.com
|
||||
SMTP_PW_SALES=your_sales_password
|
||||
|
||||
# 구매팀 (발주서 등) — accountType=PURCHASE 사용 시
|
||||
SMTP_USER_PURCHASE=purchase@example.com
|
||||
SMTP_PW_PURCHASE=your_purchase_password
|
||||
|
||||
|
||||
Generated
+37
@@ -38,6 +38,7 @@
|
||||
"node-pop3": "^0.11.0",
|
||||
"nodemailer": "^6.10.1",
|
||||
"oracledb": "^6.9.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pg": "^8.16.3",
|
||||
"quill": "^2.0.3",
|
||||
"react-quill": "^2.0.0",
|
||||
@@ -2363,6 +2364,24 @@
|
||||
"@noble/hashes": "^1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/standard-fonts": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/upng": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
|
||||
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
@@ -9686,6 +9705,24 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pdf-lib": {
|
||||
"version": "1.17.1",
|
||||
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
|
||||
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pdf-lib/standard-fonts": "^1.0.0",
|
||||
"@pdf-lib/upng": "^1.0.1",
|
||||
"pako": "^1.0.11",
|
||||
"tslib": "^1.11.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pdf-lib/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/peberminta": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"node-pop3": "^0.11.0",
|
||||
"nodemailer": "^6.10.1",
|
||||
"oracledb": "^6.9.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pg": "^8.16.3",
|
||||
"quill": "^2.0.3",
|
||||
"react-quill": "^2.0.0",
|
||||
|
||||
@@ -75,28 +75,59 @@ export async function remove(req: AuthenticatedRequest, res: Response) {
|
||||
export async function sendMail(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
const { contractObjid, toEmails, ccEmails, subject, contents, isSend } = req.body ?? {};
|
||||
const { contractObjid, toEmails, ccEmails, subject, contents, pdfBase64, useAddEstOnly } = req.body ?? {};
|
||||
if (!contractObjid || !toEmails || !subject) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수값 누락 (contractObjid, toEmails, subject)",
|
||||
});
|
||||
}
|
||||
const data = await salesEstimateService.sendMail(userId, {
|
||||
const result = await salesEstimateService.sendMail(userId, {
|
||||
contractObjid,
|
||||
toEmails,
|
||||
ccEmails,
|
||||
subject,
|
||||
contents: contents ?? "",
|
||||
isSend,
|
||||
pdfBase64,
|
||||
useAddEstOnly,
|
||||
});
|
||||
return res.json({ success: true, data, message: "메일 발송 이력이 등록되었습니다." });
|
||||
if (!result.success) {
|
||||
return res.status(500).json(result);
|
||||
}
|
||||
return res.json(result);
|
||||
} catch (error: any) {
|
||||
logger.error("견적 메일 발송 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/sales/estimate/:contractObjid/mail-info — 메일 다이얼로그 자동 채움용 (제목/수신/참조) */
|
||||
export async function getMailInfo(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const info = await salesEstimateService.getContractInfoForMail(id);
|
||||
if (!info) {
|
||||
return res.status(404).json({ success: false, message: "계약 정보를 찾을 수 없습니다." });
|
||||
}
|
||||
return res.json({ success: true, data: info });
|
||||
} catch (error: any) {
|
||||
logger.error("메일 자동채움 정보 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/sales/estimate/customer/:customerObjid/managers — 고객사 담당자 리스트 */
|
||||
export async function getMailManagers(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { customerObjid } = req.params;
|
||||
const managers = await salesEstimateService.getCustomerManagerList(customerObjid);
|
||||
return res.json({ success: true, data: managers });
|
||||
} catch (error: any) {
|
||||
logger.error("고객사 담당자 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// G5 견적작성 — estimate_template
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -7,7 +7,11 @@ router.use(authenticateToken);
|
||||
|
||||
router.get("/list", salesEstimateController.getList);
|
||||
router.get("/generate-number", salesEstimateController.generateNumber);
|
||||
|
||||
// G6 메일 발송 — /:id 라우트보다 위에 (mail-info/managers는 path param 충돌 방지)
|
||||
router.post("/mail", salesEstimateController.sendMail);
|
||||
router.get("/mail-info/:id", salesEstimateController.getMailInfo);
|
||||
router.get("/customer/:customerObjid/managers", salesEstimateController.getMailManagers);
|
||||
|
||||
// G5 견적작성 (estimate_template) — /:id 라우트보다 위에
|
||||
router.post("/template1", salesEstimateController.saveTemplate1);
|
||||
|
||||
@@ -4,8 +4,12 @@
|
||||
// 헤더 컨텍스트: contract_mgmt (영업번호 = contract_no)
|
||||
// ============================================================
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { sendMailUTF8 } from "../utils/mailUtil";
|
||||
import { generateContractNo } from "./salesOrderMgmtService";
|
||||
|
||||
// ─── 타입 ─────────────────────────────────────────────────────
|
||||
@@ -552,10 +556,13 @@ export async function update(userId: string, objid: string, body: EstimateBody)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 메일 발송 이력 등록 ──────────────────────────────────────
|
||||
// 실제 SMTP 발송은 mailSendSimpleService(별도 인프라) 연동 시 추가.
|
||||
// 1차에서는 mail_log INSERT만 수행 → 그리드 mail_send_status/date 컬럼 표시.
|
||||
// title에 [OBJID:nnn] 패턴을 포함시켜 LEFT JOIN의 SUBSTRING 매칭과 호환.
|
||||
// ─── 메일 발송 (G6) ───────────────────────────────────────────
|
||||
// wace ContractMgmtService.sendEstimateMailCustom (line 3582) 1:1.
|
||||
// - 본문(contents)는 다이얼로그 입력 그대로 사용 (HTML 견적 양식 본문 생성기는 미이식 — 양식은 PDF 첨부에 포함됨)
|
||||
// - 첨부: 기본 견적 PDF (프론트 hidden iframe에서 base64 생성 후 전달) + estimate02 N건을 pdf-lib로 합본
|
||||
// - useAddEstOnly='Y': 견적 PDF 생략, estimate02만 첨부 (수가 0이면 에러)
|
||||
// - mail_log title에 [OBJID:nnn] 토큰 부착 (그리드 LEFT JOIN의 LIKE 매칭과 호환)
|
||||
// - SMTP 계정: SALES (Constants.Mail.ACCOUNT_TYPE_SALES)
|
||||
|
||||
export interface EstimateMailBody {
|
||||
contractObjid: string;
|
||||
@@ -563,42 +570,269 @@ export interface EstimateMailBody {
|
||||
ccEmails?: string;
|
||||
subject: string;
|
||||
contents: string;
|
||||
isSend?: "Y" | "N"; // 실제 SMTP 발송 결과 (생략 시 Y로 기록 — UI 단계에서 결과 반영)
|
||||
/** 프론트에서 hidden iframe으로 template1/2 페이지 렌더 후 html2canvas+jsPDF로 생성한 base64 (data URL 또는 raw base64) */
|
||||
pdfBase64?: string;
|
||||
/** 'Y'면 견적 PDF 생성 스킵하고 estimate02만 첨부 */
|
||||
useAddEstOnly?: "Y" | "N";
|
||||
}
|
||||
|
||||
export async function sendMail(userId: string, body: EstimateMailBody) {
|
||||
// ─── 메일 발송 helper SQL ─────────────────────────────────────
|
||||
|
||||
/** wace getContractInfoForMail — 메일 다이얼로그 자동채움용 (제목/수신/참조) */
|
||||
export async function getContractInfoForMail(contractObjid: string) {
|
||||
const pool = getPool();
|
||||
const objid = genVarcharObjid("ML");
|
||||
const titleWithTag = body.subject.includes("[OBJID:")
|
||||
? body.subject
|
||||
: `${body.subject} [OBJID:${body.contractObjid}]`;
|
||||
const sql = `
|
||||
SELECT
|
||||
cm.objid AS contract_objid,
|
||||
cm.contract_no AS contract_no,
|
||||
cm.customer_objid AS customer_objid,
|
||||
cm.writer AS writer,
|
||||
c.id AS customer_pk,
|
||||
c.customer_name AS customer_name,
|
||||
c.email AS customer_email,
|
||||
uw.email AS writer_email,
|
||||
uw.user_name AS writer_name
|
||||
FROM contract_mgmt cm
|
||||
LEFT JOIN customer_mng c
|
||||
ON c.customer_code = CASE
|
||||
WHEN cm.customer_objid LIKE 'C_%' THEN substring(cm.customer_objid, 3)
|
||||
ELSE cm.customer_objid
|
||||
END
|
||||
LEFT JOIN user_info uw ON uw.user_id = cm.writer
|
||||
WHERE cm.objid = $1
|
||||
LIMIT 1`;
|
||||
const r = await pool.query(sql, [contractObjid]);
|
||||
return r.rows[0] ?? null;
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO mail_log (
|
||||
objid, system_name, send_user_id, from_addr,
|
||||
reception_user_id, receiver_to,
|
||||
title, contents, log_time, is_send, mail_type
|
||||
) VALUES (
|
||||
$1, 'vexplor_rps', $2, NULL,
|
||||
NULL, $3,
|
||||
$4, $5, NOW(), $6, 'CONTRACT_ESTIMATE'
|
||||
)`,
|
||||
[
|
||||
objid,
|
||||
userId,
|
||||
body.toEmails + (body.ccEmails ? `; cc: ${body.ccEmails}` : ""),
|
||||
titleWithTag,
|
||||
body.contents,
|
||||
body.isSend ?? "Y",
|
||||
],
|
||||
/** wace getCustomerManagerList — 메일 다이얼로그 담당자 체크박스 리스트용.
|
||||
* RPS는 wace manager1~5 슬롯 패턴이 아니라 customer_contact 테이블 N건 구조.
|
||||
*/
|
||||
export async function getCustomerManagerList(customerObjid: string) {
|
||||
const pool = getPool();
|
||||
// customer_objid (C_xxxx) → customer_mng.customer_code → customer_mng.id → customer_contact.customer_id
|
||||
const code = customerObjid.startsWith("C_") ? customerObjid.substring(2) : customerObjid;
|
||||
const sql = `
|
||||
SELECT
|
||||
cc.contact_name AS name,
|
||||
cc.contact_email AS email,
|
||||
cc.contact_phone AS phone,
|
||||
cc.department AS department,
|
||||
cc.is_main AS is_main
|
||||
FROM customer_contact cc
|
||||
JOIN customer_mng cm ON cm.id::text = cc.customer_id::text
|
||||
WHERE cm.customer_code = $1
|
||||
AND COALESCE(cc.contact_name, '') <> ''
|
||||
ORDER BY (CASE WHEN cc.is_main = 'Y' THEN 0 ELSE 1 END), cc.contact_name`;
|
||||
const r = await pool.query(sql, [code]);
|
||||
return r.rows;
|
||||
}
|
||||
|
||||
/** wace getLatestEstimateTemplate — 최신 차수 견적서 */
|
||||
export async function getLatestEstimateTemplate(contractObjid: string) {
|
||||
const pool = getPool();
|
||||
const r = await pool.query(
|
||||
`SELECT * FROM estimate_template
|
||||
WHERE contract_objid = $1
|
||||
ORDER BY regdate DESC
|
||||
LIMIT 1`,
|
||||
[contractObjid],
|
||||
);
|
||||
return r.rows[0] ?? null;
|
||||
}
|
||||
|
||||
logger.info("견적 메일 발송 이력 등록", {
|
||||
objid,
|
||||
contractObjid: body.contractObjid,
|
||||
to: body.toEmails,
|
||||
// ─── 메일 발송 본체 ───────────────────────────────────────────
|
||||
|
||||
export async function sendMail(userId: string, body: EstimateMailBody) {
|
||||
const useAddEstOnly = body.useAddEstOnly === "Y";
|
||||
|
||||
// 1. 계약 정보 조회 (수신/참조 fallback + 자동 cc: writer)
|
||||
const contractInfo = await getContractInfoForMail(body.contractObjid);
|
||||
if (!contractInfo) {
|
||||
return { success: false, message: "계약 정보를 찾을 수 없습니다." };
|
||||
}
|
||||
|
||||
// 2. 최신 차수 견적서 (useAddEstOnly='Y'면 생략 가능)
|
||||
const estimateTemplate = useAddEstOnly ? null : await getLatestEstimateTemplate(body.contractObjid);
|
||||
if (!useAddEstOnly && !estimateTemplate) {
|
||||
return { success: false, message: "견적서를 찾을 수 없습니다." };
|
||||
}
|
||||
|
||||
// 3. 수신/참조 정리 (쉼표·세미콜론 모두 split)
|
||||
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);
|
||||
// 작성자 이메일 자동 추가 (wace sendEstimateMailCustom과 동일)
|
||||
if (contractInfo.writer_email && !ccEmails.includes(contractInfo.writer_email)) {
|
||||
ccEmails.push(contractInfo.writer_email);
|
||||
}
|
||||
|
||||
// 4. 첨부 PDF 준비
|
||||
const attachments: { filename: string; content: Buffer; contentType: string }[] = [];
|
||||
|
||||
// 4-1. estimate02 (추가견적) 파일 목록 조회
|
||||
const addEstFiles = await fetchEstimate02Files(body.contractObjid);
|
||||
|
||||
if (useAddEstOnly) {
|
||||
// 추가견적만 발송 — 견적 PDF 없이 estimate02 파일들을 그대로 첨부
|
||||
if (addEstFiles.length === 0) {
|
||||
return { success: false, message: "발송할 추가견적 PDF가 없습니다." };
|
||||
}
|
||||
for (const f of addEstFiles) {
|
||||
const buf = readUploadFileBuffer(f.file_path, f.saved_file_name);
|
||||
if (buf) {
|
||||
attachments.push({
|
||||
filename: f.real_file_name || f.saved_file_name,
|
||||
content: buf,
|
||||
contentType: "application/pdf",
|
||||
});
|
||||
return { objid };
|
||||
}
|
||||
}
|
||||
if (attachments.length === 0) {
|
||||
return { success: false, message: "추가견적 PDF 파일을 디스크에서 찾을 수 없습니다." };
|
||||
}
|
||||
} else {
|
||||
// 견적 PDF (프론트 base64) + estimate02 합본
|
||||
if (!body.pdfBase64) {
|
||||
return { success: false, message: "견적 PDF가 전달되지 않았습니다." };
|
||||
}
|
||||
const basePdfBuf = decodeBase64Pdf(body.pdfBase64);
|
||||
|
||||
// estimate02 파일들을 같이 받아 PDF 합본
|
||||
const addEstBufs: Buffer[] = [];
|
||||
for (const f of addEstFiles) {
|
||||
const buf = readUploadFileBuffer(f.file_path, f.saved_file_name);
|
||||
if (buf) addEstBufs.push(buf);
|
||||
}
|
||||
|
||||
let finalPdf: Buffer;
|
||||
if (addEstBufs.length > 0) {
|
||||
try {
|
||||
finalPdf = await mergePdfBuffers([basePdfBuf, ...addEstBufs]);
|
||||
logger.info("PDF 합본 완료", { contractObjid: body.contractObjid, addEstCount: addEstBufs.length });
|
||||
} catch (mergeErr) {
|
||||
logger.warn("PDF 합본 실패 — 견적서만 첨부", { error: (mergeErr as Error).message });
|
||||
finalPdf = basePdfBuf;
|
||||
}
|
||||
} else {
|
||||
finalPdf = basePdfBuf;
|
||||
}
|
||||
|
||||
const estimateNo = (estimateTemplate?.estimate_no || "견적서").toString().replace(/[^\w가-힣\-_.]/g, "_");
|
||||
const ts = new Date().toISOString().replace(/[-:.TZ]/g, "").substring(0, 14);
|
||||
attachments.push({
|
||||
filename: `${estimateNo}_${ts}.pdf`,
|
||||
content: finalPdf,
|
||||
contentType: "application/pdf",
|
||||
});
|
||||
}
|
||||
|
||||
// 5. mail_log title에 [OBJID:nnn] 토큰 부착 (그리드 LEFT JOIN과 호환)
|
||||
const subject = body.subject.trim();
|
||||
const subjectForLog = subject.includes("[OBJID:")
|
||||
? subject
|
||||
: `${subject} [OBJID:${body.contractObjid}]`;
|
||||
|
||||
// 6. HTML 본문 (다이얼로그 plain text → <br> 변환)
|
||||
const html = textToHtml(body.contents);
|
||||
|
||||
// 7. SMTP 발송 (SALES 계정)
|
||||
const result = await sendMailUTF8({
|
||||
accountType: "SALES",
|
||||
fromUserId: userId,
|
||||
toEmails,
|
||||
ccEmails: ccEmails.length > 0 ? ccEmails : undefined,
|
||||
subject,
|
||||
subjectForLog,
|
||||
html,
|
||||
attachments,
|
||||
mailType: "CONTRACT_ESTIMATE",
|
||||
});
|
||||
|
||||
logger.info("견적 메일 발송 요청 완료", {
|
||||
contractObjid: body.contractObjid,
|
||||
mailLogObjid: result.objid,
|
||||
sent: result.sent,
|
||||
to: toEmails,
|
||||
cc: ccEmails,
|
||||
attachmentCount: attachments.length,
|
||||
});
|
||||
|
||||
if (!result.sent) {
|
||||
return {
|
||||
success: false,
|
||||
message: result.error || "메일 발송에 실패했습니다.",
|
||||
objid: result.objid,
|
||||
};
|
||||
}
|
||||
return { success: true, message: "견적서가 성공적으로 발송되었습니다.", objid: result.objid };
|
||||
}
|
||||
|
||||
// ─── 메일 발송 내부 helper ────────────────────────────────────
|
||||
|
||||
async function fetchEstimate02Files(contractObjid: string) {
|
||||
const pool = getPool();
|
||||
const r = await pool.query(
|
||||
`SELECT objid, real_file_name, saved_file_name, file_path
|
||||
FROM attach_file_info
|
||||
WHERE target_objid = $1
|
||||
AND doc_type = 'estimate02'
|
||||
AND UPPER(status) = 'ACTIVE'
|
||||
ORDER BY regdate ASC`,
|
||||
[contractObjid],
|
||||
);
|
||||
return r.rows;
|
||||
}
|
||||
|
||||
function decodeBase64Pdf(input: string): Buffer {
|
||||
// data URL prefix(`data:application/pdf;base64,...`) 또는 raw base64 둘 다 수용
|
||||
const comma = input.indexOf(",");
|
||||
const raw = input.startsWith("data:") && comma !== -1 ? input.substring(comma + 1) : input;
|
||||
return Buffer.from(raw, "base64");
|
||||
}
|
||||
|
||||
function readUploadFileBuffer(filePath: string | null, savedFileName: string | null): Buffer | null {
|
||||
if (!filePath || !savedFileName) return null;
|
||||
// attach_file_info.file_path는 보통 `/uploads/.../...` 형식.
|
||||
// fileController의 해석 패턴과 동일하게 `/uploads/` 이후를 cwd/uploads와 결합.
|
||||
const idx = filePath.indexOf("/uploads/");
|
||||
const relative = idx !== -1 ? filePath.substring(idx + "/uploads/".length) : filePath;
|
||||
const baseUpload = path.join(process.cwd(), "uploads");
|
||||
const fullPath = path.join(baseUpload, relative, savedFileName);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
logger.warn("첨부 파일 없음 — 스킵", { fullPath });
|
||||
return null;
|
||||
}
|
||||
return fs.readFileSync(fullPath);
|
||||
}
|
||||
|
||||
async function mergePdfBuffers(buffers: Buffer[]): Promise<Buffer> {
|
||||
const merged = await PDFDocument.create();
|
||||
for (const buf of buffers) {
|
||||
const src = await PDFDocument.load(buf, { ignoreEncryption: true });
|
||||
const pages = await merged.copyPages(src, src.getPageIndices());
|
||||
for (const p of pages) merged.addPage(p);
|
||||
}
|
||||
const bytes = await merged.save();
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
function textToHtml(text: string): string {
|
||||
// wace는 textarea contents를 그대로 HTML body로 사용 (\n은 줄바꿈으로 보이게 <br> 변환)
|
||||
const escaped = text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
return `<html><body><div style="font-family:'Malgun Gothic','맑은 고딕',Arial,sans-serif;font-size:13px;line-height:1.6;white-space:pre-wrap;">${escaped.replace(/\n/g, "<br>")}</div></body></html>`;
|
||||
}
|
||||
|
||||
// ─── 삭제 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
// ============================================================
|
||||
// SMTP 발송 유틸 — wace MailUtil.sendMailWithAttachFileUTF8 1:1
|
||||
// ([MailUtil.java:810-1034](../../../../wace_plm/src/com/pms/common/utils/MailUtil.java))
|
||||
//
|
||||
// 계정 타입(ERP/SALES/PURCHASE)별 SMTP 분기 — wace Constants.Mail 동일.
|
||||
// 발송 흐름: mail_log INSERT(is_send='N') → nodemailer 발송
|
||||
// → 성공: UPDATE is_send='Y', log_time=NOW()
|
||||
// → 실패: UPDATE is_send='N', error_log
|
||||
// ============================================================
|
||||
|
||||
import nodemailer from "nodemailer";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "./logger";
|
||||
|
||||
export type SmtpAccountType = "ERP" | "SALES" | "PURCHASE";
|
||||
|
||||
export interface MailAttachment {
|
||||
filename: string; // 표시될 파일명 (UTF-8 가능)
|
||||
path?: string; // 디스크 경로 (path 또는 content 둘 중 하나)
|
||||
content?: Buffer; // 메모리 바이너리
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
export interface SendMailUTF8Params {
|
||||
accountType?: SmtpAccountType; // 기본 ERP
|
||||
fromUserId: string; // mail_log.send_user_id
|
||||
toEmails: string[]; // 수신
|
||||
ccEmails?: string[]; // 참조
|
||||
bccEmails?: string[]; // 숨은참조
|
||||
toUserIds?: string[]; // mail_log.reception_user_id (참고용)
|
||||
subject: string; // 실제 메일 제목
|
||||
subjectForLog?: string; // mail_log.title (기본: subject 그대로). 그리드 LEFT JOIN용 [OBJID:nnn] 토큰은 호출 측에서 부착.
|
||||
html: string; // 본문 HTML (text/plain은 \n → <br>로 직접 변환)
|
||||
attachments?: MailAttachment[];
|
||||
mailType: string; // mail_log.mail_type
|
||||
important?: "High" | "Normal" | "Low";
|
||||
}
|
||||
|
||||
interface SmtpConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
pass: string;
|
||||
secure: boolean;
|
||||
}
|
||||
|
||||
function readSmtpConfig(accountType: SmtpAccountType): SmtpConfig {
|
||||
const host = process.env.SMTP_HOST || "";
|
||||
const port = parseInt(process.env.SMTP_PORT || "25", 10);
|
||||
const secure = (process.env.SMTP_TLS || "false").toLowerCase() === "true";
|
||||
|
||||
let user: string;
|
||||
let pass: string;
|
||||
switch (accountType) {
|
||||
case "SALES":
|
||||
user = process.env.SMTP_USER_SALES || "";
|
||||
pass = process.env.SMTP_PW_SALES || "";
|
||||
break;
|
||||
case "PURCHASE":
|
||||
user = process.env.SMTP_USER_PURCHASE || "";
|
||||
pass = process.env.SMTP_PW_PURCHASE || "";
|
||||
break;
|
||||
case "ERP":
|
||||
default:
|
||||
user = process.env.SMTP_USER_ERP || "";
|
||||
pass = process.env.SMTP_PW_ERP || "";
|
||||
break;
|
||||
}
|
||||
|
||||
return { host, port, user, pass, secure };
|
||||
}
|
||||
|
||||
function genMailLogObjid(): string {
|
||||
// mail_log.objid (VARCHAR). wace는 CommonUtils.createObjId() 사용 — 우리는 ML-{ms}-{rand} 패턴 통일.
|
||||
const ms = Date.now();
|
||||
const rand = Math.floor(Math.random() * 1000).toString().padStart(3, "0");
|
||||
return `ML-${ms}-${rand}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* UTF-8 HTML 본문 + 첨부파일 메일 발송.
|
||||
* wace MailUtil.sendMailWithAttachFileUTF8 1:1.
|
||||
*
|
||||
* @returns { objid: mail_log.objid, sent: 실제 발송 성공 여부 }
|
||||
* SMTP_SEND_SWITCH !== 'Y'면 INSERT만 하고 sent=false 리턴 (wace sendMailSwitch와 동일).
|
||||
*/
|
||||
export async function sendMailUTF8(params: SendMailUTF8Params): Promise<{ objid: string; sent: boolean; error?: string }> {
|
||||
const accountType: SmtpAccountType = params.accountType || "ERP";
|
||||
const cfg = readSmtpConfig(accountType);
|
||||
const sendSwitch = (process.env.SMTP_SEND_SWITCH || "Y").toUpperCase() === "Y";
|
||||
|
||||
const subjectForLog = params.subjectForLog && params.subjectForLog.trim() !== ""
|
||||
? params.subjectForLog
|
||||
: params.subject;
|
||||
|
||||
// ─── 1. mail_log INSERT (is_send 기본 NULL/N, 발송 후 갱신) ───
|
||||
const pool = getPool();
|
||||
const objid = genMailLogObjid();
|
||||
const receiverTo = params.toEmails.join(", ") + (params.ccEmails && params.ccEmails.length > 0 ? `; cc: ${params.ccEmails.join(", ")}` : "");
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO mail_log (
|
||||
objid, system_name, send_user_id, from_addr,
|
||||
reception_user_id, receiver_to,
|
||||
title, contents, log_time, is_send, mail_type
|
||||
) VALUES (
|
||||
$1, 'vexplor_rps', $2, $3,
|
||||
$4, $5,
|
||||
$6, $7, NOW(), 'N', $8
|
||||
)`,
|
||||
[
|
||||
objid,
|
||||
params.fromUserId,
|
||||
cfg.user || null,
|
||||
params.toUserIds && params.toUserIds.length > 0 ? params.toUserIds.join(", ") : null,
|
||||
receiverTo,
|
||||
subjectForLog,
|
||||
params.html,
|
||||
params.mailType,
|
||||
],
|
||||
);
|
||||
} catch (logErr) {
|
||||
logger.error("mail_log INSERT 실패", { objid, error: (logErr as Error).message });
|
||||
throw logErr;
|
||||
}
|
||||
|
||||
// ─── 2. 발송 스킵 모드: wace Constants.Mail.sendMailSwitch == false ───
|
||||
if (!sendSwitch) {
|
||||
logger.warn("SMTP_SEND_SWITCH !== 'Y' — 실제 발송 스킵, mail_log만 기록", { objid });
|
||||
return { objid, sent: false };
|
||||
}
|
||||
|
||||
// ─── 3. 실제 SMTP 발송 ───
|
||||
try {
|
||||
if (!cfg.host || !cfg.user) {
|
||||
throw new Error(`SMTP 설정 누락 (accountType=${accountType}, host=${cfg.host}, user=${cfg.user})`);
|
||||
}
|
||||
if (params.toEmails.length === 0) {
|
||||
throw new Error("수신인 이메일이 없습니다.");
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: cfg.host,
|
||||
port: cfg.port,
|
||||
secure: cfg.secure,
|
||||
auth: { user: cfg.user, pass: cfg.pass },
|
||||
// wace JavaMail: mail.smtp.starttls.enable=false, mail.smtp.ssl.enable=false, plain auth on 25
|
||||
tls: { rejectUnauthorized: false },
|
||||
logger: false,
|
||||
debug: false,
|
||||
});
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: cfg.user,
|
||||
to: params.toEmails.join(", "),
|
||||
cc: params.ccEmails && params.ccEmails.length > 0 ? params.ccEmails.join(", ") : undefined,
|
||||
bcc: params.bccEmails && params.bccEmails.length > 0 ? params.bccEmails.join(", ") : undefined,
|
||||
subject: params.subject,
|
||||
html: params.html,
|
||||
priority: params.important === "High" ? "high" : params.important === "Low" ? "low" : "normal",
|
||||
attachments: params.attachments?.map((a) => ({
|
||||
filename: a.filename,
|
||||
path: a.path,
|
||||
content: a.content,
|
||||
contentType: a.contentType,
|
||||
})),
|
||||
});
|
||||
|
||||
logger.info("메일 발송 성공", { objid, messageId: info.messageId, accountType, to: params.toEmails });
|
||||
|
||||
// mail_log 갱신 — 성공
|
||||
await pool.query(
|
||||
`UPDATE mail_log SET is_send='Y', log_time=NOW() WHERE objid=$1`,
|
||||
[objid],
|
||||
);
|
||||
|
||||
return { objid, sent: true };
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
logger.error("메일 발송 실패", { objid, accountType, error: msg });
|
||||
|
||||
// mail_log 갱신 — 실패. 컬럼이 없으면 best-effort.
|
||||
try {
|
||||
await pool.query(
|
||||
`UPDATE mail_log SET is_send='N', error_log=$2 WHERE objid=$1`,
|
||||
[objid, msg],
|
||||
);
|
||||
} catch (updErr) {
|
||||
// error_log 컬럼이 없는 환경에서는 silently skip
|
||||
logger.warn("mail_log 실패 상태 기록 스킵 (error_log 컬럼 없을 수 있음)", { objid });
|
||||
}
|
||||
|
||||
return { objid, sent: false, error: msg };
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,17 @@ services:
|
||||
DEFAULT_COMPANY_CODE: ${COMPANY_CODE:-SPIFOX}
|
||||
# 로깅
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
# SMTP (견적서/발주서 등 메일 발송) — 호스트 .env에서 실값 주입
|
||||
SMTP_HOST: ${SMTP_HOST:-erp.rps-korea.com}
|
||||
SMTP_PORT: ${SMTP_PORT:-25}
|
||||
SMTP_TLS: ${SMTP_TLS:-false}
|
||||
SMTP_SEND_SWITCH: ${SMTP_SEND_SWITCH:-Y}
|
||||
SMTP_USER_ERP: ${SMTP_USER_ERP:-}
|
||||
SMTP_PW_ERP: ${SMTP_PW_ERP:-}
|
||||
SMTP_USER_SALES: ${SMTP_USER_SALES:-}
|
||||
SMTP_PW_SALES: ${SMTP_PW_SALES:-}
|
||||
SMTP_USER_PURCHASE: ${SMTP_USER_PURCHASE:-}
|
||||
SMTP_PW_PURCHASE: ${SMTP_PW_PURCHASE:-}
|
||||
volumes:
|
||||
- backend_uploads:/app/uploads
|
||||
- backend_data:/app/data
|
||||
|
||||
@@ -24,6 +24,17 @@ services:
|
||||
EXPRESSWAY_API_KEY: ${EXPRESSWAY_API_KEY:-}
|
||||
SMART_FACTORY_API_KEY_COMPANY_10: ${SMART_FACTORY_API_KEY_COMPANY_10:-}
|
||||
SMART_FACTORY_API_KEY_COMPANY_9: ${SMART_FACTORY_API_KEY_COMPANY_9:-}
|
||||
# SMTP (견적서/발주서 등 메일 발송)
|
||||
SMTP_HOST: ${SMTP_HOST:-erp.rps-korea.com}
|
||||
SMTP_PORT: ${SMTP_PORT:-25}
|
||||
SMTP_TLS: ${SMTP_TLS:-false}
|
||||
SMTP_SEND_SWITCH: ${SMTP_SEND_SWITCH:-Y}
|
||||
SMTP_USER_ERP: ${SMTP_USER_ERP:-}
|
||||
SMTP_PW_ERP: ${SMTP_PW_ERP:-}
|
||||
SMTP_USER_SALES: ${SMTP_USER_SALES:-}
|
||||
SMTP_PW_SALES: ${SMTP_PW_SALES:-}
|
||||
SMTP_USER_PURCHASE: ${SMTP_USER_PURCHASE:-}
|
||||
SMTP_PW_PURCHASE: ${SMTP_PW_PURCHASE:-}
|
||||
volumes:
|
||||
- backend_uploads:/app/uploads
|
||||
- backend_data:/app/data
|
||||
|
||||
@@ -28,6 +28,17 @@ services:
|
||||
- SMART_FACTORY_API_KEY_COMPANY_9=${SMART_FACTORY_API_KEY_COMPANY_9:-}
|
||||
- SMART_FACTORY_API_KEY_COMPANY_10=${SMART_FACTORY_API_KEY_COMPANY_10:-}
|
||||
- SMART_FACTORY_API_KEY_COMPANY_16=${SMART_FACTORY_API_KEY_COMPANY_16:-}
|
||||
# SMTP (견적서/발주서 등 메일 발송)
|
||||
- SMTP_HOST=${SMTP_HOST:-erp.rps-korea.com}
|
||||
- SMTP_PORT=${SMTP_PORT:-25}
|
||||
- SMTP_TLS=${SMTP_TLS:-false}
|
||||
- SMTP_SEND_SWITCH=${SMTP_SEND_SWITCH:-Y}
|
||||
- SMTP_USER_ERP=${SMTP_USER_ERP:-}
|
||||
- SMTP_PW_ERP=${SMTP_PW_ERP:-}
|
||||
- SMTP_USER_SALES=${SMTP_USER_SALES:-}
|
||||
- SMTP_PW_SALES=${SMTP_PW_SALES:-}
|
||||
- SMTP_USER_PURCHASE=${SMTP_USER_PURCHASE:-}
|
||||
- SMTP_PW_PURCHASE=${SMTP_PW_PURCHASE:-}
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3001/health"]
|
||||
|
||||
@@ -21,6 +21,7 @@ import { PartSelect } from "@/components/common/PartSelect";
|
||||
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
|
||||
import { ItemSearchDialog, ItemRow } from "@/components/common/ItemSearchDialog";
|
||||
import { AttachmentDialog } from "@/components/common/AttachmentDialog";
|
||||
import { EstimateMailDialog } from "@/components/sales/EstimateMailDialog";
|
||||
import { salesEstimateApi, EstimateRow, EstimateBody, EstimateItem } from "@/lib/api/salesEstimate";
|
||||
|
||||
// ─── 컬럼 ─────────────────────────────────────────────────────
|
||||
@@ -131,15 +132,8 @@ export default function SalesEstimatePage() {
|
||||
const [seqStartNo, setSeqStartNo] = useState("");
|
||||
const [seqCount, setSeqCount] = useState("");
|
||||
|
||||
// 메일 발송 다이얼로그
|
||||
// 메일 발송 다이얼로그 — wace estimateMailFormPopup.jsp 1:1
|
||||
const [mailDialogOpen, setMailDialogOpen] = useState(false);
|
||||
const [mailSending, setMailSending] = useState(false);
|
||||
const [mailForm, setMailForm] = useState({
|
||||
toEmails: "",
|
||||
ccEmails: "",
|
||||
subject: "",
|
||||
contents: "",
|
||||
});
|
||||
|
||||
// 첨부파일 다이얼로그 (추가견적 클립 컬럼 클릭 시)
|
||||
// G5 견적작성 — 일반/장비 선택 다이얼로그
|
||||
@@ -360,55 +354,19 @@ export default function SalesEstimatePage() {
|
||||
};
|
||||
|
||||
// ─── 메일 발송 ──────────────────────────────────────────────
|
||||
// 1차에서는 mail_log INSERT만 (이력만 등록 → 그리드 메일발송 컬럼 표시).
|
||||
// 실제 SMTP 발송은 mailSendSimple 인프라 통합 시 추가 예정.
|
||||
// 실제 SMTP는 backend-node SMTP_SEND_SWITCH='Y'일 때 sales 계정(sales@rps-korea.com)으로 발송.
|
||||
// PDF 첨부: 견적관리 → "메일발송" 클릭 → EstimateMailDialog가
|
||||
// 최신 차수 template1/template2 페이지를 hidden iframe에 렌더 →
|
||||
// fn_generateAndUploadPdf로 base64 추출 → backend가 estimate02 N건과 합본 첨부.
|
||||
|
||||
const openMailDialog = () => {
|
||||
if (!selected) {
|
||||
toast.warning("메일 발송할 견적을 선택하세요.");
|
||||
return;
|
||||
}
|
||||
const customer = selected.customer_name ?? "";
|
||||
const estNo = selected.estimate_no ?? selected.contract_no ?? "";
|
||||
setMailForm({
|
||||
toEmails: "",
|
||||
ccEmails: "",
|
||||
subject: `[${customer}] 견적서 발송 - ${estNo}`,
|
||||
contents: `안녕하세요, ${customer} 담당자님.\n\n요청하신 견적서(${estNo})를 발송드립니다.\n검토 후 회신 부탁드립니다.\n\n감사합니다.`,
|
||||
});
|
||||
setMailDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSendMail = async () => {
|
||||
if (!selected) return;
|
||||
if (!mailForm.toEmails.trim()) {
|
||||
toast.error("받는 사람을 입력하세요.");
|
||||
return;
|
||||
}
|
||||
if (!mailForm.subject.trim()) {
|
||||
toast.error("제목을 입력하세요.");
|
||||
return;
|
||||
}
|
||||
setMailSending(true);
|
||||
try {
|
||||
await salesEstimateApi.sendMail({
|
||||
contractObjid: selected.objid,
|
||||
toEmails: mailForm.toEmails.trim(),
|
||||
ccEmails: mailForm.ccEmails.trim() || undefined,
|
||||
subject: mailForm.subject,
|
||||
contents: mailForm.contents,
|
||||
isSend: "Y",
|
||||
});
|
||||
toast.success("메일 발송 이력이 등록되었습니다.");
|
||||
setMailDialogOpen(false);
|
||||
await fetchList();
|
||||
} catch (err: any) {
|
||||
toast.error(`메일 발송 실패: ${err?.response?.data?.message ?? err.message}`);
|
||||
} finally {
|
||||
setMailSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 라인 편집 ──────────────────────────────────────────────
|
||||
|
||||
const updateItem = (idx: number, key: keyof EstimateItem, val: any) => {
|
||||
@@ -824,54 +782,15 @@ export default function SalesEstimatePage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 메일 발송 Dialog */}
|
||||
<Dialog open={mailDialogOpen} onOpenChange={setMailDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>견적 메일 발송</DialogTitle>
|
||||
<DialogDescription>
|
||||
발송 이력이 mail_log에 기록되어 그리드 메일발송 컬럼에 표시됩니다.
|
||||
{selected && <> · 영업번호: <strong>{selected.contract_no ?? selected.objid}</strong></>}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">받는 사람 (To) *</Label>
|
||||
<Input value={mailForm.toEmails}
|
||||
onChange={(e) => setMailForm({ ...mailForm, toEmails: e.target.value })}
|
||||
placeholder="example@company.com (여러 명은 ; 또는 , 로 구분)" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">참조 (CC)</Label>
|
||||
<Input value={mailForm.ccEmails}
|
||||
onChange={(e) => setMailForm({ ...mailForm, ccEmails: e.target.value })}
|
||||
placeholder="(선택)" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">제목 *</Label>
|
||||
<Input value={mailForm.subject}
|
||||
onChange={(e) => setMailForm({ ...mailForm, subject: e.target.value })} />
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
저장 시 [OBJID:{selected?.objid ?? ""}] 태그가 자동으로 추가됩니다.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">본문</Label>
|
||||
<Textarea rows={8} value={mailForm.contents}
|
||||
onChange={(e) => setMailForm({ ...mailForm, contents: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setMailDialogOpen(false)} disabled={mailSending}>취소</Button>
|
||||
<Button onClick={handleSendMail} disabled={mailSending}>
|
||||
{mailSending ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Save className="w-4 h-4 mr-1" />}
|
||||
발송 (이력 등록)
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* 메일 발송 Dialog — wace estimateMailFormPopup.jsp 1:1 */}
|
||||
<EstimateMailDialog
|
||||
open={mailDialogOpen}
|
||||
onOpenChange={setMailDialogOpen}
|
||||
contractObjid={selected?.objid ?? null}
|
||||
addEstCount={Number((selected as any)?.add_est_cnt ?? 0)}
|
||||
estStatusCount={Number(selected?.est_status ?? 0)}
|
||||
onSent={() => { fetchList(); }}
|
||||
/>
|
||||
|
||||
{/* 첨부파일 다이얼로그 — 추가견적 등 클립 컬럼 클릭 시 */}
|
||||
{attachContext && (
|
||||
|
||||
+60
-7
@@ -9,7 +9,7 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useParams, useSearchParams, useRouter } from "next/navigation";
|
||||
import { salesEstimateApi, EstimateTemplateItemRow } from "@/lib/api/salesEstimate";
|
||||
import { CustomerSelect } from "@/components/common/CustomerSelect";
|
||||
import { CustomerSelect, fetchCustomers } from "@/components/common/CustomerSelect";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
// ─── 포맷 헬퍼 (wace addComma / getCurrencySymbol 1:1) ────────
|
||||
@@ -224,6 +224,10 @@ export default function EstimateTemplate1Page() {
|
||||
|
||||
// 4) 로그인 사용자 정보 — 담당자 이름/연락처 자동 채움 (기존 값 없을 때만)
|
||||
// (백엔드에 별도 user 조회 API가 있으면 사용; 일단 비워두고 수동 입력 허용)
|
||||
|
||||
// 5) 수신처 CustomerSelect 옵션 미리 로드 — PDF 캡처 시 selectedLabel이 비어있는 현상 방지
|
||||
// (hidden iframe 메일 발송 흐름에서 dataLoaded 트리거 시점에 옵션 fetch가 안 끝나면 빈 셀렉트로 잡힘)
|
||||
try { await fetchCustomers(); } catch {}
|
||||
} finally {
|
||||
if (!cancel) setLoading(false);
|
||||
}
|
||||
@@ -296,16 +300,14 @@ export default function EstimateTemplate1Page() {
|
||||
window.print();
|
||||
}
|
||||
|
||||
// PDF 다운로드 — wace fn_generatePdf 1:1 (클라이언트 html2canvas + jsPDF)
|
||||
async function handleDownloadPdf() {
|
||||
if (!confirm("PDF로 다운로드 하시겠습니까?")) return;
|
||||
try {
|
||||
// html2canvas-pro: oklab/oklch 등 모던 CSS color function 지원 (Tailwind 4 호환)
|
||||
// ─── 견적서 PDF 생성 (공용) — wace fn_generatePdf 1:1 ─────────────
|
||||
// 다운로드/메일발송 모두 같은 캡처 로직 사용. 반환값은 jsPDF 인스턴스.
|
||||
async function generatePdfDocument(): Promise<any> {
|
||||
const html2canvas = (await import("html2canvas-pro")).default;
|
||||
const { jsPDF } = await import("jspdf");
|
||||
|
||||
const container = document.querySelector(".estimate-container") as HTMLElement | null;
|
||||
if (!container) return;
|
||||
if (!container) throw new Error("견적서 컨테이너(.estimate-container)를 찾을 수 없습니다.");
|
||||
|
||||
const canvas = await html2canvas(container, {
|
||||
scale: 2,
|
||||
@@ -327,6 +329,22 @@ export default function EstimateTemplate1Page() {
|
||||
const opt = el.options[el.selectedIndex];
|
||||
replaceWithText(el as unknown as HTMLElement, opt?.text || "");
|
||||
});
|
||||
// SmartSelect/CustomerSelect (role="combobox") → 선택된 라벨만 텍스트로
|
||||
// (button 안 span.truncate가 selectedLabel — 옵션 fetch 미완 등 엣지케이스에서 텍스트가 비어 캡처 누락되는 현상 방지)
|
||||
doc.querySelectorAll<HTMLElement>('[role="combobox"]').forEach((el) => {
|
||||
const labelSpan = el.querySelector(".truncate");
|
||||
// 실제 렌더 텍스트는 라이브 DOM에서 가져오기 (clone된 doc에서는 빈 경우 있음)
|
||||
const liveBtn = document.querySelectorAll<HTMLElement>('[role="combobox"]');
|
||||
let text = (labelSpan?.textContent ?? "").trim();
|
||||
if (text === "" || /^거래처\s*선택$|^고객사\s*선택$|^선택$/i.test(text)) {
|
||||
// placeholder만 잡혔으면 빈 문자열로
|
||||
const idx = Array.from(doc.querySelectorAll('[role="combobox"]')).indexOf(el);
|
||||
const live = liveBtn[idx];
|
||||
const liveLabel = live?.querySelector(".truncate")?.textContent?.trim() ?? "";
|
||||
text = /^거래처\s*선택$|^고객사\s*선택$|^선택$/i.test(liveLabel) ? "" : liveLabel;
|
||||
}
|
||||
replaceWithText(el, text);
|
||||
});
|
||||
// 인쇄 비대상 요소 숨김
|
||||
doc.querySelectorAll<HTMLElement>(".no-print, .btn-area, .delete-btn-cell button").forEach(el => { el.style.display = "none"; });
|
||||
},
|
||||
@@ -347,7 +365,14 @@ export default function EstimateTemplate1Page() {
|
||||
pdf.addImage(imgData, "JPEG", 0, position, imgWidth, imgHeight, undefined, "FAST");
|
||||
heightLeft -= pageHeight;
|
||||
}
|
||||
return pdf;
|
||||
}
|
||||
|
||||
// PDF 다운로드 — wace fn_generatePdf 1:1
|
||||
async function handleDownloadPdf() {
|
||||
if (!confirm("PDF로 다운로드 하시겠습니까?")) return;
|
||||
try {
|
||||
const pdf = await generatePdfDocument();
|
||||
const fileName = (estimateNo || "견적서") + ".pdf";
|
||||
pdf.save(fileName);
|
||||
} catch (e: any) {
|
||||
@@ -356,6 +381,34 @@ export default function EstimateTemplate1Page() {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 부모 창의 메일 다이얼로그용 글로벌 노출 ──────────────────────────
|
||||
// wace estimateMailFormPopup.jsp가 hidden iframe으로 이 페이지를 열고
|
||||
// iframe.contentWindow.fn_generateAndUploadPdf(cb)를 호출한다.
|
||||
// 데이터 로딩 완료 후 dataLoaded=true 세팅 → 부모가 폴링으로 감지.
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (typeof window === "undefined") return;
|
||||
const w = window as any;
|
||||
w.dataLoaded = true;
|
||||
w.fn_generateAndUploadPdf = async (callback: (pdfBase64: string) => void) => {
|
||||
try {
|
||||
const pdf = await generatePdfDocument();
|
||||
// jsPDF.output('datauristring') → data:application/pdf;base64,...
|
||||
const dataUri: string = pdf.output("datauristring");
|
||||
callback(dataUri);
|
||||
} catch (e) {
|
||||
console.error("fn_generateAndUploadPdf error", e);
|
||||
callback("");
|
||||
}
|
||||
};
|
||||
return () => {
|
||||
try {
|
||||
w.dataLoaded = false;
|
||||
delete w.fn_generateAndUploadPdf;
|
||||
} catch {}
|
||||
};
|
||||
}, [loading]);
|
||||
|
||||
function handleClose() {
|
||||
// 새 탭으로 열린 경우 닫기 시도 + 안되면 router back
|
||||
if (window.opener) {
|
||||
|
||||
+81
-1
@@ -10,7 +10,7 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useParams, useSearchParams, useRouter } from "next/navigation";
|
||||
import { salesEstimateApi } from "@/lib/api/salesEstimate";
|
||||
import { CustomerSelect } from "@/components/common/CustomerSelect";
|
||||
import { CustomerSelect, fetchCustomers } from "@/components/common/CustomerSelect";
|
||||
|
||||
// ─── 포맷 헬퍼 ────────────────────────────────────────────────
|
||||
function addComma(num: number | string): string {
|
||||
@@ -276,6 +276,8 @@ export default function EstimateTemplate2Page() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 수신처 CustomerSelect 옵션 미리 로드 — PDF 캡처 selectedLabel 누락 방지
|
||||
try { await fetchCustomers(); } catch {}
|
||||
} finally {
|
||||
if (!cancel) setLoading(false);
|
||||
}
|
||||
@@ -359,6 +361,84 @@ export default function EstimateTemplate2Page() {
|
||||
else router.back();
|
||||
}
|
||||
|
||||
// ─── 메일 다이얼로그용 PDF 생성 노출 (template1과 동일 패턴) ─────────────
|
||||
// wace estimateMailFormPopup.jsp가 hidden iframe으로 이 페이지를 열고
|
||||
// iframe.contentWindow.fn_generateAndUploadPdf(cb)를 호출.
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (typeof window === "undefined") return;
|
||||
const w = window as any;
|
||||
w.dataLoaded = true;
|
||||
w.fn_generateAndUploadPdf = async (callback: (pdfBase64: string) => void) => {
|
||||
try {
|
||||
const html2canvas = (await import("html2canvas-pro")).default;
|
||||
const { jsPDF } = await import("jspdf");
|
||||
|
||||
const container = document.querySelector(".estimate-container") as HTMLElement | null;
|
||||
if (!container) throw new Error("견적서 컨테이너(.estimate-container)를 찾을 수 없습니다.");
|
||||
|
||||
const canvas = await html2canvas(container, {
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
logging: false,
|
||||
backgroundColor: "#ffffff",
|
||||
onclone: (doc) => {
|
||||
const replaceWithText = (el: HTMLElement, text: string) => {
|
||||
const span = doc.createElement("span");
|
||||
span.textContent = text;
|
||||
const style = el.getAttribute("style") || "";
|
||||
span.setAttribute("style", style + ";display:inline-block;white-space:pre-wrap;");
|
||||
el.parentNode?.replaceChild(span, el);
|
||||
};
|
||||
doc.querySelectorAll<HTMLInputElement>('input[type="text"],input[type="date"]').forEach(el => replaceWithText(el, el.value || ""));
|
||||
doc.querySelectorAll<HTMLTextAreaElement>("textarea").forEach(el => replaceWithText(el, el.value || ""));
|
||||
doc.querySelectorAll<HTMLSelectElement>("select").forEach(el => {
|
||||
const opt = el.options[el.selectedIndex];
|
||||
replaceWithText(el as unknown as HTMLElement, opt?.text || "");
|
||||
});
|
||||
// SmartSelect/CustomerSelect → 선택된 라벨만 텍스트로 (placeholder는 무시)
|
||||
doc.querySelectorAll<HTMLElement>('[role="combobox"]').forEach((el) => {
|
||||
const idx = Array.from(doc.querySelectorAll('[role="combobox"]')).indexOf(el);
|
||||
const live = document.querySelectorAll<HTMLElement>('[role="combobox"]')[idx];
|
||||
const liveLabel = live?.querySelector(".truncate")?.textContent?.trim() ?? "";
|
||||
const text = /^거래처\s*선택$|^고객사\s*선택$|^선택$/i.test(liveLabel) ? "" : liveLabel;
|
||||
replaceWithText(el, text);
|
||||
});
|
||||
// contenteditable 영역도 보존 (template2의 비고)
|
||||
doc.querySelectorAll<HTMLElement>(".no-print, .btn-area, button").forEach(el => { el.style.display = "none"; });
|
||||
},
|
||||
});
|
||||
|
||||
const imgData = canvas.toDataURL("image/jpeg", 0.85);
|
||||
const pdf = new jsPDF("p", "mm", "a4");
|
||||
const imgWidth = 210, 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 > 1) {
|
||||
position = heightLeft - imgHeight;
|
||||
pdf.addPage();
|
||||
pdf.addImage(imgData, "JPEG", 0, position, imgWidth, imgHeight, undefined, "FAST");
|
||||
heightLeft -= pageHeight;
|
||||
}
|
||||
|
||||
callback(pdf.output("datauristring"));
|
||||
} catch (e) {
|
||||
console.error("fn_generateAndUploadPdf (template2) error", e);
|
||||
callback("");
|
||||
}
|
||||
};
|
||||
return () => {
|
||||
try {
|
||||
w.dataLoaded = false;
|
||||
delete w.fn_generateAndUploadPdf;
|
||||
} catch {}
|
||||
};
|
||||
}, [loading]);
|
||||
|
||||
if (loading) return <div style={{ padding: 40 }}>견적서를 불러오는 중...</div>;
|
||||
|
||||
// ─── 렌더링 (wace 마크업 1:1) ────────────────────────────────
|
||||
|
||||
@@ -18,7 +18,7 @@ let inflight: Promise<SmartSelectOption[]> | null = null;
|
||||
|
||||
// 운영 wace 데이터: contract_mgmt.customer_objid = 'C_' + customer_mng.customer_code
|
||||
// (이전엔 customer_mng.id padded로 매핑했으나 운영 데이터와 어긋났음 — 26C-0801 라온기술/정림유리 미스매치 사례)
|
||||
const fetchCustomers = async (): Promise<SmartSelectOption[]> => {
|
||||
export const fetchCustomers = async (): Promise<SmartSelectOption[]> => {
|
||||
if (cached) return cached;
|
||||
if (inflight) return inflight;
|
||||
inflight = (async () => {
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* EstimateMailDialog — wace estimateMailFormPopup.jsp 1:1 이식
|
||||
*
|
||||
* 견적관리 그리드 "메일발송" 버튼에서 호출. 발송 흐름:
|
||||
* 1. open 시 mail-info API로 고객사/작성자 자동 채움 (제목/contents 템플릿/cc=writer_email)
|
||||
* 2. 고객사 담당자 체크박스 리스트 표시 → 체크 시 toEmails에 자동 추가
|
||||
* 3. "발송" 클릭 →
|
||||
* - hasBaseEst='N' && hasAddEst='Y': useAddEstOnly='Y'로 API 호출 (PDF 생성 스킵)
|
||||
* - 그 외: 최신 차수 template1/template2 페이지를 hidden iframe으로 렌더 → fn_generateAndUploadPdf로 base64 추출 → API 호출
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, 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 { salesEstimateApi, EstimateTemplateRow } from "@/lib/api/salesEstimate";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export interface EstimateMailDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** contract_mgmt.objid (영업번호의 헤더 ID) */
|
||||
contractObjid: string | null;
|
||||
/** 그리드의 추가견적(estimate02) 카운트 — hasAddEst 분기에 사용 */
|
||||
addEstCount?: number;
|
||||
/** 그리드의 견적 차수 카운트 — hasBaseEst 분기에 사용 (0이면 견적서 없음) */
|
||||
estStatusCount?: number;
|
||||
/** 발송 완료 후 그리드 갱신 콜백 */
|
||||
onSent?: () => void;
|
||||
}
|
||||
|
||||
interface Manager {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
department: string;
|
||||
is_main: string;
|
||||
}
|
||||
|
||||
export function EstimateMailDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
contractObjid,
|
||||
addEstCount = 0,
|
||||
estStatusCount = 0,
|
||||
onSent,
|
||||
}: EstimateMailDialogProps) {
|
||||
const hasAddEst = addEstCount > 0;
|
||||
const hasBaseEst = estStatusCount > 0;
|
||||
// 추가견적만 있는 경우: PDF 생성 스킵
|
||||
const useAddEstOnly = !hasBaseEst && hasAddEst;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [progress, setProgress] = useState("");
|
||||
const [managers, setManagers] = useState<Manager[]>([]);
|
||||
const [checkedEmails, setCheckedEmails] = useState<Record<string, boolean>>({});
|
||||
const [form, setForm] = useState({
|
||||
toEmails: "",
|
||||
ccEmails: "",
|
||||
subject: "",
|
||||
contents: "",
|
||||
});
|
||||
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
|
||||
// ─── open 시 mail-info + 담당자 자동 로드 ──────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!open || !contractObjid) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
setManagers([]);
|
||||
setCheckedEmails({});
|
||||
setForm({ toEmails: "", ccEmails: "", subject: "", contents: "" });
|
||||
try {
|
||||
const info = await salesEstimateApi.getMailInfo(contractObjid);
|
||||
if (cancelled || !info) return;
|
||||
|
||||
const customerName = info.customer_name ?? "";
|
||||
const contractNo = info.contract_no ?? "";
|
||||
// wace estimateMailFormPopup.jsp fn_generateMailTemplate 1:1
|
||||
const template =
|
||||
`안녕하세요.\n\n` +
|
||||
`${customerName} 귀하께서 요청하신 견적서를 첨부파일로 송부드립니다.\n\n` +
|
||||
`영업번호: ${contractNo}\n\n` +
|
||||
`첨부된 견적서를 검토하신 후 문의사항이 있으시면 연락 주시기 바랍니다.\n\n` +
|
||||
`감사합니다.\n`;
|
||||
|
||||
setForm({
|
||||
toEmails: info.customer_email ?? "",
|
||||
ccEmails: info.writer_email ?? "",
|
||||
subject: `[${customerName}] ${contractNo} 견적서`,
|
||||
contents: template,
|
||||
});
|
||||
|
||||
// 고객사 담당자 리스트 (별도 API)
|
||||
if (info.customer_objid) {
|
||||
try {
|
||||
const mgrs = await salesEstimateApi.getMailManagers(info.customer_objid);
|
||||
if (!cancelled) setManagers(mgrs);
|
||||
} catch {
|
||||
// 담당자 조회 실패는 무시 — 수신인 직접 입력 가능
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error("계약 정보를 불러올 수 없습니다: " + (e?.message ?? ""));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, contractObjid]);
|
||||
|
||||
// ─── 담당자 체크박스 → toEmails 자동 추가 ─────────────────────────────────
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 최신 차수 견적서를 hidden iframe으로 렌더 → PDF base64 추출 ───────────
|
||||
const generatePdfBase64 = useCallback(async (latest: EstimateTemplateRow): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const templateType = latest.template_type === "2" ? "2" : "1";
|
||||
const url = `/COMPANY_16/sales/estimate/template${templateType}/pop/${encodeURIComponent(
|
||||
contractObjid!,
|
||||
)}?templateObjid=${encodeURIComponent(latest.objid)}`;
|
||||
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.style.cssText =
|
||||
"position:absolute;left:-9999px;top:-9999px;width:900px;height:1200px;border:none;";
|
||||
iframe.src = url;
|
||||
iframeRef.current = iframe;
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
let attempts = 0;
|
||||
const maxAttempts = 1200; // 100ms × 1200 = 120초
|
||||
|
||||
const cleanup = () => {
|
||||
try {
|
||||
if (iframe.parentNode) iframe.parentNode.removeChild(iframe);
|
||||
} catch {}
|
||||
iframeRef.current = null;
|
||||
};
|
||||
|
||||
// 데이터 로딩 완료 대기 (wace estimateMailFormPopup.jsp의 dataLoaded 폴링과 동일)
|
||||
const checkDataLoaded = () => {
|
||||
attempts++;
|
||||
if (attempts > maxAttempts) {
|
||||
cleanup();
|
||||
reject(new Error("견적서 데이터 로딩 시간이 초과되었습니다. (120초)"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const w = iframe.contentWindow as any;
|
||||
if (w && w.dataLoaded === true && typeof w.fn_generateAndUploadPdf === "function") {
|
||||
w.fn_generateAndUploadPdf((pdfBase64: string) => {
|
||||
cleanup();
|
||||
if (pdfBase64) resolve(pdfBase64);
|
||||
else reject(new Error("PDF 생성에 실패했습니다."));
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// cross-origin은 아니지만, 초기에는 contentWindow가 빈 상태일 수 있음
|
||||
}
|
||||
setTimeout(checkDataLoaded, 100);
|
||||
};
|
||||
|
||||
iframe.addEventListener("load", () => checkDataLoaded());
|
||||
|
||||
// 전체 타임아웃 (180초) — wace와 동일
|
||||
setTimeout(() => {
|
||||
if (iframeRef.current) {
|
||||
cleanup();
|
||||
reject(new Error("PDF 생성 시간이 초과되었습니다. (180초)"));
|
||||
}
|
||||
}, 180_000);
|
||||
});
|
||||
}, [contractObjid]);
|
||||
|
||||
// ─── 발송 ────────────────────────────────────────────────────────────────
|
||||
async function handleSend() {
|
||||
if (!contractObjid) return;
|
||||
const toEmails = form.toEmails.trim();
|
||||
const subject = form.subject.trim();
|
||||
const contents = form.contents.trim();
|
||||
|
||||
if (toEmails === "") { toast.error("수신인을 입력해주세요."); return; }
|
||||
if (subject === "") { toast.error("제목을 입력해주세요."); return; }
|
||||
if (contents === "") { toast.error("내용을 입력해주세요."); 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("견적서를 발송하시겠습니까?")) return;
|
||||
|
||||
setSending(true);
|
||||
setProgress("발송 준비 중...");
|
||||
try {
|
||||
let pdfBase64: string | undefined;
|
||||
|
||||
if (!useAddEstOnly) {
|
||||
// 견적 PDF 생성 필요 — 최신 차수 조회 후 iframe으로 렌더
|
||||
setProgress("최신 차수 견적서 조회 중...");
|
||||
const list = await salesEstimateApi.listTemplates(contractObjid);
|
||||
if (!list || list.length === 0) {
|
||||
toast.error("견적서를 찾을 수 없습니다.");
|
||||
setSending(false);
|
||||
setProgress("");
|
||||
return;
|
||||
}
|
||||
const latest = list[0];
|
||||
|
||||
setProgress("견적서 PDF 생성 중... (최대 3분)");
|
||||
pdfBase64 = await generatePdfBase64(latest);
|
||||
}
|
||||
|
||||
setProgress("메일 발송 중...");
|
||||
const result = await salesEstimateApi.sendMail({
|
||||
contractObjid,
|
||||
toEmails,
|
||||
ccEmails: form.ccEmails.trim() || undefined,
|
||||
subject,
|
||||
contents,
|
||||
pdfBase64,
|
||||
useAddEstOnly: useAddEstOnly ? "Y" : "N",
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(`발송 실패: ${result.message}`);
|
||||
} else {
|
||||
toast.success(result.message || "견적서가 성공적으로 발송되었습니다.");
|
||||
onOpenChange(false);
|
||||
onSent?.();
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(`발송 실패: ${e?.response?.data?.message ?? e?.message ?? "알 수 없는 오류"}`);
|
||||
} finally {
|
||||
setSending(false);
|
||||
setProgress("");
|
||||
}
|
||||
}
|
||||
|
||||
// 다이얼로그 닫힐 때 iframe 정리
|
||||
useEffect(() => {
|
||||
if (!open && iframeRef.current) {
|
||||
try { iframeRef.current.parentNode?.removeChild(iframeRef.current); } catch {}
|
||||
iframeRef.current = null;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const pdfNoticeText = useAddEstOnly
|
||||
? "PDF 첨부: 추가견적 PDF가 첨부됩니다."
|
||||
: hasAddEst
|
||||
? "PDF 첨부: 최종 차수 견적서 + 추가견적 PDF가 합본으로 첨부됩니다."
|
||||
: "PDF 첨부: 최종 차수 견적서가 자동으로 첨부됩니다.";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!sending) onOpenChange(o); }}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>견적서 메일 발송</DialogTitle>
|
||||
<DialogDescription>{pdfNoticeText}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 mr-2 animate-spin" />
|
||||
계약 정보를 불러오는 중...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* 고객사 담당자 체크박스 리스트 */}
|
||||
<div>
|
||||
<Label className="text-xs">고객사 담당자 선택</Label>
|
||||
<div className="border rounded-md p-2 bg-muted/30 max-h-[150px] overflow-y-auto text-sm">
|
||||
{managers.length === 0 ? (
|
||||
<div className="text-muted-foreground text-center py-2">
|
||||
등록된 담당자가 없습니다. 수신인을 직접 입력해주세요.
|
||||
</div>
|
||||
) : (
|
||||
managers.map((m, i) => {
|
||||
const email = m.email ?? "";
|
||||
const id = `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>}
|
||||
{m.is_main === "Y" && <span className="ml-1 text-[10px] px-1 bg-blue-100 dark:bg-blue-900 rounded">대표</span>}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* To */}
|
||||
<div>
|
||||
<Label className="text-xs">수신인 (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">여러 개는 쉼표(,) 또는 세미콜론(;)으로 구분</p>
|
||||
</div>
|
||||
|
||||
{/* CC */}
|
||||
<div>
|
||||
<Label className="text-xs">참조 (CC)</Label>
|
||||
<Input
|
||||
value={form.ccEmails}
|
||||
onChange={(e) => setForm({ ...form, ccEmails: e.target.value })}
|
||||
placeholder="참조 이메일 주소 (선택사항)"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">작성자 이메일이 자동으로 참조에 추가됩니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 제목 */}
|
||||
<div>
|
||||
<Label className="text-xs">제목 <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">내용 <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}>취소</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" />}
|
||||
발송
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -135,10 +135,41 @@ export const salesEstimateApi = {
|
||||
ccEmails?: string;
|
||||
subject: string;
|
||||
contents: string;
|
||||
isSend?: "Y" | "N";
|
||||
pdfBase64?: string;
|
||||
useAddEstOnly?: "Y" | "N";
|
||||
}) {
|
||||
const res = await apiClient.post("/sales/estimate/mail", body);
|
||||
return res.data?.data as { objid: string };
|
||||
const res = await apiClient.post("/sales/estimate/mail", body, {
|
||||
// PDF base64 포함 시 페이로드 큼 — 충분한 타임아웃
|
||||
timeout: 120_000,
|
||||
});
|
||||
return res.data as { success: boolean; message: string; objid?: string };
|
||||
},
|
||||
|
||||
/** 메일 다이얼로그 자동 채움 (제목/수신/참조용 고객·작성자 정보) */
|
||||
async getMailInfo(contractObjid: string) {
|
||||
const res = await apiClient.get(`/sales/estimate/mail-info/${contractObjid}`);
|
||||
return res.data?.data as {
|
||||
contract_objid: string;
|
||||
contract_no: string | null;
|
||||
customer_objid: string | null;
|
||||
customer_name: string | null;
|
||||
customer_email: string | null;
|
||||
writer: string | null;
|
||||
writer_email: string | null;
|
||||
writer_name: string | null;
|
||||
};
|
||||
},
|
||||
|
||||
/** 메일 다이얼로그 고객사 담당자 체크박스 리스트용 */
|
||||
async getMailManagers(customerObjid: string) {
|
||||
const res = await apiClient.get(`/sales/estimate/customer/${customerObjid}/managers`);
|
||||
return (res.data?.data ?? []) as {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
department: string;
|
||||
is_main: string;
|
||||
}[];
|
||||
},
|
||||
|
||||
// ─── G5 견적작성 (estimate_template) ──────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user