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:
180
src/app/api/pdf/generate/route.ts
Normal file
180
src/app/api/pdf/generate/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user