919 lines
38 KiB
TypeScript
919 lines
38 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
|
|
|
|
DialogHeader,
|
|
|
|
} from "@/components/ui/resizable-dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Printer, FileDown, FileText } from "lucide-react";
|
|
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
|
import { useState } from "react";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
// @ts-ignore - docx 라이브러리 타입 이슈
|
|
import {
|
|
Document,
|
|
Packer,
|
|
Paragraph,
|
|
TextRun,
|
|
Table,
|
|
TableCell,
|
|
TableRow,
|
|
WidthType,
|
|
ImageRun,
|
|
AlignmentType,
|
|
VerticalAlign,
|
|
convertInchesToTwip,
|
|
} from "docx";
|
|
import { getFullImageUrl } from "@/lib/api/client";
|
|
|
|
interface ReportPreviewModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) {
|
|
const { layoutConfig, getQueryResult, reportDetail } = useReportDesigner();
|
|
const [isExporting, setIsExporting] = useState(false);
|
|
const { toast } = useToast();
|
|
|
|
// 컴포넌트의 실제 표시 값 가져오기
|
|
const getComponentValue = (component: any): string => {
|
|
if (component.queryId && component.fieldName) {
|
|
const queryResult = getQueryResult(component.queryId);
|
|
if (queryResult && queryResult.rows.length > 0) {
|
|
const value = queryResult.rows[0][component.fieldName];
|
|
if (value !== null && value !== undefined) {
|
|
return String(value);
|
|
}
|
|
}
|
|
return `{${component.fieldName}}`;
|
|
}
|
|
return component.defaultValue || "텍스트";
|
|
};
|
|
|
|
const handlePrint = () => {
|
|
// HTML 생성하여 인쇄
|
|
const printHtml = generatePrintHTML();
|
|
|
|
const printWindow = window.open("", "_blank");
|
|
if (!printWindow) return;
|
|
|
|
printWindow.document.write(printHtml);
|
|
printWindow.document.close();
|
|
printWindow.print();
|
|
};
|
|
|
|
// 페이지별 컴포넌트 HTML 생성
|
|
const generatePageHTML = (
|
|
pageComponents: any[],
|
|
pageWidth: number,
|
|
pageHeight: number,
|
|
backgroundColor: string,
|
|
): string => {
|
|
const componentsHTML = pageComponents
|
|
.map((component) => {
|
|
const queryResult = component.queryId ? getQueryResult(component.queryId) : null;
|
|
let content = "";
|
|
|
|
// Text/Label 컴포넌트
|
|
if (component.type === "text" || component.type === "label") {
|
|
const displayValue = getComponentValue(component);
|
|
content = `<div style="font-size: ${component.fontSize || 13}px; color: ${component.fontColor || "#000000"}; font-weight: ${component.fontWeight || "normal"}; text-align: ${component.textAlign || "left"};">${displayValue}</div>`;
|
|
}
|
|
|
|
// Image 컴포넌트
|
|
else if (component.type === "image" && component.imageUrl) {
|
|
const imageUrl = component.imageUrl.startsWith("data:")
|
|
? component.imageUrl
|
|
: getFullImageUrl(component.imageUrl);
|
|
content = `<img src="${imageUrl}" style="width: 100%; height: 100%; object-fit: ${component.objectFit || "contain"};" />`;
|
|
}
|
|
|
|
// Divider 컴포넌트
|
|
else if (component.type === "divider") {
|
|
const width = component.orientation === "horizontal" ? "100%" : `${component.lineWidth || 1}px`;
|
|
const height = component.orientation === "vertical" ? "100%" : `${component.lineWidth || 1}px`;
|
|
content = `<div style="width: ${width}; height: ${height}; background-color: ${component.lineColor || "#000000"};"></div>`;
|
|
}
|
|
|
|
// Signature 컴포넌트
|
|
else if (component.type === "signature") {
|
|
const labelPosition = component.labelPosition || "left";
|
|
const showLabel = component.showLabel !== false;
|
|
const labelText = component.labelText || "서명:";
|
|
const imageUrl = component.imageUrl
|
|
? component.imageUrl.startsWith("data:")
|
|
? component.imageUrl
|
|
: getFullImageUrl(component.imageUrl)
|
|
: "";
|
|
|
|
if (labelPosition === "left" || labelPosition === "right") {
|
|
content = `
|
|
<div style="display: flex; align-items: center; flex-direction: ${labelPosition === "right" ? "row-reverse" : "row"}; gap: 8px; height: 100%;">
|
|
${showLabel ? `<div style="font-size: 12px; white-space: nowrap;">${labelText}</div>` : ""}
|
|
<div style="flex: 1; position: relative;">
|
|
${imageUrl ? `<img src="${imageUrl}" style="width: 100%; height: 100%; object-fit: ${component.objectFit || "contain"};" />` : ""}
|
|
${component.showUnderline ? '<div style="position: absolute; bottom: 0; left: 0; right: 0; height: 1px; background-color: #000000;"></div>' : ""}
|
|
</div>
|
|
</div>`;
|
|
} else {
|
|
content = `
|
|
<div style="display: flex; flex-direction: column; align-items: center; height: 100%;">
|
|
${showLabel && labelPosition === "top" ? `<div style="font-size: 12px;">${labelText}</div>` : ""}
|
|
<div style="flex: 1; width: 100%; position: relative;">
|
|
${imageUrl ? `<img src="${imageUrl}" style="width: 100%; height: 100%; object-fit: ${component.objectFit || "contain"};" />` : ""}
|
|
${component.showUnderline ? '<div style="position: absolute; bottom: 0; left: 0; right: 0; height: 1px; background-color: #000000;"></div>' : ""}
|
|
</div>
|
|
${showLabel && labelPosition === "bottom" ? `<div style="font-size: 12px;">${labelText}</div>` : ""}
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
// Stamp 컴포넌트
|
|
else if (component.type === "stamp") {
|
|
const showLabel = component.showLabel !== false;
|
|
const labelText = component.labelText || "(인)";
|
|
const personName = component.personName || "";
|
|
const imageUrl = component.imageUrl
|
|
? component.imageUrl.startsWith("data:")
|
|
? component.imageUrl
|
|
: getFullImageUrl(component.imageUrl)
|
|
: "";
|
|
|
|
content = `
|
|
<div style="display: flex; align-items: center; gap: 8px; height: 100%;">
|
|
${personName ? `<div style="font-size: 12px;">${personName}</div>` : ""}
|
|
<div style="position: relative; width: ${component.width}px; height: ${component.height}px;">
|
|
${imageUrl ? `<img src="${imageUrl}" style="width: 100%; height: 100%; object-fit: ${component.objectFit || "contain"}; border-radius: 50%;" />` : ""}
|
|
${showLabel ? `<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 12px; font-weight: bold; color: #dc2626;">${labelText}</div>` : ""}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// Table 컴포넌트
|
|
else if (component.type === "table" && queryResult && queryResult.rows.length > 0) {
|
|
const columns =
|
|
component.tableColumns && component.tableColumns.length > 0
|
|
? component.tableColumns
|
|
: queryResult.fields.map((field) => ({
|
|
field,
|
|
header: field,
|
|
align: "left" as const,
|
|
width: undefined,
|
|
}));
|
|
|
|
const tableRows = queryResult.rows
|
|
.map(
|
|
(row) => `
|
|
<tr>
|
|
${columns.map((col: { field: string; align?: string }) => `<td style="border: ${component.showBorder !== false ? "1px solid #d1d5db" : "none"}; padding: 6px 8px; text-align: ${col.align || "left"}; height: ${component.rowHeight || "auto"}px;">${String(row[col.field] ?? "")}</td>`).join("")}
|
|
</tr>
|
|
`,
|
|
)
|
|
.join("");
|
|
|
|
content = `
|
|
<table style="width: 100%; border-collapse: ${component.showBorder !== false ? "collapse" : "separate"}; font-size: 12px;">
|
|
<thead style="display: table-header-group; break-inside: avoid; break-after: avoid;">
|
|
<tr style="background-color: ${component.headerBackgroundColor || "#f3f4f6"}; color: ${component.headerTextColor || "#111827"};">
|
|
${columns.map((col: { header: string; align?: string; width?: number }) => `<th style="border: ${component.showBorder !== false ? "1px solid #d1d5db" : "none"}; padding: 6px 8px; text-align: ${col.align || "left"}; width: ${col.width ? `${col.width}px` : "auto"}; font-weight: 600;">${col.header}</th>`).join("")}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${tableRows}
|
|
</tbody>
|
|
</table>`;
|
|
}
|
|
|
|
return `
|
|
<div style="position: absolute; left: ${component.x}px; top: ${component.y}px; width: ${component.width}px; height: ${component.height}px; background-color: ${component.backgroundColor || "transparent"}; border: ${component.borderWidth ? `${component.borderWidth}px solid ${component.borderColor}` : "none"}; padding: 8px; box-sizing: border-box;">
|
|
${content}
|
|
</div>`;
|
|
})
|
|
.join("");
|
|
|
|
return `
|
|
<div style="position: relative; width: ${pageWidth}mm; min-height: ${pageHeight}mm; background-color: ${backgroundColor}; margin: 0 auto;">
|
|
${componentsHTML}
|
|
</div>`;
|
|
};
|
|
|
|
// 모든 페이지 HTML 생성 (인쇄/PDF용)
|
|
const generatePrintHTML = (): string => {
|
|
const pagesHTML = layoutConfig.pages
|
|
.sort((a, b) => a.page_order - b.page_order)
|
|
.map((page) => generatePageHTML(page.components, page.width, page.height, page.background_color))
|
|
.join('<div style="page-break-after: always;"></div>');
|
|
|
|
return `
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>리포트 인쇄</title>
|
|
<style>
|
|
* { box-sizing: border-box; }
|
|
@page {
|
|
size: A4;
|
|
margin: 10mm;
|
|
}
|
|
@media print {
|
|
body { margin: 0; padding: 0; }
|
|
.print-page { page-break-after: always; page-break-inside: avoid; }
|
|
.print-page:last-child { page-break-after: auto; }
|
|
}
|
|
body {
|
|
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
|
|
margin: 0;
|
|
padding: 20px;
|
|
-webkit-print-color-adjust: exact;
|
|
print-color-adjust: exact;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
${pagesHTML}
|
|
<script>
|
|
window.onload = function() {
|
|
// 이미지 로드 대기 후 인쇄
|
|
const images = document.getElementsByTagName('img');
|
|
if (images.length === 0) {
|
|
setTimeout(() => window.print(), 100);
|
|
} else {
|
|
let loadedCount = 0;
|
|
Array.from(images).forEach(img => {
|
|
if (img.complete) {
|
|
loadedCount++;
|
|
} else {
|
|
img.onload = () => {
|
|
loadedCount++;
|
|
if (loadedCount === images.length) {
|
|
setTimeout(() => window.print(), 100);
|
|
}
|
|
};
|
|
}
|
|
});
|
|
if (loadedCount === images.length) {
|
|
setTimeout(() => window.print(), 100);
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
};
|
|
|
|
// PDF 다운로드 (브라우저 인쇄 기능 이용)
|
|
const handleDownloadPDF = () => {
|
|
const printHtml = generatePrintHTML();
|
|
|
|
const printWindow = window.open("", "_blank");
|
|
if (!printWindow) return;
|
|
|
|
printWindow.document.write(printHtml);
|
|
printWindow.document.close();
|
|
|
|
toast({
|
|
title: "안내",
|
|
description: "인쇄 대화상자에서 'PDF로 저장'을 선택하세요.",
|
|
});
|
|
};
|
|
|
|
// Base64를 Uint8Array로 변환
|
|
const base64ToUint8Array = (base64: string): Uint8Array => {
|
|
const base64Data = base64.split(",")[1] || base64;
|
|
const binaryString = atob(base64Data);
|
|
const bytes = new Uint8Array(binaryString.length);
|
|
for (let i = 0; i < binaryString.length; i++) {
|
|
bytes[i] = binaryString.charCodeAt(i);
|
|
}
|
|
return bytes;
|
|
};
|
|
|
|
// 컴포넌트를 TableCell로 변환
|
|
const createTableCell = (component: any, pageWidth: number, widthPercent?: number): TableCell | null => {
|
|
const cellWidth = widthPercent || 100;
|
|
|
|
if (component.type === "text" || component.type === "label") {
|
|
const value = getComponentValue(component);
|
|
return new TableCell({
|
|
children: [
|
|
new Paragraph({
|
|
children: [
|
|
new TextRun({
|
|
text: value,
|
|
size: (component.fontSize || 13) * 2,
|
|
color: component.fontColor?.replace("#", "") || "000000",
|
|
bold: component.fontWeight === "bold",
|
|
}),
|
|
],
|
|
alignment:
|
|
component.textAlign === "center"
|
|
? AlignmentType.CENTER
|
|
: component.textAlign === "right"
|
|
? AlignmentType.RIGHT
|
|
: AlignmentType.LEFT,
|
|
}),
|
|
],
|
|
width: { size: cellWidth, type: WidthType.PERCENTAGE },
|
|
verticalAlign: VerticalAlign.CENTER,
|
|
borders: {
|
|
top: { style: 0, size: 0, color: "FFFFFF" },
|
|
bottom: { style: 0, size: 0, color: "FFFFFF" },
|
|
left: { style: 0, size: 0, color: "FFFFFF" },
|
|
right: { style: 0, size: 0, color: "FFFFFF" },
|
|
},
|
|
});
|
|
} else if (component.type === "signature" || component.type === "stamp") {
|
|
if (component.imageUrl) {
|
|
try {
|
|
const imageData = base64ToUint8Array(component.imageUrl);
|
|
return new TableCell({
|
|
children: [
|
|
new Paragraph({
|
|
children: [
|
|
new ImageRun({
|
|
data: imageData,
|
|
transformation: {
|
|
width: component.width || 150,
|
|
height: component.height || 50,
|
|
},
|
|
}),
|
|
],
|
|
alignment: AlignmentType.CENTER,
|
|
}),
|
|
],
|
|
width: { size: cellWidth, type: WidthType.PERCENTAGE },
|
|
verticalAlign: VerticalAlign.CENTER,
|
|
borders: {
|
|
top: { style: 0, size: 0, color: "FFFFFF" },
|
|
bottom: { style: 0, size: 0, color: "FFFFFF" },
|
|
left: { style: 0, size: 0, color: "FFFFFF" },
|
|
right: { style: 0, size: 0, color: "FFFFFF" },
|
|
},
|
|
});
|
|
} catch {
|
|
return new TableCell({
|
|
children: [
|
|
new Paragraph({
|
|
children: [
|
|
new TextRun({
|
|
text: `[${component.type === "signature" ? "서명" : "도장"}]`,
|
|
size: 24,
|
|
}),
|
|
],
|
|
}),
|
|
],
|
|
width: { size: cellWidth, type: WidthType.PERCENTAGE },
|
|
borders: {
|
|
top: { style: 0, size: 0, color: "FFFFFF" },
|
|
bottom: { style: 0, size: 0, color: "FFFFFF" },
|
|
left: { style: 0, size: 0, color: "FFFFFF" },
|
|
right: { style: 0, size: 0, color: "FFFFFF" },
|
|
},
|
|
});
|
|
}
|
|
}
|
|
} else if (component.type === "table" && component.queryId) {
|
|
const queryResult = getQueryResult(component.queryId);
|
|
if (queryResult && queryResult.rows.length > 0) {
|
|
const headerCells = queryResult.fields.map(
|
|
(field) =>
|
|
new TableCell({
|
|
children: [new Paragraph({ text: field })],
|
|
width: { size: 100 / queryResult.fields.length, type: WidthType.PERCENTAGE },
|
|
}),
|
|
);
|
|
|
|
const dataRows = queryResult.rows.map(
|
|
(row) =>
|
|
new TableRow({
|
|
children: queryResult.fields.map(
|
|
(field) =>
|
|
new TableCell({
|
|
children: [new Paragraph({ text: String(row[field] ?? "") })],
|
|
}),
|
|
),
|
|
}),
|
|
);
|
|
|
|
const table = new Table({
|
|
rows: [new TableRow({ children: headerCells }), ...dataRows],
|
|
width: { size: 100, type: WidthType.PERCENTAGE },
|
|
});
|
|
|
|
return new TableCell({
|
|
children: [table],
|
|
width: { size: cellWidth, type: WidthType.PERCENTAGE },
|
|
borders: {
|
|
top: { style: 0, size: 0, color: "FFFFFF" },
|
|
bottom: { style: 0, size: 0, color: "FFFFFF" },
|
|
left: { style: 0, size: 0, color: "FFFFFF" },
|
|
right: { style: 0, size: 0, color: "FFFFFF" },
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
// WORD 다운로드
|
|
const handleDownloadWord = async () => {
|
|
setIsExporting(true);
|
|
try {
|
|
// 페이지별로 섹션 생성
|
|
const sections = layoutConfig.pages
|
|
.sort((a, b) => a.page_order - b.page_order)
|
|
.map((page) => {
|
|
// 페이지 크기 설정 (A4 기준)
|
|
const pageWidth = convertInchesToTwip(8.27); // A4 width in inches
|
|
const pageHeight = convertInchesToTwip(11.69); // A4 height in inches
|
|
const marginTop = convertInchesToTwip(page.margins.top / 96); // px to inches (96 DPI)
|
|
const marginBottom = convertInchesToTwip(page.margins.bottom / 96);
|
|
const marginLeft = convertInchesToTwip(page.margins.left / 96);
|
|
const marginRight = convertInchesToTwip(page.margins.right / 96);
|
|
|
|
// 페이지 내 컴포넌트를 Y좌표 기준으로 정렬
|
|
const sortedComponents = [...page.components].sort((a, b) => {
|
|
// Y좌표 우선, 같으면 X좌표
|
|
if (Math.abs(a.y - b.y) < 5) {
|
|
return a.x - b.x;
|
|
}
|
|
return a.y - b.y;
|
|
});
|
|
|
|
// 컴포넌트들을 행으로 그룹화 (같은 Y 범위 = 같은 행)
|
|
const rows: Array<Array<(typeof sortedComponents)[0]>> = [];
|
|
const rowTolerance = 20; // Y 좌표 허용 오차
|
|
|
|
for (const component of sortedComponents) {
|
|
const existingRow = rows.find((row) => Math.abs(row[0].y - component.y) < rowTolerance);
|
|
if (existingRow) {
|
|
existingRow.push(component);
|
|
} else {
|
|
rows.push([component]);
|
|
}
|
|
}
|
|
|
|
// 각 행 내에서 X좌표로 정렬
|
|
rows.forEach((row) => row.sort((a, b) => a.x - b.x));
|
|
|
|
// 페이지 전체를 큰 테이블로 구성 (레이아웃 제어용)
|
|
const tableRows: TableRow[] = [];
|
|
|
|
for (const row of rows) {
|
|
if (row.length === 1) {
|
|
// 단일 컴포넌트 - 전체 너비 사용
|
|
const component = row[0];
|
|
const cell = createTableCell(component, pageWidth);
|
|
if (cell) {
|
|
tableRows.push(
|
|
new TableRow({
|
|
children: [cell],
|
|
height: { value: component.height * 15, rule: 1 }, // 최소 높이 설정
|
|
}),
|
|
);
|
|
}
|
|
} else {
|
|
// 여러 컴포넌트 - 가로 배치
|
|
const cells: TableCell[] = [];
|
|
const totalWidth = row.reduce((sum, c) => sum + c.width, 0);
|
|
|
|
for (const component of row) {
|
|
const widthPercent = (component.width / totalWidth) * 100;
|
|
const cell = createTableCell(component, pageWidth, widthPercent);
|
|
if (cell) {
|
|
cells.push(cell);
|
|
}
|
|
}
|
|
|
|
if (cells.length > 0) {
|
|
const maxHeight = Math.max(...row.map((c) => c.height));
|
|
tableRows.push(
|
|
new TableRow({
|
|
children: cells,
|
|
height: { value: maxHeight * 15, rule: 1 },
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
properties: {
|
|
page: {
|
|
width: pageWidth,
|
|
height: pageHeight,
|
|
margin: {
|
|
top: marginTop,
|
|
bottom: marginBottom,
|
|
left: marginLeft,
|
|
right: marginRight,
|
|
},
|
|
},
|
|
},
|
|
children:
|
|
tableRows.length > 0
|
|
? [
|
|
new Table({
|
|
rows: tableRows,
|
|
width: { size: 100, type: WidthType.PERCENTAGE },
|
|
borders: {
|
|
top: { style: 0, size: 0, color: "FFFFFF" },
|
|
bottom: { style: 0, size: 0, color: "FFFFFF" },
|
|
left: { style: 0, size: 0, color: "FFFFFF" },
|
|
right: { style: 0, size: 0, color: "FFFFFF" },
|
|
insideHorizontal: { style: 0, size: 0, color: "FFFFFF" },
|
|
insideVertical: { style: 0, size: 0, color: "FFFFFF" },
|
|
},
|
|
}),
|
|
]
|
|
: [new Paragraph({ text: "" })],
|
|
};
|
|
});
|
|
|
|
// 문서 생성
|
|
const doc = new Document({
|
|
sections,
|
|
});
|
|
|
|
// Blob 생성 및 다운로드
|
|
const blob = await Packer.toBlob(doc);
|
|
const fileName = reportDetail?.report?.report_name_kor || "리포트";
|
|
const timestamp = new Date().toISOString().slice(0, 10);
|
|
|
|
const url = window.URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = `${fileName}_${timestamp}.docx`;
|
|
link.click();
|
|
window.URL.revokeObjectURL(url);
|
|
|
|
toast({
|
|
title: "성공",
|
|
description: "WORD 파일이 다운로드되었습니다.",
|
|
});
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : "WORD 생성에 실패했습니다.";
|
|
toast({
|
|
title: "오류",
|
|
description: errorMessage,
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setIsExporting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
<DialogContent className="max-w-4xl">
|
|
<DialogHeader>
|
|
<ResizableDialogTitle>미리보기</ResizableDialogTitle>
|
|
<DialogDescription>
|
|
현재 레이아웃의 미리보기입니다. 인쇄하거나 파일로 다운로드할 수 있습니다.
|
|
</ResizableDialogDescription>
|
|
</DialogHeader>
|
|
|
|
{/* 미리보기 영역 - 모든 페이지 표시 */}
|
|
<div className="max-h-[500px] overflow-auto rounded border bg-gray-100 p-4">
|
|
<div className="space-y-4">
|
|
{layoutConfig.pages
|
|
.sort((a, b) => a.page_order - b.page_order)
|
|
.map((page) => (
|
|
<div key={page.page_id} className="relative">
|
|
{/* 페이지 번호 라벨 */}
|
|
<div className="mb-2 text-center text-xs text-gray-500">
|
|
페이지 {page.page_order + 1} - {page.page_name}
|
|
</div>
|
|
|
|
{/* 페이지 컨텐츠 */}
|
|
<div
|
|
className="relative mx-auto shadow-lg"
|
|
style={{
|
|
width: `${page.width}mm`,
|
|
minHeight: `${page.height}mm`,
|
|
backgroundColor: page.background_color,
|
|
}}
|
|
>
|
|
{page.components.map((component) => {
|
|
const displayValue = getComponentValue(component);
|
|
const queryResult = component.queryId ? getQueryResult(component.queryId) : null;
|
|
|
|
return (
|
|
<div
|
|
key={component.id}
|
|
className="absolute"
|
|
style={{
|
|
left: `${component.x}px`,
|
|
top: `${component.y}px`,
|
|
width: `${component.width}px`,
|
|
height: `${component.height}px`,
|
|
backgroundColor: component.backgroundColor,
|
|
border: component.borderWidth
|
|
? `${component.borderWidth}px solid ${component.borderColor}`
|
|
: "none",
|
|
padding: "8px",
|
|
}}
|
|
>
|
|
{component.type === "text" && (
|
|
<div
|
|
style={{
|
|
fontSize: `${component.fontSize}px`,
|
|
color: component.fontColor,
|
|
fontWeight: component.fontWeight,
|
|
textAlign: component.textAlign as "left" | "center" | "right",
|
|
}}
|
|
>
|
|
{displayValue}
|
|
</div>
|
|
)}
|
|
|
|
{component.type === "label" && (
|
|
<div
|
|
style={{
|
|
fontSize: `${component.fontSize}px`,
|
|
color: component.fontColor,
|
|
fontWeight: component.fontWeight,
|
|
textAlign: component.textAlign as "left" | "center" | "right",
|
|
}}
|
|
>
|
|
{displayValue}
|
|
</div>
|
|
)}
|
|
|
|
{component.type === "table" && queryResult && queryResult.rows.length > 0 ? (
|
|
(() => {
|
|
// tableColumns가 없으면 자동 생성
|
|
const columns =
|
|
component.tableColumns && component.tableColumns.length > 0
|
|
? component.tableColumns
|
|
: queryResult.fields.map((field) => ({
|
|
field,
|
|
header: field,
|
|
align: "left" as const,
|
|
width: undefined,
|
|
}));
|
|
|
|
return (
|
|
<table
|
|
style={{
|
|
width: "100%",
|
|
borderCollapse: component.showBorder !== false ? "collapse" : "separate",
|
|
fontSize: "12px",
|
|
}}
|
|
>
|
|
<thead>
|
|
<tr
|
|
style={{
|
|
backgroundColor: component.headerBackgroundColor || "#f3f4f6",
|
|
color: component.headerTextColor || "#111827",
|
|
}}
|
|
>
|
|
{columns.map((col) => (
|
|
<th
|
|
key={col.field}
|
|
style={{
|
|
border: component.showBorder !== false ? "1px solid #d1d5db" : "none",
|
|
padding: "6px 8px",
|
|
textAlign: col.align || "left",
|
|
width: col.width ? `${col.width}px` : "auto",
|
|
fontWeight: "600",
|
|
}}
|
|
>
|
|
{col.header}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{queryResult.rows.map((row, idx) => (
|
|
<tr key={idx}>
|
|
{columns.map((col) => (
|
|
<td
|
|
key={col.field}
|
|
style={{
|
|
border: component.showBorder !== false ? "1px solid #d1d5db" : "none",
|
|
padding: "6px 8px",
|
|
textAlign: col.align || "left",
|
|
height: component.rowHeight ? `${component.rowHeight}px` : "auto",
|
|
}}
|
|
>
|
|
{String(row[col.field] ?? "")}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
);
|
|
})()
|
|
) : component.type === "table" ? (
|
|
<div className="text-xs text-gray-400">쿼리를 실행해주세요</div>
|
|
) : null}
|
|
|
|
{component.type === "image" && component.imageUrl && (
|
|
<img
|
|
src={getFullImageUrl(component.imageUrl)}
|
|
alt="이미지"
|
|
style={{
|
|
width: "100%",
|
|
height: "100%",
|
|
objectFit: component.objectFit || "contain",
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{component.type === "divider" && (
|
|
<div
|
|
style={{
|
|
width:
|
|
component.orientation === "horizontal" ? "100%" : `${component.lineWidth || 1}px`,
|
|
height: component.orientation === "vertical" ? "100%" : `${component.lineWidth || 1}px`,
|
|
backgroundColor: component.lineColor || "#000000",
|
|
...(component.lineStyle === "dashed" && {
|
|
backgroundImage: `repeating-linear-gradient(
|
|
${component.orientation === "horizontal" ? "90deg" : "0deg"},
|
|
${component.lineColor || "#000000"} 0px,
|
|
${component.lineColor || "#000000"} 10px,
|
|
transparent 10px,
|
|
transparent 20px
|
|
)`,
|
|
backgroundColor: "transparent",
|
|
}),
|
|
...(component.lineStyle === "dotted" && {
|
|
backgroundImage: `repeating-linear-gradient(
|
|
${component.orientation === "horizontal" ? "90deg" : "0deg"},
|
|
${component.lineColor || "#000000"} 0px,
|
|
${component.lineColor || "#000000"} 3px,
|
|
transparent 3px,
|
|
transparent 10px
|
|
)`,
|
|
backgroundColor: "transparent",
|
|
}),
|
|
...(component.lineStyle === "double" && {
|
|
boxShadow:
|
|
component.orientation === "horizontal"
|
|
? `0 ${(component.lineWidth || 1) * 2}px 0 0 ${component.lineColor || "#000000"}`
|
|
: `${(component.lineWidth || 1) * 2}px 0 0 0 ${component.lineColor || "#000000"}`,
|
|
}),
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{component.type === "signature" && (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
gap: "8px",
|
|
flexDirection:
|
|
component.labelPosition === "top" || component.labelPosition === "bottom"
|
|
? "column"
|
|
: "row",
|
|
...(component.labelPosition === "right" || component.labelPosition === "bottom"
|
|
? {
|
|
flexDirection:
|
|
component.labelPosition === "right" ? "row-reverse" : "column-reverse",
|
|
}
|
|
: {}),
|
|
}}
|
|
>
|
|
{component.showLabel !== false && (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
fontSize: "12px",
|
|
fontWeight: "500",
|
|
minWidth:
|
|
component.labelPosition === "left" || component.labelPosition === "right"
|
|
? "40px"
|
|
: "auto",
|
|
}}
|
|
>
|
|
{component.labelText || "서명:"}
|
|
</div>
|
|
)}
|
|
<div style={{ flex: 1, position: "relative" }}>
|
|
{component.imageUrl && (
|
|
<img
|
|
src={getFullImageUrl(component.imageUrl)}
|
|
alt="서명"
|
|
style={{
|
|
width: "100%",
|
|
height: "100%",
|
|
objectFit: component.objectFit || "contain",
|
|
}}
|
|
/>
|
|
)}
|
|
{component.showUnderline !== false && (
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
bottom: "0",
|
|
left: "0",
|
|
right: "0",
|
|
borderBottom: "2px solid #000000",
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{component.type === "stamp" && (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
gap: "8px",
|
|
width: "100%",
|
|
height: "100%",
|
|
}}
|
|
>
|
|
{component.personName && (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
fontSize: "12px",
|
|
fontWeight: "500",
|
|
}}
|
|
>
|
|
{component.personName}
|
|
</div>
|
|
)}
|
|
<div
|
|
style={{
|
|
position: "relative",
|
|
flex: 1,
|
|
}}
|
|
>
|
|
{component.imageUrl && (
|
|
<img
|
|
src={getFullImageUrl(component.imageUrl)}
|
|
alt="도장"
|
|
style={{
|
|
width: "100%",
|
|
height: "100%",
|
|
objectFit: component.objectFit || "contain",
|
|
}}
|
|
/>
|
|
)}
|
|
{component.showLabel !== false && (
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
top: "0",
|
|
left: "0",
|
|
width: "100%",
|
|
height: "100%",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
fontSize: "12px",
|
|
fontWeight: "500",
|
|
pointerEvents: "none",
|
|
}}
|
|
>
|
|
{component.labelText || "(인)"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<ResizableDialogFooter>
|
|
<Button variant="outline" onClick={onClose} disabled={isExporting}>
|
|
닫기
|
|
</Button>
|
|
<Button variant="outline" onClick={handlePrint} disabled={isExporting} className="gap-2">
|
|
<Printer className="h-4 w-4" />
|
|
인쇄
|
|
</Button>
|
|
<Button onClick={handleDownloadPDF} className="gap-2">
|
|
<FileDown className="h-4 w-4" />
|
|
PDF
|
|
</Button>
|
|
<Button onClick={handleDownloadWord} disabled={isExporting} variant="secondary" className="gap-2">
|
|
<FileText className="h-4 w-4" />
|
|
{isExporting ? "생성 중..." : "WORD"}
|
|
</Button>
|
|
</ResizableDialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|