588 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|