{/* 눈금자와 캔버스를 감싸는 컨테이너 */}
{/* 좌상단 코너 + 가로 눈금자 */}
diff --git a/frontend/components/report/designer/ReportDesignerRightPanel.tsx b/frontend/components/report/designer/ReportDesignerRightPanel.tsx
index bc2eea74..ff832e21 100644
--- a/frontend/components/report/designer/ReportDesignerRightPanel.tsx
+++ b/frontend/components/report/designer/ReportDesignerRightPanel.tsx
@@ -5,6 +5,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -27,6 +28,7 @@ export function ReportDesignerRightPanel() {
currentPage,
currentPageId,
updatePageSettings,
+ getQueryResult,
} = context;
const [activeTab, setActiveTab] = useState
("properties");
const [uploadingImage, setUploadingImage] = useState(false);
@@ -918,6 +920,717 @@ export function ReportDesignerRightPanel() {
)}
+ {/* 페이지 번호 설정 */}
+ {selectedComponent.type === "pageNumber" && (
+
+
+ 페이지 번호 설정
+
+
+
+
+
+
+
+
+ )}
+
+ {/* 카드 컴포넌트 설정 */}
+ {selectedComponent.type === "card" && (
+
+
+ 카드 설정
+
+
+ {/* 제목 표시 여부 */}
+
+
+ updateComponent(selectedComponent.id, {
+ showCardTitle: e.target.checked,
+ })
+ }
+ className="h-4 w-4"
+ />
+
+
+
+ {/* 제목 텍스트 */}
+ {selectedComponent.showCardTitle !== false && (
+
+
+
+ updateComponent(selectedComponent.id, {
+ cardTitle: e.target.value,
+ })
+ }
+ placeholder="정보 카드"
+ className="h-8"
+ />
+
+ )}
+
+ {/* 라벨 너비 */}
+
+
+
+ updateComponent(selectedComponent.id, {
+ labelWidth: Number(e.target.value),
+ })
+ }
+ min={40}
+ max={200}
+ className="h-8"
+ />
+
+
+ {/* 테두리 표시 */}
+
+
+ updateComponent(selectedComponent.id, {
+ showCardBorder: e.target.checked,
+ borderWidth: e.target.checked ? 1 : 0,
+ })
+ }
+ className="h-4 w-4"
+ />
+
+
+
+ {/* 폰트 크기 설정 */}
+
+
+ {/* 색상 설정 */}
+
+
+ {/* 항목 목록 관리 */}
+
+
+
+
+
+
+ {/* 쿼리 선택 (데이터 바인딩용) */}
+
+
+
+
+
+ {/* 항목 리스트 */}
+
+ {(selectedComponent.cardItems || []).map(
+ (item: { label: string; value: string; fieldName?: string }, index: number) => (
+
+
+ 항목 {index + 1}
+
+
+
+
+
+ {
+ const currentItems = [...(selectedComponent.cardItems || [])];
+ currentItems[index] = { ...item, label: e.target.value };
+ updateComponent(selectedComponent.id, {
+ cardItems: currentItems,
+ });
+ }}
+ className="h-6 text-xs"
+ placeholder="항목명"
+ />
+
+ {selectedComponent.queryId ? (
+
+
+
+
+ ) : (
+
+
+ {
+ const currentItems = [...(selectedComponent.cardItems || [])];
+ currentItems[index] = { ...item, value: e.target.value };
+ updateComponent(selectedComponent.id, {
+ cardItems: currentItems,
+ });
+ }}
+ className="h-6 text-xs"
+ placeholder="내용"
+ />
+
+ )}
+
+
+ ),
+ )}
+
+
+
+
+ )}
+
+ {/* 계산 컴포넌트 설정 */}
+ {selectedComponent.type === "calculation" && (
+
+
+ 계산 설정
+
+
+ {/* 결과 라벨 */}
+
+
+
+ updateComponent(selectedComponent.id, {
+ resultLabel: e.target.value,
+ })
+ }
+ placeholder="합계 금액"
+ className="h-8"
+ />
+
+
+ {/* 라벨 너비 */}
+
+
+
+ updateComponent(selectedComponent.id, {
+ labelWidth: Number(e.target.value),
+ })
+ }
+ min={60}
+ max={200}
+ className="h-8"
+ />
+
+
+ {/* 숫자 포맷 */}
+
+
+
+
+
+ {/* 통화 접미사 */}
+ {selectedComponent.numberFormat === "currency" && (
+
+
+
+ updateComponent(selectedComponent.id, {
+ currencySuffix: e.target.value,
+ })
+ }
+ placeholder="원"
+ className="h-8"
+ />
+
+ )}
+
+ {/* 폰트 크기 설정 */}
+
+
+ {/* 색상 설정 */}
+
+
+ {/* 계산 항목 목록 관리 */}
+
+
+
+
+
+
+ {/* 쿼리 선택 (데이터 바인딩용) */}
+
+
+
+
+
+ {/* 항목 리스트 */}
+
+ {(selectedComponent.calcItems || []).map((item, index: number) => (
+
+
+ 항목 {index + 1}
+
+
+
+
+
+ {
+ const currentItems = [...(selectedComponent.calcItems || [])];
+ currentItems[index] = { ...currentItems[index], label: e.target.value };
+ updateComponent(selectedComponent.id, {
+ calcItems: currentItems,
+ });
+ }}
+ className="h-6 text-xs"
+ placeholder="항목명"
+ />
+
+ {/* 두 번째 항목부터 연산자 표시 */}
+ {index > 0 && (
+
+
+
+
+ )}
+
+
+ {selectedComponent.queryId ? (
+
+
+
+
+ ) : (
+
+
+ {
+ const currentItems = [...(selectedComponent.calcItems || [])];
+ currentItems[index] = {
+ ...currentItems[index],
+ value: Number(e.target.value),
+ };
+ updateComponent(selectedComponent.id, {
+ calcItems: currentItems,
+ });
+ }}
+ className="h-6 text-xs"
+ placeholder="0"
+ />
+
+ )}
+
+
+ ))}
+
+
+
+
+ )}
+
{/* 데이터 바인딩 (텍스트/라벨/테이블 컴포넌트) */}
{(selectedComponent.type === "text" ||
selectedComponent.type === "label" ||
@@ -1120,16 +1833,16 @@ export function ReportDesignerRightPanel() {
{/* 기본값 (텍스트/라벨만) */}
{(selectedComponent.type === "text" || selectedComponent.type === "label") && (
-
- 텍스트 내용
+
)}
diff --git a/frontend/components/report/designer/ReportPreviewModal.tsx b/frontend/components/report/designer/ReportPreviewModal.tsx
index 97b3ac48..ded27f37 100644
--- a/frontend/components/report/designer/ReportPreviewModal.tsx
+++ b/frontend/components/report/designer/ReportPreviewModal.tsx
@@ -13,21 +13,6 @@ import { Printer, FileDown, FileText } from "lucide-react";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
-// @ts-ignore - docx 라이브러리 타입 이슈
-import {
- Document,
- Packer,
- Paragraph,
- TextRun,
- Table,
- TableCell,
- TableRow,
- WidthType,
- ImageRun,
- AlignmentType,
- VerticalAlign,
- convertInchesToTwip,
-} from "docx";
import { getFullImageUrl } from "@/lib/api/client";
interface ReportPreviewModalProps {
@@ -73,6 +58,8 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
pageWidth: number,
pageHeight: number,
backgroundColor: string,
+ pageIndex: number = 0,
+ totalPages: number = 1,
): string => {
const componentsHTML = pageComponents
.map((component) => {
@@ -82,7 +69,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
// Text/Label 컴포넌트
if (component.type === "text" || component.type === "label") {
const displayValue = getComponentValue(component);
- content = `${displayValue}
`;
+ content = `${displayValue}
`;
}
// Image 컴포넌트
@@ -154,6 +141,163 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
`;
}
+ // PageNumber 컴포넌트
+ else if (component.type === "pageNumber") {
+ const format = component.pageNumberFormat || "number";
+ let pageNumberText = "";
+ switch (format) {
+ case "number":
+ pageNumberText = `${pageIndex + 1}`;
+ break;
+ case "numberTotal":
+ pageNumberText = `${pageIndex + 1} / ${totalPages}`;
+ break;
+ case "koreanNumber":
+ pageNumberText = `${pageIndex + 1} 페이지`;
+ break;
+ default:
+ pageNumberText = `${pageIndex + 1}`;
+ }
+ content = `
${pageNumberText}
`;
+ }
+
+ // Card 컴포넌트
+ else if (component.type === "card") {
+ const cardTitle = component.cardTitle || "정보 카드";
+ const cardItems = component.cardItems || [];
+ const labelWidth = component.labelWidth || 80;
+ const showCardTitle = component.showCardTitle !== false;
+ const titleFontSize = component.titleFontSize || 14;
+ const labelFontSize = component.labelFontSize || 13;
+ const valueFontSize = component.valueFontSize || 13;
+ const titleColor = component.titleColor || "#1e40af";
+ const labelColor = component.labelColor || "#374151";
+ const valueColor = component.valueColor || "#000000";
+ const borderColor = component.borderColor || "#e5e7eb";
+
+ // 쿼리 바인딩된 값 가져오기
+ const getCardValue = (item: { label: string; value: string; fieldName?: string }) => {
+ if (item.fieldName && queryResult && queryResult.rows && queryResult.rows.length > 0) {
+ const row = queryResult.rows[0];
+ return row[item.fieldName] !== undefined ? String(row[item.fieldName]) : item.value;
+ }
+ return item.value;
+ };
+
+ const itemsHtml = cardItems
+ .map(
+ (item: { label: string; value: string; fieldName?: string }) => `
+
+ ${item.label}
+ ${getCardValue(item)}
+
+ `
+ )
+ .join("");
+
+ content = `
+
+ ${
+ showCardTitle
+ ? `
+
+ ${cardTitle}
+
+
+ `
+ : ""
+ }
+
+ ${itemsHtml}
+
+
`;
+ }
+
+ // 계산 컴포넌트
+ else if (component.type === "calculation") {
+ const calcItems = component.calcItems || [];
+ const resultLabel = component.resultLabel || "합계";
+ const calcLabelWidth = component.labelWidth || 120;
+ const calcLabelFontSize = component.labelFontSize || 13;
+ const calcValueFontSize = component.valueFontSize || 13;
+ const calcResultFontSize = component.resultFontSize || 16;
+ const calcLabelColor = component.labelColor || "#374151";
+ const calcValueColor = component.valueColor || "#000000";
+ const calcResultColor = component.resultColor || "#2563eb";
+ const numberFormat = component.numberFormat || "currency";
+ const currencySuffix = component.currencySuffix || "원";
+ const borderColor = component.borderColor || "#374151";
+
+ // 숫자 포맷팅 함수
+ const formatNumber = (num: number): string => {
+ if (numberFormat === "none") return String(num);
+ if (numberFormat === "comma") return num.toLocaleString();
+ if (numberFormat === "currency") return num.toLocaleString() + currencySuffix;
+ return String(num);
+ };
+
+ // 쿼리 바인딩된 값 가져오기
+ const getCalcItemValue = (item: { label: string; value: number | string; operator: string; fieldName?: string }): number => {
+ if (item.fieldName && queryResult && queryResult.rows && queryResult.rows.length > 0) {
+ const row = queryResult.rows[0];
+ const val = row[item.fieldName];
+ return typeof val === "number" ? val : parseFloat(String(val)) || 0;
+ }
+ return typeof item.value === "number" ? item.value : parseFloat(String(item.value)) || 0;
+ };
+
+ // 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용)
+ let calcResult = 0;
+ if (calcItems.length > 0) {
+ // 첫 번째 항목은 기준값
+ calcResult = getCalcItemValue(calcItems[0] as { label: string; value: number | string; operator: string; fieldName?: string });
+
+ // 두 번째 항목부터 연산자 적용
+ for (let i = 1; i < calcItems.length; i++) {
+ const item = calcItems[i];
+ const val = getCalcItemValue(item as { label: string; value: number | string; operator: string; fieldName?: string });
+ switch ((item as { operator: string }).operator) {
+ case "+":
+ calcResult += val;
+ break;
+ case "-":
+ calcResult -= val;
+ break;
+ case "x":
+ calcResult *= val;
+ break;
+ case "÷":
+ calcResult = val !== 0 ? calcResult / val : calcResult;
+ break;
+ }
+ }
+ }
+
+ const itemsHtml = calcItems
+ .map((item: { label: string; value: number | string; operator: string; fieldName?: string }) => {
+ const itemValue = getCalcItemValue(item);
+ return `
+
+ ${item.label}
+ ${formatNumber(itemValue)}
+
+ `;
+ })
+ .join("");
+
+ content = `
+
+
+ ${itemsHtml}
+
+
+
+ ${resultLabel}
+ ${formatNumber(calcResult)}
+
+
`;
+ }
+
// Table 컴포넌트
else if (component.type === "table" && queryResult && queryResult.rows.length > 0) {
const columns =
@@ -204,9 +348,20 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
// 모든 페이지 HTML 생성 (인쇄/PDF용)
const generatePrintHTML = (): string => {
- const pagesHTML = layoutConfig.pages
- .sort((a, b) => a.page_order - b.page_order)
- .map((page) => generatePageHTML(page.components, page.width, page.height, page.background_color))
+ const sortedPages = layoutConfig.pages.sort((a, b) => a.page_order - b.page_order);
+ const totalPages = sortedPages.length;
+
+ const pagesHTML = sortedPages
+ .map((page, pageIndex) =>
+ generatePageHTML(
+ Array.isArray(page.components) ? page.components : [],
+ page.width,
+ page.height,
+ page.background_color,
+ pageIndex,
+ totalPages,
+ ),
+ )
.join('
');
return `
@@ -282,270 +437,94 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
});
};
- // Base64를 Uint8Array로 변환
- const base64ToUint8Array = (base64: string): Uint8Array => {
- const base64Data = base64.split(",")[1] || base64;
- const binaryString = atob(base64Data);
- const bytes = new Uint8Array(binaryString.length);
- for (let i = 0; i < binaryString.length; i++) {
- bytes[i] = binaryString.charCodeAt(i);
- }
- return bytes;
- };
+ // 이미지 URL을 Base64로 변환
+ const imageUrlToBase64 = async (url: string): Promise
=> {
+ try {
+ // 이미 Base64인 경우 그대로 반환
+ if (url.startsWith("data:")) {
+ return url;
+ }
- // 컴포넌트를 TableCell로 변환
- const createTableCell = (component: any, pageWidth: number, widthPercent?: number): TableCell | null => {
- const cellWidth = widthPercent || 100;
+ // 서버 이미지 URL을 fetch하여 Base64로 변환
+ const fullUrl = getFullImageUrl(url);
+ const response = await fetch(fullUrl);
+ const blob = await response.blob();
- if (component.type === "text" || component.type === "label") {
- const value = getComponentValue(component);
- return new TableCell({
- children: [
- new Paragraph({
- children: [
- new TextRun({
- text: value,
- size: (component.fontSize || 13) * 2,
- color: component.fontColor?.replace("#", "") || "000000",
- bold: component.fontWeight === "bold",
- }),
- ],
- alignment:
- component.textAlign === "center"
- ? AlignmentType.CENTER
- : component.textAlign === "right"
- ? AlignmentType.RIGHT
- : AlignmentType.LEFT,
- }),
- ],
- width: { size: cellWidth, type: WidthType.PERCENTAGE },
- verticalAlign: VerticalAlign.CENTER,
- borders: {
- top: { style: 0, size: 0, color: "FFFFFF" },
- bottom: { style: 0, size: 0, color: "FFFFFF" },
- left: { style: 0, size: 0, color: "FFFFFF" },
- right: { style: 0, size: 0, color: "FFFFFF" },
- },
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onloadend = () => resolve(reader.result as string);
+ reader.onerror = reject;
+ reader.readAsDataURL(blob);
});
- } else if (component.type === "signature" || component.type === "stamp") {
- if (component.imageUrl) {
- try {
- const imageData = base64ToUint8Array(component.imageUrl);
- return new TableCell({
- children: [
- new Paragraph({
- children: [
- new ImageRun({
- data: imageData,
- transformation: {
- width: component.width || 150,
- height: component.height || 50,
- },
- }),
- ],
- alignment: AlignmentType.CENTER,
- }),
- ],
- width: { size: cellWidth, type: WidthType.PERCENTAGE },
- verticalAlign: VerticalAlign.CENTER,
- borders: {
- top: { style: 0, size: 0, color: "FFFFFF" },
- bottom: { style: 0, size: 0, color: "FFFFFF" },
- left: { style: 0, size: 0, color: "FFFFFF" },
- right: { style: 0, size: 0, color: "FFFFFF" },
- },
- });
- } catch {
- return new TableCell({
- children: [
- new Paragraph({
- children: [
- new TextRun({
- text: `[${component.type === "signature" ? "서명" : "도장"}]`,
- size: 24,
- }),
- ],
- }),
- ],
- width: { size: cellWidth, type: WidthType.PERCENTAGE },
- borders: {
- top: { style: 0, size: 0, color: "FFFFFF" },
- bottom: { style: 0, size: 0, color: "FFFFFF" },
- left: { style: 0, size: 0, color: "FFFFFF" },
- right: { style: 0, size: 0, color: "FFFFFF" },
- },
- });
- }
- }
- } 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 },
- });
-
- return new TableCell({
- children: [table],
- width: { size: cellWidth, type: WidthType.PERCENTAGE },
- borders: {
- top: { style: 0, size: 0, color: "FFFFFF" },
- bottom: { style: 0, size: 0, color: "FFFFFF" },
- left: { style: 0, size: 0, color: "FFFFFF" },
- right: { style: 0, size: 0, color: "FFFFFF" },
- },
- });
- }
+ } catch (error) {
+ console.error("이미지 변환 실패:", error);
+ return "";
}
-
- return null;
};
- // WORD 다운로드
+ // WORD 다운로드 (백엔드 API 사용 - 컴포넌트 데이터 전송)
const handleDownloadWord = async () => {
setIsExporting(true);
try {
- // 페이지별로 섹션 생성
- const sections = layoutConfig.pages
- .sort((a, b) => a.page_order - b.page_order)
- .map((page) => {
- // 페이지 크기 설정 (A4 기준)
- const pageWidth = convertInchesToTwip(8.27); // A4 width in inches
- const pageHeight = convertInchesToTwip(11.69); // A4 height in inches
- const marginTop = convertInchesToTwip(page.margins.top / 96); // px to inches (96 DPI)
- const marginBottom = convertInchesToTwip(page.margins.bottom / 96);
- const marginLeft = convertInchesToTwip(page.margins.left / 96);
- const marginRight = convertInchesToTwip(page.margins.right / 96);
-
- // 페이지 내 컴포넌트를 Y좌표 기준으로 정렬
- const sortedComponents = [...page.components].sort((a, b) => {
- // Y좌표 우선, 같으면 X좌표
- if (Math.abs(a.y - b.y) < 5) {
- return a.x - b.x;
- }
- return a.y - b.y;
- });
-
- // 컴포넌트들을 행으로 그룹화 (같은 Y 범위 = 같은 행)
- const rows: Array> = [];
- const rowTolerance = 20; // Y 좌표 허용 오차
-
- for (const component of sortedComponents) {
- const existingRow = rows.find((row) => Math.abs(row[0].y - component.y) < rowTolerance);
- if (existingRow) {
- existingRow.push(component);
- } else {
- rows.push([component]);
- }
- }
-
- // 각 행 내에서 X좌표로 정렬
- rows.forEach((row) => row.sort((a, b) => a.x - b.x));
-
- // 페이지 전체를 큰 테이블로 구성 (레이아웃 제어용)
- const tableRows: TableRow[] = [];
-
- for (const row of rows) {
- if (row.length === 1) {
- // 단일 컴포넌트 - 전체 너비 사용
- const component = row[0];
- const cell = createTableCell(component, pageWidth);
- if (cell) {
- tableRows.push(
- new TableRow({
- children: [cell],
- height: { value: component.height * 15, rule: 1 }, // 최소 높이 설정
- }),
- );
- }
- } else {
- // 여러 컴포넌트 - 가로 배치
- const cells: TableCell[] = [];
- const totalWidth = row.reduce((sum, c) => sum + c.width, 0);
-
- for (const component of row) {
- const widthPercent = (component.width / totalWidth) * 100;
- const cell = createTableCell(component, pageWidth, widthPercent);
- if (cell) {
- cells.push(cell);
- }
- }
-
- if (cells.length > 0) {
- const maxHeight = Math.max(...row.map((c) => c.height));
- tableRows.push(
- new TableRow({
- children: cells,
- height: { value: maxHeight * 15, rule: 1 },
- }),
- );
- }
- }
- }
-
- return {
- properties: {
- page: {
- width: pageWidth,
- height: pageHeight,
- margin: {
- top: marginTop,
- bottom: marginBottom,
- left: marginLeft,
- right: marginRight,
- },
- },
- },
- children:
- tableRows.length > 0
- ? [
- new Table({
- rows: tableRows,
- width: { size: 100, type: WidthType.PERCENTAGE },
- borders: {
- top: { style: 0, size: 0, color: "FFFFFF" },
- bottom: { style: 0, size: 0, color: "FFFFFF" },
- left: { style: 0, size: 0, color: "FFFFFF" },
- right: { style: 0, size: 0, color: "FFFFFF" },
- insideHorizontal: { style: 0, size: 0, color: "FFFFFF" },
- insideVertical: { style: 0, size: 0, color: "FFFFFF" },
- },
- }),
- ]
- : [new Paragraph({ text: "" })],
- };
- });
-
- // 문서 생성
- const doc = new Document({
- sections,
+ toast({
+ title: "처리 중",
+ description: "WORD 파일을 생성하고 있습니다...",
});
- // Blob 생성 및 다운로드
- const blob = await Packer.toBlob(doc);
- const fileName = reportDetail?.report?.report_name_kor || "리포트";
- const timestamp = new Date().toISOString().slice(0, 10);
+ // 이미지를 Base64로 변환하여 컴포넌트 데이터에 포함
+ const pagesWithBase64 = await Promise.all(
+ layoutConfig.pages.map(async (page) => {
+ const componentsWithBase64 = await Promise.all(
+ (Array.isArray(page.components) ? page.components : []).map(async (component) => {
+ // 이미지가 있는 컴포넌트는 Base64로 변환
+ if (component.imageUrl) {
+ try {
+ const base64 = await imageUrlToBase64(component.imageUrl);
+ return { ...component, imageBase64: base64 };
+ } catch {
+ return component;
+ }
+ }
+ return component;
+ }),
+ );
+ return { ...page, components: componentsWithBase64 };
+ }),
+ );
+ // 쿼리 결과 수집
+ const queryResults: Record[] }> = {};
+ for (const page of layoutConfig.pages) {
+ const pageComponents = Array.isArray(page.components) ? page.components : [];
+ for (const component of pageComponents) {
+ if (component.queryId) {
+ const result = getQueryResult(component.queryId);
+ if (result) {
+ queryResults[component.queryId] = result;
+ }
+ }
+ }
+ }
+
+ const fileName = reportDetail?.report?.report_name_kor || "리포트";
+
+ // 백엔드 API 호출 (컴포넌트 데이터 전송)
+ const { apiClient } = await import("@/lib/api/client");
+ const response = await apiClient.post(
+ "/admin/reports/export-word",
+ {
+ layoutConfig: { ...layoutConfig, pages: pagesWithBase64 },
+ queryResults,
+ fileName,
+ },
+ { responseType: "blob" },
+ );
+
+ // Blob 다운로드
+ const blob = new Blob([response.data], {
+ type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ });
+ const timestamp = new Date().toISOString().slice(0, 10);
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
@@ -558,6 +537,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
description: "WORD 파일이 다운로드되었습니다.",
});
} catch (error) {
+ console.error("WORD 변환 오류:", error);
const errorMessage = error instanceof Error ? error.message : "WORD 생성에 실패했습니다.";
toast({
title: "오류",
@@ -586,11 +566,6 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
.sort((a, b) => a.page_order - b.page_order)
.map((page) => (
- {/* 페이지 번호 라벨 */}
-
- 페이지 {page.page_order + 1} - {page.page_name}
-
-
{/* 페이지 컨텐츠 */}
- {page.components.map((component) => {
+ {(Array.isArray(page.components) ? page.components : []).map((component) => {
const displayValue = getComponentValue(component);
const queryResult = component.queryId ? getQueryResult(component.queryId) : null;
@@ -627,6 +602,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
color: component.fontColor,
fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right",
+ whiteSpace: "pre-wrap",
}}
>
{displayValue}
@@ -640,6 +616,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
color: component.fontColor,
fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right",
+ whiteSpace: "pre-wrap",
}}
>
{displayValue}
@@ -886,6 +863,256 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
)}
+
+ {component.type === "pageNumber" && (() => {
+ const format = component.pageNumberFormat || "number";
+ const pageIndex = layoutConfig.pages
+ .sort((a, b) => a.page_order - b.page_order)
+ .findIndex((p) => p.page_id === page.page_id);
+ const totalPages = layoutConfig.pages.length;
+ let pageNumberText = "";
+ switch (format) {
+ case "number":
+ pageNumberText = `${pageIndex + 1}`;
+ break;
+ case "numberTotal":
+ pageNumberText = `${pageIndex + 1} / ${totalPages}`;
+ break;
+ case "koreanNumber":
+ pageNumberText = `${pageIndex + 1} 페이지`;
+ break;
+ default:
+ pageNumberText = `${pageIndex + 1}`;
+ }
+ return (
+
+ {pageNumberText}
+
+ );
+ })()}
+
+ {/* Card 컴포넌트 */}
+ {component.type === "card" && (() => {
+ const cardTitle = component.cardTitle || "정보 카드";
+ const cardItems = component.cardItems || [];
+ const labelWidth = component.labelWidth || 80;
+ const showCardTitle = component.showCardTitle !== false;
+ const titleFontSize = component.titleFontSize || 14;
+ const labelFontSize = component.labelFontSize || 13;
+ const valueFontSize = component.valueFontSize || 13;
+ const titleColor = component.titleColor || "#1e40af";
+ const labelColor = component.labelColor || "#374151";
+ const valueColor = component.valueColor || "#000000";
+ const borderColor = component.borderColor || "#e5e7eb";
+
+ // 쿼리 바인딩된 값 가져오기
+ const getCardValue = (item: { label: string; value: string; fieldName?: string }) => {
+ if (item.fieldName && component.queryId) {
+ const qResult = getQueryResult(component.queryId);
+ if (qResult && qResult.rows && qResult.rows.length > 0) {
+ const row = qResult.rows[0];
+ return row[item.fieldName] !== undefined ? String(row[item.fieldName]) : item.value;
+ }
+ }
+ return item.value;
+ };
+
+ return (
+
+ {showCardTitle && (
+ <>
+
+ {cardTitle}
+
+
+ >
+ )}
+
+ {cardItems.map((item: { label: string; value: string; fieldName?: string }, idx: number) => (
+
+
+ {item.label}
+
+
+ {getCardValue(item)}
+
+
+ ))}
+
+
+ );
+ })()}
+
+ {/* 계산 컴포넌트 */}
+ {component.type === "calculation" && (() => {
+ const calcItems = component.calcItems || [];
+ const resultLabel = component.resultLabel || "합계";
+ const calcLabelWidth = component.labelWidth || 120;
+ const calcLabelFontSize = component.labelFontSize || 13;
+ const calcValueFontSize = component.valueFontSize || 13;
+ const calcResultFontSize = component.resultFontSize || 16;
+ const calcLabelColor = component.labelColor || "#374151";
+ const calcValueColor = component.valueColor || "#000000";
+ const calcResultColor = component.resultColor || "#2563eb";
+ const numberFormat = component.numberFormat || "currency";
+ const currencySuffix = component.currencySuffix || "원";
+ const borderColor = component.borderColor || "#374151";
+
+ // 숫자 포맷팅 함수
+ const formatNumber = (num: number): string => {
+ if (numberFormat === "none") return String(num);
+ if (numberFormat === "comma") return num.toLocaleString();
+ if (numberFormat === "currency") return num.toLocaleString() + currencySuffix;
+ return String(num);
+ };
+
+ // 쿼리 바인딩된 값 가져오기
+ const getCalcItemValue = (item: { label: string; value: number | string; operator: string; fieldName?: string }): number => {
+ if (item.fieldName && component.queryId) {
+ const qResult = getQueryResult(component.queryId);
+ if (qResult && qResult.rows && qResult.rows.length > 0) {
+ const row = qResult.rows[0];
+ const val = row[item.fieldName];
+ return typeof val === "number" ? val : parseFloat(String(val)) || 0;
+ }
+ }
+ return typeof item.value === "number" ? item.value : parseFloat(String(item.value)) || 0;
+ };
+
+ // 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용)
+ let calcResult = 0;
+ if (calcItems.length > 0) {
+ // 첫 번째 항목은 기준값
+ calcResult = getCalcItemValue(calcItems[0] as { label: string; value: number | string; operator: string; fieldName?: string });
+
+ // 두 번째 항목부터 연산자 적용
+ for (let i = 1; i < calcItems.length; i++) {
+ const item = calcItems[i];
+ const val = getCalcItemValue(item as { label: string; value: number | string; operator: string; fieldName?: string });
+ switch ((item as { operator: string }).operator) {
+ case "+":
+ calcResult += val;
+ break;
+ case "-":
+ calcResult -= val;
+ break;
+ case "x":
+ calcResult *= val;
+ break;
+ case "÷":
+ calcResult = val !== 0 ? calcResult / val : calcResult;
+ break;
+ }
+ }
+ }
+
+ return (
+
+
+ {calcItems.map((item: { label: string; value: number | string; operator: string; fieldName?: string }, idx: number) => {
+ const itemValue = getCalcItemValue(item);
+ return (
+
+
+ {item.label}
+
+
+ {formatNumber(itemValue)}
+
+
+ );
+ })}
+
+
+
+
+ {resultLabel}
+
+
+ {formatNumber(calcResult)}
+
+
+
+ );
+ })()}
);
})}
diff --git a/frontend/components/report/designer/TemplatePalette.tsx b/frontend/components/report/designer/TemplatePalette.tsx
index 276ccff4..268b2dcc 100644
--- a/frontend/components/report/designer/TemplatePalette.tsx
+++ b/frontend/components/report/designer/TemplatePalette.tsx
@@ -6,7 +6,6 @@ import { Trash2, Loader2, RefreshCw } from "lucide-react";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
-import { Badge } from "@/components/ui/badge";
interface Template {
template_id: string;
@@ -17,7 +16,6 @@ interface Template {
export function TemplatePalette() {
const { applyTemplate } = useReportDesigner();
- const [systemTemplates, setSystemTemplates] = useState