diff --git a/docs/리포트_관리_시스템_구현_진행상황.md b/docs/리포트_관리_시스템_구현_진행상황.md index 66bad755..7590260f 100644 --- a/docs/리포트_관리_시스템_구현_진행상황.md +++ b/docs/리포트_관리_시스템_구현_진행상황.md @@ -106,19 +106,24 @@ - `frontend/components/report/designer/ReportDesignerRightPanel.tsx` - `frontend/components/report/designer/CanvasComponent.tsx` -### 8. 미리보기 및 인쇄 +### 8. 미리보기 및 내보내기 - [x] 미리보기 모달 - [x] 실제 쿼리 데이터로 렌더링 - [x] 편집용 UI 제거 (순수 데이터만 표시) - [x] 브라우저 인쇄 기능 -- [ ] PDF 다운로드 (추후 구현) -- [ ] WORD 다운로드 (추후 구현) +- [x] PDF 다운로드 (브라우저 네이티브 인쇄 기능) +- [x] WORD 다운로드 (docx 라이브러리) +- [x] 파일명 자동 생성 (리포트명\_날짜) **파일**: - `frontend/components/report/designer/ReportPreviewModal.tsx` +**사용 라이브러리**: + +- `docx`: WORD 문서 생성 (PDF는 브라우저 기본 기능 사용) + ### 9. 템플릿 시스템 - [x] 시스템 템플릿 적용 (발주서, 청구서, 기본) @@ -175,21 +180,14 @@ ### Phase 1: 사용성 개선 (권장) -1. **PDF/WORD 내보내기** ⬅️ 다음 권장 작업 - - - jsPDF 또는 pdfmake 라이브러리 사용 - - HTML to DOCX 변환 - - 다운로드 기능 구현 - - 미리보기 모달에 버튼 추가 - -2. **레이아웃 도구** +1. **레이아웃 도구** ⬅️ 다음 권장 작업 - 격자 스냅 (Grid Snap) - 정렬 가이드라인 - 컴포넌트 그룹화 - 실행 취소/다시 실행 (Undo/Redo) -3. **쿼리 관리 개선** +2. **쿼리 관리 개선** - 쿼리 미리보기 개선 (테이블 형태) - 쿼리 저장/불러오기 - 쿼리 템플릿 @@ -315,4 +313,4 @@ **최종 업데이트**: 2025-10-01 **작성자**: AI Assistant -**상태**: 핵심 기능 구현 완료 (90% 완료) +**상태**: PDF/WORD 내보내기 완료 (95% 완료) diff --git a/frontend/components/report/designer/ReportPreviewModal.tsx b/frontend/components/report/designer/ReportPreviewModal.tsx index f271a003..91e84570 100644 --- a/frontend/components/report/designer/ReportPreviewModal.tsx +++ b/frontend/components/report/designer/ReportPreviewModal.tsx @@ -9,8 +9,11 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { Printer, FileDown } from "lucide-react"; +import { Printer, FileDown, FileText } from "lucide-react"; import { useReportDesigner } from "@/contexts/ReportDesignerContext"; +import { useState } from "react"; +import { useToast } from "@/hooks/use-toast"; +import { Document, Packer, Paragraph, TextRun, Table, TableCell, TableRow, WidthType } from "docx"; interface ReportPreviewModalProps { isOpen: boolean; @@ -18,7 +21,9 @@ interface ReportPreviewModalProps { } export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) { - const { components, canvasWidth, canvasHeight, getQueryResult } = useReportDesigner(); + const { components, canvasWidth, canvasHeight, getQueryResult, reportDetail } = useReportDesigner(); + const [isExporting, setIsExporting] = useState(false); + const { toast } = useToast(); // 컴포넌트의 실제 표시 값 가져오기 const getComponentValue = (component: any): string => { @@ -63,12 +68,165 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) printWindow.print(); }; + // PDF 다운로드 (브라우저 인쇄 기능 이용) const handleDownloadPDF = () => { - alert("PDF 다운로드 기능은 추후 구현 예정입니다."); + const printContent = document.getElementById("preview-content"); + if (!printContent) return; + + const printWindow = window.open("", "_blank"); + if (!printWindow) return; + + printWindow.document.write(` + + + 리포트 인쇄 + + + + ${printContent.innerHTML} + + + + `); + printWindow.document.close(); + + toast({ + title: "안내", + description: "인쇄 대화상자에서 'PDF로 저장'을 선택하세요.", + }); }; - const handleDownloadWord = () => { - alert("WORD 다운로드 기능은 추후 구현 예정입니다."); + // WORD 다운로드 + const handleDownloadWord = async () => { + setIsExporting(true); + try { + // 컴포넌트를 Paragraph로 변환 + const paragraphs: (Paragraph | Table)[] = []; + + // Y 좌표로 정렬 + const sortedComponents = [...components].sort((a, b) => a.y - b.y); + + for (const component of sortedComponents) { + if (component.type === "text" || component.type === "label") { + const value = getComponentValue(component); + paragraphs.push( + new Paragraph({ + children: [ + new TextRun({ + text: value, + size: (component.fontSize || 13) * 2, // pt to half-pt + color: component.fontColor?.replace("#", "") || "000000", + bold: component.fontWeight === "bold", + }), + ], + spacing: { + after: 200, + }, + }), + ); + } else if (component.type === "table" && component.queryId) { + const queryResult = getQueryResult(component.queryId); + if (queryResult && queryResult.rows.length > 0) { + // 테이블 헤더 + const headerCells = queryResult.fields.map( + (field) => + new TableCell({ + children: [new Paragraph({ text: field })], + width: { size: 100 / queryResult.fields.length, type: WidthType.PERCENTAGE }, + }), + ); + + // 테이블 행 + const dataRows = queryResult.rows.map( + (row) => + new TableRow({ + children: queryResult.fields.map( + (field) => + new TableCell({ + children: [new Paragraph({ text: String(row[field] ?? "") })], + }), + ), + }), + ); + + const table = new Table({ + rows: [new TableRow({ children: headerCells }), ...dataRows], + width: { size: 100, type: WidthType.PERCENTAGE }, + }); + + paragraphs.push(table); + } + } + } + + // 문서 생성 + const doc = new Document({ + sections: [ + { + properties: {}, + children: paragraphs, + }, + ], + }); + + // Blob 생성 및 다운로드 + const blob = await Packer.toBlob(doc); + const fileName = reportDetail?.report?.report_name_kor || "리포트"; + const timestamp = new Date().toISOString().slice(0, 10); + + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${fileName}_${timestamp}.docx`; + link.click(); + window.URL.revokeObjectURL(url); + + toast({ + title: "성공", + description: "WORD 파일이 다운로드되었습니다.", + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "WORD 생성에 실패했습니다."; + toast({ + title: "오류", + description: errorMessage, + variant: "destructive", + }); + } finally { + setIsExporting(false); + } }; return ( @@ -170,10 +328,10 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) - - @@ -181,9 +339,9 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) PDF - diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 56c36b4c..3c649158 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -39,6 +39,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", + "docx": "^9.5.1", "docx-preview": "^0.3.6", "lucide-react": "^0.525.0", "mammoth": "^1.11.0", @@ -4500,6 +4501,23 @@ "node": ">=0.10.0" } }, + "node_modules/docx": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/docx/-/docx-9.5.1.tgz", + "integrity": "sha512-ABDI7JEirFD2+bHhOBlsGZxaG1UgZb2M/QMKhLSDGgVNhxDesTCDcP+qoDnDGjZ4EOXTRfUjUgwHVuZ6VSTfWQ==", + "license": "MIT", + "dependencies": { + "@types/node": "^24.0.1", + "hash.js": "^1.1.7", + "jszip": "^3.10.1", + "nanoid": "^5.1.3", + "xml": "^1.0.1", + "xml-js": "^1.6.8" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/docx-preview": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.3.6.tgz", @@ -4509,6 +4527,39 @@ "jszip": ">=3.0.0" } }, + "node_modules/docx/node_modules/@types/node": { + "version": "24.6.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.1.tgz", + "integrity": "sha512-ljvjjs3DNXummeIaooB4cLBKg2U6SPI6Hjra/9rRIy7CpM0HpLtG9HptkMKAb4HYWy5S7HUvJEuWgr/y0U8SHw==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.13.0" + } + }, + "node_modules/docx/node_modules/nanoid": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/docx/node_modules/undici-types": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.13.0.tgz", + "integrity": "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -5769,6 +5820,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -6824,6 +6885,12 @@ "node": ">= 0.6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -8049,6 +8116,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sax": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", + "license": "ISC" + }, "node_modules/scheduler": { "version": "0.26.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", @@ -9153,6 +9226,24 @@ "node": ">=0.8" } }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "license": "MIT" + }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, "node_modules/xmlbuilder": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 28b80494..ca6379b7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -47,6 +47,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", + "docx": "^9.5.1", "docx-preview": "^0.3.6", "lucide-react": "^0.525.0", "mammoth": "^1.11.0",