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:
@@ -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, 이메일, 팩스, 카카오톡, 인쇄)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user