인쇄 기능 개선 - 중복 호출 제거 및 레이아웃 정확도 향상

This commit is contained in:
dohyeons 2025-12-23 17:37:22 +09:00
parent 82a7ff62ee
commit 859d68fff8
2 changed files with 68 additions and 60 deletions

View File

@ -357,11 +357,11 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
height: snappedSize, height: snappedSize,
}); });
} else { } else {
// Grid Snap 적용 // Grid Snap 적용
updateComponent(component.id, { updateComponent(component.id, {
width: snapValueToGrid(boundedWidth), width: snapValueToGrid(boundedWidth),
height: snapValueToGrid(boundedHeight), height: snapValueToGrid(boundedHeight),
}); });
} }
} }
}; };
@ -444,17 +444,17 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
case "text": case "text":
case "label": case "label":
return ( return (
<div <div
className="h-full w-full" className="h-full w-full"
style={{ style={{
fontSize: `${component.fontSize}px`, fontSize: `${component.fontSize}px`,
color: component.fontColor, color: component.fontColor,
fontWeight: component.fontWeight, fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right", textAlign: component.textAlign as "left" | "center" | "right",
whiteSpace: "pre-wrap", whiteSpace: "pre-wrap",
}} }}
> >
{displayValue} {displayValue}
</div> </div>
); );
@ -534,7 +534,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
// 기본 테이블 (데이터 없을 때) // 기본 테이블 (데이터 없을 때)
return ( return (
<div className="flex h-full w-full items-center justify-center border-2 border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400"> <div className="flex h-full w-full items-center justify-center border-2 border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400">
</div> </div>
); );
@ -858,12 +858,12 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
// 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용) // 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용)
const calculateResult = (): number => { const calculateResult = (): number => {
if (calcItems.length === 0) return 0; if (calcItems.length === 0) return 0;
// 첫 번째 항목은 기준값 // 첫 번째 항목은 기준값
let result = getCalcItemValue( let result = getCalcItemValue(
calcItems[0] as { label: string; value: number | string; operator: string; fieldName?: string }, calcItems[0] as { label: string; value: number | string; operator: string; fieldName?: string },
); );
// 두 번째 항목부터 연산자 적용 // 두 번째 항목부터 연산자 적용
for (let i = 1; i < calcItems.length; i++) { for (let i = 1; i < calcItems.length; i++) {
const item = calcItems[i]; const item = calcItems[i];
@ -899,30 +899,30 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
item: { label: string; value: number | string; operator: string; fieldName?: string }, item: { label: string; value: number | string; operator: string; fieldName?: string },
index: number, index: number,
) => { ) => {
const itemValue = getCalcItemValue(item); const itemValue = getCalcItemValue(item);
return ( return (
<div key={index} className="flex items-center justify-between py-1"> <div key={index} className="flex items-center justify-between py-1">
<span <span
className="flex-shrink-0" className="flex-shrink-0"
style={{ style={{
width: `${calcLabelWidth}px`, width: `${calcLabelWidth}px`,
fontSize: `${calcLabelFontSize}px`, fontSize: `${calcLabelFontSize}px`,
color: calcLabelColor, color: calcLabelColor,
}} }}
> >
{item.label} {item.label}
</span> </span>
<span <span
className="text-right" className="text-right"
style={{ style={{
fontSize: `${calcValueFontSize}px`, fontSize: `${calcValueFontSize}px`,
color: calcValueColor, color: calcValueColor,
}} }}
> >
{formatNumber(itemValue)} {formatNumber(itemValue)}
</span> </span>
</div> </div>
); );
}, },
)} )}
</div> </div>

View File

