Files
wace_rps/frontend/components/sales/EstimateMailDialog.tsx
T
hjjeong 902118d46e 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>
2026-05-11 16:09:10 +09:00

401 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}