From 99c09603254d725a5a482ecbd73b1b8a110c9a7b Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 22 Dec 2025 17:42:35 +0900 Subject: [PATCH 1/9] =?UTF-8?q?=EC=84=9C=EB=AA=85=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EC=8B=9C=20=ED=8F=B0=ED=8A=B8=EA=B0=80=20=EC=9D=BC=EB=B6=80=20?= =?UTF-8?q?=EA=B8=80=EC=9E=90=EC=97=90=EB=A7=8C=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report/designer/SignatureGenerator.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/frontend/components/report/designer/SignatureGenerator.tsx b/frontend/components/report/designer/SignatureGenerator.tsx index 9a3bd29f..a36b0b8c 100644 --- a/frontend/components/report/designer/SignatureGenerator.tsx +++ b/frontend/components/report/designer/SignatureGenerator.tsx @@ -109,6 +109,22 @@ export function SignatureGenerator({ onSignatureSelect }: SignatureGeneratorProp }); } + // 사용자가 입력한 텍스트로 각 폰트의 글리프를 미리 로드 + const preloadCanvas = document.createElement("canvas"); + preloadCanvas.width = 500; + preloadCanvas.height = 200; + const preloadCtx = preloadCanvas.getContext("2d"); + + if (preloadCtx) { + for (const font of fonts) { + preloadCtx.font = `${font.weight} 124px ${font.style}`; + preloadCtx.fillText(name, 0, 100); + } + } + + // 글리프 로드 대기 (중요: 첫 렌더링 후 폰트가 완전히 로드되도록) + await new Promise((resolve) => setTimeout(resolve, 300)); + const newSignatures: string[] = []; // 동기적으로 하나씩 생성 -- 2.43.0 From e1a032933dc6080a470dd094124dbf600a213d03 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 22 Dec 2025 18:17:58 +0900 Subject: [PATCH 2/9] =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=97=AC=EB=B0=B1?= =?UTF-8?q?=EC=97=90=20=EC=B5=9C=EC=86=9F=EA=B0=92=200=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report/designer/ReportDesignerRightPanel.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/components/report/designer/ReportDesignerRightPanel.tsx b/frontend/components/report/designer/ReportDesignerRightPanel.tsx index e3a24025..037125ee 100644 --- a/frontend/components/report/designer/ReportDesignerRightPanel.tsx +++ b/frontend/components/report/designer/ReportDesignerRightPanel.tsx @@ -2589,12 +2589,13 @@ export function ReportDesignerRightPanel() { updatePageSettings(currentPageId, { margins: { ...currentPage.margins, - top: Number(e.target.value), + top: Math.max(0, Number(e.target.value)), }, }) } @@ -2605,12 +2606,13 @@ export function ReportDesignerRightPanel() { updatePageSettings(currentPageId, { margins: { ...currentPage.margins, - bottom: Number(e.target.value), + bottom: Math.max(0, Number(e.target.value)), }, }) } @@ -2621,12 +2623,13 @@ export function ReportDesignerRightPanel() { updatePageSettings(currentPageId, { margins: { ...currentPage.margins, - left: Number(e.target.value), + left: Math.max(0, Number(e.target.value)), }, }) } @@ -2637,12 +2640,13 @@ export function ReportDesignerRightPanel() { updatePageSettings(currentPageId, { margins: { ...currentPage.margins, - right: Number(e.target.value), + right: Math.max(0, Number(e.target.value)), }, }) } -- 2.43.0 From 7875d8ab86c1b0d41c35a41788106a6b39b9528c Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 22 Dec 2025 18:20:16 +0900 Subject: [PATCH 3/9] =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=ED=81=AC?= =?UTF-8?q?=EA=B8=B0=EC=97=90=20=EC=B5=9C=EC=86=9F=EA=B0=92=201=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/report/designer/ReportDesignerRightPanel.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/components/report/designer/ReportDesignerRightPanel.tsx b/frontend/components/report/designer/ReportDesignerRightPanel.tsx index 037125ee..bf401680 100644 --- a/frontend/components/report/designer/ReportDesignerRightPanel.tsx +++ b/frontend/components/report/designer/ReportDesignerRightPanel.tsx @@ -2502,10 +2502,11 @@ export function ReportDesignerRightPanel() { updatePageSettings(currentPageId, { - width: Number(e.target.value), + width: Math.max(1, Number(e.target.value)), }) } className="mt-1" @@ -2515,10 +2516,11 @@ export function ReportDesignerRightPanel() { updatePageSettings(currentPageId, { - height: Number(e.target.value), + height: Math.max(1, Number(e.target.value)), }) } className="mt-1" -- 2.43.0 From da195200a87025432b423c7dbb5a1506c81d79bd Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 23 Dec 2025 09:49:44 +0900 Subject: [PATCH 4/9] =?UTF-8?q?=20UX=20=EA=B0=9C=EC=84=A0=20-=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EA=B0=92=20=EA=B2=80=EC=A6=9D=20=EB=B0=8F=20confirm?= =?UTF-8?q?=EC=9D=84=20=EB=AA=A8=EB=8B=AC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report/designer/ReportDesignerToolbar.tsx | 64 ++++++++++++++++--- .../report/designer/TemplatePalette.tsx | 52 +++++++++++++-- 2 files changed, 99 insertions(+), 17 deletions(-) diff --git a/frontend/components/report/designer/ReportDesignerToolbar.tsx b/frontend/components/report/designer/ReportDesignerToolbar.tsx index dba01fbb..484dae80 100644 --- a/frontend/components/report/designer/ReportDesignerToolbar.tsx +++ b/frontend/components/report/designer/ReportDesignerToolbar.tsx @@ -42,6 +42,16 @@ 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 { reportApi } from "@/lib/api/reportApi"; @@ -93,6 +103,8 @@ export function ReportDesignerToolbar() { } = useReportDesigner(); const [showPreview, setShowPreview] = useState(false); const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false); + const [showBackConfirm, setShowBackConfirm] = useState(false); + const [showResetConfirm, setShowResetConfirm] = useState(false); const { toast } = useToast(); // 버튼 활성화 조건 @@ -120,16 +132,14 @@ export function ReportDesignerToolbar() { router.push("/admin/report"); }; - const handleReset = async () => { - if (confirm("현재 변경사항을 모두 취소하고 마지막 저장 상태로 되돌리시겠습니까?")) { - await loadLayout(); - } + const handleResetConfirm = async () => { + setShowResetConfirm(false); + await loadLayout(); }; - const handleBack = () => { - if (confirm("저장하지 않은 변경사항이 있을 수 있습니다. 목록으로 돌아가시겠습니까?")) { - router.push("/admin/report"); - } + const handleBackConfirm = () => { + setShowBackConfirm(false); + router.push("/admin/report"); }; const handleSaveAsTemplate = async (data: { @@ -193,7 +203,7 @@ export function ReportDesignerToolbar() { <>
- @@ -437,7 +447,7 @@ export function ReportDesignerToolbar() { - @@ -491,6 +501,40 @@ export function ReportDesignerToolbar() { onClose={() => setShowSaveAsTemplate(false)} onSave={handleSaveAsTemplate} /> + + {/* 목록으로 돌아가기 확인 모달 */} + + + + 목록으로 돌아가기 + + 저장하지 않은 변경사항이 있을 수 있습니다. +
+ 목록으로 돌아가시겠습니까? +
+
+ + 취소 + 확인 + +
+
+ + {/* 초기화 확인 모달 */} + + + + 초기화 + + 현재 변경사항을 모두 취소하고 마지막 저장 상태로 되돌리시겠습니까? + + + + 취소 + 확인 + + + ); } diff --git a/frontend/components/report/designer/TemplatePalette.tsx b/frontend/components/report/designer/TemplatePalette.tsx index 268b2dcc..27a062ad 100644 --- a/frontend/components/report/designer/TemplatePalette.tsx +++ b/frontend/components/report/designer/TemplatePalette.tsx @@ -3,6 +3,16 @@ import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { Trash2, Loader2, RefreshCw } from "lucide-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { useReportDesigner } from "@/contexts/ReportDesignerContext"; import { reportApi } from "@/lib/api/reportApi"; import { useToast } from "@/hooks/use-toast"; @@ -19,6 +29,7 @@ export function TemplatePalette() { const [customTemplates, setCustomTemplates] = useState([]); const [isLoading, setIsLoading] = useState(false); const [deletingId, setDeletingId] = useState(null); + const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null); const { toast } = useToast(); const fetchTemplates = async () => { @@ -49,14 +60,18 @@ export function TemplatePalette() { await applyTemplate(templateId); }; - const handleDeleteTemplate = async (templateId: string, templateName: string) => { - if (!confirm(`"${templateName}" 템플릿을 삭제하시겠습니까?`)) { - return; - } + const handleDeleteClick = (templateId: string, templateName: string) => { + setDeleteTarget({ id: templateId, name: templateName }); + }; + + const handleDeleteConfirm = async () => { + if (!deleteTarget) return; + + setDeletingId(deleteTarget.id); + setDeleteTarget(null); - setDeletingId(templateId); try { - const response = await reportApi.deleteTemplate(templateId); + const response = await reportApi.deleteTemplate(deleteTarget.id); if (response.success) { toast({ title: "성공", @@ -108,7 +123,7 @@ export function TemplatePalette() { size="sm" onClick={(e) => { e.stopPropagation(); - handleDeleteTemplate(template.template_id, template.template_name_kor); + handleDeleteClick(template.template_id, template.template_name_kor); }} disabled={deletingId === template.template_id} className="absolute top-1/2 right-1 h-6 w-6 -translate-y-1/2 p-0 opacity-0 transition-opacity group-hover:opacity-100" @@ -123,6 +138,29 @@ export function TemplatePalette() { )) )}
+ + {/* 삭제 확인 모달 */} + !open && setDeleteTarget(null)}> + + + 템플릿 삭제 + + "{deleteTarget?.name}" 템플릿을 삭제하시겠습니까? +
+ 삭제된 템플릿은 복구할 수 없습니다. +
+
+ + 취소 + + 삭제 + + +
+
); } -- 2.43.0 From e1567d3f778f84b0f0b8027f0cba09afa5614b4f Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 23 Dec 2025 13:56:15 +0900 Subject: [PATCH 5/9] =?UTF-8?q?=EC=9B=8C=EB=93=9C=20export=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=95=84=EC=9B=83=20=EB=B0=8F=20=EB=B0=94=EC=BD=94?= =?UTF-8?q?=EB=93=9C/=EC=84=9C=EB=AA=85=20=EB=A0=8C=EB=8D=94=EB=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/reportController.ts | 32 +++++++++++++++---- .../report/designer/ReportPreviewModal.tsx | 19 ++++++++--- 2 files changed, 39 insertions(+), 12 deletions(-) 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/frontend/components/report/designer/ReportPreviewModal.tsx b/frontend/components/report/designer/ReportPreviewModal.tsx index b8fcb9ce..0ba67bae 100644 --- a/frontend/components/report/designer/ReportPreviewModal.tsx +++ b/frontend/components/report/designer/ReportPreviewModal.tsx @@ -1052,7 +1052,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) description: "WORD 파일을 생성하고 있습니다...", }); - // 이미지를 Base64로 변환하여 컴포넌트 데이터에 포함 + // 이미지 및 바코드를 Base64로 변환하여 컴포넌트 데이터에 포함 const pagesWithBase64 = await Promise.all( layoutConfig.pages.map(async (page) => { const componentsWithBase64 = await Promise.all( @@ -1066,12 +1066,21 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) return component; } } + // 바코드/QR코드 컴포넌트는 이미지로 변환 + if (component.type === "barcode") { + try { + const barcodeImage = await generateBarcodeImage(component); + return { ...component, barcodeImageBase64: barcodeImage }; + } catch { + return component; + } + } return component; - }), - ); + }) + ); return { ...page, components: componentsWithBase64 }; - }), - ); + }) + ); // 쿼리 결과 수집 const queryResults: Record[] }> = {}; -- 2.43.0 From 050a183c9606eb7432f8b86657c0b8ca261f8b7d Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 23 Dec 2025 14:34:49 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat(report):=20=EB=A6=AC=ED=8F=AC=ED=8A=B8?= =?UTF-8?q?-=EB=A9=94=EB=89=B4=20=EC=97=B0=EA=B2=B0=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/services/reportService.ts | 50 +++ backend-node/src/types/report.ts | 14 +- .../report/designer/MenuSelectModal.tsx | 320 ++++++++++++++++++ .../report/designer/ReportDesignerToolbar.tsx | 30 +- frontend/contexts/ReportDesignerContext.tsx | 81 ++++- frontend/types/report.ts | 2 + 6 files changed, 489 insertions(+), 8 deletions(-) create mode 100644 frontend/components/report/designer/MenuSelectModal.tsx 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/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/ReportDesignerToolbar.tsx b/frontend/components/report/designer/ReportDesignerToolbar.tsx index 484dae80..2b0ef7b0 100644 --- a/frontend/components/report/designer/ReportDesignerToolbar.tsx +++ b/frontend/components/report/designer/ReportDesignerToolbar.tsx @@ -54,6 +54,7 @@ import { } 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"; @@ -62,7 +63,7 @@ export function ReportDesignerToolbar() { const router = useRouter(); const { reportDetail, - saveLayout, + saveLayoutWithMenus, isSaving, loadLayout, components, @@ -100,11 +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(); // 버튼 활성화 조건 @@ -123,13 +127,21 @@ 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 handleMenuSelectConfirm = async (selectedMenuObjids: number[]) => { + await saveLayoutWithMenus(selectedMenuObjids); + if (pendingSaveAndClose) { + router.push("/admin/report"); + } }; const handleResetConfirm = async () => { @@ -501,6 +513,12 @@ export function ReportDesignerToolbar() { onClose={() => setShowSaveAsTemplate(false)} onSave={handleSaveAsTemplate} /> + setShowMenuSelect(false)} + onConfirm={handleMenuSelectConfirm} + selectedMenuObjids={menuObjids} + /> {/* 목록으로 돌아가기 확인 모달 */} diff --git a/frontend/contexts/ReportDesignerContext.tsx b/frontend/contexts/ReportDesignerContext.tsx index f8764d15..42fb9504 100644 --- a/frontend/contexts/ReportDesignerContext.tsx +++ b/frontend/contexts/ReportDesignerContext.tsx @@ -138,6 +138,11 @@ interface ReportDesignerContextType { // 그룹화 groupComponents: () => void; ungroupComponents: () => void; + + // 메뉴 연결 + menuObjids: number[]; + setMenuObjids: (menuObjids: number[]) => void; + saveLayoutWithMenus: (menuObjids: number[]) => Promise; } const ReportDesignerContext = createContext(undefined); @@ -158,6 +163,7 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin const [selectedComponentIds, setSelectedComponentIds] = useState([]); // 다중 선택 const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); + const [menuObjids, setMenuObjids] = useState([]); // 연결된 메뉴 ID 목록 const { toast } = useToast(); // 현재 페이지 계산 @@ -1043,6 +1049,13 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin })); setQueries(loadedQueries); } + + // 연결된 메뉴 로드 + if (detailResponse.data.menuObjids && detailResponse.data.menuObjids.length > 0) { + setMenuObjids(detailResponse.data.menuObjids); + } else { + setMenuObjids([]); + } } // 레이아웃 조회 @@ -1331,6 +1344,7 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin ...q, externalConnectionId: q.externalConnectionId || undefined, })), + menuObjids, // 연결된 메뉴 목록 }); toast({ @@ -1352,7 +1366,68 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin } finally { setIsSaving(false); } - }, [reportId, layoutConfig, queries, toast, loadLayout]); + }, [reportId, layoutConfig, queries, menuObjids, toast, loadLayout]); + + // 메뉴를 선택하고 저장하는 함수 + const saveLayoutWithMenus = useCallback( + async (selectedMenuObjids: number[]) => { + // 먼저 메뉴 상태 업데이트 + setMenuObjids(selectedMenuObjids); + + setIsSaving(true); + try { + let actualReportId = reportId; + + // 새 리포트인 경우 먼저 리포트 생성 + if (reportId === "new") { + const createResponse = await reportApi.createReport({ + reportNameKor: "새 리포트", + reportType: "BASIC", + description: "새로 생성된 리포트입니다.", + }); + + if (!createResponse.success || !createResponse.data) { + throw new Error("리포트 생성에 실패했습니다."); + } + + actualReportId = createResponse.data.reportId; + + // URL 업데이트 (페이지 리로드 없이) + window.history.replaceState({}, "", `/admin/report/designer/${actualReportId}`); + } + + // 레이아웃 저장 (선택된 메뉴와 함께) + await reportApi.saveLayout(actualReportId, { + layoutConfig, + queries: queries.map((q) => ({ + ...q, + externalConnectionId: q.externalConnectionId || undefined, + })), + menuObjids: selectedMenuObjids, + }); + + toast({ + title: "성공", + description: reportId === "new" ? "리포트가 생성되었습니다." : "레이아웃이 저장되었습니다.", + }); + + // 새 리포트였다면 데이터 다시 로드 + if (reportId === "new") { + await loadLayout(); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "저장에 실패했습니다."; + toast({ + title: "오류", + description: errorMessage, + variant: "destructive", + }); + } finally { + setIsSaving(false); + } + }, + [reportId, layoutConfig, queries, toast, loadLayout], + ); // 템플릿 적용 const applyTemplate = useCallback( @@ -1553,6 +1628,10 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin // 그룹화 groupComponents, ungroupComponents, + // 메뉴 연결 + menuObjids, + setMenuObjids, + saveLayoutWithMenus, }; return {children}; diff --git a/frontend/types/report.ts b/frontend/types/report.ts index 3631f831..4241035f 100644 --- a/frontend/types/report.ts +++ b/frontend/types/report.ts @@ -237,6 +237,7 @@ export interface ReportDetail { report: ReportMaster; layout: ReportLayout | null; queries: ReportQuery[]; + menuObjids?: number[]; // 연결된 메뉴 ID 목록 } // 리포트 목록 응답 @@ -288,6 +289,7 @@ export interface SaveLayoutRequest { parameters: string[]; externalConnectionId?: number; }>; + menuObjids?: number[]; // 연결할 메뉴 ID 목록 // 하위 호환성 (deprecated) canvasWidth?: number; -- 2.43.0 From 83f171189bb21eb130434fd5954e572676372264 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 23 Dec 2025 15:12:21 +0900 Subject: [PATCH 7/9] =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EC=A6=88=20=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=9C=84=EC=B9=98/=ED=81=AC?= =?UTF-8?q?=EA=B8=B0=20=EB=B9=84=EC=9C=A8=20=EC=9E=90=EB=8F=99=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/contexts/ReportDesignerContext.tsx | 74 +++++++++++++++++++-- 1 file changed, 70 insertions(+), 4 deletions(-) diff --git a/frontend/contexts/ReportDesignerContext.tsx b/frontend/contexts/ReportDesignerContext.tsx index 42fb9504..3db07bc9 100644 --- a/frontend/contexts/ReportDesignerContext.tsx +++ b/frontend/contexts/ReportDesignerContext.tsx @@ -147,6 +147,40 @@ interface ReportDesignerContextType { const ReportDesignerContext = createContext(undefined); +// 페이지 사이즈 변경 시 컴포넌트 위치 및 크기 재계산 유틸리티 함수 +const recalculateComponentPositions = ( + components: ComponentConfig[], + oldWidth: number, + oldHeight: number, + newWidth: number, + newHeight: number +): ComponentConfig[] => { + // 사이즈가 동일하면 그대로 반환 + if (oldWidth === newWidth && oldHeight === newHeight) { + return components; + } + + const widthRatio = newWidth / oldWidth; + const heightRatio = newHeight / oldHeight; + + return components.map((comp) => { + // 위치와 크기 모두 비율대로 재계산 + // 소수점 2자리까지만 유지 + const newX = Math.round(comp.x * widthRatio * 100) / 100; + const newY = Math.round(comp.y * heightRatio * 100) / 100; + const newCompWidth = Math.round(comp.width * widthRatio * 100) / 100; + const newCompHeight = Math.round(comp.height * heightRatio * 100) / 100; + + return { + ...comp, + x: newX, + y: newY, + width: newCompWidth, + height: newCompHeight, + }; + }); +}; + export function ReportDesignerProvider({ reportId, children }: { reportId: string; children: ReactNode }) { const [reportDetail, setReportDetail] = useState(null); const [layout, setLayout] = useState(null); @@ -994,10 +1028,42 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin }, []); const updatePageSettings = useCallback((pageId: string, settings: Partial) => { - setLayoutConfig((prev) => ({ - ...prev, - pages: prev.pages.map((page) => (page.page_id === pageId ? { ...page, ...settings } : page)), - })); + setLayoutConfig((prev) => { + const targetPage = prev.pages.find((p) => p.page_id === pageId); + if (!targetPage) { + return prev; + } + + // 페이지 사이즈 변경 감지 + const isWidthChanging = settings.width !== undefined && settings.width !== targetPage.width; + const isHeightChanging = settings.height !== undefined && settings.height !== targetPage.height; + + // 사이즈 변경 시 컴포넌트 위치 재계산 + let updatedComponents = targetPage.components; + if (isWidthChanging || isHeightChanging) { + const oldWidth = targetPage.width; + const oldHeight = targetPage.height; + const newWidth = settings.width ?? targetPage.width; + const newHeight = settings.height ?? targetPage.height; + + updatedComponents = recalculateComponentPositions( + targetPage.components, + oldWidth, + oldHeight, + newWidth, + newHeight + ); + } + + return { + ...prev, + pages: prev.pages.map((page) => + page.page_id === pageId + ? { ...page, ...settings, components: updatedComponents } + : page + ), + }; + }); }, []); // 전체 페이지 공유 워터마크 업데이트 -- 2.43.0 From 82a7ff62ee2858dad6111bb52050b232786211b8 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 23 Dec 2025 16:00:25 +0900 Subject: [PATCH 8/9] =?UTF-8?q?=EC=84=9C=EB=AA=85=20=EB=B0=91=EC=A4=84=20?= =?UTF-8?q?=EC=98=B5=EC=85=98=20=EC=99=84=EC=A0=84=ED=9E=88=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report/designer/CanvasComponent.tsx | 9 --------- .../report/designer/ReportDesignerCanvas.tsx | 1 - .../designer/ReportDesignerRightPanel.tsx | 20 ------------------- .../report/designer/ReportPreviewModal.tsx | 13 ------------ frontend/types/report.ts | 1 - 5 files changed, 44 deletions(-) diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx index 238b79a9..554c7065 100644 --- a/frontend/components/report/designer/CanvasComponent.tsx +++ b/frontend/components/report/designer/CanvasComponent.tsx @@ -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 && ( -
- )}
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 bf401680..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" && (
diff --git a/frontend/components/report/designer/ReportPreviewModal.tsx b/frontend/components/report/designer/ReportPreviewModal.tsx index 0ba67bae..bf0603b7 100644 --- a/frontend/components/report/designer/ReportPreviewModal.tsx +++ b/frontend/components/report/designer/ReportPreviewModal.tsx @@ -624,7 +624,6 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) ${showLabel ? `
${labelText}
` : ""}
${imageUrl ? `` : ""} - ${component.showUnderline ? '
' : ""}
`; } else { @@ -633,7 +632,6 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) ${showLabel && labelPosition === "top" ? `
${labelText}
` : ""}
${imageUrl ? `` : ""} - ${component.showUnderline ? '
' : ""}
${showLabel && labelPosition === "bottom" ? `
${labelText}
` : ""} `; @@ -1386,17 +1384,6 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) }} /> )} - {component.showUnderline !== false && ( -
- )}
)} diff --git a/frontend/types/report.ts b/frontend/types/report.ts index 4241035f..bd0ff896 100644 --- a/frontend/types/report.ts +++ b/frontend/types/report.ts @@ -162,7 +162,6 @@ export interface ComponentConfig { showLabel?: boolean; // 레이블 표시 여부 ("서명:", "(인)") labelText?: string; // 커스텀 레이블 텍스트 labelPosition?: "top" | "left" | "bottom" | "right"; // 레이블 위치 - showUnderline?: boolean; // 서명란 밑줄 표시 여부 personName?: string; // 도장란 이름 (예: "홍길동") // 테이블 전용 tableColumns?: Array<{ -- 2.43.0 From 859d68fff8125fe7e99538b8db80d81d2e03239e Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 23 Dec 2025 17:37:22 +0900 Subject: [PATCH 9/9] =?UTF-8?q?=EC=9D=B8=EC=87=84=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20-=20=EC=A4=91=EB=B3=B5=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EC=A0=95=ED=99=95=EB=8F=84=20=ED=96=A5?= =?UTF-8?q?=EC=83=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report/designer/CanvasComponent.tsx | 84 +++++++++---------- .../report/designer/ReportPreviewModal.tsx | 44 ++++++---- 2 files changed, 68 insertions(+), 60 deletions(-) diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx index 554c7065..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 (
- 쿼리를 연결하세요 + 쿼리를 연결하세요
); @@ -858,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]; @@ -899,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/ReportPreviewModal.tsx b/frontend/components/report/designer/ReportPreviewModal.tsx index bf0603b7..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(""); @@ -650,9 +653,9 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) : ""; content = ` -
- ${personName ? `
${personName}
` : ""} -
+
+ ${personName ? `
${personName}
` : ""} +
${imageUrl ? `` : ""} ${showLabel ? `
${labelText}
` : ""}
@@ -891,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}
`; }) @@ -901,7 +911,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) const watermarkHTML = generateWatermarkHTML(watermark, pageWidth, pageHeight); return ` -
+ `; @@ -933,20 +943,18 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) 리포트 인쇄