"use client"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { FileDown, FileText, Loader2 } from "lucide-react"; import { ComponentConfig, ReportDetail, ReportMaster, ReportPage, ReportQuery, WatermarkConfig } from "@/types/report"; import { reportApi } from "@/lib/api/reportApi"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useToast } from "@/hooks/use-toast"; import { getFullImageUrl } from "@/lib/api/client"; import { TextRenderer, TableRenderer, ImageRenderer, DividerRenderer, SignatureRenderer, StampRenderer, PageNumberRenderer, CardRenderer, CalculationRenderer, BarcodeCanvasRenderer, CheckboxRenderer, } from "./designer/renderers"; import { MM_TO_PX } from "@/lib/report/constants"; interface QueryResult { queryId: string; fields: string[]; rows: Record[]; } interface ReportListPreviewModalProps { report: ReportMaster | null; onClose: () => void; /** 컨텍스트에서 자동 주입할 쿼리 파라미터 (formData 기반) */ contextParams?: Record; } export function ReportListPreviewModal({ report, onClose, contextParams }: ReportListPreviewModalProps) { const [detail, setDetail] = useState(null); const [queryResults, setQueryResults] = useState([]); const [isLoading, setIsLoading] = useState(false); const [isExporting, setIsExporting] = useState(false); const previewRef = useRef(null); const { toast } = useToast(); const getQueryResult = useCallback( (queryId: string): QueryResult | null => { return queryResults.find((r) => r.queryId === queryId) || null; }, [queryResults], ); useEffect(() => { if (!report) { setDetail(null); setQueryResults([]); return; } let cancelled = false; setIsLoading(true); (async () => { try { const res = await reportApi.getReportById(report.report_id); if (cancelled || !res.success) return; setDetail(res.data); // 쿼리 자동 실행 const queries: ReportQuery[] = res.data.queries ?? []; if (queries.length === 0) return; // contextParams를 $1, $2 ... 형식으로 매핑 (휴리스틱) const contextEntries = Object.values(contextParams ?? {}); const buildParams = (parameters: string[]): Record => { const result: Record = {}; parameters.forEach((param, idx) => { result[param] = contextEntries[idx] ?? contextParams?.[param] ?? null; }); return result; }; const results: QueryResult[] = []; for (const q of queries) { try { const params = buildParams(q.parameters ?? []); const execRes = await reportApi.executeQuery( report.report_id, q.query_id, params, q.sql_query, q.external_connection_id, ); if (execRes.success && execRes.data) { results.push({ queryId: q.query_id, fields: execRes.data.fields, rows: execRes.data.rows, }); } } catch { // 개별 쿼리 실패는 무시 } } if (!cancelled) setQueryResults(results); } catch { if (!cancelled) { toast({ title: "오류", description: "리포트를 불러올 수 없습니다.", variant: "destructive" }); } } finally { if (!cancelled) setIsLoading(false); } })(); return () => { cancelled = true; }; }, [report?.report_id, contextParams]); const { pages, watermark } = useMemo(() => { const empty = { pages: [] as ReportPage[], watermark: undefined as WatermarkConfig | undefined }; if (!detail?.layout) return empty; const layout = detail.layout as unknown as Record; let config: Record | null = null; let raw: unknown = layout.components; while (typeof raw === "string") { try { raw = JSON.parse(raw); } catch { break; } } if (raw && typeof raw === "object" && !Array.isArray(raw)) { config = raw as Record; } if (!config && Array.isArray(layout.pages)) { config = layout; } if (!config) return empty; const foundPages = Array.isArray(config.pages) ? (config.pages as ReportPage[]) : []; const foundWatermark = config.watermark as WatermarkConfig | undefined; return { pages: foundPages, watermark: foundWatermark }; }, [detail?.layout]); const handleDownloadPDF = async () => { if (!previewRef.current || pages.length === 0) return; setIsExporting(true); try { const [{ jsPDF }, html2canvas] = await Promise.all([ import("jspdf"), import("html2canvas").then((m) => m.default), ]); const pageEls = previewRef.current.querySelectorAll("[data-list-preview-page]"); if (pageEls.length === 0) return; const firstPage = pages[0]; const doc = new jsPDF({ orientation: firstPage.orientation === "landscape" ? "l" : "p", unit: "mm", format: [firstPage.width, firstPage.height], }); for (let i = 0; i < pageEls.length; i++) { const canvas = await html2canvas(pageEls[i], { scale: 2, useCORS: true, allowTaint: true, backgroundColor: "#ffffff", }); if (i > 0) { const p = pages[i] ?? firstPage; doc.addPage([p.width, p.height], p.orientation === "landscape" ? "l" : "p"); } const p = pages[i] ?? firstPage; doc.addImage(canvas.toDataURL("image/jpeg", 0.92), "JPEG", 0, 0, p.width, p.height); } doc.save(`${report?.report_name_kor ?? "report"}.pdf`); } catch { toast({ title: "오류", description: "PDF 다운로드에 실패했습니다.", variant: "destructive" }); } finally { setIsExporting(false); } }; const containerRef = useRef(null); const [scale, setScale] = useState(1); useEffect(() => { if (!containerRef.current || pages.length === 0) return; const calculateScale = () => { const container = containerRef.current; if (!container) return; const firstPage = pages[0]; const pageWidthPx = firstPage.width * MM_TO_PX; const pageHeightPx = firstPage.height * MM_TO_PX; const availableWidth = container.clientWidth - 48; const availableHeight = container.clientHeight - 48; const scaleX = availableWidth / pageWidthPx; const scaleY = availableHeight / pageHeightPx; setScale(Math.min(scaleX, scaleY, 1)); }; const observer = new ResizeObserver(calculateScale); observer.observe(containerRef.current); calculateScale(); return () => observer.disconnect(); }, [pages]); return ( !open && onClose()}> {report?.report_name_kor ?? "리포트 미리보기"}
{isLoading ? (
) : pages.length === 0 ? (

{detail ? "저장된 레이아웃이 없습니다." : "데이터를 불러오는 중..."}

) : (
{[...pages] .sort((a, b) => a.page_order - b.page_order) .map((page, pageIndex) => (
))}
)}
); } function WatermarkLayer({ watermark, pageWidth, pageHeight, }: { watermark: WatermarkConfig; pageWidth: number; pageHeight: number; }) { const baseStyle: React.CSSProperties = { position: "absolute", top: 0, left: 0, width: "100%", height: "100%", pointerEvents: "none", overflow: "hidden", zIndex: 0, }; const rotation = watermark.rotation ?? -45; const textOrImage = watermark.type === "text" ? ( {watermark.text || "WATERMARK"} ) : watermark.imageUrl ? ( // eslint-disable-next-line @next/next/no-img-element ) : null; if (watermark.style === "diagonal") { return (
{textOrImage}
); } if (watermark.style === "center") { return (
{textOrImage}
); } 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; return (
{Array.from({ length: rows * cols }).map((_, i) => (
{textOrImage}
))}
); } return null; } function PagePreview({ page, pageIndex, totalPages, pages, watermark, getQueryResult, }: { page: ReportPage; pageIndex: number; totalPages: number; pages: ReportPage[]; watermark?: WatermarkConfig; getQueryResult: (queryId: string) => QueryResult | null; }) { return (
{watermark?.enabled && } {(page.components ?? []) .sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0)) .map((comp) => ( ))}
); } function BarcodePreviewRenderer({ component, getQueryResult, }: { component: ComponentConfig; getQueryResult: (queryId: string) => QueryResult | null; }) { return ; } function ComponentRenderer({ comp, pageIndex, totalPages, pages, getQueryResult, }: { comp: ComponentConfig; pageIndex: number; totalPages: number; pages: ReportPage[]; getQueryResult: (queryId: string) => QueryResult | null; }) { const isDivider = comp.type === "divider"; const baseStyle: React.CSSProperties = { position: "absolute", left: `${comp.x}px`, top: `${comp.y}px`, width: `${comp.width}px`, height: `${comp.height}px`, boxSizing: "border-box", overflow: "hidden", zIndex: comp.zIndex ?? 0, backgroundColor: comp.backgroundColor || "transparent", ...(comp.borderWidth ? { borderWidth: `${comp.borderWidth}px`, borderColor: comp.borderColor || "#000", borderStyle: "solid" } : {}), ...(comp.borderRadius ? { borderRadius: `${comp.borderRadius}px` } : {}), padding: isDivider ? 0 : comp.padding != null ? typeof comp.padding === "number" ? `${comp.padding}px` : comp.padding : "8px", }; const getComponentValue = (c: ComponentConfig): string => { if (c.queryId && c.fieldName) { const qr = getQueryResult(c.queryId); if (qr?.rows?.length) { const val = qr.rows[0][c.fieldName]; if (val != null) return String(val); } return `{${c.fieldName}}`; } return c.defaultValue || ""; }; const displayValue = getComponentValue(comp); const sortedPages = [...pages].sort((a, b) => a.page_order - b.page_order); const currentPageId = sortedPages[pageIndex]?.page_id ?? null; const layoutConfig = { pages: sortedPages.map((p) => ({ page_id: p.page_id, page_order: p.page_order })) }; return (
{(comp.type === "text" || comp.type === "label") && ( )} {comp.type === "table" && ( )} {comp.type === "image" && } {comp.type === "divider" && } {comp.type === "signature" && } {comp.type === "stamp" && } {comp.type === "pageNumber" && ( )} {comp.type === "card" && ( )} {comp.type === "calculation" && ( )} {comp.type === "barcode" && ( )} {comp.type === "checkbox" && ( )}
); }