@ -17,6 +17,9 @@ import { getFullImageUrl } from "@/lib/api/client";
import JsBarcode from "jsbarcode"; import JsBarcode from "jsbarcode";
import QRCode from "qrcode"; import QRCode from "qrcode";
// mm -> px 변환 상수
const MM_TO_PX = 4;
interface ReportPreviewModalProps { interface ReportPreviewModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
@ -149,8 +152,8 @@ function PreviewWatermarkLayer({ watermark, pageWidth, pageHeight }: PreviewWate
// 타일 스타일 // 타일 스타일
if (watermark.style === "tile") { if (watermark.style === "tile") {
const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150; const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150;
const cols = Math.ceil((pageWidth * 3.7795) / tileSize) + 2; const cols = Math.ceil((pageWidth * MM_TO_PX) / tileSize) + 2;
const rows = Math.ceil((pageHeight * 3.7795) / tileSize) + 2; const rows = Math.ceil((pageHeight * MM_TO_PX) / tileSize) + 2;
return ( return (
<div style={baseStyle}> <div style={baseStyle}>
@ -514,7 +517,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
printWindow.document.write(printHtml); printWindow.document.write(printHtml);
printWindow.document.close(); printWindow.document.close();
printWindow.print(); // print()는 HTML 내 스크립트에서 이미지 로드 완료 후 자동 호출됨
}; };
// 워터마크 HTML 생성 헬퍼 함수 // 워터마크 HTML 생성 헬퍼 함수
@ -554,8 +557,8 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
if (watermark.style === "tile") { if (watermark.style === "tile") {
const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150; const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150;
const cols = Math.ceil((pageWidth * 3.7795) / tileSize) + 2; const cols = Math.ceil((pageWidth * MM_TO_PX) / tileSize) + 2;
const rows = Math.ceil((pageHeight * 3.7795) / tileSize) + 2; const rows = Math.ceil((pageHeight * MM_TO_PX) / tileSize) + 2;
const tileItems = Array.from({ length: rows * cols }) 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>`) .map(() => `<div style="width: ${tileSize}px; height: ${tileSize}px; display: flex; align-items: center; justify-content: center;">${textContent}</div>`)
.join(""); .join("");
@ -650,9 +653,9 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
: ""; : "";
content = ` content = `
<div style="display: flex; align-items: center; gap: 8px; height: 100%;"> <div style="display: flex; align-items: center; gap: 8px; width: 100%; height: 100%;">
${personName ? `<div style="font-size: 12px;">${personName}</div>` : ""} ${personName ? `<div style="font-size: 12px; white-space: nowrap;">${personName}</div>` : ""}
<div style="position: relative; width: ${component.width}px; height: ${component.height}px;"> <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%;" />` : ""} ${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>` : ""} ${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>
@ -891,8 +894,15 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
</table>`; </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 ` return `
<div style="position: absolute; left: ${component.x}px; top: ${component.y}px; width: ${component.width}px; height: ${component.height}px; background-color: ${component.backgroundColor || "transparent"}; border: ${component.borderWidth ? `${component.borderWidth}px solid ${component.borderColor}` : "none"}; padding: 8px; box-sizing: border-box;"> <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} ${content}
</div>`; </div>`;
}) })
@ -901,7 +911,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
const watermarkHTML = generateWatermarkHTML(watermark, pageWidth, pageHeight); const watermarkHTML = generateWatermarkHTML(watermark, pageWidth, pageHeight);
return ` return `
<div style="position: relative; width: ${pageWidth}mm; min-height: ${pageHeight}mm; background-color: ${backgroundColor}; margin: 0 auto;"> <div class="print-page" style="position: relative; width: ${pageWidth}mm; min-height: ${pageHeight}mm; background-color: ${backgroundColor};">
${watermarkHTML} ${watermarkHTML}
${componentsHTML} ${componentsHTML}
</div>`; </div>`;
@ -933,20 +943,18 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
<meta charset="UTF-8"> <meta charset="UTF-8">
<title> </title> <title> </title>
<style> <style>
* { box-sizing: border-box; } * { box-sizing: border-box; margin: 0; padding: 0; }
@page { @page {
size: A4; size: A4;
margin: 10mm; margin: 0;
} }
@media print { @media print {
body { margin: 0; padding: 0; } html, body { width: 210mm; height: 297mm; }
.print-page { page-break-after: always; page-break-inside: avoid; } .print-page { page-break-after: always; page-break-inside: avoid; }
.print-page:last-child { page-break-after: auto; } .print-page:last-child { page-break-after: auto; }
} }
body { body {
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif; font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
margin: 0;
padding: 20px;
-webkit-print-color-adjust: exact; -webkit-print-color-adjust: exact;
print-color-adjust: exact; print-color-adjust: exact;
} }
@ -1070,15 +1078,15 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
const barcodeImage = await generateBarcodeImage(component); const barcodeImage = await generateBarcodeImage(component);
return { ...component, barcodeImageBase64: barcodeImage }; return { ...component, barcodeImageBase64: barcodeImage };
} catch { } catch {
return component; return component;
} }
} }
return component; return component;
}) })
); );
return { ...page, components: componentsWithBase64 }; return { ...page, components: componentsWithBase64 };
}) })
); );
// 쿼리 결과 수집 // 쿼리 결과 수집
const queryResults: Record<string, { fields: string[]; rows: Record<string, unknown>[] }> = {}; const queryResults: Record<string, { fields: string[]; rows: Record<string, unknown>[] }> = {};