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)
-
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",