레포트에 페이지번호 컴포넌트 추가

This commit is contained in:
dohyeons 2025-12-18 09:45:07 +09:00
parent 0abe87ae1a
commit 0ed8e686c0
7 changed files with 271 additions and 9 deletions

View File

@ -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];

View File

@ -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) {
</div>
);
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 (
<div
className="flex h-full w-full items-center justify-center"
style={{
fontSize: `${component.fontSize}px`,
color: component.fontColor,
fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right",
}}
>
{pageNumberText}
</div>
);
default:
return <div> </div>;
}

View File

@ -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: <Minus className="h-4 w-4" /> },
{ type: "signature", label: "서명란", icon: <PenLine className="h-4 w-4" /> },
{ type: "stamp", label: "도장란", icon: <StampIcon className="h-4 w-4" /> },
{ type: "pageNumber", label: "페이지번호", icon: <Hash className="h-4 w-4" /> },
];
function DraggableComponentItem({ type, label, icon }: ComponentItem) {

View File

@ -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,

View File

@ -919,6 +919,37 @@ export function ReportDesignerRightPanel() {
</Card>
)}
{/* 페이지 번호 설정 */}
{selectedComponent.type === "pageNumber" && (
<Card className="mt-4 border-purple-200 bg-purple-50">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-purple-900"> </CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<Label className="text-xs"> </Label>
<Select
value={selectedComponent.pageNumberFormat || "number"}
onValueChange={(value) =>
updateComponent(selectedComponent.id, {
pageNumberFormat: value as "number" | "numberTotal" | "koreanNumber",
})
}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="number"> (1, 2, 3...)</SelectItem>
<SelectItem value="numberTotal">/ (1 / 3)</SelectItem>
<SelectItem value="koreanNumber"> (1 )</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
)}
{/* 데이터 바인딩 (텍스트/라벨/테이블 컴포넌트) */}
{(selectedComponent.type === "text" ||
selectedComponent.type === "label" ||

View File

@ -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)
</div>`;
}
// PageNumber 컴포넌트
else if (component.type === "pageNumber") {
const format = component.pageNumberFormat || "number";
let pageNumberText = "";
switch (format) {
case "number":
pageNumberText = `${pageIndex + 1}`;
break;
case "numberTotal":
pageNumberText = `${pageIndex + 1} / ${totalPages}`;
break;
case "koreanNumber":
pageNumberText = `${pageIndex + 1} 페이지`;
break;
default:
pageNumberText = `${pageIndex + 1}`;
}
content = `<div style="display: flex; align-items: center; justify-content: center; height: 100%; font-size: ${component.fontSize}px; color: ${component.fontColor}; font-weight: ${component.fontWeight}; text-align: ${component.textAlign};">${pageNumberText}</div>`;
}
// 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('<div style="page-break-after: always;"></div>');
@ -700,6 +726,44 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
</div>
</div>
)}
{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 (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "100%",
fontSize: `${component.fontSize}px`,
color: component.fontColor,
fontWeight: component.fontWeight,
}}
>
{pageNumberText}
</div>
);
})()}
</div>
);
})}

View File

@ -158,6 +158,8 @@ export interface ComponentConfig {
headerTextColor?: string; // 헤더 텍스트 색상
showBorder?: boolean; // 테두리 표시
rowHeight?: number; // 행 높이 (px)
// 페이지 번호 전용
pageNumberFormat?: "number" | "numberTotal" | "koreanNumber"; // 페이지 번호 포맷
}
// 리포트 상세