diff --git a/backend-node/src/controllers/reportController.ts b/backend-node/src/controllers/reportController.ts index c6605d3e..d334e46e 100644 --- a/backend-node/src/controllers/reportController.ts +++ b/backend-node/src/controllers/reportController.ts @@ -30,6 +30,7 @@ import { Header, Footer, HeadingLevel, + TableLayoutType, } from "docx"; import { WatermarkConfig } from "../types/report"; import bwipjs from "bwip-js"; @@ -592,8 +593,12 @@ export class ReportController { // mm를 twip으로 변환 const mmToTwip = (mm: number) => convertMillimetersToTwip(mm); - // px를 twip으로 변환 (1px = 15twip at 96DPI) - const pxToTwip = (px: number) => Math.round(px * 15); + + // 프론트엔드와 동일한 MM_TO_PX 상수 (캔버스에서 mm를 px로 변환할 때 사용하는 값) + const MM_TO_PX = 4; + // 1mm = 56.692913386 twip (docx 라이브러리 기준) + // px를 twip으로 변환: px -> mm -> twip + const pxToTwip = (px: number) => Math.round((px / MM_TO_PX) * 56.692913386); // 쿼리 결과 맵 const queryResultsMap: Record< @@ -726,6 +731,9 @@ export class ReportController { const base64Data = component.imageBase64.split(",")[1] || component.imageBase64; const imageBuffer = Buffer.from(base64Data, "base64"); + // 서명 이미지 크기: 라벨 옆에 인라인으로 표시될 수 있도록 적절한 크기로 조정 + const sigImageHeight = 30; // 고정 높이 (약 40px) + const sigImageWidth = Math.round((component.width / component.height) * sigImageHeight) || 80; result.push( new ParagraphRef({ children: [ @@ -733,8 +741,8 @@ export class ReportController { new ImageRunRef({ data: imageBuffer, transformation: { - width: Math.round(component.width * 0.75), - height: Math.round(component.height * 0.75), + width: sigImageWidth, + height: sigImageHeight, }, type: "png", }), @@ -1443,7 +1451,11 @@ export class ReportController { try { const barcodeType = component.barcodeType || "CODE128"; const barcodeColor = (component.barcodeColor || "#000000").replace("#", ""); - const barcodeBackground = (component.barcodeBackground || "#ffffff").replace("#", ""); + // transparent는 bwip-js에서 지원하지 않으므로 흰색으로 변환 + let barcodeBackground = (component.barcodeBackground || "#ffffff").replace("#", ""); + if (barcodeBackground === "transparent" || barcodeBackground === "") { + barcodeBackground = "ffffff"; + } // 바코드 값 결정 (쿼리 바인딩 또는 고정값) let barcodeValue = component.barcodeValue || "SAMPLE123"; @@ -1739,6 +1751,7 @@ export class ReportController { const rowTable = new Table({ rows: [new TableRow({ children: cells })], width: { size: 100, type: WidthType.PERCENTAGE }, + layout: TableLayoutType.FIXED, // 셀 너비 고정 borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, @@ -1821,6 +1834,7 @@ export class ReportController { const textTable = new Table({ rows: [new TableRow({ children: [textCell] })], width: { size: pxToTwip(component.width), type: WidthType.DXA }, + layout: TableLayoutType.FIXED, // 셀 너비 고정 indent: { size: indentLeft, type: WidthType.DXA }, borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, @@ -1970,6 +1984,10 @@ export class ReportController { component.imageBase64.split(",")[1] || component.imageBase64; const imageBuffer = Buffer.from(base64Data, "base64"); + // 서명 이미지 크기: 라벨 옆에 인라인으로 표시될 수 있도록 적절한 크기로 조정 + const sigImageHeight = 30; // 고정 높이 + const sigImageWidth = Math.round((component.width / component.height) * sigImageHeight) || 80; + const paragraph = new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, @@ -1978,8 +1996,8 @@ export class ReportController { new ImageRun({ data: imageBuffer, transformation: { - width: Math.round(component.width * 0.75), - height: Math.round(component.height * 0.75), + width: sigImageWidth, + height: sigImageHeight, }, type: "png", }), diff --git a/backend-node/src/services/reportService.ts b/backend-node/src/services/reportService.ts index f4991863..6e2df6b2 100644 --- a/backend-node/src/services/reportService.ts +++ b/backend-node/src/services/reportService.ts @@ -234,10 +234,23 @@ export class ReportService { `; const queries = await query(queriesQuery, [reportId]); + // 메뉴 매핑 조회 + const menuMappingQuery = ` + SELECT menu_objid + FROM report_menu_mapping + WHERE report_id = $1 + ORDER BY created_at + `; + const menuMappings = await query<{ menu_objid: number }>(menuMappingQuery, [ + reportId, + ]); + const menuObjids = menuMappings?.map((m) => Number(m.menu_objid)) || []; + return { report, layout, queries: queries || [], + menuObjids, }; } @@ -696,6 +709,43 @@ export class ReportService { } } + // 3. 메뉴 매핑 저장 (있는 경우) + if (data.menuObjids !== undefined) { + // 기존 메뉴 매핑 모두 삭제 + await client.query( + `DELETE FROM report_menu_mapping WHERE report_id = $1`, + [reportId] + ); + + // 새 메뉴 매핑 삽입 + if (data.menuObjids.length > 0) { + // 리포트의 company_code 조회 + const reportResult = await client.query( + `SELECT company_code FROM report_master WHERE report_id = $1`, + [reportId] + ); + const companyCode = reportResult.rows[0]?.company_code || "*"; + + const insertMappingSql = ` + INSERT INTO report_menu_mapping ( + report_id, + menu_objid, + company_code, + created_by + ) VALUES ($1, $2, $3, $4) + `; + + for (const menuObjid of data.menuObjids) { + await client.query(insertMappingSql, [ + reportId, + menuObjid, + companyCode, + userId, + ]); + } + } + } + return true; }); } diff --git a/backend-node/src/types/report.ts b/backend-node/src/types/report.ts index 27254b0d..fc79df32 100644 --- a/backend-node/src/types/report.ts +++ b/backend-node/src/types/report.ts @@ -71,11 +71,12 @@ export interface ReportQuery { updated_by: string | null; } -// 리포트 상세 (마스터 + 레이아웃 + 쿼리) +// 리포트 상세 (마스터 + 레이아웃 + 쿼리 + 연결된 메뉴) export interface ReportDetail { report: ReportMaster; layout: ReportLayout | null; queries: ReportQuery[]; + menuObjids?: number[]; // 연결된 메뉴 ID 목록 } // 리포트 목록 조회 파라미터 @@ -166,6 +167,17 @@ export interface SaveLayoutRequest { parameters: string[]; externalConnectionId?: number; }>; + menuObjids?: number[]; // 연결할 메뉴 ID 목록 +} + +// 리포트-메뉴 매핑 +export interface ReportMenuMapping { + mapping_id: number; + report_id: string; + menu_objid: number; + company_code: string; + created_at: Date; + created_by: string | null; } // 템플릿 목록 응답 diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx index 238b79a9..ccc3aa8a 100644 --- a/frontend/components/report/designer/CanvasComponent.tsx +++ b/frontend/components/report/designer/CanvasComponent.tsx @@ -357,11 +357,11 @@ export function CanvasComponent({ component }: CanvasComponentProps) { height: snappedSize, }); } else { - // Grid Snap 적용 - updateComponent(component.id, { - width: snapValueToGrid(boundedWidth), - height: snapValueToGrid(boundedHeight), - }); + // Grid Snap 적용 + updateComponent(component.id, { + width: snapValueToGrid(boundedWidth), + height: snapValueToGrid(boundedHeight), + }); } } }; @@ -444,17 +444,17 @@ export function CanvasComponent({ component }: CanvasComponentProps) { case "text": case "label": return ( -
- {displayValue} + style={{ + fontSize: `${component.fontSize}px`, + color: component.fontColor, + fontWeight: component.fontWeight, + textAlign: component.textAlign as "left" | "center" | "right", + whiteSpace: "pre-wrap", + }} + > + {displayValue}
); @@ -534,7 +534,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) { // 기본 테이블 (데이터 없을 때) return (
- 쿼리를 연결하세요 + 쿼리를 연결하세요
); @@ -606,7 +606,6 @@ export function CanvasComponent({ component }: CanvasComponentProps) { const sigLabelPos = component.labelPosition || "left"; const sigShowLabel = component.showLabel !== false; const sigLabelText = component.labelText || "서명:"; - const sigShowUnderline = component.showUnderline !== false; return (
@@ -653,14 +652,6 @@ export function CanvasComponent({ component }: CanvasComponentProps) { 서명 이미지
)} - {sigShowUnderline && ( -
- )}
@@ -867,12 +858,12 @@ export function CanvasComponent({ component }: CanvasComponentProps) { // 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용) const calculateResult = (): number => { if (calcItems.length === 0) return 0; - + // 첫 번째 항목은 기준값 let result = 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]; @@ -908,30 +899,30 @@ export function CanvasComponent({ component }: CanvasComponentProps) { item: { label: string; value: number | string; operator: string; fieldName?: string }, index: number, ) => { - const itemValue = getCalcItemValue(item); - return ( -
- - {item.label} - - - {formatNumber(itemValue)} - -
- ); + const itemValue = getCalcItemValue(item); + return ( +
+ + {item.label} + + + {formatNumber(itemValue)} + +
+ ); }, )} diff --git a/frontend/components/report/designer/MenuSelectModal.tsx b/frontend/components/report/designer/MenuSelectModal.tsx new file mode 100644 index 00000000..32455191 --- /dev/null +++ b/frontend/components/report/designer/MenuSelectModal.tsx @@ -0,0 +1,320 @@ +"use client"; + +import { useState, useEffect, useCallback, useMemo } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Loader2, Search, ChevronRight, ChevronDown, FolderOpen, FileText } from "lucide-react"; +import { menuApi } from "@/lib/api/menu"; +import { MenuItem } from "@/types/menu"; +import { cn } from "@/lib/utils"; + +interface MenuSelectModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: (menuObjids: number[]) => void; + selectedMenuObjids?: number[]; +} + +// 트리 구조의 메뉴 노드 +interface MenuTreeNode { + objid: string; + menuNameKor: string; + menuUrl: string; + level: number; + children: MenuTreeNode[]; + parentObjId: string; +} + +export function MenuSelectModal({ + isOpen, + onClose, + onConfirm, + selectedMenuObjids = [], +}: MenuSelectModalProps) { + const [menus, setMenus] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [searchText, setSearchText] = useState(""); + const [selectedIds, setSelectedIds] = useState>(new Set(selectedMenuObjids)); + const [expandedIds, setExpandedIds] = useState>(new Set()); + + // 초기 선택 상태 동기화 + useEffect(() => { + if (isOpen) { + setSelectedIds(new Set(selectedMenuObjids)); + } + }, [isOpen, selectedMenuObjids]); + + // 메뉴 목록 로드 + useEffect(() => { + if (isOpen) { + fetchMenus(); + } + }, [isOpen]); + + const fetchMenus = async () => { + setIsLoading(true); + try { + const response = await menuApi.getUserMenus(); + if (response.success && response.data) { + setMenus(response.data); + // 처음 2레벨까지 자동 확장 + const initialExpanded = new Set(); + response.data.forEach((menu) => { + const level = menu.lev || menu.LEV || 1; + if (level <= 2) { + initialExpanded.add(menu.objid || menu.OBJID || ""); + } + }); + setExpandedIds(initialExpanded); + } + } catch (error) { + console.error("메뉴 로드 오류:", error); + } finally { + setIsLoading(false); + } + }; + + // 메뉴 트리 구조 생성 + const menuTree = useMemo(() => { + const menuMap = new Map(); + const rootMenus: MenuTreeNode[] = []; + + // 모든 메뉴를 노드로 변환 + menus.forEach((menu) => { + const objid = menu.objid || menu.OBJID || ""; + const parentObjId = menu.parentObjId || menu.PARENT_OBJ_ID || ""; + const menuNameKor = menu.menuNameKor || menu.MENU_NAME_KOR || menu.translated_name || menu.TRANSLATED_NAME || ""; + const menuUrl = menu.menuUrl || menu.MENU_URL || ""; + const level = menu.lev || menu.LEV || 1; + + menuMap.set(objid, { + objid, + menuNameKor, + menuUrl, + level, + children: [], + parentObjId, + }); + }); + + // 부모-자식 관계 설정 + menus.forEach((menu) => { + const objid = menu.objid || menu.OBJID || ""; + const parentObjId = menu.parentObjId || menu.PARENT_OBJ_ID || ""; + const node = menuMap.get(objid); + + if (!node) return; + + // 최상위 메뉴인지 확인 (parent가 없거나, 특정 루트 ID) + const parent = menuMap.get(parentObjId); + if (parent) { + parent.children.push(node); + } else { + rootMenus.push(node); + } + }); + + // 자식 메뉴 정렬 + const sortChildren = (nodes: MenuTreeNode[]) => { + nodes.sort((a, b) => a.menuNameKor.localeCompare(b.menuNameKor, "ko")); + nodes.forEach((node) => sortChildren(node.children)); + }; + sortChildren(rootMenus); + + return rootMenus; + }, [menus]); + + // 검색 필터링 + const filteredTree = useMemo(() => { + if (!searchText.trim()) return menuTree; + + const searchLower = searchText.toLowerCase(); + + // 검색어에 맞는 노드와 그 조상 노드를 포함 + const filterNodes = (nodes: MenuTreeNode[]): MenuTreeNode[] => { + return nodes + .map((node) => { + const filteredChildren = filterNodes(node.children); + const matches = node.menuNameKor.toLowerCase().includes(searchLower); + + if (matches || filteredChildren.length > 0) { + return { + ...node, + children: filteredChildren, + }; + } + return null; + }) + .filter((node): node is MenuTreeNode => node !== null); + }; + + return filterNodes(menuTree); + }, [menuTree, searchText]); + + // 체크박스 토글 + const toggleSelect = useCallback((objid: string) => { + const numericId = Number(objid); + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(numericId)) { + next.delete(numericId); + } else { + next.add(numericId); + } + return next; + }); + }, []); + + // 확장/축소 토글 + const toggleExpand = useCallback((objid: string) => { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(objid)) { + next.delete(objid); + } else { + next.add(objid); + } + return next; + }); + }, []); + + // 확인 버튼 클릭 + const handleConfirm = () => { + onConfirm(Array.from(selectedIds)); + onClose(); + }; + + // 메뉴 노드 렌더링 + const renderMenuNode = (node: MenuTreeNode, depth: number = 0) => { + const hasChildren = node.children.length > 0; + const isExpanded = expandedIds.has(node.objid); + const isSelected = selectedIds.has(Number(node.objid)); + + return ( +
+
toggleSelect(node.objid)} + > + {/* 확장/축소 버튼 */} + {hasChildren ? ( + + ) : ( +
+ )} + + {/* 체크박스 - 모든 메뉴에서 선택 가능 */} + toggleSelect(node.objid)} + onClick={(e) => e.stopPropagation()} + /> + + {/* 아이콘 */} + {hasChildren ? ( + + ) : ( + + )} + + {/* 메뉴명 */} + + {node.menuNameKor} + +
+ + {/* 자식 메뉴 */} + {hasChildren && isExpanded && ( +
{node.children.map((child) => renderMenuNode(child, depth + 1))}
+ )} +
+ ); + }; + + return ( + + + + 사용 메뉴 선택 + + 이 리포트를 사용할 메뉴를 선택하세요. 선택한 메뉴에서 이 리포트를 사용할 수 있습니다. + + + + {/* 검색 */} +
+ + setSearchText(e.target.value)} + className="pl-10" + /> +
+ + {/* 선택된 메뉴 수 */} +
+ {selectedIds.size}개 메뉴 선택됨 +
+ + {/* 메뉴 트리 */} + + {isLoading ? ( +
+ + 메뉴 로드 중... +
+ ) : filteredTree.length === 0 ? ( +
+ {searchText ? "검색 결과가 없습니다." : "표시할 메뉴가 없습니다."} +
+ ) : ( +
{filteredTree.map((node) => renderMenuNode(node))}
+ )} +
+ + + + + +
+
+ ); +} + diff --git a/frontend/components/report/designer/ReportDesignerCanvas.tsx b/frontend/components/report/designer/ReportDesignerCanvas.tsx index 85dc89b8..6684047b 100644 --- a/frontend/components/report/designer/ReportDesignerCanvas.tsx +++ b/frontend/components/report/designer/ReportDesignerCanvas.tsx @@ -319,7 +319,6 @@ export function ReportDesignerCanvas() { showLabel: true, labelText: "서명:", labelPosition: "left" as const, - showUnderline: true, borderWidth: 0, borderColor: "#cccccc", }), diff --git a/frontend/components/report/designer/ReportDesignerRightPanel.tsx b/frontend/components/report/designer/ReportDesignerRightPanel.tsx index e3a24025..7fcfed4e 100644 --- a/frontend/components/report/designer/ReportDesignerRightPanel.tsx +++ b/frontend/components/report/designer/ReportDesignerRightPanel.tsx @@ -947,26 +947,6 @@ export function ReportDesignerRightPanel() { )} - {/* 밑줄 표시 (서명란만) */} - {selectedComponent.type === "signature" && ( -
- - updateComponent(selectedComponent.id, { - showUnderline: e.target.checked, - }) - } - className="h-4 w-4" - /> - -
- )} - {/* 이름 입력 (도장란만) */} {selectedComponent.type === "stamp" && (
@@ -2502,10 +2482,11 @@ export function ReportDesignerRightPanel() { updatePageSettings(currentPageId, { - width: Number(e.target.value), + width: Math.max(1, Number(e.target.value)), }) } className="mt-1" @@ -2515,10 +2496,11 @@ export function ReportDesignerRightPanel() { updatePageSettings(currentPageId, { - height: Number(e.target.value), + height: Math.max(1, Number(e.target.value)), }) } className="mt-1" @@ -2589,12 +2571,13 @@ export function ReportDesignerRightPanel() { updatePageSettings(currentPageId, { margins: { ...currentPage.margins, - top: Number(e.target.value), + top: Math.max(0, Number(e.target.value)), }, }) } @@ -2605,12 +2588,13 @@ export function ReportDesignerRightPanel() { updatePageSettings(currentPageId, { margins: { ...currentPage.margins, - bottom: Number(e.target.value), + bottom: Math.max(0, Number(e.target.value)), }, }) } @@ -2621,12 +2605,13 @@ export function ReportDesignerRightPanel() { updatePageSettings(currentPageId, { margins: { ...currentPage.margins, - left: Number(e.target.value), + left: Math.max(0, Number(e.target.value)), }, }) } @@ -2637,12 +2622,13 @@ export function ReportDesignerRightPanel() { updatePageSettings(currentPageId, { margins: { ...currentPage.margins, - right: Number(e.target.value), + right: Math.max(0, Number(e.target.value)), }, }) } diff --git a/frontend/components/report/designer/ReportDesignerToolbar.tsx b/frontend/components/report/designer/ReportDesignerToolbar.tsx index dba01fbb..2b0ef7b0 100644 --- a/frontend/components/report/designer/ReportDesignerToolbar.tsx +++ b/frontend/components/report/designer/ReportDesignerToolbar.tsx @@ -42,8 +42,19 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { SaveAsTemplateModal } from "./SaveAsTemplateModal"; +import { MenuSelectModal } from "./MenuSelectModal"; import { reportApi } from "@/lib/api/reportApi"; import { useToast } from "@/hooks/use-toast"; import { ReportPreviewModal } from "./ReportPreviewModal"; @@ -52,7 +63,7 @@ export function ReportDesignerToolbar() { const router = useRouter(); const { reportDetail, - saveLayout, + saveLayoutWithMenus, isSaving, loadLayout, components, @@ -90,9 +101,14 @@ export function ReportDesignerToolbar() { setShowRuler, groupComponents, ungroupComponents, + menuObjids, } = useReportDesigner(); const [showPreview, setShowPreview] = useState(false); const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false); + const [showBackConfirm, setShowBackConfirm] = useState(false); + const [showResetConfirm, setShowResetConfirm] = useState(false); + const [showMenuSelect, setShowMenuSelect] = useState(false); + const [pendingSaveAndClose, setPendingSaveAndClose] = useState(false); const { toast } = useToast(); // 버튼 활성화 조건 @@ -111,27 +127,33 @@ export function ReportDesignerToolbar() { setShowGrid(newValue); }; - const handleSave = async () => { - await saveLayout(); + const handleSave = () => { + setPendingSaveAndClose(false); + setShowMenuSelect(true); }; - const handleSaveAndClose = async () => { - await saveLayout(); - router.push("/admin/report"); + const handleSaveAndClose = () => { + setPendingSaveAndClose(true); + setShowMenuSelect(true); }; - const handleReset = async () => { - if (confirm("현재 변경사항을 모두 취소하고 마지막 저장 상태로 되돌리시겠습니까?")) { - await loadLayout(); - } - }; - - const handleBack = () => { - if (confirm("저장하지 않은 변경사항이 있을 수 있습니다. 목록으로 돌아가시겠습니까?")) { + const handleMenuSelectConfirm = async (selectedMenuObjids: number[]) => { + await saveLayoutWithMenus(selectedMenuObjids); + if (pendingSaveAndClose) { router.push("/admin/report"); } }; + const handleResetConfirm = async () => { + setShowResetConfirm(false); + await loadLayout(); + }; + + const handleBackConfirm = () => { + setShowBackConfirm(false); + router.push("/admin/report"); + }; + const handleSaveAsTemplate = async (data: { templateNameKor: string; templateNameEng?: string; @@ -193,7 +215,7 @@ export function ReportDesignerToolbar() { <>
- @@ -437,7 +459,7 @@ export function ReportDesignerToolbar() { - @@ -491,6 +513,46 @@ export function ReportDesignerToolbar() { onClose={() => setShowSaveAsTemplate(false)} onSave={handleSaveAsTemplate} /> + setShowMenuSelect(false)} + onConfirm={handleMenuSelectConfirm} + selectedMenuObjids={menuObjids} + /> + + {/* 목록으로 돌아가기 확인 모달 */} + + + + 목록으로 돌아가기 + + 저장하지 않은 변경사항이 있을 수 있습니다. +
+ 목록으로 돌아가시겠습니까? +
+
+ + 취소 + 확인 + +
+
+ + {/* 초기화 확인 모달 */} + + + + 초기화 + + 현재 변경사항을 모두 취소하고 마지막 저장 상태로 되돌리시겠습니까? + + + + 취소 + 확인 + + + ); } diff --git a/frontend/components/report/designer/ReportPreviewModal.tsx b/frontend/components/report/designer/ReportPreviewModal.tsx index b8fcb9ce..7069bb75 100644 --- a/frontend/components/report/designer/ReportPreviewModal.tsx +++ b/frontend/components/report/designer/ReportPreviewModal.tsx @@ -17,6 +17,9 @@ import { getFullImageUrl } from "@/lib/api/client"; import JsBarcode from "jsbarcode"; import QRCode from "qrcode"; +// mm -> px 변환 상수 +const MM_TO_PX = 4; + interface ReportPreviewModalProps { isOpen: boolean; onClose: () => void; @@ -149,8 +152,8 @@ function PreviewWatermarkLayer({ watermark, pageWidth, pageHeight }: PreviewWate // 타일 스타일 if (watermark.style === "tile") { const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150; - const cols = Math.ceil((pageWidth * 3.7795) / tileSize) + 2; - const rows = Math.ceil((pageHeight * 3.7795) / tileSize) + 2; + const cols = Math.ceil((pageWidth * MM_TO_PX) / tileSize) + 2; + const rows = Math.ceil((pageHeight * MM_TO_PX) / tileSize) + 2; return (
@@ -514,7 +517,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) printWindow.document.write(printHtml); printWindow.document.close(); - printWindow.print(); + // print()는 HTML 내 스크립트에서 이미지 로드 완료 후 자동 호출됨 }; // 워터마크 HTML 생성 헬퍼 함수 @@ -554,8 +557,8 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) if (watermark.style === "tile") { const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150; - const cols = Math.ceil((pageWidth * 3.7795) / tileSize) + 2; - const rows = Math.ceil((pageHeight * 3.7795) / tileSize) + 2; + const cols = Math.ceil((pageWidth * MM_TO_PX) / tileSize) + 2; + const rows = Math.ceil((pageHeight * MM_TO_PX) / tileSize) + 2; const tileItems = Array.from({ length: rows * cols }) .map(() => `
${textContent}
`) .join(""); @@ -624,7 +627,6 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) ${showLabel ? `
${labelText}
` : ""}
${imageUrl ? `` : ""} - ${component.showUnderline ? '
' : ""}
`; } else { @@ -633,7 +635,6 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) ${showLabel && labelPosition === "top" ? `
${labelText}
` : ""}
${imageUrl ? `` : ""} - ${component.showUnderline ? '
' : ""}
${showLabel && labelPosition === "bottom" ? `
${labelText}
` : ""}
`; @@ -652,9 +653,9 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) : ""; content = ` -
- ${personName ? `
${personName}
` : ""} -
+
+ ${personName ? `
${personName}
` : ""} +
${imageUrl ? `` : ""} ${showLabel ? `
${labelText}
` : ""}
@@ -893,8 +894,15 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) `; } + // 컴포넌트 값은 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 ` -
+
${content}
`; }) @@ -903,7 +911,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) const watermarkHTML = generateWatermarkHTML(watermark, pageWidth, pageHeight); return ` -
+ `; @@ -935,20 +943,18 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) 리포트 인쇄