ERP-node/frontend/components/report/ReportListPreviewModal.tsx

588 lines
18 KiB
TypeScript

"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<string, unknown>[];
}
interface ReportListPreviewModalProps {
report: ReportMaster | null;
onClose: () => void;
/** 컨텍스트에서 자동 주입할 쿼리 파라미터 (formData 기반) */
contextParams?: Record<string, unknown>;
}
export function ReportListPreviewModal({ report, onClose, contextParams }: ReportListPreviewModalProps) {
const [detail, setDetail] = useState<ReportDetail | null>(null);
const [queryResults, setQueryResults] = useState<QueryResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const previewRef = useRef<HTMLDivElement>(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<string, unknown> => {
const result: Record<string, unknown> = {};
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<string, unknown>;
let config: Record<string, unknown> | 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<string, unknown>;
}
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<HTMLElement>("[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<HTMLDivElement>(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 (
<Dialog open={!!report} onOpenChange={(open) => !open && onClose()}>
<DialogContent
className="flex flex-col gap-0 p-0"
style={{ height: "85vh", width: "calc(85vh / 1.414)", maxWidth: "95vw" }}
>
<DialogHeader className="shrink-0 border-b px-7 py-5">
<DialogTitle className="flex items-center gap-2.5 text-lg">
<FileText className="h-6 w-6 text-blue-600" />
{report?.report_name_kor ?? "리포트 미리보기"}
</DialogTitle>
</DialogHeader>
<div ref={containerRef} className="min-h-0 flex-1 overflow-auto bg-gray-100">
{isLoading ? (
<div className="flex h-72 items-center justify-center">
<Loader2 className="h-10 w-10 animate-spin text-gray-400" />
</div>
) : pages.length === 0 ? (
<div className="flex h-72 flex-col items-center justify-center gap-3 text-gray-400">
<FileText className="h-14 w-14 opacity-30" />
<p className="text-base">{detail ? "저장된 레이아웃이 없습니다." : "데이터를 불러오는 중..."}</p>
</div>
) : (
<div ref={previewRef} className="flex flex-col items-center p-6" style={{ gap: `${24 * scale}px` }}>
{[...pages]
.sort((a, b) => a.page_order - b.page_order)
.map((page, pageIndex) => (
<div
key={page.page_id}
style={{
width: `${Math.ceil(page.width * MM_TO_PX * scale) + 1}px`,
height: `${Math.ceil(page.height * MM_TO_PX * scale) + 1}px`,
flexShrink: 0,
overflow: "hidden",
}}
>
<div
style={{
transform: `scale(${scale})`,
transformOrigin: "top left",
width: `${page.width * MM_TO_PX}px`,
height: `${page.height * MM_TO_PX}px`,
}}
>
<PagePreview
page={page}
pageIndex={pageIndex}
totalPages={pages.length}
pages={pages}
watermark={watermark}
getQueryResult={getQueryResult}
/>
</div>
</div>
))}
</div>
)}
</div>
<DialogFooter className="shrink-0 border-t bg-white px-7 py-5">
<Button variant="outline" onClick={onClose} className="text-base">
</Button>
<Button
onClick={handleDownloadPDF}
disabled={isExporting || isLoading || pages.length === 0}
className="gap-2 bg-blue-600 text-base text-white hover:bg-blue-700"
>
{isExporting ? <Loader2 className="h-5 w-5 animate-spin" /> : <FileDown className="h-5 w-5" />}
PDF
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
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" ? (
<span
style={{
fontSize: `${watermark.fontSize || 48}px`,
color: watermark.fontColor || "#cccccc",
fontWeight: "bold",
userSelect: "none",
whiteSpace: "nowrap",
}}
>
{watermark.text || "WATERMARK"}
</span>
) : watermark.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={watermark.imageUrl.startsWith("data:") ? watermark.imageUrl : getFullImageUrl(watermark.imageUrl)}
alt=""
style={{ maxWidth: "50%", maxHeight: "50%", objectFit: "contain" }}
/>
) : null;
if (watermark.style === "diagonal") {
return (
<div style={baseStyle}>
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
opacity: watermark.opacity,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{textOrImage}
</div>
</div>
);
}
if (watermark.style === "center") {
return (
<div style={baseStyle}>
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
opacity: watermark.opacity,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{textOrImage}
</div>
</div>
);
}
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 (
<div style={baseStyle}>
<div
style={{
position: "absolute",
top: "-50%",
left: "-50%",
width: "200%",
height: "200%",
display: "flex",
flexWrap: "wrap",
alignContent: "flex-start",
transform: `rotate(${rotation}deg)`,
opacity: watermark.opacity,
}}
>
{Array.from({ length: rows * cols }).map((_, i) => (
<div
key={i}
style={{
width: `${tileSize}px`,
height: `${tileSize}px`,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{textOrImage}
</div>
))}
</div>
</div>
);
}
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 (
<div
data-list-preview-page={page.page_id}
className="relative shadow-md"
style={{
width: `${page.width * MM_TO_PX}px`,
height: `${page.height * MM_TO_PX}px`,
backgroundColor: page.background_color || "#ffffff",
flexShrink: 0,
overflow: "visible",
}}
>
{watermark?.enabled && <WatermarkLayer watermark={watermark} pageWidth={page.width} pageHeight={page.height} />}
{(page.components ?? [])
.sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0))
.map((comp) => (
<ComponentRenderer
key={comp.id}
comp={comp}
pageIndex={pageIndex}
totalPages={totalPages}
pages={pages}
getQueryResult={getQueryResult}
/>
))}
</div>
);
}
function BarcodePreviewRenderer({
component,
getQueryResult,
}: {
component: ComponentConfig;
getQueryResult: (queryId: string) => QueryResult | null;
}) {
return <BarcodeCanvasRenderer component={component} getQueryResult={getQueryResult} />;
}
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 (
<div style={baseStyle}>
{(comp.type === "text" || comp.type === "label") && (
<TextRenderer component={comp} displayValue={displayValue} getQueryResult={getQueryResult} />
)}
{comp.type === "table" && (
<TableRenderer component={comp} getQueryResult={getQueryResult} />
)}
{comp.type === "image" && <ImageRenderer component={comp} />}
{comp.type === "divider" && <DividerRenderer component={comp} />}
{comp.type === "signature" && <SignatureRenderer component={comp} />}
{comp.type === "stamp" && <StampRenderer component={comp} />}
{comp.type === "pageNumber" && (
<PageNumberRenderer component={comp} currentPageId={currentPageId} layoutConfig={layoutConfig} />
)}
{comp.type === "card" && (
<CardRenderer component={comp} getQueryResult={getQueryResult} />
)}
{comp.type === "calculation" && (
<CalculationRenderer component={comp} getQueryResult={getQueryResult} />
)}
{comp.type === "barcode" && (
<BarcodeCanvasRenderer component={comp} getQueryResult={getQueryResult} />
)}
{comp.type === "checkbox" && (
<CheckboxRenderer component={comp} getQueryResult={getQueryResult} />
)}
</div>
);
}