ERP-node/frontend/components/report/designer/ReportPreviewModal.tsx

1146 lines
54 KiB
TypeScript

"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/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";
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,
pageIndex: number = 0,
totalPages: number = 1,
): 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"}; white-space: pre-wrap;">${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>`;
}
// PageNumber 컴포넌트
else if (component.type === "pageNumber") {
const format = component.pageNumberFormat || "number";
let pageNumberText = "";
switch (format) {
case "number":
pageNumberText = `${pageIndex + 1}`;
break;
case "numberTotal":
pageNumberText = `${pageIndex + 1} / ${totalPages}`;
break;
case "koreanNumber":
pageNumberText = `${pageIndex + 1} 페이지`;
break;
default:
pageNumberText = `${pageIndex + 1}`;
}
content = `<div style="display: flex; align-items: center; justify-content: center; height: 100%; font-size: ${component.fontSize}px; color: ${component.fontColor}; font-weight: ${component.fontWeight}; text-align: ${component.textAlign};">${pageNumberText}</div>`;
}
// Card 컴포넌트
else if (component.type === "card") {
const cardTitle = component.cardTitle || "정보 카드";
const cardItems = component.cardItems || [];
const labelWidth = component.labelWidth || 80;
const showCardTitle = component.showCardTitle !== false;
const titleFontSize = component.titleFontSize || 14;
const labelFontSize = component.labelFontSize || 13;
const valueFontSize = component.valueFontSize || 13;
const titleColor = component.titleColor || "#1e40af";
const labelColor = component.labelColor || "#374151";
const valueColor = component.valueColor || "#000000";
const borderColor = component.borderColor || "#e5e7eb";
// 쿼리 바인딩된 값 가져오기
const getCardValue = (item: { label: string; value: string; fieldName?: string }) => {
if (item.fieldName && queryResult && queryResult.rows && queryResult.rows.length > 0) {
const row = queryResult.rows[0];
return row[item.fieldName] !== undefined ? String(row[item.fieldName]) : item.value;
}
return item.value;
};
const itemsHtml = cardItems
.map(
(item: { label: string; value: string; fieldName?: string }) => `
<div style="display: flex; padding: 2px 0;">
<span style="width: ${labelWidth}px; flex-shrink: 0; font-size: ${labelFontSize}px; color: ${labelColor}; font-weight: 500;">${item.label}</span>
<span style="flex: 1; font-size: ${valueFontSize}px; color: ${valueColor};">${getCardValue(item)}</span>
</div>
`
)
.join("");
content = `
<div style="display: flex; flex-direction: column; height: 100%; overflow: hidden;">
${
showCardTitle
? `
<div style="flex-shrink: 0; padding: 4px 8px; font-size: ${titleFontSize}px; font-weight: 600; color: ${titleColor};">
${cardTitle}
</div>
<div style="flex-shrink: 0; margin: 0 4px; border-bottom: 1px solid ${borderColor};"></div>
`
: ""
}
<div style="flex: 1; padding: 4px 8px; overflow: auto;">
${itemsHtml}
</div>
</div>`;
}
// 계산 컴포넌트
else if (component.type === "calculation") {
const calcItems = component.calcItems || [];
const resultLabel = component.resultLabel || "합계";
const calcLabelWidth = component.labelWidth || 120;
const calcLabelFontSize = component.labelFontSize || 13;
const calcValueFontSize = component.valueFontSize || 13;
const calcResultFontSize = component.resultFontSize || 16;
const calcLabelColor = component.labelColor || "#374151";
const calcValueColor = component.valueColor || "#000000";
const calcResultColor = component.resultColor || "#2563eb";
const numberFormat = component.numberFormat || "currency";
const currencySuffix = component.currencySuffix || "원";
const borderColor = component.borderColor || "#374151";
// 숫자 포맷팅 함수
const formatNumber = (num: number): string => {
if (numberFormat === "none") return String(num);
if (numberFormat === "comma") return num.toLocaleString();
if (numberFormat === "currency") return num.toLocaleString() + currencySuffix;
return String(num);
};
// 쿼리 바인딩된 값 가져오기
const getCalcItemValue = (item: { label: string; value: number | string; operator: string; fieldName?: string }): number => {
if (item.fieldName && queryResult && queryResult.rows && queryResult.rows.length > 0) {
const row = queryResult.rows[0];
const val = row[item.fieldName];
return typeof val === "number" ? val : parseFloat(String(val)) || 0;
}
return typeof item.value === "number" ? item.value : parseFloat(String(item.value)) || 0;
};
// 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용)
let calcResult = 0;
if (calcItems.length > 0) {
// 첫 번째 항목은 기준값
calcResult = getCalcItemValue(calcItems[0] as { label: string; value: number | string; operator: string; fieldName?: string });
// 두 번째 항목부터 연산자 적용
for (let i = 1; i < calcItems.length; i++) {
const item = calcItems[i];
const val = getCalcItemValue(item as { label: string; value: number | string; operator: string; fieldName?: string });
switch ((item as { operator: string }).operator) {
case "+":
calcResult += val;
break;
case "-":
calcResult -= val;
break;
case "x":
calcResult *= val;
break;
case "÷":
calcResult = val !== 0 ? calcResult / val : calcResult;
break;
}
}
}
const itemsHtml = calcItems
.map((item: { label: string; value: number | string; operator: string; fieldName?: string }) => {
const itemValue = getCalcItemValue(item);
return `
<div style="display: flex; justify-content: space-between; padding: 4px 8px;">
<span style="width: ${calcLabelWidth}px; font-size: ${calcLabelFontSize}px; color: ${calcLabelColor};">${item.label}</span>
<span style="font-size: ${calcValueFontSize}px; color: ${calcValueColor}; text-align: right;">${formatNumber(itemValue)}</span>
</div>
`;
})
.join("");
content = `
<div style="display: flex; flex-direction: column; height: 100%;">
<div style="flex: 1;">
${itemsHtml}
</div>
<div style="border-top: 1px solid ${borderColor}; margin: 4px 8px;"></div>
<div style="display: flex; justify-content: space-between; padding: 4px 8px;">
<span style="width: ${calcLabelWidth}px; font-size: ${calcResultFontSize}px; font-weight: 600; color: ${calcLabelColor};">${resultLabel}</span>
<span style="font-size: ${calcResultFontSize}px; font-weight: 700; color: ${calcResultColor}; text-align: right;">${formatNumber(calcResult)}</span>
</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 sortedPages = layoutConfig.pages.sort((a, b) => a.page_order - b.page_order);
const totalPages = sortedPages.length;
const pagesHTML = sortedPages
.map((page, pageIndex) =>
generatePageHTML(
Array.isArray(page.components) ? page.components : [],
page.width,
page.height,
page.background_color,
pageIndex,
totalPages,
),
)
.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로 저장'을 선택하세요.",
});
};
// 이미지 URL을 Base64로 변환
const imageUrlToBase64 = async (url: string): Promise<string> => {
try {
// 이미 Base64인 경우 그대로 반환
if (url.startsWith("data:")) {
return url;
}
// 서버 이미지 URL을 fetch하여 Base64로 변환
const fullUrl = getFullImageUrl(url);
const response = await fetch(fullUrl);
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
} catch (error) {
console.error("이미지 변환 실패:", error);
return "";
}
};
// WORD 다운로드 (백엔드 API 사용 - 컴포넌트 데이터 전송)
const handleDownloadWord = async () => {
setIsExporting(true);
try {
toast({
title: "처리 중",
description: "WORD 파일을 생성하고 있습니다...",
});
// 이미지를 Base64로 변환하여 컴포넌트 데이터에 포함
const pagesWithBase64 = await Promise.all(
layoutConfig.pages.map(async (page) => {
const componentsWithBase64 = await Promise.all(
(Array.isArray(page.components) ? page.components : []).map(async (component) => {
// 이미지가 있는 컴포넌트는 Base64로 변환
if (component.imageUrl) {
try {
const base64 = await imageUrlToBase64(component.imageUrl);
return { ...component, imageBase64: base64 };
} catch {
return component;
}
}
return component;
}),
);
return { ...page, components: componentsWithBase64 };
}),
);
// 쿼리 결과 수집
const queryResults: Record<string, { fields: string[]; rows: Record<string, unknown>[] }> = {};
for (const page of layoutConfig.pages) {
const pageComponents = Array.isArray(page.components) ? page.components : [];
for (const component of pageComponents) {
if (component.queryId) {
const result = getQueryResult(component.queryId);
if (result) {
queryResults[component.queryId] = result;
}
}
}
}
const fileName = reportDetail?.report?.report_name_kor || "리포트";
// 백엔드 API 호출 (컴포넌트 데이터 전송)
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.post(
"/admin/reports/export-word",
{
layoutConfig: { ...layoutConfig, pages: pagesWithBase64 },
queryResults,
fileName,
},
{ responseType: "blob" },
);
// Blob 다운로드
const blob = new Blob([response.data], {
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
});
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) {
console.error("WORD 변환 오류:", 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>
<DialogTitle></DialogTitle>
<DialogDescription>
. .
</DialogDescription>
</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="relative mx-auto shadow-lg"
style={{
width: `${page.width}mm`,
minHeight: `${page.height}mm`,
backgroundColor: page.background_color,
}}
>
{(Array.isArray(page.components) ? 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",
whiteSpace: "pre-wrap",
}}
>
{displayValue}
</div>
)}
{component.type === "label" && (
<div
style={{
fontSize: `${component.fontSize}px`,
color: component.fontColor,
fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right",
whiteSpace: "pre-wrap",
}}
>
{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>
)}
{component.type === "pageNumber" && (() => {
const format = component.pageNumberFormat || "number";
const pageIndex = layoutConfig.pages
.sort((a, b) => a.page_order - b.page_order)
.findIndex((p) => p.page_id === page.page_id);
const totalPages = layoutConfig.pages.length;
let pageNumberText = "";
switch (format) {
case "number":
pageNumberText = `${pageIndex + 1}`;
break;
case "numberTotal":
pageNumberText = `${pageIndex + 1} / ${totalPages}`;
break;
case "koreanNumber":
pageNumberText = `${pageIndex + 1} 페이지`;
break;
default:
pageNumberText = `${pageIndex + 1}`;
}
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "100%",
fontSize: `${component.fontSize}px`,
color: component.fontColor,
fontWeight: component.fontWeight,
}}
>
{pageNumberText}
</div>
);
})()}
{/* Card 컴포넌트 */}
{component.type === "card" && (() => {
const cardTitle = component.cardTitle || "정보 카드";
const cardItems = component.cardItems || [];
const labelWidth = component.labelWidth || 80;
const showCardTitle = component.showCardTitle !== false;
const titleFontSize = component.titleFontSize || 14;
const labelFontSize = component.labelFontSize || 13;
const valueFontSize = component.valueFontSize || 13;
const titleColor = component.titleColor || "#1e40af";
const labelColor = component.labelColor || "#374151";
const valueColor = component.valueColor || "#000000";
const borderColor = component.borderColor || "#e5e7eb";
// 쿼리 바인딩된 값 가져오기
const getCardValue = (item: { label: string; value: string; fieldName?: string }) => {
if (item.fieldName && component.queryId) {
const qResult = getQueryResult(component.queryId);
if (qResult && qResult.rows && qResult.rows.length > 0) {
const row = qResult.rows[0];
return row[item.fieldName] !== undefined ? String(row[item.fieldName]) : item.value;
}
}
return item.value;
};
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
overflow: "hidden",
}}
>
{showCardTitle && (
<>
<div
style={{
flexShrink: 0,
padding: "4px 8px",
fontSize: `${titleFontSize}px`,
fontWeight: 600,
color: titleColor,
}}
>
{cardTitle}
</div>
<div
style={{
flexShrink: 0,
margin: "0 4px",
borderBottom: `1px solid ${borderColor}`,
}}
/>
</>
)}
<div style={{ flex: 1, padding: "4px 8px", overflow: "auto" }}>
{cardItems.map((item: { label: string; value: string; fieldName?: string }, idx: number) => (
<div key={idx} style={{ display: "flex", padding: "2px 0" }}>
<span
style={{
width: `${labelWidth}px`,
flexShrink: 0,
fontSize: `${labelFontSize}px`,
color: labelColor,
fontWeight: 500,
}}
>
{item.label}
</span>
<span
style={{
flex: 1,
fontSize: `${valueFontSize}px`,
color: valueColor,
}}
>
{getCardValue(item)}
</span>
</div>
))}
</div>
</div>
);
})()}
{/* 계산 컴포넌트 */}
{component.type === "calculation" && (() => {
const calcItems = component.calcItems || [];
const resultLabel = component.resultLabel || "합계";
const calcLabelWidth = component.labelWidth || 120;
const calcLabelFontSize = component.labelFontSize || 13;
const calcValueFontSize = component.valueFontSize || 13;
const calcResultFontSize = component.resultFontSize || 16;
const calcLabelColor = component.labelColor || "#374151";
const calcValueColor = component.valueColor || "#000000";
const calcResultColor = component.resultColor || "#2563eb";
const numberFormat = component.numberFormat || "currency";
const currencySuffix = component.currencySuffix || "원";
const borderColor = component.borderColor || "#374151";
// 숫자 포맷팅 함수
const formatNumber = (num: number): string => {
if (numberFormat === "none") return String(num);
if (numberFormat === "comma") return num.toLocaleString();
if (numberFormat === "currency") return num.toLocaleString() + currencySuffix;
return String(num);
};
// 쿼리 바인딩된 값 가져오기
const getCalcItemValue = (item: { label: string; value: number | string; operator: string; fieldName?: string }): number => {
if (item.fieldName && component.queryId) {
const qResult = getQueryResult(component.queryId);
if (qResult && qResult.rows && qResult.rows.length > 0) {
const row = qResult.rows[0];
const val = row[item.fieldName];
return typeof val === "number" ? val : parseFloat(String(val)) || 0;
}
}
return typeof item.value === "number" ? item.value : parseFloat(String(item.value)) || 0;
};
// 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용)
let calcResult = 0;
if (calcItems.length > 0) {
// 첫 번째 항목은 기준값
calcResult = getCalcItemValue(calcItems[0] as { label: string; value: number | string; operator: string; fieldName?: string });
// 두 번째 항목부터 연산자 적용
for (let i = 1; i < calcItems.length; i++) {
const item = calcItems[i];
const val = getCalcItemValue(item as { label: string; value: number | string; operator: string; fieldName?: string });
switch ((item as { operator: string }).operator) {
case "+":
calcResult += val;
break;
case "-":
calcResult -= val;
break;
case "x":
calcResult *= val;
break;
case "÷":
calcResult = val !== 0 ? calcResult / val : calcResult;
break;
}
}
}
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
}}
>
<div style={{ flex: 1 }}>
{calcItems.map((item: { label: string; value: number | string; operator: string; fieldName?: string }, idx: number) => {
const itemValue = getCalcItemValue(item);
return (
<div key={idx} style={{ display: "flex", justifyContent: "space-between", padding: "4px 8px" }}>
<span
style={{
width: `${calcLabelWidth}px`,
fontSize: `${calcLabelFontSize}px`,
color: calcLabelColor,
}}
>
{item.label}
</span>
<span
style={{
fontSize: `${calcValueFontSize}px`,
color: calcValueColor,
textAlign: "right",
}}
>
{formatNumber(itemValue)}
</span>
</div>
);
})}
</div>
<div style={{ borderTop: `1px solid ${borderColor}`, margin: "4px 8px" }} />
<div style={{ display: "flex", justifyContent: "space-between", padding: "4px 8px" }}>
<span
style={{
width: `${calcLabelWidth}px`,
fontSize: `${calcResultFontSize}px`,
fontWeight: 600,
color: calcLabelColor,
}}
>
{resultLabel}
</span>
<span
style={{
fontSize: `${calcResultFontSize}px`,
fontWeight: 700,
color: calcResultColor,
textAlign: "right",
}}
>
{formatNumber(calcResult)}
</span>
</div>
</div>
);
})()}
</div>
);
})}
</div>
</div>
))}
</div>
</div>
<DialogFooter>
<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>
</DialogFooter>
</DialogContent>
</Dialog>
);
}