"use client"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Printer, FileDown, FileText, Loader2 } from "lucide-react"; import { useReportDesigner } from "@/contexts/ReportDesignerContext"; import { reportApi } from "@/lib/api/reportApi"; import { useState, useRef, useEffect } from "react"; import { useToast } from "@/hooks/use-toast"; import { getFullImageUrl } from "@/lib/api/client"; import { WatermarkLayer } from "./WatermarkLayer"; import { ConditionalRule } from "@/types/report"; import { TextRenderer, TableRenderer, ImageRenderer, DividerRenderer, SignatureRenderer, StampRenderer, PageNumberRenderer, CardRenderer, CalculationRenderer, BarcodeCanvasRenderer, CheckboxRenderer } from "./renderers"; import JsBarcode from "jsbarcode"; import QRCode from "qrcode"; import { MM_TO_PX } from "@/lib/report/constants"; import { evaluateConditionalRules } from "@/lib/report/conditionalUtils"; function evaluateConditionalRule( rule: ConditionalRule | undefined, getQueryResult: (queryId: string) => { fields: string[]; rows: Record[] } | null, rules?: ConditionalRule[], ): boolean { const allRules = rules?.length ? rules : rule ? [rule] : []; return evaluateConditionalRules(allRules, getQueryResult); } interface ReportPreviewModalProps { isOpen: boolean; onClose: () => void; } export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) { const { layoutConfig, getQueryResult, reportDetail, reportId, queries, setQueryResult } = useReportDesigner(); const [isExporting, setIsExporting] = useState(false); const [isLoadingQueries, setIsLoadingQueries] = useState(false); const { toast } = useToast(); const previewPagesRef = useRef(null); useEffect(() => { if (!isOpen || queries.length === 0) return; const queriesToRun = queries.filter((q) => { const result = getQueryResult(q.id); return !result || result.rows.length === 0; }); if (queriesToRun.length === 0) return; let cancelled = false; setIsLoadingQueries(true); (async () => { const actualReportId = reportId === "new" ? "__preview__" : reportId; for (const q of queriesToRun) { if (cancelled) break; try { const res = await reportApi.executeQuery(actualReportId, q.id, {}, q.sqlQuery, q.externalConnectionId); if (!cancelled && res.success && res.data) { setQueryResult(q.id, res.data.fields, res.data.rows); } } catch { // 개별 쿼리 실패는 무시 } } if (!cancelled) setIsLoadingQueries(false); })(); return () => { cancelled = true; }; // queries 배열의 id가 변경되면 재실행되도록 queries를 직렬화하여 의존성에 포함 // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen, JSON.stringify(queries.map((q) => q.id)), reportId]); // 컴포넌트의 실제 표시 값 가져오기 const getComponentValue = (component: any): string => { if (component.queryId && component.fieldName) { const queryResult = getQueryResult(component.queryId); if (queryResult && queryResult.rows.length > 0) { const value = queryResult.rows[0][component.fieldName]; if (value !== null && value !== undefined) { return String(value); } } return `{${component.fieldName}}`; } return component.defaultValue || ""; }; // 바코드/QR코드를 base64 이미지로 변환 const generateBarcodeImage = async (component: any): Promise => { const barcodeType = component.barcodeType || "CODE128"; const isQR = barcodeType === "QR"; // 바코드 값 결정 const getBarcodeValue = (): string => { if ( isQR && component.qrUseMultiField && component.qrDataFields && component.qrDataFields.length > 0 && component.queryId ) { const queryResult = getQueryResult(component.queryId); if (queryResult && queryResult.rows && queryResult.rows.length > 0) { if (component.qrIncludeAllRows) { const allRowsData: Record[] = []; queryResult.rows.forEach((row) => { const rowData: Record = {}; component.qrDataFields.forEach((field: { fieldName: string; label: string }) => { if (field.fieldName && field.label) { const val = row[field.fieldName]; rowData[field.label] = val !== null && val !== undefined ? String(val) : ""; } }); allRowsData.push(rowData); }); return JSON.stringify(allRowsData); } const row = queryResult.rows[0]; const jsonData: Record = {}; component.qrDataFields.forEach((field: { fieldName: string; label: string }) => { if (field.fieldName && field.label) { const val = row[field.fieldName]; jsonData[field.label] = val !== null && val !== undefined ? String(val) : ""; } }); return JSON.stringify(jsonData); } } if (component.barcodeFieldName && component.queryId) { const queryResult = getQueryResult(component.queryId); if (queryResult && queryResult.rows && queryResult.rows.length > 0) { if (isQR && component.qrIncludeAllRows) { const allValues = queryResult.rows .map((row) => { const val = row[component.barcodeFieldName]; return val !== null && val !== undefined ? String(val) : ""; }) .filter((v) => v !== ""); return JSON.stringify(allValues); } const row = queryResult.rows[0]; const val = row[component.barcodeFieldName]; if (val !== null && val !== undefined) { return String(val); } } } return component.barcodeValue || "SAMPLE123"; }; const barcodeValue = getBarcodeValue(); try { if (isQR) { // QR코드를 canvas에 렌더링 후 base64로 변환 const canvas = document.createElement("canvas"); const bgColor = component.barcodeBackground === "transparent" ? "#ffffff" : component.barcodeBackground || "#ffffff"; await QRCode.toCanvas(canvas, barcodeValue, { width: Math.min(component.width, component.height) - 10, margin: 2, color: { dark: component.barcodeColor || "#000000", light: bgColor, }, errorCorrectionLevel: component.qrErrorCorrectionLevel || "M", }); return canvas.toDataURL("image/png"); } else { // 1D 바코드를 SVG로 렌더링 후 base64로 변환 const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); const bgColor = component.barcodeBackground === "transparent" ? "" : component.barcodeBackground || "#ffffff"; JsBarcode(svg, barcodeValue.trim(), { format: barcodeType.toLowerCase(), width: 2, height: Math.max(30, component.height - 40), displayValue: component.showBarcodeText !== false, lineColor: component.barcodeColor || "#000000", background: bgColor, margin: component.barcodeMargin ?? 10, fontSize: 12, textMargin: 2, }); const svgData = new XMLSerializer().serializeToString(svg); const svgBase64 = btoa(unescape(encodeURIComponent(svgData))); return `data:image/svg+xml;base64,${svgBase64}`; } } catch (error) { console.error("바코드 생성 오류:", error); return null; } }; const handlePrint = async () => { // 바코드 이미지 미리 생성 const pagesWithBarcodes = await Promise.all( layoutConfig.pages.map(async (page) => { const componentsWithBarcodes = await Promise.all( (Array.isArray(page.components) ? page.components : []).map(async (component) => { if (component.type === "barcode") { const barcodeImage = await generateBarcodeImage(component); return { ...component, barcodeImageBase64: barcodeImage }; } return component; }), ); return { ...page, components: componentsWithBarcodes }; }), ); // HTML 생성하여 인쇄 const printHtml = generatePrintHTML(pagesWithBarcodes); const printWindow = window.open("", "_blank"); if (!printWindow) return; printWindow.document.write(printHtml); printWindow.document.close(); // print()는 HTML 내 스크립트에서 이미지 로드 완료 후 자동 호출됨 }; // 워터마크 HTML 생성 헬퍼 함수 const generateWatermarkHTML = (watermark: any, pageWidth: number, pageHeight: number): string => { if (!watermark?.enabled) return ""; const opacity = watermark.opacity ?? 0.3; const rotation = watermark.rotation ?? -45; // 공통 래퍼 스타일 const wrapperStyle = `position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; overflow: hidden; z-index: 0;`; // 텍스트 컨텐츠 생성 const textContent = watermark.type === "text" ? `${watermark.text || "WATERMARK"}` : watermark.imageUrl ? `` : ""; if (watermark.style === "diagonal") { return `
${textContent}
`; } if (watermark.style === "center") { return `
${textContent}
`; } if (watermark.style === "tile") { const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150; const cols = Math.ceil((pageWidth * MM_TO_PX) / tileSize) + 2; const rows = Math.ceil((pageHeight * MM_TO_PX) / tileSize) + 2; const tileItems = Array.from({ length: rows * cols }) .map( () => `
${textContent}
`, ) .join(""); return `
${tileItems}
`; } return ""; }; // 페이지별 컴포넌트 HTML 생성 const generatePageHTML = ( pageComponents: any[], pageWidth: number, pageHeight: number, backgroundColor: string, pageIndex: number = 0, totalPages: number = 1, watermark?: any, ): string => { const componentsHTML = pageComponents .filter((component) => { const isGrid = component.gridMode === true; const rule = isGrid ? component.gridConditionalRule : component.conditionalRule; const rules = isGrid ? component.gridConditionalRules : component.conditionalRules; return evaluateConditionalRule(rule, getQueryResult, rules); }) .map((component) => { const queryResult = component.queryId ? getQueryResult(component.queryId) : null; let content = ""; // Text/Label 컴포넌트 if (component.type === "text" || component.type === "label") { const displayValue = getComponentValue(component); const isOverflowHidden = component.textOverflow === "clip" || component.textOverflow === "ellipsis"; const vAlignMap: Record = { top: "flex-start", middle: "center", bottom: "flex-end" }; const vAlign = vAlignMap[component.verticalAlign || "top"] || "flex-start"; content = `
${displayValue}
`; } // Image 컴포넌트 else if (component.type === "image" && component.imageUrl) { const imageUrl = component.imageUrl.startsWith("data:") ? component.imageUrl : getFullImageUrl(component.imageUrl); const objectFit = component.objectFit || "contain"; const imageOpacity = component.imageOpacity ?? 1; const imageBorderRadius = component.imageBorderRadius || 0; const hasCrop = component.imageCropWidth != null && component.imageCropHeight != null && component.imageCropWidth < 100; let transformParts = []; if (component.imageRotation) transformParts.push(`rotate(${component.imageRotation}deg)`); if (component.imageFlipH) transformParts.push("scaleX(-1)"); if (component.imageFlipV) transformParts.push("scaleY(-1)"); const transformStr = transformParts.length > 0 ? transformParts.join(" ") : ""; let imageStyle = ""; if (hasCrop) { const scaleX = 100 / (component.imageCropWidth ?? 100); const scaleY = 100 / (component.imageCropHeight ?? 100); const translateX = -(component.imageCropX ?? 0) * scaleX; const translateY = -(component.imageCropY ?? 0) * scaleY; const cropTransform = `translate(${translateX}%, ${translateY}%) scale(${scaleX}, ${scaleY})`; const extraTransform = transformStr ? ` ${transformStr}` : ""; imageStyle = `display: block; width: 100%; height: 100%; object-fit: none; object-position: 0% 0%; opacity: ${imageOpacity}; transform: ${cropTransform}${extraTransform}; transform-origin: 0% 0%;`; } else { imageStyle = `display: block; width: 100%; height: 100%; object-fit: ${objectFit}; opacity: ${imageOpacity};${transformStr ? ` transform: ${transformStr};` : ""}`; } let captionHtml = ""; if (component.imageCaption) { captionHtml = `
${component.imageCaption}
`; } const topCaption = component.imageCaptionPosition === "top" ? captionHtml : ""; const bottomCaption = component.imageCaptionPosition !== "top" ? captionHtml : ""; content = `
${topCaption}
${component.imageAlt ||
${bottomCaption}
`; } // Divider 컴포넌트 else if (component.type === "divider") { const width = component.orientation === "horizontal" ? "100%" : `${component.lineWidth || 1}px`; const height = component.orientation === "vertical" ? "100%" : `${component.lineWidth || 1}px`; content = `
`; } // Signature 컴포넌트 else if (component.type === "signature") { const labelPosition = component.labelPosition || "left"; const showLabel = component.showLabel !== false; const labelText = component.labelText || "서명:"; const imageUrl = component.imageUrl ? component.imageUrl.startsWith("data:") ? component.imageUrl : getFullImageUrl(component.imageUrl) : ""; if (labelPosition === "left" || labelPosition === "right") { content = `
${showLabel ? `
${labelText}
` : ""}
${imageUrl ? `` : ""}
`; } else { content = `
${showLabel && labelPosition === "top" ? `
${labelText}
` : ""}
${imageUrl ? `` : ""}
${showLabel && labelPosition === "bottom" ? `
${labelText}
` : ""}
`; } } // Stamp 컴포넌트 else if (component.type === "stamp") { const showLabel = component.showLabel !== false; const labelText = component.labelText || "(인)"; const personName = component.personName || ""; const imageUrl = component.imageUrl ? component.imageUrl.startsWith("data:") ? component.imageUrl : getFullImageUrl(component.imageUrl) : ""; content = `
${personName ? `
${personName}
` : ""}
${imageUrl ? `` : ""} ${showLabel ? `
${labelText}
` : ""}
`; } // 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)}
`; } // 바코드/QR코드 컴포넌트 (인쇄용 - base64 이미지 사용) else if (component.type === "barcode") { // 바코드 이미지는 미리 생성된 base64 사용 (handlePrint에서 생성) const barcodeImage = (component as any).barcodeImageBase64; if (barcodeImage) { content = ``; } else { content = `
바코드
`; } } // 체크박스 컴포넌트 (인쇄용) else if (component.type === "checkbox") { const checkboxSize = component.checkboxSize || 18; const checkboxColor = component.checkboxColor || "#2563eb"; const checkboxBorderColor = component.checkboxBorderColor || "#6b7280"; const checkboxLabel = component.checkboxLabel || ""; const checkboxLabelPosition = component.checkboxLabelPosition || "right"; // 체크 상태 결정 let isChecked = component.checkboxChecked === true; if (component.checkboxFieldName && queryResult && queryResult.rows && queryResult.rows.length > 0) { const val = queryResult.rows[0][component.checkboxFieldName]; isChecked = val === true || val === "Y" || val === "1" || val === 1 || val === "true"; } const checkboxHTML = `
${isChecked ? `` : ""}
`; content = `
${checkboxHTML} ${checkboxLabel ? `${checkboxLabel}` : ""}
`; } // Table 컴포넌트 — 그리드 양식 모드 else if (component.type === "table" && component.gridMode && component.gridCells) { const gridCells = component.gridCells; const gridRowCount = component.gridRowCount ?? 0; const gridColCount = component.gridColCount ?? 0; const gridColWidths = component.gridColWidths ?? []; const gridRowHeights = component.gridRowHeights ?? []; const dataRow = queryResult?.rows?.[0] as Record | undefined; const findCell = (r: number, c: number) => gridCells.find((gc: any) => gc.row === r && gc.col === c); const totalW = gridColWidths.reduce((a: number, b: number) => a + b, 0); let gridRows = ""; for (let r = 0; r < gridRowCount; r++) { let tds = ""; for (let c = 0; c < gridColCount; c++) { const cell = findCell(r, c); if (!cell || cell.merged) continue; const rSpan = cell.rowSpan ?? 1; const cSpan = cell.colSpan ?? 1; const w = gridColWidths.slice(c, c + cSpan).reduce((a: number, b: number) => a + b, 0); const h = gridRowHeights.slice(r, r + rSpan).reduce((a: number, b: number) => a + b, 0); const borderW = cell.borderStyle === "medium" ? 2 : cell.borderStyle === "thick" ? 3 : cell.borderStyle === "none" ? 0 : 1; let displayValue = cell.value ?? ""; if (cell.cellType === "field" && cell.field && dataRow) { displayValue = String(dataRow[cell.field] ?? ""); } tds += ` 1 ? ` rowspan="${rSpan}"` : ""}${cSpan > 1 ? ` colspan="${cSpan}"` : ""} style="width:${w}px;height:${h}px;min-width:${w}px;background:${cell.backgroundColor || "white"};border:${borderW}px solid #d1d5db;padding:2px 4px;font-size:${cell.fontSize ?? 12}px;font-weight:${cell.fontWeight === "bold" ? 700 : 400};color:${cell.textColor || "#111827"};text-align:${cell.align || "center"};vertical-align:${cell.verticalAlign || "middle"};overflow:hidden;white-space:pre-line;word-break:break-word;">${displayValue}`; } gridRows += `${tds}`; } const colgroup = gridColWidths.map((w: number) => ``).join(""); content = ` ${colgroup}${gridRows}
`; } // Table 컴포넌트 — 기존 단순 테이블 모드 else if (component.type === "table" && queryResult && queryResult.rows.length > 0) { const columns = component.tableColumns && component.tableColumns.length > 0 ? component.tableColumns : queryResult.fields.map((field: string) => ({ field, header: field, align: "left" as const, width: undefined, })); const tableRows = queryResult.rows .map( (row: Record) => ` ${columns.map((col: { field: string; align?: string }) => `${String(row[col.field] ?? "")}`).join("")} `, ) .join(""); content = ` ${columns.map((col: { header: string; align?: string; width?: number }) => ``).join("")} ${tableRows}
${col.header}
`; } // 컴포넌트 값은 px로 저장됨 (캔버스는 pageWidth * MM_TO_PX px) // 인쇄용 mm 단위로 변환: px / MM_TO_PX = mm const xMm = component.x / MM_TO_PX; const yMm = component.y / MM_TO_PX; const widthMm = component.width / MM_TO_PX; const heightMm = component.height / MM_TO_PX; const isDividerComp = component.type === "divider"; const printPadding = isDividerComp ? "0" : component.padding != null ? typeof component.padding === "number" ? `${component.padding}px` : component.padding : "8px"; return `
${content}
`; }) .join(""); const watermarkHTML = generateWatermarkHTML(watermark, pageWidth, pageHeight); const pageNumberHTML = `
${pageIndex + 1}
`; return ` `; }; // 모든 페이지 HTML 생성 (인쇄/PDF용) const generatePrintHTML = (pagesWithBarcodes?: any[]): string => { const pages = pagesWithBarcodes || layoutConfig.pages; const sortedPages = 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, layoutConfig.watermark, // 전체 페이지 공유 워터마크 ), ) .join('
'); return ` 리포트 인쇄 ${pagesHTML} `; }; // PDF 직접 다운로드 (html2canvas + jsPDF) const handleDownloadPDF = async () => { const sortedPages = [...layoutConfig.pages].sort((a, b) => a.page_order - b.page_order); if (sortedPages.length === 0) { toast({ title: "오류", description: "페이지가 없습니다.", variant: "destructive" }); return; } setIsExporting(true); try { const [{ jsPDF }, html2canvas] = await Promise.all([ import("jspdf"), import("html2canvas").then((m) => m.default), ]); const pageElements = previewPagesRef.current?.querySelectorAll("[data-preview-page]"); if (!pageElements || pageElements.length === 0) { toast({ title: "오류", description: "미리보기 요소를 찾을 수 없습니다.", variant: "destructive" }); return; } const firstPage = sortedPages[0]; const doc = new jsPDF({ orientation: firstPage.orientation === "landscape" ? "l" : "p", unit: "mm", format: [firstPage.width, firstPage.height], }); for (let i = 0; i < sortedPages.length; i++) { const pageConfig = sortedPages[i]; const pageEl = pageElements[i] as HTMLElement; if (!pageEl) continue; const canvas = await html2canvas(pageEl, { scale: 2, useCORS: true, allowTaint: true, backgroundColor: pageConfig.background_color || "#ffffff", }); const imgData = canvas.toDataURL("image/jpeg", 0.95); if (i > 0) { doc.addPage([pageConfig.width, pageConfig.height], pageConfig.orientation === "landscape" ? "l" : "p"); } doc.addImage(imgData, "JPEG", 0, 0, pageConfig.width, pageConfig.height); } const fileName = reportDetail?.report?.report_name_kor ? `${reportDetail.report.report_name_kor}.pdf` : `report_${Date.now()}.pdf`; doc.save(fileName); toast({ title: "성공", description: "PDF가 다운로드되었습니다." }); } catch (error) { console.error("PDF 생성 오류:", error); toast({ title: "오류", description: "PDF 생성에 실패했습니다.", variant: "destructive" }); } finally { setIsExporting(false); } }; // 이미지 URL을 Base64로 변환 const imageUrlToBase64 = async (url: string): Promise => { try { // 이미 Base64인 경우 그대로 반환 if (url.startsWith("data:")) { return url; } // 서버 이미지 URL을 fetch하여 Base64로 변환 const fullUrl = getFullImageUrl(url); const response = await fetch(fullUrl); const blob = await response.blob(); return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result as string); reader.onerror = reject; reader.readAsDataURL(blob); }); } catch (error) { console.error("이미지 변환 실패:", error); return ""; } }; // WORD 다운로드 (백엔드 API 사용 - 컴포넌트 데이터 전송) const handleDownloadWord = async () => { setIsExporting(true); try { toast({ title: "처리 중", description: "WORD 파일을 생성하고 있습니다...", }); // 이미지 및 바코드를 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; } } // 바코드/QR코드 컴포넌트는 이미지로 변환 if (component.type === "barcode") { try { const barcodeImage = await generateBarcodeImage(component); return { ...component, barcodeImageBase64: barcodeImage }; } 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; link.download = `${fileName}_${timestamp}.docx`; link.click(); window.URL.revokeObjectURL(url); toast({ title: "성공", description: "WORD 파일이 다운로드되었습니다.", }); } catch (error) { console.error("WORD 변환 오류:", error); const errorMessage = error instanceof Error ? error.message : "WORD 생성에 실패했습니다."; toast({ title: "오류", description: errorMessage, variant: "destructive", }); } finally { setIsExporting(false); } }; const sortedPages = [...layoutConfig.pages].sort((a, b) => a.page_order - b.page_order); return ( 미리보기 현재 레이아웃의 미리보기입니다. 인쇄하거나 파일로 다운로드할 수 있습니다. {/* 미리보기 영역 - 스케일 적용하여 A4 전체 표시 */}
{isLoadingQueries && (
쿼리 데이터를 불러오는 중...
)}
{sortedPages.map((page, pageIdx) => { const pw = page.width * MM_TO_PX; const ph = page.height * MM_TO_PX; return (
{/* 페이지 컨텐츠 */}
{/* 워터마크 렌더링 (전체 페이지 공유) */} {layoutConfig.watermark?.enabled && ( )} {(Array.isArray(page.components) ? page.components : []).map((component) => { const isGrid = component.gridMode === true; const condRule = isGrid ? component.gridConditionalRule : component.conditionalRule; const condRules = isGrid ? component.gridConditionalRules : component.conditionalRules; if (!evaluateConditionalRule(condRule, getQueryResult, condRules)) return null; const displayValue = getComponentValue(component); const queryResult = component.queryId ? getQueryResult(component.queryId) : null; const isDivider = component.type === "divider"; return (
{(component.type === "text" || component.type === "label") && ( )} {/* 그리드 양식 모드 테이블 */} {component.type === "table" && ( )} {component.type === "image" && } {component.type === "divider" && } {component.type === "signature" && } {component.type === "stamp" && } {component.type === "pageNumber" && ( )} {/* Card 컴포넌트 */} {component.type === "card" && ( )} {/* 계산 컴포넌트 */} {component.type === "calculation" && ( )} {/* 바코드/QR코드 컴포넌트 */} {component.type === "barcode" && ( )} {/* 체크박스 컴포넌트 */} {component.type === "checkbox" && ( )}
); })} {/* 자동 페이지 번호 (우측 하단) */}
{pageIdx + 1}
); })}
); }