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

@@ -16,7 +16,7 @@ export const DOCUMENT_PRESETS: Record<PresetType, PresetConfig> = {
print: true,
download: true,
},
actions: ['print', 'download'],
actions: ['pdf', 'print', 'download'],
},
// 건설 프로젝트용 (CRUD)
@@ -27,7 +27,7 @@ export const DOCUMENT_PRESETS: Record<PresetType, PresetConfig> = {
print: true,
download: false,
},
actions: ['edit', 'delete', 'print'],
actions: ['edit', 'delete', 'pdf', 'print'],
},
// 결재 문서용 (기본)
@@ -38,7 +38,7 @@ export const DOCUMENT_PRESETS: Record<PresetType, PresetConfig> = {
print: true,
download: false,
},
actions: ['edit', 'submit', 'print'],
actions: ['edit', 'submit', 'pdf', 'print'],
},
// 결재 문서용 - 기안함 모드 (임시저장 상태: 복제, 상신, 인쇄)
@@ -49,7 +49,7 @@ export const DOCUMENT_PRESETS: Record<PresetType, PresetConfig> = {
print: true,
download: false,
},
actions: ['copy', 'submit', 'print'],
actions: ['copy', 'submit', 'pdf', 'print'],
},
// 결재 문서용 - 결재함 모드 (수정, 반려, 승인, 인쇄)
@@ -60,7 +60,7 @@ export const DOCUMENT_PRESETS: Record<PresetType, PresetConfig> = {
print: true,
download: false,
},
actions: ['edit', 'reject', 'approve', 'print'],
actions: ['edit', 'reject', 'approve', 'pdf', 'print'],
},
// 조회 전용
@@ -71,7 +71,7 @@ export const DOCUMENT_PRESETS: Record<PresetType, PresetConfig> = {
print: true,
download: false,
},
actions: ['print'],
actions: ['pdf', 'print'],
},
// 견적서/문서 전송용 (PDF, 이메일, 팩스, 카카오톡, 인쇄)

View File

@@ -144,6 +144,20 @@ export interface CustomBlock {
// DocumentViewer Props
// ============================================================
// ============================================================
// PDF Meta Types
// ============================================================
export interface PdfMeta {
documentNumber?: string;
createdDate?: string;
showHeaderFooter?: boolean;
}
// ============================================================
// DocumentViewer Props
// ============================================================
export interface DocumentViewerProps {
// Config 기반 (권장)
config?: DocumentConfig;
@@ -158,6 +172,9 @@ export interface DocumentViewerProps {
// 데이터
data?: any;
// PDF 메타 정보 (헤더/푸터용)
pdfMeta?: PdfMeta;
// 액션 핸들러
onPrint?: () => void;
onDownload?: () => void;

View File

@@ -1,12 +1,13 @@
'use client';
import React, { useEffect, ReactNode } from 'react';
import React, { useEffect, ReactNode, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { toast } from 'sonner';
import { printArea } from '@/lib/print-utils';
import { DocumentToolbar } from './DocumentToolbar';
import { DocumentContent } from './DocumentContent';
@@ -17,6 +18,7 @@ import {
DocumentViewerProps,
ActionType,
DocumentFeatures,
PdfMeta,
} from '../types';
/**
@@ -59,6 +61,9 @@ export function DocumentViewer({
// 데이터
data,
// PDF 메타 정보
pdfMeta,
// 액션 핸들러
onPrint: propOnPrint,
onDownload,
@@ -120,6 +125,118 @@ export function DocumentViewer({
}
};
// 인라인 스타일 적용된 HTML 복제 생성
const cloneWithInlineStyles = (element: HTMLElement): HTMLElement => {
const clone = element.cloneNode(true) as HTMLElement;
// 원본 요소들과 복제 요소들 매칭
const originalElements = element.querySelectorAll('*');
const clonedElements = clone.querySelectorAll('*');
// 루트 요소 스타일 적용
const rootStyle = window.getComputedStyle(element);
applyStyles(clone, rootStyle);
// 모든 하위 요소 스타일 적용
originalElements.forEach((orig, index) => {
const cloned = clonedElements[index] as HTMLElement;
if (cloned) {
const computedStyle = window.getComputedStyle(orig);
applyStyles(cloned, computedStyle);
}
});
return clone;
};
// 계산된 스타일을 인라인으로 적용
const applyStyles = (element: HTMLElement, computedStyle: CSSStyleDeclaration) => {
const importantStyles = [
'display', 'position', 'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height',
'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
'border', 'border-width', 'border-style', 'border-color',
'border-top', 'border-right', 'border-bottom', 'border-left',
'border-collapse', 'border-spacing',
'background', 'background-color',
'color', 'font-family', 'font-size', 'font-weight', 'font-style',
'text-align', 'text-decoration', 'vertical-align', 'line-height', 'white-space',
'flex', 'flex-direction', 'flex-wrap', 'justify-content', 'align-items', 'gap',
'grid-template-columns', 'grid-template-rows', 'grid-gap',
'table-layout', 'overflow', 'visibility', 'opacity',
];
importantStyles.forEach((prop) => {
const value = computedStyle.getPropertyValue(prop);
if (value && value !== 'none' && value !== 'normal' && value !== 'auto') {
element.style.setProperty(prop, value);
}
});
};
// PDF 핸들러 (서버사이드 Puppeteer로 생성)
const handlePdf = useCallback(async () => {
if (onPdf) {
onPdf();
return;
}
try {
toast.loading('PDF 생성 중...', { id: 'pdf-generating' });
// print-area 영역 찾기
const printAreaEl = document.querySelector('.print-area') as HTMLElement;
if (!printAreaEl) {
toast.error('PDF 생성 실패: 문서 영역을 찾을 수 없습니다.', { id: 'pdf-generating' });
return;
}
// 인라인 스타일 적용된 HTML 복제
const clonedElement = cloneWithInlineStyles(printAreaEl);
const html = clonedElement.outerHTML;
// 서버 API 호출
const response = await fetch('/api/pdf/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
html,
title,
orientation: 'portrait',
documentNumber: pdfMeta?.documentNumber || '',
createdDate: pdfMeta?.createdDate || new Date().toISOString().slice(0, 10),
showHeaderFooter: pdfMeta?.showHeaderFooter !== false,
}),
});
if (!response.ok) {
throw new Error('PDF 생성 API 오류');
}
// PDF Blob 다운로드
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
// 파일명 생성 (날짜 포함)
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
link.download = `${title}_${date}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
toast.success('PDF가 생성되었습니다.', { id: 'pdf-generating' });
} catch (error) {
console.error('PDF 생성 오류:', error);
toast.error('PDF 생성 중 오류가 발생했습니다.', { id: 'pdf-generating' });
}
}, [onPdf, title, pdfMeta]);
// 콘텐츠 렌더링
const renderContent = (): ReactNode => {
// 1. children이 있으면 children 사용 (정적 모드)
@@ -179,7 +296,7 @@ export function DocumentViewer({
onApprove={onApprove}
onReject={onReject}
onCopy={onCopy}
onPdf={onPdf}
onPdf={handlePdf}
onEmail={onEmail}
onFax={onFax}
onKakao={onKakao}