feat(WEB): 견적 시스템 개선, 엑셀 다운로드, PDF 생성 기능 추가

견적 시스템:
- QuoteRegistrationV2: 할인 모달, 거래명세서 모달, vatType 필드 추가
- DiscountModal: 할인율/할인금액 상호 계산 모달
- QuoteTransactionModal: 거래명세서 미리보기 모달
- LocationDetailPanel, LocationListPanel 개선

템플릿 기능:
- UniversalListPage: 엑셀 다운로드 기능 추가 (전체/선택 다운로드)
- DocumentViewer: PDF 생성 기능 개선

신규 API:
- /api/pdf/generate: Puppeteer 기반 PDF 생성 엔드포인트

UI 개선:
- 입력 컴포넌트 placeholder 스타일 개선 (opacity 50%)
- 각종 리스트 컴포넌트 정렬/필터링 개선

패키지 추가:
- html2canvas, jspdf, puppeteer, dom-to-image-more

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-27 19:49:03 +09:00
parent c4644489e7
commit afd7bda269
35 changed files with 3493 additions and 946 deletions

View File

@@ -0,0 +1,180 @@
import { NextRequest, NextResponse } from 'next/server';
import puppeteer from 'puppeteer';
/**
* PDF 생성 API
* POST /api/pdf/generate
*
* Body: {
* html: string,
* styles: string,
* title?: string,
* orientation?: 'portrait' | 'landscape',
* documentNumber?: string,
* createdDate?: string,
* showHeaderFooter?: boolean
* }
* Response: PDF blob
*/
export async function POST(request: NextRequest) {
try {
const {
html,
styles = '',
title = '문서',
orientation = 'portrait',
documentNumber = '',
createdDate = '',
showHeaderFooter = true,
} = await request.json();
if (!html) {
return NextResponse.json(
{ error: 'HTML content is required' },
{ status: 400 }
);
}
// Puppeteer 브라우저 실행
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
],
});
const page = await browser.newPage();
// 전체 HTML 문서 구성 (인라인 스타일 포함)
const fullHtml = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title}</title>
<style>
/* 기본 리셋 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 11px;
line-height: 1.4;
color: #000;
background: #fff;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
/* 문서 컨테이너 - A4에 맞게 조정 */
.document-container {
width: 100%;
max-width: 100%;
padding: 0;
}
/* 페이지 나눔 설정 */
table { page-break-inside: auto; }
tr { page-break-inside: avoid; page-break-after: auto; }
thead { display: table-header-group; }
tfoot { display: table-footer-group; }
/* 이미지/SVG 크기 제한 */
img, svg {
max-width: 100%;
height: auto;
}
/* 클라이언트에서 추출한 computed styles */
${styles}
</style>
</head>
<body>
<div class="document-container">
${html}
</div>
</body>
</html>
`;
// 뷰포트 설정 (문서 전체가 보이도록 넓게)
await page.setViewport({
width: 1200,
height: 1600,
deviceScaleFactor: 2,
});
// HTML 설정
await page.setContent(fullHtml, {
waitUntil: 'networkidle0',
});
// 헤더 템플릿 (문서번호, 생성일)
const headerTemplate = showHeaderFooter
? `
<div style="width: 100%; font-size: 9px; font-family: 'Pretendard', sans-serif; padding: 0 15mm;">
<div style="border-bottom: 1px solid #ddd; padding-bottom: 5px; display: flex; justify-content: space-between; color: #666;">
<span>${documentNumber ? `문서번호: ${documentNumber}` : ''}</span>
<span>${createdDate ? `생성일: ${createdDate}` : ''}</span>
</div>
</div>
`
: '';
// 푸터 템플릿 (라인 + 페이지번호)
const footerTemplate = showHeaderFooter
? `
<div style="width: 100%; font-size: 9px; font-family: 'Pretendard', sans-serif; padding: 0 15mm;">
<div style="border-top: 1px solid #ddd; padding-top: 5px; display: flex; justify-content: space-between; color: #666;">
<span>${title}</span>
<span>Page <span class="pageNumber"></span> / <span class="totalPages"></span></span>
</div>
</div>
`
: '';
// PDF 생성 (자동 스케일로 A4에 맞춤)
const pdfBuffer = await page.pdf({
format: 'A4',
landscape: orientation === 'landscape',
printBackground: true,
preferCSSPageSize: false,
scale: 0.75, // 문서를 75%로 축소하여 A4에 맞춤
displayHeaderFooter: showHeaderFooter,
headerTemplate: headerTemplate,
footerTemplate: footerTemplate,
margin: {
top: showHeaderFooter ? '20mm' : '10mm',
right: '10mm',
bottom: showHeaderFooter ? '20mm' : '10mm',
left: '10mm',
},
});
// 브라우저 종료
await browser.close();
// PDF 응답
return new NextResponse(pdfBuffer, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${encodeURIComponent(title)}.pdf"`,
},
});
} catch (error) {
console.error('PDF 생성 오류:', error);
return NextResponse.json(
{ error: 'PDF 생성 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}