From 0decfe95de1d86b5141a28309ebb5c4d7c0f291b Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 22 Dec 2025 13:58:12 +0900 Subject: [PATCH] =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0/=EC=9D=B8?= =?UTF-8?q?=EC=87=84=EC=97=90=20=EB=B0=94=EC=BD=94=EB=93=9C,=20QR=EC=BD=94?= =?UTF-8?q?=EB=93=9C,=20=EC=B2=B4=ED=81=AC=EB=B0=95=EC=8A=A4=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=EB=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report/designer/CanvasComponent.tsx | 2 +- .../report/designer/ReportPreviewModal.tsx | 410 +++++++++++++++++- 2 files changed, 404 insertions(+), 8 deletions(-) diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx index 8816bfe4..1329f847 100644 --- a/frontend/components/report/designer/CanvasComponent.tsx +++ b/frontend/components/report/designer/CanvasComponent.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useState, useEffect, useCallback } from "react"; +import { useRef, useState, useEffect } from "react"; import { ComponentConfig } from "@/types/report"; import { useReportDesigner } from "@/contexts/ReportDesignerContext"; import { getFullImageUrl } from "@/lib/api/client"; diff --git a/frontend/components/report/designer/ReportPreviewModal.tsx b/frontend/components/report/designer/ReportPreviewModal.tsx index ded27f37..0851fa92 100644 --- a/frontend/components/report/designer/ReportPreviewModal.tsx +++ b/frontend/components/report/designer/ReportPreviewModal.tsx @@ -11,15 +11,162 @@ import { import { Button } from "@/components/ui/button"; import { Printer, FileDown, FileText } from "lucide-react"; import { useReportDesigner } from "@/contexts/ReportDesignerContext"; -import { useState } from "react"; +import { useState, useRef, useEffect } from "react"; import { useToast } from "@/hooks/use-toast"; import { getFullImageUrl } from "@/lib/api/client"; +import JsBarcode from "jsbarcode"; +import QRCode from "qrcode"; interface ReportPreviewModalProps { isOpen: boolean; onClose: () => void; } +// 바코드/QR코드 미리보기 컴포넌트 +function BarcodePreview({ + component, + getQueryResult, +}: { + component: any; + getQueryResult: (queryId: string) => { fields: string[]; rows: Record[] } | null; +}) { + const svgRef = useRef(null); + const canvasRef = useRef(null); + const [error, setError] = useState(null); + + const barcodeType = component.barcodeType || "CODE128"; + const isQR = barcodeType === "QR"; + + // 바코드 값 결정 + const getBarcodeValue = (): string => { + // QR코드 다중 필드 모드 + 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.barcodeFieldName}}`; + } + return component.barcodeValue || "SAMPLE123"; + }; + + const barcodeValue = getBarcodeValue(); + + useEffect(() => { + setError(null); + + if (isQR) { + // QR코드 렌더링 + if (canvasRef.current && barcodeValue) { + const bgColor = component.barcodeBackground === "transparent" ? "#ffffff" : (component.barcodeBackground || "#ffffff"); + QRCode.toCanvas( + canvasRef.current, + barcodeValue, + { + width: Math.min(component.width, component.height) - 20, + margin: 2, + color: { + dark: component.barcodeColor || "#000000", + light: bgColor, + }, + errorCorrectionLevel: component.qrErrorCorrectionLevel || "M", + }, + (err) => { + if (err) setError(err.message || "QR코드 생성 실패"); + } + ); + } + } else { + // 1D 바코드 렌더링 + if (svgRef.current && barcodeValue) { + try { + const bgColor = component.barcodeBackground === "transparent" ? "" : (component.barcodeBackground || "#ffffff"); + JsBarcode(svgRef.current, 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, + }); + } catch (err: any) { + setError(err?.message || "바코드 생성 실패"); + } + } + } + }, [barcodeValue, barcodeType, isQR, component]); + + if (error) { + return ( +
+ {error} +
+ ); + } + + return ( +
+ {isQR ? ( + + ) : ( + + )} +
+ ); +} + export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) { const { layoutConfig, getQueryResult, reportDetail } = useReportDesigner(); const [isExporting, setIsExporting] = useState(false); @@ -40,9 +187,131 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) return component.defaultValue || "텍스트"; }; - const handlePrint = () => { + // 바코드/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(); + const printHtml = generatePrintHTML(pagesWithBarcodes); const printWindow = window.open("", "_blank"); if (!printWindow) return; @@ -298,6 +567,46 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) `; } + // 바코드/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" && queryResult && queryResult.rows.length > 0) { const columns = @@ -347,8 +656,9 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) }; // 모든 페이지 HTML 생성 (인쇄/PDF용) - const generatePrintHTML = (): string => { - const sortedPages = layoutConfig.pages.sort((a, b) => a.page_order - b.page_order); + 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 @@ -422,8 +732,24 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) }; // PDF 다운로드 (브라우저 인쇄 기능 이용) - const handleDownloadPDF = () => { - const printHtml = generatePrintHTML(); + const handleDownloadPDF = 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 }; + }) + ); + + const printHtml = generatePrintHTML(pagesWithBarcodes); const printWindow = window.open("", "_blank"); if (!printWindow) return; @@ -1113,6 +1439,76 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) ); })()} + + {/* 바코드/QR코드 컴포넌트 */} + {component.type === "barcode" && ( + + )} + + {/* 체크박스 컴포넌트 */} + {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 && component.queryId) { + const qResult = getQueryResult(component.queryId); + if (qResult && qResult.rows && qResult.rows.length > 0) { + const val = qResult.rows[0][component.checkboxFieldName]; + isChecked = val === true || val === "Y" || val === "1" || val === 1 || val === "true"; + } + } + + return ( +
+
+ {isChecked && ( + + + + )} +
+ {checkboxLabel && ( + {checkboxLabel} + )} +
+ ); + })()} ); })}