PR-C G5-B 일반 견적서 PDF 다운로드 (html2canvas-pro + jspdf)

- frontend deps: html2canvas-pro@^2.0.2, jspdf@^3.0.4
- template1 페이지 "PDF 다운로드" 버튼 → wace fn_generatePdf 1:1 포팅
  · 클라이언트 dynamic import (SSR 회피)
  · html2canvas-pro 캡처 (scale 2, JPEG 0.85) → jsPDF A4 페이지 분할 → save({estimateNo}.pdf)
  · onclone 콜백에서 input/textarea/select → 텍스트 노드로 교체 (글자 잘림 방지)
  · .no-print, .btn-area, 삭제버튼은 클론에서 숨김
- html2canvas-pro 사용 이유: Tailwind 4 oklab/oklch CSS color function 지원
  (기존 html2canvas 1.4.1은 "Attempting to parse an unsupported color function 'oklab'" 에러)

장비 견적서(template2)는 wace 원본부터 PDF 다운로드 버튼이 없어 그대로 유지.
메일 발송용 PDF 합본은 G6 SMTP 묶음에서 처리.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-11 14:32:29 +09:00
parent 844216c298
commit 8fdc42df79
3 changed files with 82 additions and 8 deletions
@@ -286,6 +286,66 @@ 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 호환)
const html2canvas = (await import("html2canvas-pro")).default;
const { jsPDF } = await import("jspdf");
const container = document.querySelector(".estimate-container") as HTMLElement | null;
if (!container) return;
const canvas = await html2canvas(container, {
scale: 2,
useCORS: true,
logging: false,
backgroundColor: "#ffffff",
// 클론된 DOM에서 input/textarea/select → 텍스트 노드로 교체 (글자 잘림 방지)
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 || "");
});
// 인쇄 비대상 요소 숨김
doc.querySelectorAll<HTMLElement>(".no-print, .btn-area, .delete-btn-cell 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;
}
const fileName = (estimateNo || "견적서") + ".pdf";
pdf.save(fileName);
} catch (e: any) {
console.error("PDF 생성 오류", e);
alert("PDF 생성 중 오류가 발생했습니다.\n" + (e?.message ?? ""));
}
}
function handleClose() {
// 새 탭으로 열린 경우 닫기 시도 + 안되면 router back
if (window.opener) {
@@ -668,8 +728,7 @@ export default function EstimateTemplate1Page() {
{/* 버튼 영역 (고정 하단) */}
<div className="btn-area no-print">
<button type="button" className="estimate-btn" onClick={handlePrint}></button>
<button type="button" className="estimate-btn"
onClick={() => alert("PDF 다운로드 기능은 다음 단계(G5-B)에서 추가됩니다.")}>PDF </button>
<button type="button" className="estimate-btn" onClick={handleDownloadPdf}>PDF </button>
<button type="button" className="estimate-btn" onClick={handleSave} disabled={readOnly}></button>
<button type="button" className="estimate-btn" onClick={handleClose}></button>
</div>
+19 -5
View File
@@ -68,9 +68,10 @@
"exceljs": "^4.4.0",
"html-to-image": "^1.11.13",
"html2canvas": "^1.4.1",
"html2canvas-pro": "^2.0.2",
"isomorphic-dompurify": "^2.28.0",
"jsbarcode": "^3.12.1",
"jspdf": "^3.0.3",
"jspdf": "^3.0.4",
"leaflet": "^1.9.4",
"lucide-react": "^0.525.0",
"mammoth": "^1.11.0",
@@ -10942,6 +10943,19 @@
"node": ">=8.0.0"
}
},
"node_modules/html2canvas-pro": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html2canvas-pro/-/html2canvas-pro-2.0.2.tgz",
"integrity": "sha512-9G/t0XgCZWonLwL0JwI7su6NdbOPUY7Ur4Ihpp8+XMaW9ibA2nDXF181Jr6tm94k8lX6sthpaXB3XqEnsMd5Cw==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -11696,12 +11710,12 @@
}
},
"node_modules/jspdf": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.3.tgz",
"integrity": "sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ==",
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.4.tgz",
"integrity": "sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.26.9",
"@babel/runtime": "^7.28.4",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},
+2 -1
View File
@@ -77,9 +77,10 @@
"exceljs": "^4.4.0",
"html-to-image": "^1.11.13",
"html2canvas": "^1.4.1",
"html2canvas-pro": "^2.0.2",
"isomorphic-dompurify": "^2.28.0",
"jsbarcode": "^3.12.1",
"jspdf": "^3.0.3",
"jspdf": "^3.0.4",
"leaflet": "^1.9.4",
"lucide-react": "^0.525.0",
"mammoth": "^1.11.0",