- Added new entries to .gitignore for multi-agent MCP task queue and related rules. - Removed "즉시 저장" (quick insert) options from the ScreenSettingModal and BasicTab components to streamline button configurations. - Cleaned up unused event options in the V2ButtonConfigPanel to enhance clarity and maintainability. These changes aim to improve project organization and simplify the user interface by eliminating redundant options.
1810 lines
79 KiB
TypeScript
1810 lines
79 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, useRef, useEffect } from "react";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { getFullImageUrl } from "@/lib/api/client";
|
|
import JsBarcode from "jsbarcode";
|
|
import QRCode from "qrcode";
|
|
|
|
// mm -> px 변환 상수
|
|
const MM_TO_PX = 4;
|
|
|
|
interface ReportPreviewModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
// 미리보기용 워터마크 레이어 컴포넌트
|
|
interface PreviewWatermarkLayerProps {
|
|
watermark: {
|
|
enabled: boolean;
|
|
type: "text" | "image";
|
|
text?: string;
|
|
fontSize?: number;
|
|
fontColor?: string;
|
|
imageUrl?: string;
|
|
opacity: number;
|
|
style: "diagonal" | "center" | "tile";
|
|
rotation?: number;
|
|
};
|
|
pageWidth: number;
|
|
pageHeight: number;
|
|
}
|
|
|
|
function PreviewWatermarkLayer({ watermark, pageWidth, pageHeight }: PreviewWatermarkLayerProps) {
|
|
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;
|
|
|
|
// 대각선 스타일
|
|
if (watermark.style === "diagonal") {
|
|
return (
|
|
<div style={baseStyle}>
|
|
<div
|
|
className="absolute flex items-center justify-center"
|
|
style={{
|
|
top: "50%",
|
|
left: "50%",
|
|
transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
|
|
opacity: watermark.opacity,
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{watermark.type === "text" ? (
|
|
<span
|
|
style={{
|
|
fontSize: `${watermark.fontSize || 48}px`,
|
|
color: watermark.fontColor || "#cccccc",
|
|
fontWeight: "bold",
|
|
userSelect: "none",
|
|
}}
|
|
>
|
|
{watermark.text || "WATERMARK"}
|
|
</span>
|
|
) : (
|
|
watermark.imageUrl && (
|
|
<img
|
|
src={
|
|
watermark.imageUrl.startsWith("data:")
|
|
? watermark.imageUrl
|
|
: getFullImageUrl(watermark.imageUrl)
|
|
}
|
|
alt="watermark"
|
|
style={{
|
|
maxWidth: "50%",
|
|
maxHeight: "50%",
|
|
objectFit: "contain",
|
|
}}
|
|
/>
|
|
)
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 중앙 스타일
|
|
if (watermark.style === "center") {
|
|
return (
|
|
<div style={baseStyle}>
|
|
<div
|
|
className="absolute flex items-center justify-center"
|
|
style={{
|
|
top: "50%",
|
|
left: "50%",
|
|
transform: "translate(-50%, -50%)",
|
|
opacity: watermark.opacity,
|
|
}}
|
|
>
|
|
{watermark.type === "text" ? (
|
|
<span
|
|
style={{
|
|
fontSize: `${watermark.fontSize || 48}px`,
|
|
color: watermark.fontColor || "#cccccc",
|
|
fontWeight: "bold",
|
|
userSelect: "none",
|
|
}}
|
|
>
|
|
{watermark.text || "WATERMARK"}
|
|
</span>
|
|
) : (
|
|
watermark.imageUrl && (
|
|
<img
|
|
src={
|
|
watermark.imageUrl.startsWith("data:")
|
|
? watermark.imageUrl
|
|
: getFullImageUrl(watermark.imageUrl)
|
|
}
|
|
alt="watermark"
|
|
style={{
|
|
maxWidth: "50%",
|
|
maxHeight: "50%",
|
|
objectFit: "contain",
|
|
}}
|
|
/>
|
|
)
|
|
)}
|
|
</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((_, index) => (
|
|
<div
|
|
key={index}
|
|
style={{
|
|
width: `${tileSize}px`,
|
|
height: `${tileSize}px`,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
{watermark.type === "text" ? (
|
|
<span
|
|
style={{
|
|
fontSize: `${watermark.fontSize || 24}px`,
|
|
color: watermark.fontColor || "#cccccc",
|
|
fontWeight: "bold",
|
|
userSelect: "none",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{watermark.text || "WATERMARK"}
|
|
</span>
|
|
) : (
|
|
watermark.imageUrl && (
|
|
<img
|
|
src={
|
|
watermark.imageUrl.startsWith("data:")
|
|
? watermark.imageUrl
|
|
: getFullImageUrl(watermark.imageUrl)
|
|
}
|
|
alt="watermark"
|
|
style={{
|
|
width: `${tileSize * 0.6}px`,
|
|
height: `${tileSize * 0.6}px`,
|
|
objectFit: "contain",
|
|
}}
|
|
/>
|
|
)
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// 바코드/QR코드 미리보기 컴포넌트
|
|
function BarcodePreview({
|
|
component,
|
|
getQueryResult,
|
|
}: {
|
|
component: any;
|
|
getQueryResult: (queryId: string) => { fields: string[]; rows: Record<string, unknown>[] } | null;
|
|
}) {
|
|
const svgRef = useRef<SVGSVGElement>(null);
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const barcodeType = component.barcodeType || "CODE128";
|
|
const isQR = barcodeType === "QR";
|
|
|
|
// 바코드 값 결정
|
|
const getBarcodeValue = (): string => {
|
|
// QR코드 다중 필드 모드
|
|
if (
|
|
isQR &&
|
|
component.qrUseMultiField &&
|
|
component.qrDataFields &&
|
|
component.qrDataFields.length > 0 &&
|
|
component.queryId
|
|
) {
|
|
const queryResult = getQueryResult(component.queryId);
|
|
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
|
if (component.qrIncludeAllRows) {
|
|
const allRowsData: Record<string, string>[] = [];
|
|
queryResult.rows.forEach((row) => {
|
|
const rowData: Record<string, string> = {};
|
|
component.qrDataFields.forEach((field: { fieldName: string; label: string }) => {
|
|
if (field.fieldName && field.label) {
|
|
const val = row[field.fieldName];
|
|
rowData[field.label] = val !== null && val !== undefined ? String(val) : "";
|
|
}
|
|
});
|
|
allRowsData.push(rowData);
|
|
});
|
|
return JSON.stringify(allRowsData);
|
|
}
|
|
const row = queryResult.rows[0];
|
|
const jsonData: Record<string, string> = {};
|
|
component.qrDataFields.forEach((field: { fieldName: string; label: string }) => {
|
|
if (field.fieldName && field.label) {
|
|
const val = row[field.fieldName];
|
|
jsonData[field.label] = val !== null && val !== undefined ? String(val) : "";
|
|
}
|
|
});
|
|
return JSON.stringify(jsonData);
|
|
}
|
|
}
|
|
|
|
// 단일 필드 바인딩
|
|
if (component.barcodeFieldName && component.queryId) {
|
|
const queryResult = getQueryResult(component.queryId);
|
|
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
|
if (isQR && component.qrIncludeAllRows) {
|
|
const allValues = queryResult.rows
|
|
.map((row) => {
|
|
const val = row[component.barcodeFieldName];
|
|
return val !== null && val !== undefined ? String(val) : "";
|
|
})
|
|
.filter((v) => v !== "");
|
|
return JSON.stringify(allValues);
|
|
}
|
|
const row = queryResult.rows[0];
|
|
const val = row[component.barcodeFieldName];
|
|
if (val !== null && val !== undefined) {
|
|
return String(val);
|
|
}
|
|
}
|
|
return `{${component.barcodeFieldName}}`;
|
|
}
|
|
return component.barcodeValue || "SAMPLE123";
|
|
};
|
|
|
|
const barcodeValue = getBarcodeValue();
|
|
|
|
useEffect(() => {
|
|
setError(null);
|
|
|
|
if (isQR) {
|
|
// QR코드 렌더링
|
|
if (canvasRef.current && barcodeValue) {
|
|
const bgColor = component.barcodeBackground === "transparent" ? "#ffffff" : (component.barcodeBackground || "#ffffff");
|
|
QRCode.toCanvas(
|
|
canvasRef.current,
|
|
barcodeValue,
|
|
{
|
|
width: Math.min(component.width, component.height) - 20,
|
|
margin: 2,
|
|
color: {
|
|
dark: component.barcodeColor || "#000000",
|
|
light: bgColor,
|
|
},
|
|
errorCorrectionLevel: component.qrErrorCorrectionLevel || "M",
|
|
},
|
|
(err) => {
|
|
if (err) setError(err.message || "QR코드 생성 실패");
|
|
}
|
|
);
|
|
}
|
|
} else {
|
|
// 1D 바코드 렌더링
|
|
if (svgRef.current && barcodeValue) {
|
|
try {
|
|
const bgColor = component.barcodeBackground === "transparent" ? "" : (component.barcodeBackground || "#ffffff");
|
|
JsBarcode(svgRef.current, barcodeValue.trim(), {
|
|
format: barcodeType.toLowerCase(),
|
|
width: 2,
|
|
height: Math.max(30, component.height - 40),
|
|
displayValue: component.showBarcodeText !== false,
|
|
lineColor: component.barcodeColor || "#000000",
|
|
background: bgColor,
|
|
margin: component.barcodeMargin ?? 10,
|
|
fontSize: 12,
|
|
textMargin: 2,
|
|
});
|
|
} catch (err: any) {
|
|
setError(err?.message || "바코드 생성 실패");
|
|
}
|
|
}
|
|
}
|
|
}, [barcodeValue, barcodeType, isQR, component]);
|
|
|
|
if (error) {
|
|
return (
|
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", color: "#ef4444", fontSize: "12px" }}>
|
|
{error}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", backgroundColor: component.barcodeBackground || "transparent" }}>
|
|
{isQR ? (
|
|
<canvas ref={canvasRef} style={{ maxWidth: "100%", maxHeight: "100%" }} />
|
|
) : (
|
|
<svg ref={svgRef} style={{ maxWidth: "100%", maxHeight: "100%" }} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 || "텍스트";
|
|
};
|
|
|
|
// 바코드/QR코드를 base64 이미지로 변환
|
|
const generateBarcodeImage = async (component: any): Promise<string | null> => {
|
|
const barcodeType = component.barcodeType || "CODE128";
|
|
const isQR = barcodeType === "QR";
|
|
|
|
// 바코드 값 결정
|
|
const getBarcodeValue = (): string => {
|
|
if (
|
|
isQR &&
|
|
component.qrUseMultiField &&
|
|
component.qrDataFields &&
|
|
component.qrDataFields.length > 0 &&
|
|
component.queryId
|
|
) {
|
|
const queryResult = getQueryResult(component.queryId);
|
|
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
|
if (component.qrIncludeAllRows) {
|
|
const allRowsData: Record<string, string>[] = [];
|
|
queryResult.rows.forEach((row) => {
|
|
const rowData: Record<string, string> = {};
|
|
component.qrDataFields.forEach((field: { fieldName: string; label: string }) => {
|
|
if (field.fieldName && field.label) {
|
|
const val = row[field.fieldName];
|
|
rowData[field.label] = val !== null && val !== undefined ? String(val) : "";
|
|
}
|
|
});
|
|
allRowsData.push(rowData);
|
|
});
|
|
return JSON.stringify(allRowsData);
|
|
}
|
|
const row = queryResult.rows[0];
|
|
const jsonData: Record<string, string> = {};
|
|
component.qrDataFields.forEach((field: { fieldName: string; label: string }) => {
|
|
if (field.fieldName && field.label) {
|
|
const val = row[field.fieldName];
|
|
jsonData[field.label] = val !== null && val !== undefined ? String(val) : "";
|
|
}
|
|
});
|
|
return JSON.stringify(jsonData);
|
|
}
|
|
}
|
|
|
|
if (component.barcodeFieldName && component.queryId) {
|
|
const queryResult = getQueryResult(component.queryId);
|
|
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
|
if (isQR && component.qrIncludeAllRows) {
|
|
const allValues = queryResult.rows
|
|
.map((row) => {
|
|
const val = row[component.barcodeFieldName];
|
|
return val !== null && val !== undefined ? String(val) : "";
|
|
})
|
|
.filter((v) => v !== "");
|
|
return JSON.stringify(allValues);
|
|
}
|
|
const row = queryResult.rows[0];
|
|
const val = row[component.barcodeFieldName];
|
|
if (val !== null && val !== undefined) {
|
|
return String(val);
|
|
}
|
|
}
|
|
}
|
|
return component.barcodeValue || "SAMPLE123";
|
|
};
|
|
|
|
const barcodeValue = getBarcodeValue();
|
|
|
|
try {
|
|
if (isQR) {
|
|
// QR코드를 canvas에 렌더링 후 base64로 변환
|
|
const canvas = document.createElement("canvas");
|
|
const bgColor = component.barcodeBackground === "transparent" ? "#ffffff" : (component.barcodeBackground || "#ffffff");
|
|
await QRCode.toCanvas(canvas, barcodeValue, {
|
|
width: Math.min(component.width, component.height) - 10,
|
|
margin: 2,
|
|
color: {
|
|
dark: component.barcodeColor || "#000000",
|
|
light: bgColor,
|
|
},
|
|
errorCorrectionLevel: component.qrErrorCorrectionLevel || "M",
|
|
});
|
|
return canvas.toDataURL("image/png");
|
|
} else {
|
|
// 1D 바코드를 SVG로 렌더링 후 base64로 변환
|
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
const bgColor = component.barcodeBackground === "transparent" ? "" : (component.barcodeBackground || "#ffffff");
|
|
JsBarcode(svg, barcodeValue.trim(), {
|
|
format: barcodeType.toLowerCase(),
|
|
width: 2,
|
|
height: Math.max(30, component.height - 40),
|
|
displayValue: component.showBarcodeText !== false,
|
|
lineColor: component.barcodeColor || "#000000",
|
|
background: bgColor,
|
|
margin: component.barcodeMargin ?? 10,
|
|
fontSize: 12,
|
|
textMargin: 2,
|
|
});
|
|
const svgData = new XMLSerializer().serializeToString(svg);
|
|
const svgBase64 = btoa(unescape(encodeURIComponent(svgData)));
|
|
return `data:image/svg+xml;base64,${svgBase64}`;
|
|
}
|
|
} catch (error) {
|
|
console.error("바코드 생성 오류:", error);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const handlePrint = async () => {
|
|
// 바코드 이미지 미리 생성
|
|
const pagesWithBarcodes = await Promise.all(
|
|
layoutConfig.pages.map(async (page) => {
|
|
const componentsWithBarcodes = await Promise.all(
|
|
(Array.isArray(page.components) ? page.components : []).map(async (component) => {
|
|
if (component.type === "barcode") {
|
|
const barcodeImage = await generateBarcodeImage(component);
|
|
return { ...component, barcodeImageBase64: barcodeImage };
|
|
}
|
|
return component;
|
|
})
|
|
);
|
|
return { ...page, components: componentsWithBarcodes };
|
|
})
|
|
);
|
|
|
|
// HTML 생성하여 인쇄
|
|
const printHtml = generatePrintHTML(pagesWithBarcodes);
|
|
|
|
const printWindow = window.open("", "_blank");
|
|
if (!printWindow) return;
|
|
|
|
printWindow.document.write(printHtml);
|
|
printWindow.document.close();
|
|
// print()는 HTML 내 스크립트에서 이미지 로드 완료 후 자동 호출됨
|
|
};
|
|
|
|
// 워터마크 HTML 생성 헬퍼 함수
|
|
const generateWatermarkHTML = (watermark: any, pageWidth: number, pageHeight: number): string => {
|
|
if (!watermark?.enabled) return "";
|
|
|
|
const opacity = watermark.opacity ?? 0.3;
|
|
const rotation = watermark.rotation ?? -45;
|
|
|
|
// 공통 래퍼 스타일
|
|
const wrapperStyle = `position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; overflow: hidden; z-index: 0;`;
|
|
|
|
// 텍스트 컨텐츠 생성
|
|
const textContent = watermark.type === "text"
|
|
? `<span style="font-size: ${watermark.fontSize || 48}px; color: ${watermark.fontColor || "#cccccc"}; font-weight: bold; white-space: nowrap;">${watermark.text || "WATERMARK"}</span>`
|
|
: watermark.imageUrl
|
|
? `<img src="${watermark.imageUrl.startsWith("data:") ? watermark.imageUrl : getFullImageUrl(watermark.imageUrl)}" style="max-width: 50%; max-height: 50%; object-fit: contain;" />`
|
|
: "";
|
|
|
|
if (watermark.style === "diagonal") {
|
|
return `
|
|
<div style="${wrapperStyle}">
|
|
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%) rotate(${rotation}deg); opacity: ${opacity};">
|
|
${textContent}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
if (watermark.style === "center") {
|
|
return `
|
|
<div style="${wrapperStyle}">
|
|
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); opacity: ${opacity};">
|
|
${textContent}
|
|
</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;
|
|
const tileItems = Array.from({ length: rows * cols })
|
|
.map(() => `<div style="width: ${tileSize}px; height: ${tileSize}px; display: flex; align-items: center; justify-content: center;">${textContent}</div>`)
|
|
.join("");
|
|
|
|
return `
|
|
<div style="${wrapperStyle}">
|
|
<div style="position: absolute; top: -50%; left: -50%; width: 200%; height: 200%; display: flex; flex-wrap: wrap; align-content: flex-start; transform: rotate(${rotation}deg); opacity: ${opacity};">
|
|
${tileItems}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
return "";
|
|
};
|
|
|
|
// 페이지별 컴포넌트 HTML 생성
|
|
const generatePageHTML = (
|
|
pageComponents: any[],
|
|
pageWidth: number,
|
|
pageHeight: number,
|
|
backgroundColor: string,
|
|
pageIndex: number = 0,
|
|
totalPages: number = 1,
|
|
watermark?: any,
|
|
): 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"};" />` : ""}
|
|
</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"};" />` : ""}
|
|
</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; width: 100%; height: 100%;">
|
|
${personName ? `<div style="font-size: 12px; white-space: nowrap;">${personName}</div>` : ""}
|
|
<div style="position: relative; flex: 1; height: 100%;">
|
|
${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>`;
|
|
}
|
|
|
|
// 바코드/QR코드 컴포넌트 (인쇄용 - base64 이미지 사용)
|
|
else if (component.type === "barcode") {
|
|
// 바코드 이미지는 미리 생성된 base64 사용 (handlePrint에서 생성)
|
|
const barcodeImage = (component as any).barcodeImageBase64;
|
|
if (barcodeImage) {
|
|
content = `<img src="${barcodeImage}" style="max-width: 100%; max-height: 100%; object-fit: contain;" />`;
|
|
} else {
|
|
content = `<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #666; font-size: 12px;">바코드</div>`;
|
|
}
|
|
}
|
|
|
|
// 체크박스 컴포넌트 (인쇄용)
|
|
else if (component.type === "checkbox") {
|
|
const checkboxSize = component.checkboxSize || 18;
|
|
const checkboxColor = component.checkboxColor || "#2563eb";
|
|
const checkboxBorderColor = component.checkboxBorderColor || "#6b7280";
|
|
const checkboxLabel = component.checkboxLabel || "";
|
|
const checkboxLabelPosition = component.checkboxLabelPosition || "right";
|
|
|
|
// 체크 상태 결정
|
|
let isChecked = component.checkboxChecked === true;
|
|
if (component.checkboxFieldName && queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
|
const val = queryResult.rows[0][component.checkboxFieldName];
|
|
isChecked = val === true || val === "Y" || val === "1" || val === 1 || val === "true";
|
|
}
|
|
|
|
const checkboxHTML = `
|
|
<div style="width: ${checkboxSize}px; height: ${checkboxSize}px; border: 2px solid ${isChecked ? checkboxColor : checkboxBorderColor}; border-radius: 2px; background-color: ${isChecked ? checkboxColor : "transparent"}; display: flex; align-items: center; justify-content: center;">
|
|
${isChecked ? `<svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="width: ${checkboxSize * 0.7}px; height: ${checkboxSize * 0.7}px;"><polyline points="20 6 9 17 4 12" /></svg>` : ""}
|
|
</div>
|
|
`;
|
|
|
|
content = `
|
|
<div style="display: flex; align-items: center; gap: 8px; height: 100%; flex-direction: ${checkboxLabelPosition === "left" ? "row-reverse" : "row"}; ${checkboxLabelPosition === "left" ? "justify-content: flex-end;" : ""}">
|
|
${checkboxHTML}
|
|
${checkboxLabel ? `<span style="font-size: 12px;">${checkboxLabel}</span>` : ""}
|
|
</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>`;
|
|
}
|
|
|
|
// 컴포넌트 값은 px로 저장됨 (캔버스는 pageWidth * MM_TO_PX px)
|
|
// 인쇄용 mm 단위로 변환: px / MM_TO_PX = mm
|
|
const xMm = component.x / MM_TO_PX;
|
|
const yMm = component.y / MM_TO_PX;
|
|
const widthMm = component.width / MM_TO_PX;
|
|
const heightMm = component.height / MM_TO_PX;
|
|
|
|
return `
|
|
<div style="position: absolute; left: ${xMm}mm; top: ${yMm}mm; width: ${widthMm}mm; height: ${heightMm}mm; background-color: ${component.backgroundColor || "transparent"}; border: ${component.borderWidth ? `${component.borderWidth}px solid ${component.borderColor}` : "none"}; box-sizing: border-box; overflow: hidden;">
|
|
${content}
|
|
</div>`;
|
|
})
|
|
.join("");
|
|
|
|
const watermarkHTML = generateWatermarkHTML(watermark, pageWidth, pageHeight);
|
|
|
|
return `
|
|
<div class="print-page" style="position: relative; width: ${pageWidth}mm; min-height: ${pageHeight}mm; background-color: ${backgroundColor};">
|
|
${watermarkHTML}
|
|
${componentsHTML}
|
|
</div>`;
|
|
};
|
|
|
|
// 모든 페이지 HTML 생성 (인쇄/PDF용)
|
|
const generatePrintHTML = (pagesWithBarcodes?: any[]): string => {
|
|
const pages = pagesWithBarcodes || layoutConfig.pages;
|
|
const sortedPages = 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,
|
|
layoutConfig.watermark, // 전체 페이지 공유 워터마크
|
|
),
|
|
)
|
|
.join('<div style="page-break-after: always;"></div>');
|
|
|
|
return `
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>리포트 인쇄</title>
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
@page {
|
|
size: A4;
|
|
margin: 0;
|
|
}
|
|
@media print {
|
|
html, body { width: 210mm; height: 297mm; }
|
|
.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;
|
|
-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 = async () => {
|
|
// 바코드 이미지 미리 생성
|
|
const pagesWithBarcodes = await Promise.all(
|
|
layoutConfig.pages.map(async (page) => {
|
|
const componentsWithBarcodes = await Promise.all(
|
|
(Array.isArray(page.components) ? page.components : []).map(async (component) => {
|
|
if (component.type === "barcode") {
|
|
const barcodeImage = await generateBarcodeImage(component);
|
|
return { ...component, barcodeImageBase64: barcodeImage };
|
|
}
|
|
return component;
|
|
})
|
|
);
|
|
return { ...page, components: componentsWithBarcodes };
|
|
})
|
|
);
|
|
|
|
const printHtml = generatePrintHTML(pagesWithBarcodes);
|
|
|
|
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;
|
|
}
|
|
}
|
|
// 바코드/QR코드 컴포넌트는 이미지로 변환
|
|
if (component.type === "barcode") {
|
|
try {
|
|
const barcodeImage = await generateBarcodeImage(component);
|
|
return { ...component, barcodeImageBase64: barcodeImage };
|
|
} 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 _rpd = new Date();
|
|
const timestamp = `${_rpd.getFullYear()}-${String(_rpd.getMonth() + 1).padStart(2, "0")}-${String(_rpd.getDate()).padStart(2, "0")}`;
|
|
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-muted 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 overflow-hidden shadow-lg"
|
|
style={{
|
|
width: `${page.width * 4}px`,
|
|
minHeight: `${page.height * 4}px`,
|
|
backgroundColor: page.background_color,
|
|
}}
|
|
>
|
|
{/* 워터마크 렌더링 (전체 페이지 공유) */}
|
|
{layoutConfig.watermark?.enabled && (
|
|
<PreviewWatermarkLayer
|
|
watermark={layoutConfig.watermark}
|
|
pageWidth={page.width}
|
|
pageHeight={page.height}
|
|
/>
|
|
)}
|
|
{(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-muted-foreground/70">쿼리를 실행해주세요</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",
|
|
}}
|
|
/>
|
|
)}
|
|
</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>
|
|
);
|
|
})()}
|
|
|
|
{/* 바코드/QR코드 컴포넌트 */}
|
|
{component.type === "barcode" && (
|
|
<BarcodePreview component={component} getQueryResult={getQueryResult} />
|
|
)}
|
|
|
|
{/* 체크박스 컴포넌트 */}
|
|
{component.type === "checkbox" && (() => {
|
|
const checkboxSize = component.checkboxSize || 18;
|
|
const checkboxColor = component.checkboxColor || "#2563eb";
|
|
const checkboxBorderColor = component.checkboxBorderColor || "#6b7280";
|
|
const checkboxLabel = component.checkboxLabel || "";
|
|
const checkboxLabelPosition = component.checkboxLabelPosition || "right";
|
|
|
|
// 체크 상태 결정
|
|
let isChecked = component.checkboxChecked === true;
|
|
if (component.checkboxFieldName && component.queryId) {
|
|
const qResult = getQueryResult(component.queryId);
|
|
if (qResult && qResult.rows && qResult.rows.length > 0) {
|
|
const val = qResult.rows[0][component.checkboxFieldName];
|
|
isChecked = val === true || val === "Y" || val === "1" || val === 1 || val === "true";
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "8px",
|
|
height: "100%",
|
|
flexDirection: checkboxLabelPosition === "left" ? "row-reverse" : "row",
|
|
justifyContent: checkboxLabelPosition === "left" ? "flex-end" : "flex-start",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: `${checkboxSize}px`,
|
|
height: `${checkboxSize}px`,
|
|
borderRadius: "2px",
|
|
border: `2px solid ${isChecked ? checkboxColor : checkboxBorderColor}`,
|
|
backgroundColor: isChecked ? checkboxColor : "transparent",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
{isChecked && (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="white"
|
|
strokeWidth="3"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
style={{
|
|
width: `${checkboxSize * 0.7}px`,
|
|
height: `${checkboxSize * 0.7}px`,
|
|
}}
|
|
>
|
|
<polyline points="20 6 9 17 4 12" />
|
|
</svg>
|
|
)}
|
|
</div>
|
|
{checkboxLabel && (
|
|
<span style={{ fontSize: "12px" }}>{checkboxLabel}</span>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|