"use client"; import React, { useEffect, useRef, useState } from "react"; import { FileDown, FileText, Loader2, Printer } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ComponentConfig, GridCell, ReportPage, WatermarkConfig } from "@/types/report"; import { useReportRenderer, QueryResult } from "@/hooks/useReportRenderer"; 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 ReportInlineViewerProps { reportId: string | null; contextParams?: Record; className?: string; showToolbar?: boolean; /** 컴포넌트 클릭 콜백 — 편집 모드에서 사용 */ onComponentClick?: (component: ComponentConfig) => void; /** cellType="input" 셀의 커스텀 값 오버라이드: { [componentId]: { [cellId]: value } } */ cellOverrides?: Record>; /** input 셀 클릭 콜백 */ onInputCellClick?: (component: ComponentConfig, cell: GridCell) => void; } export function ReportInlineViewer({ reportId, contextParams, className = "", showToolbar = true, onComponentClick, cellOverrides, onInputCellClick, }: ReportInlineViewerProps) { const { detail, pages, watermark, getQueryResult, isLoading } = useReportRenderer(reportId, contextParams); const containerRef = useRef(null); const previewRef = useRef(null); const [scale, setScale] = useState(1); const [isExporting, setIsExporting] = useState(false); const editable = !!onComponentClick; useEffect(() => { if (!containerRef.current || pages.length === 0) return; const calculateScale = () => { const container = containerRef.current; if (!container) return; const pageWidthPx = pages[0].width * MM_TO_PX; const availableWidth = container.clientWidth - 48; setScale(Math.min(availableWidth / pageWidthPx, 1)); }; const observer = new ResizeObserver(calculateScale); observer.observe(containerRef.current); calculateScale(); return () => observer.disconnect(); }, [pages]); 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(`${detail?.report?.report_name_kor ?? "report"}.pdf`); } catch { /* 무시 */ } finally { setIsExporting(false); } }; const handlePrint = () => { if (!previewRef.current || pages.length === 0) return; // 1) body의 기존 자식들 숨기기 const bodyChildren = Array.from(document.body.children) as HTMLElement[]; bodyChildren.forEach((el) => { el.setAttribute("data-print-hidden", "true"); }); // 2) 프리뷰 내용을 body 직속에 복제 (스케일 제거, 원본 크기) const printDiv = document.createElement("div"); printDiv.id = "report-print-root"; // 각 페이지를 원본 크기로 복제 const pageEls = previewRef.current.querySelectorAll("[data-list-preview-page]"); pageEls.forEach((el) => { const clone = el.cloneNode(true) as HTMLElement; clone.style.boxShadow = "none"; clone.style.position = "relative"; clone.style.pageBreakAfter = "always"; clone.style.margin = "0 auto"; printDiv.appendChild(clone); }); document.body.appendChild(printDiv); // 3) 스타일 const style = document.createElement("style"); style.id = "report-print-style"; style.textContent = ` @media print { @page { margin: 0; } * { print-color-adjust: exact !important; -webkit-print-color-adjust: exact !important; } [data-print-hidden] { display: none !important; } #report-print-root { display: block !important; } #report-print-root [data-list-preview-page] { page-break-after: always; } #report-print-root [data-list-preview-page]:last-child { page-break-after: auto; } } #report-print-root { display: none; } `; document.head.appendChild(style); // 4) 인쇄 후 정리 const cleanup = () => { printDiv.remove(); style.remove(); bodyChildren.forEach((el) => { el.removeAttribute("data-print-hidden"); }); window.removeEventListener("afterprint", cleanup); }; window.addEventListener("afterprint", cleanup); setTimeout(() => window.print(), 100); }; if (!reportId) { return (

리포트 양식을 선택해주세요

); } return (
{showToolbar && pages.length > 0 && !isLoading && (
)}
{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, editable, onComponentClick, cellOverrides, onInputCellClick }: { page: ReportPage; pageIndex: number; totalPages: number; pages: ReportPage[]; watermark?: WatermarkConfig; getQueryResult: (queryId: string) => QueryResult | null; editable?: boolean; onComponentClick?: (comp: ComponentConfig) => void; cellOverrides?: Record>; onInputCellClick?: (component: ComponentConfig, cell: GridCell) => void; }) { const comps = page.components ?? []; const sortedByY = [...comps].sort((a, b) => a.y - b.y); const growableTypes = new Set(["table", "card", "calculation"]); // 실제 렌더링 높이를 측정하여 yOffset 계산 const [measuredHeights, setMeasuredHeights] = useState>({}); const compRefs = useRef>({}); useEffect(() => { // 렌더 후 growable 컴포넌트의 실제 scrollHeight 측정 const newHeights: Record = {}; let changed = false; for (const c of sortedByY) { if (growableTypes.has(c.type)) { const el = compRefs.current[c.id]; if (el) { const actual = el.scrollHeight; if (actual > c.height && actual !== measuredHeights[c.id]) { newHeights[c.id] = actual; changed = true; } } } } if (changed) setMeasuredHeights((prev) => ({ ...prev, ...newHeights })); }); // yOffset 계산 const offsets: Record = {}; let cumulativeShift = 0; for (const c of sortedByY) { offsets[c.id] = cumulativeShift; if (growableTypes.has(c.type)) { const measured = measuredHeights[c.id]; if (measured && measured > c.height) { cumulativeShift += measured - c.height; } } } const totalPageHeight = page.height * MM_TO_PX + cumulativeShift; const setRef = (id: string) => (el: HTMLDivElement | null) => { compRefs.current[id] = el; }; return (
{watermark?.enabled && } {sortedByY.sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0)).map((comp) => ( ))}
); } function ComponentRenderer({ comp, pageIndex, totalPages, pages, getQueryResult, editable, onComponentClick, cellOverrides, onInputCellClick, yOffset = 0, measureRef }: { comp: ComponentConfig; pageIndex: number; totalPages: number; pages: ReportPage[]; getQueryResult: (queryId: string) => QueryResult | null; editable?: boolean; onComponentClick?: (comp: ComponentConfig) => void; cellOverrides?: Record>; onInputCellClick?: (component: ComponentConfig, cell: GridCell) => void; yOffset?: number; measureRef?: (el: HTMLDivElement | null) => void; }) { const [hovered, setHovered] = useState(false); const isDivider = comp.type === "divider"; const isClickable = editable && !isDivider && comp.type !== "pageNumber"; // 데이터 양에 따라 늘어나야 하는 컴포넌트 const growable = comp.type === "table" || comp.type === "card" || comp.type === "calculation"; const baseStyle: React.CSSProperties = { position: "absolute", left: `${comp.x}px`, top: `${comp.y + yOffset}px`, width: `${comp.width}px`, ...(growable ? { minHeight: `${comp.height}px` } : { height: `${comp.height}px` }), boxSizing: "border-box", overflow: growable ? "visible" : "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", // 클릭 가능 시 호버 효과 ...(isClickable ? { cursor: "pointer", transition: "outline 0.15s, box-shadow 0.15s" } : {}), ...(isClickable && hovered ? { outline: "2px solid #3b82f6", outlineOffset: "-1px", boxShadow: "0 0 0 4px rgba(59,130,246,0.15)" } : {}), }; const STATUS_LABELS: Record = { draft: "작성중", pending: "검토중", approved: "승인", rejected: "반려", converted: "수주전환" }; const getComponentValue = (c: ComponentConfig): string => { if (c.queryId && c.fieldName) { const qr = getQueryResult(c.queryId); let val = "-"; if (qr?.rows?.length) { const raw = qr.rows[0][c.fieldName]; if (raw != null && raw !== "") { val = String(raw); if (c.fieldName === "status" && STATUS_LABELS[val]) val = STATUS_LABELS[val]; } } // defaultValue가 있으면 라벨로 표시: "라벨\n값" if (c.defaultValue) return `${c.defaultValue}\n${val}`; return val; } 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 })) }; const handleClick = (e: React.MouseEvent) => { if (!isClickable) return; e.stopPropagation(); onComponentClick?.(comp); }; return (
isClickable && setHovered(true)} onMouseLeave={() => setHovered(false)} > {(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" && }
); }