From 0ed8e686c027c44792f3ba774b7dc80dc839567b Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 18 Dec 2025 09:45:07 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A0=88=ED=8F=AC=ED=8A=B8=EC=97=90=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EB=B2=88=ED=98=B8=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/reportController.ts | 127 +++++++++++++++++- .../report/designer/CanvasComponent.tsx | 39 ++++++ .../report/designer/ComponentPalette.tsx | 3 +- .../report/designer/ReportDesignerCanvas.tsx | 8 ++ .../designer/ReportDesignerRightPanel.tsx | 31 +++++ .../report/designer/ReportPreviewModal.tsx | 70 +++++++++- frontend/types/report.ts | 2 + 7 files changed, 271 insertions(+), 9 deletions(-) diff --git a/backend-node/src/controllers/reportController.ts b/backend-node/src/controllers/reportController.ts index c6cd1ee7..e59235d9 100644 --- a/backend-node/src/controllers/reportController.ts +++ b/backend-node/src/controllers/reportController.ts @@ -618,7 +618,9 @@ export class ReportController { ImageRunRef: typeof ImageRun, TableRef: typeof Table, TableRowRef: typeof TableRow, - TableCellRef: typeof TableCell + TableCellRef: typeof TableCell, + pageIndex: number = 0, + totalPages: number = 1 ): (Paragraph | Table)[] => { const result: (Paragraph | Table)[] = []; @@ -749,6 +751,46 @@ export class ReportController { } } + // PageNumber + else if (component.type === "pageNumber") { + const format = component.pageNumberFormat || "number"; + const currentPageNum = pageIndex + 1; + let pageNumberText = ""; + switch (format) { + case "number": + pageNumberText = `${currentPageNum}`; + break; + case "numberTotal": + pageNumberText = `${currentPageNum} / ${totalPages}`; + break; + case "koreanNumber": + pageNumberText = `${currentPageNum} 페이지`; + break; + default: + pageNumberText = `${currentPageNum}`; + } + const fontSizeHalfPt = pxToHalfPtFn(component.fontSize || 13); + const alignment = + component.textAlign === "center" + ? AlignmentTypeRef.CENTER + : component.textAlign === "right" + ? AlignmentTypeRef.RIGHT + : AlignmentTypeRef.LEFT; + result.push( + new ParagraphRef({ + alignment, + children: [ + new TextRunRef({ + text: pageNumberText, + size: fontSizeHalfPt, + color: (component.fontColor || "#000000").replace("#", ""), + font: "맑은 고딕", + }), + ], + }) + ); + } + // Divider - 테이블 셀로 감싸서 정확한 너비 적용 else if (component.type === "divider" && component.orientation === "horizontal") { result.push( @@ -775,9 +817,11 @@ export class ReportController { }; // 섹션 생성 (페이지별) - const sections = layoutConfig.pages - .sort((a: any, b: any) => a.page_order - b.page_order) - .map((page: any) => { + const sortedPages = layoutConfig.pages.sort((a: any, b: any) => a.page_order - b.page_order); + const totalPagesCount = sortedPages.length; + + const sections = sortedPages + .map((page: any, pageIndex: number) => { const pageWidthTwip = mmToTwip(page.width); const pageHeightTwip = mmToTwip(page.height); const marginTopMm = page.margins?.top || 10; @@ -874,7 +918,7 @@ export class ReportController { } // 컴포넌트 셀 생성 - const cellContent = createCellContent(component, displayValue, pxToHalfPt, pxToTwip, queryResultsMap, AlignmentType, VerticalAlign, BorderStyle, Paragraph, TextRun, ImageRun, Table, TableRow, TableCell); + const cellContent = createCellContent(component, displayValue, pxToHalfPt, pxToTwip, queryResultsMap, AlignmentType, VerticalAlign, BorderStyle, Paragraph, TextRun, ImageRun, Table, TableRow, TableCell, pageIndex, totalPagesCount); cells.push( new TableCell({ children: cellContent, @@ -1162,6 +1206,79 @@ export class ReportController { lastBottomY = adjustedY + component.height; } + // PageNumber 컴포넌트 - 테이블 셀로 감싸서 정확한 위치 적용 + else if (component.type === "pageNumber") { + const format = component.pageNumberFormat || "number"; + const currentPageNum = pageIndex + 1; + let pageNumberText = ""; + switch (format) { + case "number": + pageNumberText = `${currentPageNum}`; + break; + case "numberTotal": + pageNumberText = `${currentPageNum} / ${totalPagesCount}`; + break; + case "koreanNumber": + pageNumberText = `${currentPageNum} 페이지`; + break; + default: + pageNumberText = `${currentPageNum}`; + } + const pageNumFontSize = pxToHalfPt(component.fontSize || 13); + const alignment = + component.textAlign === "center" + ? AlignmentType.CENTER + : component.textAlign === "right" + ? AlignmentType.RIGHT + : AlignmentType.LEFT; + + // 테이블 셀로 감싸서 width와 indent 정확히 적용 + const pageNumCell = new TableCell({ + children: [ + new Paragraph({ + alignment, + children: [ + new TextRun({ + text: pageNumberText, + size: pageNumFontSize, + color: (component.fontColor || "#000000").replace("#", ""), + font: "맑은 고딕", + }), + ], + }), + ], + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + }, + verticalAlign: VerticalAlign.TOP, + }); + + const pageNumTable = new Table({ + rows: [new TableRow({ children: [pageNumCell] })], + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + indent: { size: indentLeft, type: WidthType.DXA }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + insideHorizontal: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + insideVertical: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + }, + }); + + // spacing을 위한 빈 paragraph + if (spacingBefore > 0) { + children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, children: [] })); + } + children.push(pageNumTable); + lastBottomY = adjustedY + component.height; + } + // Table 컴포넌트 else if (component.type === "table" && component.queryId) { const queryResult = queryResultsMap[component.queryId]; diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx index cb7b7347..90d8f21a 100644 --- a/frontend/components/report/designer/CanvasComponent.tsx +++ b/frontend/components/report/designer/CanvasComponent.tsx @@ -23,6 +23,8 @@ export function CanvasComponent({ component }: CanvasComponentProps) { canvasWidth, canvasHeight, margins, + layoutConfig, + currentPageId, } = useReportDesigner(); const [isDragging, setIsDragging] = useState(false); const [isResizing, setIsResizing] = useState(false); @@ -563,6 +565,43 @@ export function CanvasComponent({ component }: CanvasComponentProps) { ); + case "pageNumber": + // 페이지 번호 포맷 + const format = component.pageNumberFormat || "number"; + const sortedPages = layoutConfig.pages.sort((a, b) => a.page_order - b.page_order); + const currentPageIndex = sortedPages.findIndex((p) => p.page_id === currentPageId); + const totalPages = sortedPages.length; + const currentPageNum = currentPageIndex + 1; + + let pageNumberText = ""; + switch (format) { + case "number": + pageNumberText = `${currentPageNum}`; + break; + case "numberTotal": + pageNumberText = `${currentPageNum} / ${totalPages}`; + break; + case "koreanNumber": + pageNumberText = `${currentPageNum} 페이지`; + break; + default: + pageNumberText = `${currentPageNum}`; + } + + return ( +
+ {pageNumberText} +
+ ); + default: return
알 수 없는 컴포넌트
; } diff --git a/frontend/components/report/designer/ComponentPalette.tsx b/frontend/components/report/designer/ComponentPalette.tsx index c1d348b5..c21ca6ec 100644 --- a/frontend/components/report/designer/ComponentPalette.tsx +++ b/frontend/components/report/designer/ComponentPalette.tsx @@ -1,7 +1,7 @@ "use client"; import { useDrag } from "react-dnd"; -import { Type, Table, Image, Minus, PenLine, Stamp as StampIcon } from "lucide-react"; +import { Type, Table, Image, Minus, PenLine, Stamp as StampIcon, Hash } from "lucide-react"; interface ComponentItem { type: string; @@ -16,6 +16,7 @@ const COMPONENTS: ComponentItem[] = [ { type: "divider", label: "구분선", icon: }, { type: "signature", label: "서명란", icon: }, { type: "stamp", label: "도장란", icon: }, + { type: "pageNumber", label: "페이지번호", icon: }, ]; function DraggableComponentItem({ type, label, icon }: ComponentItem) { diff --git a/frontend/components/report/designer/ReportDesignerCanvas.tsx b/frontend/components/report/designer/ReportDesignerCanvas.tsx index c9c86a69..655a80c0 100644 --- a/frontend/components/report/designer/ReportDesignerCanvas.tsx +++ b/frontend/components/report/designer/ReportDesignerCanvas.tsx @@ -65,6 +65,9 @@ export function ReportDesignerCanvas() { } else if (item.componentType === "stamp") { width = 70; height = 70; + } else if (item.componentType === "pageNumber") { + width = 100; + height = 30; } // 여백을 px로 변환 (1mm ≈ 3.7795px) @@ -143,6 +146,11 @@ export function ReportDesignerCanvas() { borderWidth: 0, borderColor: "#cccccc", }), + // 페이지 번호 전용 + ...(item.componentType === "pageNumber" && { + pageNumberFormat: "number" as const, // number, numberTotal, koreanNumber + textAlign: "center" as const, + }), // 테이블 전용 ...(item.componentType === "table" && { queryId: undefined, diff --git a/frontend/components/report/designer/ReportDesignerRightPanel.tsx b/frontend/components/report/designer/ReportDesignerRightPanel.tsx index f2f0949a..0bfc2d31 100644 --- a/frontend/components/report/designer/ReportDesignerRightPanel.tsx +++ b/frontend/components/report/designer/ReportDesignerRightPanel.tsx @@ -919,6 +919,37 @@ export function ReportDesignerRightPanel() { )} + {/* 페이지 번호 설정 */} + {selectedComponent.type === "pageNumber" && ( + + + 페이지 번호 설정 + + +
+ + +
+
+
+ )} + {/* 데이터 바인딩 (텍스트/라벨/테이블 컴포넌트) */} {(selectedComponent.type === "text" || selectedComponent.type === "label" || diff --git a/frontend/components/report/designer/ReportPreviewModal.tsx b/frontend/components/report/designer/ReportPreviewModal.tsx index 28179145..4d23e78b 100644 --- a/frontend/components/report/designer/ReportPreviewModal.tsx +++ b/frontend/components/report/designer/ReportPreviewModal.tsx @@ -58,6 +58,8 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) pageWidth: number, pageHeight: number, backgroundColor: string, + pageIndex: number = 0, + totalPages: number = 1, ): string => { const componentsHTML = pageComponents .map((component) => { @@ -139,6 +141,26 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) `; } + // 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 = `
${pageNumberText}
`; + } + // Table 컴포넌트 else if (component.type === "table" && queryResult && queryResult.rows.length > 0) { const columns = @@ -189,14 +211,18 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) // 모든 페이지 HTML 생성 (인쇄/PDF용) const generatePrintHTML = (): string => { - const pagesHTML = layoutConfig.pages - .sort((a, b) => a.page_order - b.page_order) - .map((page) => + const sortedPages = layoutConfig.pages.sort((a, b) => a.page_order - b.page_order); + const totalPages = sortedPages.length; + + const pagesHTML = sortedPages + .map((page, pageIndex) => generatePageHTML( Array.isArray(page.components) ? page.components : [], page.width, page.height, page.background_color, + pageIndex, + totalPages, ), ) .join('
'); @@ -700,6 +726,44 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) )} + + {component.type === "pageNumber" && (() => { + const format = component.pageNumberFormat || "number"; + const pageIndex = layoutConfig.pages + .sort((a, b) => a.page_order - b.page_order) + .findIndex((p) => p.page_id === page.page_id); + const totalPages = layoutConfig.pages.length; + let pageNumberText = ""; + switch (format) { + case "number": + pageNumberText = `${pageIndex + 1}`; + break; + case "numberTotal": + pageNumberText = `${pageIndex + 1} / ${totalPages}`; + break; + case "koreanNumber": + pageNumberText = `${pageIndex + 1} 페이지`; + break; + default: + pageNumberText = `${pageIndex + 1}`; + } + return ( +
+ {pageNumberText} +
+ ); + })()} ); })} diff --git a/frontend/types/report.ts b/frontend/types/report.ts index 2c720d77..c31c49fa 100644 --- a/frontend/types/report.ts +++ b/frontend/types/report.ts @@ -158,6 +158,8 @@ export interface ComponentConfig { headerTextColor?: string; // 헤더 텍스트 색상 showBorder?: boolean; // 테두리 표시 rowHeight?: number; // 행 높이 (px) + // 페이지 번호 전용 + pageNumberFormat?: "number" | "numberTotal" | "koreanNumber"; // 페이지 번호 포맷 } // 리포트 상세