2025-10-01 12:00:13 +09:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
2025-11-12 18:51:20 +09:00
|
|
|
DialogDescription,
|
|
|
|
|
DialogFooter,
|
2025-10-01 12:00:13 +09:00
|
|
|
DialogHeader,
|
2025-11-12 18:51:20 +09:00
|
|
|
DialogTitle,
|
|
|
|
|
} from "@/components/ui/dialog";
|
2025-10-01 12:00:13 +09:00
|
|
|
import { Button } from "@/components/ui/button";
|
2025-10-01 15:20:25 +09:00
|
|
|
import { Printer, FileDown, FileText } from "lucide-react";
|
2025-10-01 12:00:13 +09:00
|
|
|
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
2025-12-22 13:58:12 +09:00
|
|
|
import { useState, useRef, useEffect } from "react";
|
2025-10-01 15:20:25 +09:00
|
|
|
import { useToast } from "@/hooks/use-toast";
|
2025-10-01 16:53:35 +09:00
|
|
|
import { getFullImageUrl } from "@/lib/api/client";
|
2026-03-10 18:30:18 +09:00
|
|
|
import { WatermarkLayer } from "./WatermarkLayer";
|
|
|
|
|
import { ConditionalRule } from "@/types/report";
|
2025-12-22 13:58:12 +09:00
|
|
|
import JsBarcode from "jsbarcode";
|
|
|
|
|
import QRCode from "qrcode";
|
2025-10-01 12:00:13 +09:00
|
|
|
|
2025-12-23 17:37:22 +09:00
|
|
|
// mm -> px 변환 상수
|
|
|
|
|
const MM_TO_PX = 4;
|
|
|
|
|
|
2026-03-10 18:30:18 +09:00
|
|
|
function evaluateSingleRule(
|
|
|
|
|
rule: ConditionalRule,
|
|
|
|
|
getQueryResult: (queryId: string) => { fields: string[]; rows: Record<string, unknown>[] } | null,
|
|
|
|
|
): boolean {
|
|
|
|
|
const queryResult = getQueryResult(rule.queryId);
|
|
|
|
|
if (!queryResult?.rows?.length) return false;
|
|
|
|
|
|
|
|
|
|
const rawValue = queryResult.rows[0][rule.field];
|
|
|
|
|
const fieldStr = rawValue !== null && rawValue !== undefined ? String(rawValue) : "";
|
|
|
|
|
const fieldNum = parseFloat(fieldStr);
|
|
|
|
|
const compareNum = parseFloat(rule.value);
|
|
|
|
|
|
|
|
|
|
switch (rule.operator) {
|
|
|
|
|
case "eq":
|
|
|
|
|
return fieldStr === rule.value;
|
|
|
|
|
case "ne":
|
|
|
|
|
return fieldStr !== rule.value;
|
|
|
|
|
case "gt":
|
|
|
|
|
return !isNaN(fieldNum) && !isNaN(compareNum) && fieldNum > compareNum;
|
|
|
|
|
case "lt":
|
|
|
|
|
return !isNaN(fieldNum) && !isNaN(compareNum) && fieldNum < compareNum;
|
|
|
|
|
case "gte":
|
|
|
|
|
return !isNaN(fieldNum) && !isNaN(compareNum) && fieldNum >= compareNum;
|
|
|
|
|
case "lte":
|
|
|
|
|
return !isNaN(fieldNum) && !isNaN(compareNum) && fieldNum <= compareNum;
|
|
|
|
|
case "contains":
|
|
|
|
|
return fieldStr.includes(rule.value);
|
|
|
|
|
case "notEmpty":
|
|
|
|
|
return fieldStr !== "";
|
|
|
|
|
case "empty":
|
|
|
|
|
return fieldStr === "";
|
|
|
|
|
default:
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2025-12-22 15:40:31 +09:00
|
|
|
}
|
|
|
|
|
|
2026-03-10 18:30:18 +09:00
|
|
|
/** 다중 AND 조건 평가 (하위 호환: conditionalRule 단일 필드도 지원) */
|
|
|
|
|
function evaluateConditionalRule(
|
|
|
|
|
rule: ConditionalRule | undefined,
|
|
|
|
|
getQueryResult: (queryId: string) => { fields: string[]; rows: Record<string, unknown>[] } | null,
|
|
|
|
|
rules?: ConditionalRule[],
|
|
|
|
|
): boolean {
|
|
|
|
|
const allRules = rules?.length ? rules : rule ? [rule] : [];
|
|
|
|
|
if (allRules.length === 0) return true;
|
2025-12-22 15:40:31 +09:00
|
|
|
|
2026-03-10 18:30:18 +09:00
|
|
|
const validRules = allRules.filter((r) => r.queryId && r.field);
|
|
|
|
|
if (validRules.length === 0) return true;
|
2025-12-22 15:40:31 +09:00
|
|
|
|
2026-03-10 18:30:18 +09:00
|
|
|
const action = validRules[0].action;
|
|
|
|
|
const allMet = validRules.every((r) => evaluateSingleRule(r, getQueryResult));
|
2025-12-22 15:40:31 +09:00
|
|
|
|
2026-03-10 18:30:18 +09:00
|
|
|
return action === "show" ? allMet : !allMet;
|
|
|
|
|
}
|
2025-12-22 15:40:31 +09:00
|
|
|
|
2026-03-10 18:30:18 +09:00
|
|
|
interface ReportPreviewModalProps {
|
|
|
|
|
isOpen: boolean;
|
|
|
|
|
onClose: () => void;
|
2025-12-22 15:40:31 +09:00
|
|
|
}
|
|
|
|
|
|
2025-12-22 13:58:12 +09:00
|
|
|
// 바코드/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) {
|
2026-03-10 18:30:18 +09:00
|
|
|
const bgColor =
|
|
|
|
|
component.barcodeBackground === "transparent" ? "#ffffff" : component.barcodeBackground || "#ffffff";
|
2025-12-22 13:58:12 +09:00
|
|
|
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코드 생성 실패");
|
2026-03-10 18:30:18 +09:00
|
|
|
},
|
2025-12-22 13:58:12 +09:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// 1D 바코드 렌더링
|
|
|
|
|
if (svgRef.current && barcodeValue) {
|
|
|
|
|
try {
|
2026-03-10 18:30:18 +09:00
|
|
|
const bgColor = component.barcodeBackground === "transparent" ? "" : component.barcodeBackground || "#ffffff";
|
2025-12-22 13:58:12 +09:00
|
|
|
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 (
|
2026-03-10 18:30:18 +09:00
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
display: "flex",
|
|
|
|
|
alignItems: "center",
|
|
|
|
|
justifyContent: "center",
|
|
|
|
|
height: "100%",
|
|
|
|
|
color: "#ef4444",
|
|
|
|
|
fontSize: "12px",
|
|
|
|
|
}}
|
|
|
|
|
>
|
2025-12-22 13:58:12 +09:00
|
|
|
{error}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-10 18:30:18 +09:00
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
display: "flex",
|
|
|
|
|
alignItems: "center",
|
|
|
|
|
justifyContent: "center",
|
|
|
|
|
height: "100%",
|
|
|
|
|
backgroundColor: component.barcodeBackground || "transparent",
|
|
|
|
|
}}
|
|
|
|
|
>
|
2025-12-22 13:58:12 +09:00
|
|
|
{isQR ? (
|
|
|
|
|
<canvas ref={canvasRef} style={{ maxWidth: "100%", maxHeight: "100%" }} />
|
|
|
|
|
) : (
|
|
|
|
|
<svg ref={svgRef} style={{ maxWidth: "100%", maxHeight: "100%" }} />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-01 12:00:13 +09:00
|
|
|
export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) {
|
2025-10-02 13:44:16 +09:00
|
|
|
const { layoutConfig, getQueryResult, reportDetail } = useReportDesigner();
|
2025-10-01 15:20:25 +09:00
|
|
|
const [isExporting, setIsExporting] = useState(false);
|
|
|
|
|
const { toast } = useToast();
|
2026-03-10 18:30:18 +09:00
|
|
|
const previewPagesRef = useRef<HTMLDivElement>(null);
|
2025-10-01 13:58:55 +09:00
|
|
|
|
|
|
|
|
// 컴포넌트의 실제 표시 값 가져오기
|
|
|
|
|
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 || "텍스트";
|
|
|
|
|
};
|
2025-10-01 12:00:13 +09:00
|
|
|
|
2025-12-22 13:58:12 +09:00
|
|
|
// 바코드/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");
|
2026-03-10 18:30:18 +09:00
|
|
|
const bgColor =
|
|
|
|
|
component.barcodeBackground === "transparent" ? "#ffffff" : component.barcodeBackground || "#ffffff";
|
2025-12-22 13:58:12 +09:00
|
|
|
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");
|
2026-03-10 18:30:18 +09:00
|
|
|
const bgColor = component.barcodeBackground === "transparent" ? "" : component.barcodeBackground || "#ffffff";
|
2025-12-22 13:58:12 +09:00
|
|
|
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;
|
2026-03-10 18:30:18 +09:00
|
|
|
}),
|
2025-12-22 13:58:12 +09:00
|
|
|
);
|
|
|
|
|
return { ...page, components: componentsWithBarcodes };
|
2026-03-10 18:30:18 +09:00
|
|
|
}),
|
2025-12-22 13:58:12 +09:00
|
|
|
);
|
|
|
|
|
|
2025-10-02 10:52:13 +09:00
|
|
|
// HTML 생성하여 인쇄
|
2025-12-22 13:58:12 +09:00
|
|
|
const printHtml = generatePrintHTML(pagesWithBarcodes);
|
2025-10-01 13:58:55 +09:00
|
|
|
|
|
|
|
|
const printWindow = window.open("", "_blank");
|
|
|
|
|
if (!printWindow) return;
|
|
|
|
|
|
2025-10-02 10:52:13 +09:00
|
|
|
printWindow.document.write(printHtml);
|
2025-10-01 13:58:55 +09:00
|
|
|
printWindow.document.close();
|
2025-12-23 17:37:22 +09:00
|
|
|
// print()는 HTML 내 스크립트에서 이미지 로드 완료 후 자동 호출됨
|
2025-10-01 12:00:13 +09:00
|
|
|
};
|
|
|
|
|
|
2025-12-22 15:40:31 +09:00
|
|
|
// 워터마크 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;`;
|
|
|
|
|
|
|
|
|
|
// 텍스트 컨텐츠 생성
|
2026-03-10 18:30:18 +09:00
|
|
|
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;" />`
|
|
|
|
|
: "";
|
2025-12-22 15:40:31 +09:00
|
|
|
|
|
|
|
|
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;
|
2025-12-23 17:37:22 +09:00
|
|
|
const cols = Math.ceil((pageWidth * MM_TO_PX) / tileSize) + 2;
|
|
|
|
|
const rows = Math.ceil((pageHeight * MM_TO_PX) / tileSize) + 2;
|
2025-12-22 15:40:31 +09:00
|
|
|
const tileItems = Array.from({ length: rows * cols })
|
2026-03-10 18:30:18 +09:00
|
|
|
.map(
|
|
|
|
|
() =>
|
|
|
|
|
`<div style="width: ${tileSize}px; height: ${tileSize}px; display: flex; align-items: center; justify-content: center;">${textContent}</div>`,
|
|
|
|
|
)
|
2025-12-22 15:40:31 +09:00
|
|
|
.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 "";
|
|
|
|
|
};
|
|
|
|
|
|
2025-10-02 13:44:16 +09:00
|
|
|
// 페이지별 컴포넌트 HTML 생성
|
|
|
|
|
const generatePageHTML = (
|
|
|
|
|
pageComponents: any[],
|
|
|
|
|
pageWidth: number,
|
|
|
|
|
pageHeight: number,
|
|
|
|
|
backgroundColor: string,
|
2025-12-18 09:45:07 +09:00
|
|
|
pageIndex: number = 0,
|
|
|
|
|
totalPages: number = 1,
|
2025-12-22 15:40:31 +09:00
|
|
|
watermark?: any,
|
2025-10-02 13:44:16 +09:00
|
|
|
): string => {
|
|
|
|
|
const componentsHTML = pageComponents
|
2026-03-10 18:30:18 +09:00
|
|
|
.filter((component) => {
|
|
|
|
|
const isGrid = component.gridMode === true;
|
|
|
|
|
const rule = isGrid ? component.gridConditionalRule : component.conditionalRule;
|
|
|
|
|
const rules = isGrid ? component.gridConditionalRules : component.conditionalRules;
|
|
|
|
|
return evaluateConditionalRule(rule, getQueryResult, rules);
|
|
|
|
|
})
|
2025-10-02 10:52:13 +09:00
|
|
|
.map((component) => {
|
|
|
|
|
const queryResult = component.queryId ? getQueryResult(component.queryId) : null;
|
|
|
|
|
let content = "";
|
2025-10-01 15:20:25 +09:00
|
|
|
|
2025-10-02 10:52:13 +09:00
|
|
|
// Text/Label 컴포넌트
|
|
|
|
|
if (component.type === "text" || component.type === "label") {
|
|
|
|
|
const displayValue = getComponentValue(component);
|
2026-03-10 18:30:18 +09:00
|
|
|
content = `<div style="font-size: ${component.fontSize || 13}px; font-family: ${component.fontFamily || "Malgun Gothic"}; color: ${component.fontColor || "#000000"}; font-weight: ${component.fontWeight || "normal"}; text-align: ${component.textAlign || "left"}; white-space: pre-wrap;">${displayValue}</div>`;
|
2025-10-02 10:52:13 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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 = `
|
2025-12-23 17:37:22 +09:00
|
|
|
<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%;">
|
2025-10-02 10:52:13 +09:00
|
|
|
${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>`;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-18 09:45:07 +09:00
|
|
|
// 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>`;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-18 10:39:57 +09:00
|
|
|
// 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>
|
2026-03-10 18:30:18 +09:00
|
|
|
`,
|
2025-12-18 10:39:57 +09:00
|
|
|
)
|
|
|
|
|
.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>`;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-18 11:41:48 +09:00
|
|
|
// 계산 컴포넌트
|
|
|
|
|
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);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 쿼리 바인딩된 값 가져오기
|
2026-03-10 18:30:18 +09:00
|
|
|
const getCalcItemValue = (item: {
|
|
|
|
|
label: string;
|
|
|
|
|
value: number | string;
|
|
|
|
|
operator: string;
|
|
|
|
|
fieldName?: string;
|
|
|
|
|
}): number => {
|
2025-12-18 11:41:48 +09:00
|
|
|
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) {
|
|
|
|
|
// 첫 번째 항목은 기준값
|
2026-03-10 18:30:18 +09:00
|
|
|
calcResult = getCalcItemValue(
|
|
|
|
|
calcItems[0] as { label: string; value: number | string; operator: string; fieldName?: string },
|
|
|
|
|
);
|
|
|
|
|
|
2025-12-18 11:41:48 +09:00
|
|
|
// 두 번째 항목부터 연산자 적용
|
|
|
|
|
for (let i = 1; i < calcItems.length; i++) {
|
|
|
|
|
const item = calcItems[i];
|
2026-03-10 18:30:18 +09:00
|
|
|
const val = getCalcItemValue(
|
|
|
|
|
item as { label: string; value: number | string; operator: string; fieldName?: string },
|
|
|
|
|
);
|
2025-12-18 11:41:48 +09:00
|
|
|
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>`;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-22 13:58:12 +09:00
|
|
|
// 바코드/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>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 18:30:18 +09:00
|
|
|
// Table 컴포넌트 — 그리드 양식 모드
|
|
|
|
|
else if (component.type === "table" && component.gridMode && component.gridCells) {
|
|
|
|
|
const gridCells = component.gridCells;
|
|
|
|
|
const gridRowCount = component.gridRowCount ?? 0;
|
|
|
|
|
const gridColCount = component.gridColCount ?? 0;
|
|
|
|
|
const gridColWidths = component.gridColWidths ?? [];
|
|
|
|
|
const gridRowHeights = component.gridRowHeights ?? [];
|
|
|
|
|
const dataRow = queryResult?.rows?.[0] as Record<string, unknown> | undefined;
|
|
|
|
|
|
|
|
|
|
const findCell = (r: number, c: number) => gridCells.find((gc) => gc.row === r && gc.col === c);
|
|
|
|
|
const totalW = gridColWidths.reduce((a: number, b: number) => a + b, 0);
|
|
|
|
|
|
|
|
|
|
let gridRows = "";
|
|
|
|
|
for (let r = 0; r < gridRowCount; r++) {
|
|
|
|
|
let tds = "";
|
|
|
|
|
for (let c = 0; c < gridColCount; c++) {
|
|
|
|
|
const cell = findCell(r, c);
|
|
|
|
|
if (!cell || cell.merged) continue;
|
|
|
|
|
const rSpan = cell.rowSpan ?? 1;
|
|
|
|
|
const cSpan = cell.colSpan ?? 1;
|
|
|
|
|
const w = gridColWidths.slice(c, c + cSpan).reduce((a: number, b: number) => a + b, 0);
|
|
|
|
|
const h = gridRowHeights.slice(r, r + rSpan).reduce((a: number, b: number) => a + b, 0);
|
|
|
|
|
const borderW = cell.borderStyle === "medium" ? 2 : cell.borderStyle === "thick" ? 3 : cell.borderStyle === "none" ? 0 : 1;
|
|
|
|
|
|
|
|
|
|
let displayValue = cell.value ?? "";
|
|
|
|
|
if (cell.cellType === "field" && cell.field && dataRow) {
|
|
|
|
|
displayValue = String(dataRow[cell.field] ?? "");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tds += `<td${rSpan > 1 ? ` rowspan="${rSpan}"` : ""}${cSpan > 1 ? ` colspan="${cSpan}"` : ""} style="width:${w}px;height:${h}px;min-width:${w}px;background:${cell.backgroundColor || "white"};border:${borderW}px solid #d1d5db;padding:2px 4px;font-size:${cell.fontSize ?? 12}px;font-weight:${cell.fontWeight === "bold" ? 700 : 400};color:${cell.textColor || "#111827"};text-align:${cell.align || "center"};vertical-align:${cell.verticalAlign || "middle"};overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${displayValue}</td>`;
|
|
|
|
|
}
|
|
|
|
|
gridRows += `<tr>${tds}</tr>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const colgroup = gridColWidths.map((w: number) => `<col style="width:${w}px">`).join("");
|
|
|
|
|
|
|
|
|
|
content = `
|
|
|
|
|
<table style="width:${totalW}px;border-collapse:collapse;table-layout:fixed;">
|
|
|
|
|
<colgroup>${colgroup}</colgroup>
|
|
|
|
|
<tbody>${gridRows}</tbody>
|
|
|
|
|
</table>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Table 컴포넌트 — 기존 단순 테이블 모드
|
2025-10-02 10:52:13 +09:00
|
|
|
else if (component.type === "table" && queryResult && queryResult.rows.length > 0) {
|
|
|
|
|
const columns =
|
|
|
|
|
component.tableColumns && component.tableColumns.length > 0
|
|
|
|
|
? component.tableColumns
|
2026-03-10 18:30:18 +09:00
|
|
|
: queryResult.fields.map((field: string) => ({
|
2025-10-02 10:52:13 +09:00
|
|
|
field,
|
|
|
|
|
header: field,
|
|
|
|
|
align: "left" as const,
|
|
|
|
|
width: undefined,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const tableRows = queryResult.rows
|
|
|
|
|
.map(
|
2026-03-10 18:30:18 +09:00
|
|
|
(row: Record<string, unknown>) => `
|
2025-10-02 10:52:13 +09:00
|
|
|
<tr>
|
2025-10-02 13:44:16 +09:00
|
|
|
${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("")}
|
2025-10-02 10:52:13 +09:00
|
|
|
</tr>
|
|
|
|
|
`,
|
|
|
|
|
)
|
|
|
|
|
.join("");
|
2025-10-01 15:20:25 +09:00
|
|
|
|
2025-10-02 10:52:13 +09:00
|
|
|
content = `
|
|
|
|
|
<table style="width: 100%; border-collapse: ${component.showBorder !== false ? "collapse" : "separate"}; font-size: 12px;">
|
2025-10-02 14:58:22 +09:00
|
|
|
<thead style="display: table-header-group; break-inside: avoid; break-after: avoid;">
|
2025-10-02 10:52:13 +09:00
|
|
|
<tr style="background-color: ${component.headerBackgroundColor || "#f3f4f6"}; color: ${component.headerTextColor || "#111827"};">
|
2025-10-02 13:44:16 +09:00
|
|
|
${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("")}
|
2025-10-02 10:52:13 +09:00
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
${tableRows}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>`;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-23 17:37:22 +09:00
|
|
|
// 컴포넌트 값은 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;
|
2026-03-10 18:30:18 +09:00
|
|
|
|
2025-10-02 10:52:13 +09:00
|
|
|
return `
|
2026-03-10 18:30:18 +09:00
|
|
|
<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"}; border-radius: ${component.borderRadius ? `${component.borderRadius}px` : "0"}; box-sizing: border-box; overflow: hidden;">
|
2025-10-02 10:52:13 +09:00
|
|
|
${content}
|
|
|
|
|
</div>`;
|
|
|
|
|
})
|
|
|
|
|
.join("");
|
|
|
|
|
|
2025-12-22 15:40:31 +09:00
|
|
|
const watermarkHTML = generateWatermarkHTML(watermark, pageWidth, pageHeight);
|
|
|
|
|
|
2026-03-10 18:30:18 +09:00
|
|
|
const pageNumberHTML = `
|
|
|
|
|
<div style="position: absolute; right: 8mm; bottom: 6mm; font-size: 9px; color: #6b7280; font-family: 'Malgun Gothic', sans-serif;">
|
|
|
|
|
${pageIndex + 1}
|
|
|
|
|
</div>`;
|
|
|
|
|
|
2025-10-02 13:44:16 +09:00
|
|
|
return `
|
2025-12-23 17:37:22 +09:00
|
|
|
<div class="print-page" style="position: relative; width: ${pageWidth}mm; min-height: ${pageHeight}mm; background-color: ${backgroundColor};">
|
2025-12-22 15:40:31 +09:00
|
|
|
${watermarkHTML}
|
2025-10-02 13:44:16 +09:00
|
|
|
${componentsHTML}
|
2026-03-10 18:30:18 +09:00
|
|
|
${pageNumberHTML}
|
2025-10-02 13:44:16 +09:00
|
|
|
</div>`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 모든 페이지 HTML 생성 (인쇄/PDF용)
|
2025-12-22 13:58:12 +09:00
|
|
|
const generatePrintHTML = (pagesWithBarcodes?: any[]): string => {
|
|
|
|
|
const pages = pagesWithBarcodes || layoutConfig.pages;
|
|
|
|
|
const sortedPages = pages.sort((a, b) => a.page_order - b.page_order);
|
2025-12-18 09:45:07 +09:00
|
|
|
const totalPages = sortedPages.length;
|
|
|
|
|
|
|
|
|
|
const pagesHTML = sortedPages
|
|
|
|
|
.map((page, pageIndex) =>
|
2025-12-17 17:42:38 +09:00
|
|
|
generatePageHTML(
|
|
|
|
|
Array.isArray(page.components) ? page.components : [],
|
|
|
|
|
page.width,
|
|
|
|
|
page.height,
|
|
|
|
|
page.background_color,
|
2025-12-18 09:45:07 +09:00
|
|
|
pageIndex,
|
|
|
|
|
totalPages,
|
2025-12-22 17:06:11 +09:00
|
|
|
layoutConfig.watermark, // 전체 페이지 공유 워터마크
|
2025-12-17 17:42:38 +09:00
|
|
|
),
|
|
|
|
|
)
|
2025-10-02 13:44:16 +09:00
|
|
|
.join('<div style="page-break-after: always;"></div>');
|
|
|
|
|
|
2025-10-02 10:52:13 +09:00
|
|
|
return `
|
2025-10-01 15:20:25 +09:00
|
|
|
<html>
|
|
|
|
|
<head>
|
2025-10-02 10:52:13 +09:00
|
|
|
<meta charset="UTF-8">
|
2025-10-01 15:20:25 +09:00
|
|
|
<title>리포트 인쇄</title>
|
|
|
|
|
<style>
|
2025-12-23 17:37:22 +09:00
|
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
2025-10-02 10:52:13 +09:00
|
|
|
@page {
|
|
|
|
|
size: A4;
|
2025-12-23 17:37:22 +09:00
|
|
|
margin: 0;
|
2025-10-02 10:52:13 +09:00
|
|
|
}
|
2025-10-01 15:20:25 +09:00
|
|
|
@media print {
|
2025-12-23 17:37:22 +09:00
|
|
|
html, body { width: 210mm; height: 297mm; }
|
2025-10-02 13:44:16 +09:00
|
|
|
.print-page { page-break-after: always; page-break-inside: avoid; }
|
|
|
|
|
.print-page:last-child { page-break-after: auto; }
|
2025-10-01 15:20:25 +09:00
|
|
|
}
|
|
|
|
|
body {
|
2025-10-02 10:52:13 +09:00
|
|
|
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
|
|
|
|
|
-webkit-print-color-adjust: exact;
|
|
|
|
|
print-color-adjust: exact;
|
2025-10-01 15:20:25 +09:00
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
2025-10-02 13:44:16 +09:00
|
|
|
${pagesHTML}
|
2025-10-01 15:20:25 +09:00
|
|
|
<script>
|
|
|
|
|
window.onload = function() {
|
2025-10-02 10:52:13 +09:00
|
|
|
// 이미지 로드 대기 후 인쇄
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-01 15:20:25 +09:00
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
2025-10-02 10:52:13 +09:00
|
|
|
</html>`;
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-10 18:30:18 +09:00
|
|
|
// PDF 직접 다운로드 (html2canvas + jsPDF)
|
2025-12-22 13:58:12 +09:00
|
|
|
const handleDownloadPDF = async () => {
|
2026-03-10 18:30:18 +09:00
|
|
|
const sortedPages = [...layoutConfig.pages].sort((a, b) => a.page_order - b.page_order);
|
|
|
|
|
if (sortedPages.length === 0) {
|
|
|
|
|
toast({ title: "오류", description: "페이지가 없습니다.", variant: "destructive" });
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-12-22 13:58:12 +09:00
|
|
|
|
2026-03-10 18:30:18 +09:00
|
|
|
setIsExporting(true);
|
|
|
|
|
try {
|
|
|
|
|
const [{ jsPDF }, html2canvas] = await Promise.all([
|
|
|
|
|
import("jspdf"),
|
|
|
|
|
import("html2canvas").then((m) => m.default),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const pageElements = previewPagesRef.current?.querySelectorAll("[data-preview-page]");
|
|
|
|
|
if (!pageElements || pageElements.length === 0) {
|
|
|
|
|
toast({ title: "오류", description: "미리보기 요소를 찾을 수 없습니다.", variant: "destructive" });
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-10-02 10:52:13 +09:00
|
|
|
|
2026-03-10 18:30:18 +09:00
|
|
|
const firstPage = sortedPages[0];
|
|
|
|
|
const doc = new jsPDF({
|
|
|
|
|
orientation: firstPage.orientation === "landscape" ? "l" : "p",
|
|
|
|
|
unit: "mm",
|
|
|
|
|
format: [firstPage.width, firstPage.height],
|
|
|
|
|
});
|
2025-10-02 10:52:13 +09:00
|
|
|
|
2026-03-10 18:30:18 +09:00
|
|
|
for (let i = 0; i < sortedPages.length; i++) {
|
|
|
|
|
const pageConfig = sortedPages[i];
|
|
|
|
|
const pageEl = pageElements[i] as HTMLElement;
|
|
|
|
|
if (!pageEl) continue;
|
|
|
|
|
|
|
|
|
|
const canvas = await html2canvas(pageEl, {
|
|
|
|
|
scale: 2,
|
|
|
|
|
useCORS: true,
|
|
|
|
|
allowTaint: true,
|
|
|
|
|
backgroundColor: pageConfig.background_color || "#ffffff",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const imgData = canvas.toDataURL("image/jpeg", 0.95);
|
|
|
|
|
|
|
|
|
|
if (i > 0) {
|
|
|
|
|
doc.addPage([pageConfig.width, pageConfig.height], pageConfig.orientation === "landscape" ? "l" : "p");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
doc.addImage(imgData, "JPEG", 0, 0, pageConfig.width, pageConfig.height);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fileName = reportDetail?.report?.report_name_kor
|
|
|
|
|
? `${reportDetail.report.report_name_kor}.pdf`
|
|
|
|
|
: `report_${Date.now()}.pdf`;
|
|
|
|
|
doc.save(fileName);
|
2025-10-01 15:20:25 +09:00
|
|
|
|
2026-03-10 18:30:18 +09:00
|
|
|
toast({ title: "성공", description: "PDF가 다운로드되었습니다." });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("PDF 생성 오류:", error);
|
|
|
|
|
toast({ title: "오류", description: "PDF 생성에 실패했습니다.", variant: "destructive" });
|
|
|
|
|
} finally {
|
|
|
|
|
setIsExporting(false);
|
|
|
|
|
}
|
2025-10-01 12:00:13 +09:00
|
|
|
};
|
|
|
|
|
|
2025-12-17 16:11:52 +09:00
|
|
|
// 이미지 URL을 Base64로 변환
|
|
|
|
|
const imageUrlToBase64 = async (url: string): Promise<string> => {
|
|
|
|
|
try {
|
|
|
|
|
// 이미 Base64인 경우 그대로 반환
|
|
|
|
|
if (url.startsWith("data:")) {
|
|
|
|
|
return url;
|
|
|
|
|
}
|
2025-10-13 15:08:31 +09:00
|
|
|
|
2025-12-17 16:11:52 +09:00
|
|
|
// 서버 이미지 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);
|
2025-10-13 15:08:31 +09:00
|
|
|
});
|
2025-12-17 16:11:52 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
console.error("이미지 변환 실패:", error);
|
|
|
|
|
return "";
|
2025-10-13 15:08:31 +09:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-17 16:11:52 +09:00
|
|
|
// WORD 다운로드 (백엔드 API 사용 - 컴포넌트 데이터 전송)
|
2025-10-01 15:20:25 +09:00
|
|
|
const handleDownloadWord = async () => {
|
|
|
|
|
setIsExporting(true);
|
|
|
|
|
try {
|
2025-12-17 16:11:52 +09:00
|
|
|
toast({
|
|
|
|
|
title: "처리 중",
|
|
|
|
|
description: "WORD 파일을 생성하고 있습니다...",
|
|
|
|
|
});
|
2025-10-02 13:44:16 +09:00
|
|
|
|
2025-12-23 13:56:15 +09:00
|
|
|
// 이미지 및 바코드를 Base64로 변환하여 컴포넌트 데이터에 포함
|
2025-12-17 16:11:52 +09:00
|
|
|
const pagesWithBase64 = await Promise.all(
|
|
|
|
|
layoutConfig.pages.map(async (page) => {
|
|
|
|
|
const componentsWithBase64 = await Promise.all(
|
2025-12-17 17:42:38 +09:00
|
|
|
(Array.isArray(page.components) ? page.components : []).map(async (component) => {
|
2025-12-17 16:11:52 +09:00
|
|
|
// 이미지가 있는 컴포넌트는 Base64로 변환
|
|
|
|
|
if (component.imageUrl) {
|
|
|
|
|
try {
|
|
|
|
|
const base64 = await imageUrlToBase64(component.imageUrl);
|
|
|
|
|
return { ...component, imageBase64: base64 };
|
|
|
|
|
} catch {
|
|
|
|
|
return component;
|
2025-10-13 15:08:31 +09:00
|
|
|
}
|
|
|
|
|
}
|
2025-12-23 13:56:15 +09:00
|
|
|
// 바코드/QR코드 컴포넌트는 이미지로 변환
|
|
|
|
|
if (component.type === "barcode") {
|
|
|
|
|
try {
|
|
|
|
|
const barcodeImage = await generateBarcodeImage(component);
|
|
|
|
|
return { ...component, barcodeImageBase64: barcodeImage };
|
|
|
|
|
} catch {
|
2026-03-10 18:30:18 +09:00
|
|
|
return component;
|
2025-12-23 13:56:15 +09:00
|
|
|
}
|
|
|
|
|
}
|
2025-12-17 16:11:52 +09:00
|
|
|
return component;
|
2026-03-10 18:30:18 +09:00
|
|
|
}),
|
|
|
|
|
);
|
2025-12-17 16:11:52 +09:00
|
|
|
return { ...page, components: componentsWithBase64 };
|
2026-03-10 18:30:18 +09:00
|
|
|
}),
|
|
|
|
|
);
|
2025-12-17 16:11:52 +09:00
|
|
|
|
|
|
|
|
// 쿼리 결과 수집
|
|
|
|
|
const queryResults: Record<string, { fields: string[]; rows: Record<string, unknown>[] }> = {};
|
|
|
|
|
for (const page of layoutConfig.pages) {
|
2025-12-17 17:42:38 +09:00
|
|
|
const pageComponents = Array.isArray(page.components) ? page.components : [];
|
|
|
|
|
for (const component of pageComponents) {
|
2025-12-17 16:11:52 +09:00
|
|
|
if (component.queryId) {
|
|
|
|
|
const result = getQueryResult(component.queryId);
|
|
|
|
|
if (result) {
|
|
|
|
|
queryResults[component.queryId] = result;
|
2025-10-13 15:08:31 +09:00
|
|
|
}
|
2025-10-01 15:20:25 +09:00
|
|
|
}
|
2025-12-17 16:11:52 +09:00
|
|
|
}
|
|
|
|
|
}
|
2025-10-13 15:08:31 +09:00
|
|
|
|
2025-10-01 15:20:25 +09:00
|
|
|
const fileName = reportDetail?.report?.report_name_kor || "리포트";
|
|
|
|
|
|
2025-12-17 16:11:52 +09:00
|
|
|
// 백엔드 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);
|
2025-10-01 15:20:25 +09:00
|
|
|
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) {
|
2025-12-17 16:11:52 +09:00
|
|
|
console.error("WORD 변환 오류:", error);
|
2025-10-01 15:20:25 +09:00
|
|
|
const errorMessage = error instanceof Error ? error.message : "WORD 생성에 실패했습니다.";
|
|
|
|
|
toast({
|
|
|
|
|
title: "오류",
|
|
|
|
|
description: errorMessage,
|
|
|
|
|
variant: "destructive",
|
|
|
|
|
});
|
|
|
|
|
} finally {
|
|
|
|
|
setIsExporting(false);
|
|
|
|
|
}
|
2025-10-01 12:00:13 +09:00
|
|
|
};
|
|
|
|
|
|
2026-03-10 18:30:18 +09:00
|
|
|
const sortedPages = [...layoutConfig.pages].sort((a, b) => a.page_order - b.page_order);
|
|
|
|
|
|
2025-10-01 12:00:13 +09:00
|
|
|
return (
|
|
|
|
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
2026-03-10 18:30:18 +09:00
|
|
|
<DialogContent className="flex h-[92vh] max-w-[95vw] flex-col gap-0 p-0 sm:max-w-5xl">
|
|
|
|
|
<DialogHeader className="shrink-0 border-b px-6 py-4">
|
2025-11-07 11:05:19 +09:00
|
|
|
<DialogTitle>미리보기</DialogTitle>
|
2025-10-01 12:00:13 +09:00
|
|
|
<DialogDescription>
|
|
|
|
|
현재 레이아웃의 미리보기입니다. 인쇄하거나 파일로 다운로드할 수 있습니다.
|
2025-11-07 11:05:19 +09:00
|
|
|
</DialogDescription>
|
2025-10-01 12:00:13 +09:00
|
|
|
</DialogHeader>
|
|
|
|
|
|
2026-03-10 18:30:18 +09:00
|
|
|
{/* 미리보기 영역 - 스케일 적용하여 A4 전체 표시 */}
|
|
|
|
|
<div className="flex-1 overflow-auto rounded-none bg-gray-200 p-6">
|
|
|
|
|
<div className="space-y-6" ref={previewPagesRef}>
|
|
|
|
|
{sortedPages.map((page, pageIdx) => {
|
|
|
|
|
const pw = page.width * MM_TO_PX;
|
|
|
|
|
const ph = page.height * MM_TO_PX;
|
|
|
|
|
return (
|
|
|
|
|
<div key={page.page_id} className="flex justify-center">
|
2025-10-02 13:44:16 +09:00
|
|
|
<div
|
2026-03-10 18:30:18 +09:00
|
|
|
className="origin-top"
|
2025-10-02 13:44:16 +09:00
|
|
|
style={{
|
2026-03-10 18:30:18 +09:00
|
|
|
width: `${pw}px`,
|
|
|
|
|
height: `${ph}px`,
|
2025-10-02 13:44:16 +09:00
|
|
|
}}
|
|
|
|
|
>
|
2026-03-10 18:30:18 +09:00
|
|
|
{/* 페이지 컨텐츠 */}
|
|
|
|
|
<div
|
|
|
|
|
data-preview-page={page.page_id}
|
|
|
|
|
className="relative overflow-hidden shadow-lg"
|
|
|
|
|
style={{
|
|
|
|
|
width: `${pw}px`,
|
|
|
|
|
minHeight: `${ph}px`,
|
|
|
|
|
backgroundColor: page.background_color,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{/* 워터마크 렌더링 (전체 페이지 공유) */}
|
|
|
|
|
{layoutConfig.watermark?.enabled && (
|
|
|
|
|
<WatermarkLayer
|
|
|
|
|
watermark={layoutConfig.watermark}
|
|
|
|
|
width={page.width * MM_TO_PX}
|
|
|
|
|
height={page.height * MM_TO_PX}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{(Array.isArray(page.components) ? page.components : []).map((component) => {
|
|
|
|
|
const isGrid = component.gridMode === true;
|
|
|
|
|
const condRule = isGrid ? component.gridConditionalRule : component.conditionalRule;
|
|
|
|
|
const condRules = isGrid ? component.gridConditionalRules : component.conditionalRules;
|
|
|
|
|
if (!evaluateConditionalRule(condRule, getQueryResult, condRules))
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
|
|
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",
|
|
|
|
|
borderRadius: component.borderRadius ? `${component.borderRadius}px` : undefined,
|
|
|
|
|
padding: "8px",
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{component.type === "text" && (
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
fontSize: `${component.fontSize}px`,
|
|
|
|
|
fontFamily: component.fontFamily || "Malgun Gothic",
|
|
|
|
|
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`,
|
|
|
|
|
fontFamily: component.fontFamily || "Malgun Gothic",
|
|
|
|
|
color: component.fontColor,
|
|
|
|
|
fontWeight: component.fontWeight,
|
|
|
|
|
textAlign: component.textAlign as "left" | "center" | "right",
|
|
|
|
|
whiteSpace: "pre-wrap",
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{displayValue}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 그리드 양식 모드 테이블 */}
|
|
|
|
|
{component.type === "table" && component.gridMode && component.gridCells ? (
|
|
|
|
|
(() => {
|
|
|
|
|
const gridCells = component.gridCells!;
|
|
|
|
|
const gridRowCount = component.gridRowCount ?? 0;
|
|
|
|
|
const gridColCount = component.gridColCount ?? 0;
|
|
|
|
|
const gridColWidths = component.gridColWidths ?? [];
|
|
|
|
|
const gridRowHeights = component.gridRowHeights ?? [];
|
|
|
|
|
const dataRow = queryResult?.rows?.[0] as Record<string, unknown> | undefined;
|
|
|
|
|
const totalW = gridColWidths.reduce((a, b) => a + b, 0);
|
|
|
|
|
|
|
|
|
|
const findCell = (r: number, c: number) => gridCells.find((gc) => gc.row === r && gc.col === c);
|
|
|
|
|
|
|
|
|
|
const rows: React.ReactNode[] = [];
|
|
|
|
|
for (let r = 0; r < gridRowCount; r++) {
|
|
|
|
|
const tds: React.ReactNode[] = [];
|
|
|
|
|
for (let c = 0; c < gridColCount; c++) {
|
|
|
|
|
const cell = findCell(r, c);
|
|
|
|
|
if (!cell || cell.merged) continue;
|
|
|
|
|
const rSpan = cell.rowSpan ?? 1;
|
|
|
|
|
const cSpan = cell.colSpan ?? 1;
|
|
|
|
|
const w = gridColWidths.slice(c, c + cSpan).reduce((a, b) => a + b, 0);
|
|
|
|
|
const h = gridRowHeights.slice(r, r + rSpan).reduce((a, b) => a + b, 0);
|
|
|
|
|
const borderW = cell.borderStyle === "medium" ? 2 : cell.borderStyle === "thick" ? 3 : cell.borderStyle === "none" ? 0 : 1;
|
|
|
|
|
|
|
|
|
|
let displayValue = cell.value ?? "";
|
|
|
|
|
if (cell.cellType === "field" && cell.field && dataRow) {
|
|
|
|
|
displayValue = String(dataRow[cell.field] ?? "");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tds.push(
|
|
|
|
|
<td
|
|
|
|
|
key={cell.id}
|
|
|
|
|
rowSpan={rSpan > 1 ? rSpan : undefined}
|
|
|
|
|
colSpan={cSpan > 1 ? cSpan : undefined}
|
|
|
|
|
style={{
|
|
|
|
|
width: w,
|
|
|
|
|
height: h,
|
|
|
|
|
minWidth: w,
|
|
|
|
|
backgroundColor: cell.backgroundColor || "white",
|
|
|
|
|
border: `${borderW}px solid #d1d5db`,
|
|
|
|
|
padding: "2px 4px",
|
|
|
|
|
fontSize: cell.fontSize ?? 12,
|
|
|
|
|
fontWeight: cell.fontWeight === "bold" ? 700 : 400,
|
|
|
|
|
color: cell.textColor || "#111827",
|
|
|
|
|
textAlign: (cell.align || "center") as "left" | "center" | "right",
|
|
|
|
|
verticalAlign: cell.verticalAlign || "middle",
|
|
|
|
|
overflow: "hidden",
|
|
|
|
|
whiteSpace: "nowrap" as const,
|
|
|
|
|
textOverflow: "ellipsis",
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{displayValue}
|
|
|
|
|
</td>,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
rows.push(<tr key={`grid-preview-${r}`}>{tds}</tr>);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<table style={{ width: totalW, borderCollapse: "collapse", tableLayout: "fixed" as const }}>
|
|
|
|
|
<colgroup>
|
|
|
|
|
{gridColWidths.map((w, i) => (
|
|
|
|
|
<col key={i} style={{ width: w }} />
|
2025-10-02 13:44:16 +09:00
|
|
|
))}
|
2026-03-10 18:30:18 +09:00
|
|
|
</colgroup>
|
|
|
|
|
<tbody>{rows}</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
);
|
|
|
|
|
})()
|
|
|
|
|
) : 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,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<table
|
|
|
|
|
style={{
|
|
|
|
|
width: "100%",
|
|
|
|
|
borderCollapse: component.showBorder !== false ? "collapse" : "separate",
|
|
|
|
|
fontSize: "12px",
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<thead>
|
|
|
|
|
<tr
|
|
|
|
|
style={{
|
|
|
|
|
backgroundColor: component.headerBackgroundColor || "#f3f4f6",
|
|
|
|
|
color: component.headerTextColor || "#111827",
|
|
|
|
|
}}
|
|
|
|
|
>
|
2025-10-02 13:44:16 +09:00
|
|
|
{columns.map((col) => (
|
2026-03-10 18:30:18 +09:00
|
|
|
<th
|
2025-10-02 13:44:16 +09:00
|
|
|
key={col.field}
|
|
|
|
|
style={{
|
|
|
|
|
border: component.showBorder !== false ? "1px solid #d1d5db" : "none",
|
|
|
|
|
padding: "6px 8px",
|
|
|
|
|
textAlign: col.align || "left",
|
2026-03-10 18:30:18 +09:00
|
|
|
width: col.width ? `${col.width}px` : "auto",
|
|
|
|
|
fontWeight: "600",
|
2025-10-02 13:44:16 +09:00
|
|
|
}}
|
|
|
|
|
>
|
2026-03-10 18:30:18 +09:00
|
|
|
{col.header}
|
|
|
|
|
</th>
|
2025-10-02 13:44:16 +09:00
|
|
|
))}
|
|
|
|
|
</tr>
|
2026-03-10 18:30:18 +09:00
|
|
|
</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(
|
2025-10-01 16:53:35 +09:00
|
|
|
${component.orientation === "horizontal" ? "90deg" : "0deg"},
|
|
|
|
|
${component.lineColor || "#000000"} 0px,
|
|
|
|
|
${component.lineColor || "#000000"} 10px,
|
|
|
|
|
transparent 10px,
|
|
|
|
|
transparent 20px
|
|
|
|
|
)`,
|
2026-03-10 18:30:18 +09:00
|
|
|
backgroundColor: "transparent",
|
|
|
|
|
}),
|
|
|
|
|
...(component.lineStyle === "dotted" && {
|
|
|
|
|
backgroundImage: `repeating-linear-gradient(
|
2025-10-01 16:53:35 +09:00
|
|
|
${component.orientation === "horizontal" ? "90deg" : "0deg"},
|
|
|
|
|
${component.lineColor || "#000000"} 0px,
|
|
|
|
|
${component.lineColor || "#000000"} 3px,
|
|
|
|
|
transparent 3px,
|
|
|
|
|
transparent 10px
|
|
|
|
|
)`,
|
2026-03-10 18:30:18 +09:00
|
|
|
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" && (
|
2025-10-02 13:44:16 +09:00
|
|
|
<div
|
|
|
|
|
style={{
|
2026-03-10 18:30:18 +09:00
|
|
|
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",
|
|
|
|
|
}
|
|
|
|
|
: {}),
|
2025-10-02 13:44:16 +09:00
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{component.showLabel !== false && (
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
display: "flex",
|
|
|
|
|
alignItems: "center",
|
|
|
|
|
justifyContent: "center",
|
|
|
|
|
fontSize: "12px",
|
|
|
|
|
fontWeight: "500",
|
2026-03-10 18:30:18 +09:00
|
|
|
minWidth:
|
|
|
|
|
component.labelPosition === "left" || component.labelPosition === "right"
|
|
|
|
|
? "40px"
|
|
|
|
|
: "auto",
|
2025-10-02 13:44:16 +09:00
|
|
|
}}
|
|
|
|
|
>
|
2026-03-10 18:30:18 +09:00
|
|
|
{component.labelText || "서명:"}
|
2025-10-02 13:44:16 +09:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-03-10 18:30:18 +09:00
|
|
|
<div style={{ flex: 1, position: "relative" }}>
|
|
|
|
|
{component.imageUrl && (
|
|
|
|
|
<img
|
|
|
|
|
src={getFullImageUrl(component.imageUrl)}
|
|
|
|
|
alt="서명"
|
|
|
|
|
style={{
|
|
|
|
|
width: "100%",
|
|
|
|
|
height: "100%",
|
|
|
|
|
objectFit: component.objectFit || "contain",
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2025-10-02 13:44:16 +09:00
|
|
|
</div>
|
2026-03-10 18:30:18 +09:00
|
|
|
)}
|
2025-12-18 10:39:57 +09:00
|
|
|
|
2026-03-10 18:30:18 +09:00
|
|
|
{component.type === "stamp" && (
|
2025-12-18 10:39:57 +09:00
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
display: "flex",
|
2026-03-10 18:30:18 +09:00
|
|
|
gap: "8px",
|
|
|
|
|
width: "100%",
|
2025-12-18 10:39:57 +09:00
|
|
|
height: "100%",
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-03-10 18:30:18 +09:00
|
|
|
{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="도장"
|
2025-12-18 10:39:57 +09:00
|
|
|
style={{
|
2026-03-10 18:30:18 +09:00
|
|
|
width: "100%",
|
|
|
|
|
height: "100%",
|
|
|
|
|
objectFit: component.objectFit || "contain",
|
2025-12-18 10:39:57 +09:00
|
|
|
}}
|
2026-03-10 18:30:18 +09:00
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{component.showLabel !== false && (
|
2025-12-18 10:39:57 +09:00
|
|
|
<div
|
|
|
|
|
style={{
|
2026-03-10 18:30:18 +09:00
|
|
|
position: "absolute",
|
|
|
|
|
top: "0",
|
|
|
|
|
left: "0",
|
|
|
|
|
width: "100%",
|
|
|
|
|
height: "100%",
|
|
|
|
|
display: "flex",
|
|
|
|
|
alignItems: "center",
|
|
|
|
|
justifyContent: "center",
|
|
|
|
|
fontSize: "12px",
|
|
|
|
|
fontWeight: "500",
|
|
|
|
|
pointerEvents: "none",
|
2025-12-18 10:39:57 +09:00
|
|
|
}}
|
2026-03-10 18:30:18 +09:00
|
|
|
>
|
|
|
|
|
{component.labelText || "(인)"}
|
2025-12-18 10:39:57 +09:00
|
|
|
</div>
|
2026-03-10 18:30:18 +09:00
|
|
|
)}
|
2025-12-18 10:39:57 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-03-10 18:30:18 +09:00
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{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}`;
|
2025-12-18 11:41:48 +09:00
|
|
|
break;
|
2026-03-10 18:30:18 +09:00
|
|
|
case "numberTotal":
|
|
|
|
|
pageNumberText = `${pageIndex + 1} / ${totalPages}`;
|
2025-12-18 11:41:48 +09:00
|
|
|
break;
|
2026-03-10 18:30:18 +09:00
|
|
|
case "koreanNumber":
|
|
|
|
|
pageNumberText = `${pageIndex + 1} 페이지`;
|
2025-12-18 11:41:48 +09:00
|
|
|
break;
|
2026-03-10 18:30:18 +09:00
|
|
|
default:
|
|
|
|
|
pageNumberText = `${pageIndex + 1}`;
|
2025-12-18 11:41:48 +09:00
|
|
|
}
|
2026-03-10 18:30:18 +09:00
|
|
|
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;
|
|
|
|
|
};
|
2025-12-18 11:41:48 +09:00
|
|
|
|
2026-03-10 18:30:18 +09:00
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
display: "flex",
|
|
|
|
|
flexDirection: "column",
|
|
|
|
|
height: "100%",
|
|
|
|
|
overflow: "hidden",
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{showCardTitle && (
|
|
|
|
|
<>
|
|
|
|
|
<div
|
2025-12-18 11:41:48 +09:00
|
|
|
style={{
|
2026-03-10 18:30:18 +09:00
|
|
|
flexShrink: 0,
|
|
|
|
|
padding: "4px 8px",
|
|
|
|
|
fontSize: `${titleFontSize}px`,
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
color: titleColor,
|
2025-12-18 11:41:48 +09:00
|
|
|
}}
|
|
|
|
|
>
|
2026-03-10 18:30:18 +09:00
|
|
|
{cardTitle}
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
2025-12-18 11:41:48 +09:00
|
|
|
style={{
|
2026-03-10 18:30:18 +09:00
|
|
|
flexShrink: 0,
|
|
|
|
|
margin: "0 4px",
|
|
|
|
|
borderBottom: `1px solid ${borderColor}`,
|
2025-12-18 11:41:48 +09:00
|
|
|
}}
|
2026-03-10 18:30:18 +09:00
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
<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;
|
|
|
|
|
},
|
2025-12-18 11:41:48 +09:00
|
|
|
);
|
2026-03-10 18:30:18 +09:00
|
|
|
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
|
2025-12-18 11:41:48 +09:00
|
|
|
style={{
|
2026-03-10 18:30:18 +09:00
|
|
|
display: "flex",
|
|
|
|
|
flexDirection: "column",
|
|
|
|
|
height: "100%",
|
2025-12-18 11:41:48 +09:00
|
|
|
}}
|
|
|
|
|
>
|
2026-03-10 18:30:18 +09:00
|
|
|
<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
|
2025-12-18 11:41:48 +09:00
|
|
|
style={{
|
2026-03-10 18:30:18 +09:00
|
|
|
display: "flex",
|
|
|
|
|
alignItems: "center",
|
|
|
|
|
gap: "8px",
|
|
|
|
|
height: "100%",
|
|
|
|
|
flexDirection: checkboxLabelPosition === "left" ? "row-reverse" : "row",
|
|
|
|
|
justifyContent: checkboxLabelPosition === "left" ? "flex-end" : "flex-start",
|
2025-12-18 11:41:48 +09:00
|
|
|
}}
|
|
|
|
|
>
|
2026-03-10 18:30:18 +09:00
|
|
|
<div
|
2025-12-22 13:58:12 +09:00
|
|
|
style={{
|
2026-03-10 18:30:18 +09:00
|
|
|
width: `${checkboxSize}px`,
|
|
|
|
|
height: `${checkboxSize}px`,
|
|
|
|
|
borderRadius: "2px",
|
|
|
|
|
border: `2px solid ${isChecked ? checkboxColor : checkboxBorderColor}`,
|
|
|
|
|
backgroundColor: isChecked ? checkboxColor : "transparent",
|
|
|
|
|
display: "flex",
|
|
|
|
|
alignItems: "center",
|
|
|
|
|
justifyContent: "center",
|
2025-12-22 13:58:12 +09:00
|
|
|
}}
|
|
|
|
|
>
|
2026-03-10 18:30:18 +09:00
|
|
|
{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
|
|
|
|
|
className="absolute"
|
|
|
|
|
style={{
|
|
|
|
|
right: "32px",
|
|
|
|
|
bottom: "24px",
|
|
|
|
|
fontSize: "11px",
|
|
|
|
|
color: "#6b7280",
|
|
|
|
|
fontFamily: "Malgun Gothic, sans-serif",
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{pageIdx + 1}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-10-02 13:44:16 +09:00
|
|
|
</div>
|
2025-10-01 13:58:55 +09:00
|
|
|
</div>
|
2026-03-10 18:30:18 +09:00
|
|
|
);
|
|
|
|
|
})}
|
2025-10-01 12:00:13 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-10 18:30:18 +09:00
|
|
|
<DialogFooter className="shrink-0 border-t px-6 py-3">
|
2025-10-01 15:20:25 +09:00
|
|
|
<Button variant="outline" onClick={onClose} disabled={isExporting}>
|
2025-10-01 12:00:13 +09:00
|
|
|
닫기
|
|
|
|
|
</Button>
|
2025-10-01 15:20:25 +09:00
|
|
|
<Button variant="outline" onClick={handlePrint} disabled={isExporting} className="gap-2">
|
2025-10-01 12:00:13 +09:00
|
|
|
<Printer className="h-4 w-4" />
|
|
|
|
|
인쇄
|
|
|
|
|
</Button>
|
|
|
|
|
<Button onClick={handleDownloadPDF} className="gap-2">
|
|
|
|
|
<FileDown className="h-4 w-4" />
|
|
|
|
|
PDF
|
|
|
|
|
</Button>
|
2025-10-01 15:20:25 +09:00
|
|
|
<Button onClick={handleDownloadWord} disabled={isExporting} variant="secondary" className="gap-2">
|
|
|
|
|
<FileText className="h-4 w-4" />
|
|
|
|
|
{isExporting ? "생성 중..." : "WORD"}
|
2025-10-01 12:00:13 +09:00
|
|
|
</Button>
|
2025-11-12 18:51:20 +09:00
|
|
|
</DialogFooter>
|
2025-10-01 12:00:13 +09:00
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
}
|