diff --git a/backend-node/src/controllers/reportController.ts b/backend-node/src/controllers/reportController.ts index d6c77fcd..a2e8e8a9 100644 --- a/backend-node/src/controllers/reportController.ts +++ b/backend-node/src/controllers/reportController.ts @@ -224,11 +224,17 @@ export class ReportController { } // components 컬럼에서 JSON 파싱 - const parsedComponents = layout.components ? JSON.parse(layout.components) : null; + const parsedComponents = layout.components + ? JSON.parse(layout.components) + : null; let layoutData; // 새 구조 (layoutConfig.pages)인지 확인 - if (parsedComponents && parsedComponents.pages && Array.isArray(parsedComponents.pages)) { + if ( + parsedComponents && + parsedComponents.pages && + Array.isArray(parsedComponents.pages) + ) { // pages 배열을 직접 포함하여 반환 layoutData = { ...layout, @@ -263,7 +269,11 @@ export class ReportController { const userId = (req as any).user?.userId || "SYSTEM"; // 필수 필드 검증 (페이지 기반 구조) - if (!data.layoutConfig || !data.layoutConfig.pages || data.layoutConfig.pages.length === 0) { + if ( + !data.layoutConfig || + !data.layoutConfig.pages || + data.layoutConfig.pages.length === 0 + ) { return res.status(400).json({ success: false, message: "레이아웃 설정이 필요합니다.", @@ -581,8 +591,10 @@ export class ReportController { const pxToTwip = (px: number) => Math.round(px * 15); // 쿼리 결과 맵 - const queryResultsMap: Record[] }> = - queryResults || {}; + const queryResultsMap: Record< + string, + { fields: string[]; rows: Record[] } + > = queryResults || {}; // 컴포넌트 값 가져오기 const getComponentValue = (component: any): string => { @@ -609,7 +621,10 @@ export class ReportController { displayValue: string, pxToHalfPtFn: (px: number) => number, pxToTwipFn: (px: number) => number, - queryResultsMapRef: Record[] }>, + queryResultsMapRef: Record< + string, + { fields: string[]; rows: Record[] } + >, AlignmentTypeRef: typeof AlignmentType, VerticalAlignRef: typeof VerticalAlign, BorderStyleRef: typeof BorderStyle, @@ -647,7 +662,9 @@ export class ReportController { text: line, size: fontSizeHalfPt, color: (component.fontColor || "#000000").replace("#", ""), - bold: component.fontWeight === "bold" || component.fontWeight === "600", + bold: + component.fontWeight === "bold" || + component.fontWeight === "600", font: "맑은 고딕", }) ); @@ -664,7 +681,8 @@ export class ReportController { // Image else if (component.type === "image" && component.imageBase64) { try { - const base64Data = component.imageBase64.split(",")[1] || component.imageBase64; + const base64Data = + component.imageBase64.split(",")[1] || component.imageBase64; const imageBuffer = Buffer.from(base64Data, "base64"); result.push( new ParagraphRef({ @@ -690,11 +708,18 @@ export class ReportController { const sigFontSize = pxToHalfPtFn(component.fontSize || 12); const textRuns: TextRun[] = []; if (component.showLabel !== false) { - textRuns.push(new TextRunRef({ text: (component.labelText || "서명:") + " ", size: sigFontSize, font: "맑은 고딕" })); + textRuns.push( + new TextRunRef({ + text: (component.labelText || "서명:") + " ", + size: sigFontSize, + font: "맑은 고딕", + }) + ); } if (component.imageBase64) { try { - const base64Data = component.imageBase64.split(",")[1] || component.imageBase64; + const base64Data = + component.imageBase64.split(",")[1] || component.imageBase64; const imageBuffer = Buffer.from(base64Data, "base64"); result.push( new ParagraphRef({ @@ -702,18 +727,33 @@ export class ReportController { ...textRuns, new ImageRunRef({ data: imageBuffer, - transformation: { width: Math.round(component.width * 0.75), height: Math.round(component.height * 0.75) }, + transformation: { + width: Math.round(component.width * 0.75), + height: Math.round(component.height * 0.75), + }, type: "png", }), ], }) ); } catch (e) { - textRuns.push(new TextRunRef({ text: "_".repeat(20), size: sigFontSize, font: "맑은 고딕" })); + textRuns.push( + new TextRunRef({ + text: "_".repeat(20), + size: sigFontSize, + font: "맑은 고딕", + }) + ); result.push(new ParagraphRef({ children: textRuns })); } } else { - textRuns.push(new TextRunRef({ text: "_".repeat(20), size: sigFontSize, font: "맑은 고딕" })); + textRuns.push( + new TextRunRef({ + text: "_".repeat(20), + size: sigFontSize, + font: "맑은 고딕", + }) + ); result.push(new ParagraphRef({ children: textRuns })); } } @@ -723,11 +763,18 @@ export class ReportController { const stampFontSize = pxToHalfPtFn(component.fontSize || 12); const textRuns: TextRun[] = []; if (component.personName) { - textRuns.push(new TextRunRef({ text: component.personName + " ", size: stampFontSize, font: "맑은 고딕" })); + textRuns.push( + new TextRunRef({ + text: component.personName + " ", + size: stampFontSize, + font: "맑은 고딕", + }) + ); } if (component.imageBase64) { try { - const base64Data = component.imageBase64.split(",")[1] || component.imageBase64; + const base64Data = + component.imageBase64.split(",")[1] || component.imageBase64; const imageBuffer = Buffer.from(base64Data, "base64"); result.push( new ParagraphRef({ @@ -735,18 +782,39 @@ export class ReportController { ...textRuns, new ImageRunRef({ data: imageBuffer, - transformation: { width: Math.round(Math.min(component.width, component.height) * 0.75), height: Math.round(Math.min(component.width, component.height) * 0.75) }, + transformation: { + width: Math.round( + Math.min(component.width, component.height) * 0.75 + ), + height: Math.round( + Math.min(component.width, component.height) * 0.75 + ), + }, type: "png", }), ], }) ); } catch (e) { - textRuns.push(new TextRunRef({ text: "(인)", color: "DC2626", size: stampFontSize, font: "맑은 고딕" })); + textRuns.push( + new TextRunRef({ + text: "(인)", + color: "DC2626", + size: stampFontSize, + font: "맑은 고딕", + }) + ); result.push(new ParagraphRef({ children: textRuns })); } } else { - textRuns.push(new TextRunRef({ text: "(인)", color: "DC2626", size: stampFontSize, font: "맑은 고딕" })); + textRuns.push( + new TextRunRef({ + text: "(인)", + color: "DC2626", + size: stampFontSize, + font: "맑은 고딕", + }) + ); result.push(new ParagraphRef({ children: textRuns })); } } @@ -800,18 +868,40 @@ export class ReportController { const titleFontSize = pxToHalfPtFn(component.titleFontSize || 14); const labelFontSize = pxToHalfPtFn(component.labelFontSize || 13); const valueFontSize = pxToHalfPtFn(component.valueFontSize || 13); - const titleColor = (component.titleColor || "#1e40af").replace("#", ""); - const labelColor = (component.labelColor || "#374151").replace("#", ""); - const valueColor = (component.valueColor || "#000000").replace("#", ""); - const borderColor = (component.borderColor || "#e5e7eb").replace("#", ""); + const titleColor = (component.titleColor || "#1e40af").replace( + "#", + "" + ); + const labelColor = (component.labelColor || "#374151").replace( + "#", + "" + ); + const valueColor = (component.valueColor || "#000000").replace( + "#", + "" + ); + const borderColor = (component.borderColor || "#e5e7eb").replace( + "#", + "" + ); // 쿼리 바인딩된 값 가져오기 - const getCardValueFn = (item: { label: string; value: string; fieldName?: string }) => { - if (item.fieldName && component.queryId && queryResultsMapRef[component.queryId]) { + const getCardValueFn = (item: { + label: string; + value: string; + fieldName?: string; + }) => { + if ( + item.fieldName && + component.queryId && + queryResultsMapRef[component.queryId] + ) { const qResult = queryResultsMapRef[component.queryId]; if (qResult.rows && qResult.rows.length > 0) { const row = qResult.rows[0]; - return row[item.fieldName] !== undefined ? String(row[item.fieldName]) : item.value; + return row[item.fieldName] !== undefined + ? String(row[item.fieldName]) + : item.value; } } return item.value; @@ -850,7 +940,9 @@ export class ReportController { // 항목들 for (const item of cardItems) { - const itemValue = getCardValueFn(item as { label: string; value: string; fieldName?: string }); + const itemValue = getCardValueFn( + item as { label: string; value: string; fieldName?: string } + ); result.push( new ParagraphRef({ children: [ @@ -885,45 +977,87 @@ export class ReportController { const calcLabelWidth = component.labelWidth || 120; const calcLabelFontSize = pxToHalfPtFn(component.labelFontSize || 13); const calcValueFontSize = pxToHalfPtFn(component.valueFontSize || 13); - const calcResultFontSize = pxToHalfPtFn(component.resultFontSize || 16); - const calcLabelColor = (component.labelColor || "#374151").replace("#", ""); - const calcValueColor = (component.valueColor || "#000000").replace("#", ""); - const calcResultColor = (component.resultColor || "#2563eb").replace("#", ""); + const calcResultFontSize = pxToHalfPtFn( + component.resultFontSize || 16 + ); + const calcLabelColor = (component.labelColor || "#374151").replace( + "#", + "" + ); + const calcValueColor = (component.valueColor || "#000000").replace( + "#", + "" + ); + const calcResultColor = (component.resultColor || "#2563eb").replace( + "#", + "" + ); const numberFormat = component.numberFormat || "currency"; const currencySuffix = component.currencySuffix || "원"; - const borderColor = (component.borderColor || "#374151").replace("#", ""); + const borderColor = (component.borderColor || "#374151").replace( + "#", + "" + ); // 숫자 포맷팅 함수 const formatNumberFn = (num: number): string => { if (numberFormat === "none") return String(num); if (numberFormat === "comma") return num.toLocaleString(); - if (numberFormat === "currency") return num.toLocaleString() + currencySuffix; + if (numberFormat === "currency") + return num.toLocaleString() + currencySuffix; return String(num); }; // 쿼리 바인딩된 값 가져오기 - const getCalcItemValueFn = (item: { label: string; value: number | string; operator: string; fieldName?: string }): number => { - if (item.fieldName && component.queryId && queryResultsMapRef[component.queryId]) { + const getCalcItemValueFn = (item: { + label: string; + value: number | string; + operator: string; + fieldName?: string; + }): number => { + if ( + item.fieldName && + component.queryId && + queryResultsMapRef[component.queryId] + ) { const qResult = queryResultsMapRef[component.queryId]; if (qResult.rows && qResult.rows.length > 0) { const row = qResult.rows[0]; const val = row[item.fieldName]; - return typeof val === "number" ? val : parseFloat(String(val)) || 0; + return typeof val === "number" + ? val + : parseFloat(String(val)) || 0; } } - return typeof item.value === "number" ? item.value : parseFloat(String(item.value)) || 0; + return typeof item.value === "number" + ? item.value + : parseFloat(String(item.value)) || 0; }; // 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용) let calcResult = 0; if (calcItems.length > 0) { // 첫 번째 항목은 기준값 - calcResult = getCalcItemValueFn(calcItems[0] as { label: string; value: number | string; operator: string; fieldName?: string }); - + calcResult = getCalcItemValueFn( + calcItems[0] as { + label: string; + value: number | string; + operator: string; + fieldName?: string; + } + ); + // 두 번째 항목부터 연산자 적용 for (let i = 1; i < calcItems.length; i++) { const item = calcItems[i]; - const val = getCalcItemValueFn(item as { label: string; value: number | string; operator: string; fieldName?: string }); + const val = getCalcItemValueFn( + item as { + label: string; + value: number | string; + operator: string; + fieldName?: string; + } + ); switch ((item as { operator: string }).operator) { case "+": calcResult += val; @@ -946,7 +1080,14 @@ export class ReportController { // 각 항목 for (const item of calcItems) { - const itemValue = getCalcItemValueFn(item as { label: string; value: number | string; operator: string; fieldName?: string }); + const itemValue = getCalcItemValueFn( + item as { + label: string; + value: number | string; + operator: string; + fieldName?: string; + } + ); calcTableRows.push( new TableRowRef({ children: [ @@ -963,12 +1104,31 @@ export class ReportController { ], }), ], - width: { size: pxToTwipFn(calcLabelWidth), type: WidthTypeRef.DXA }, + width: { + size: pxToTwipFn(calcLabelWidth), + type: WidthType.DXA, + }, borders: { - top: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, - bottom: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, - left: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, - right: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, + top: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, }, margins: { top: 50, bottom: 50, left: 100, right: 100 }, }), @@ -987,10 +1147,26 @@ export class ReportController { }), ], borders: { - top: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, - bottom: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, - left: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, - right: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, + top: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, }, margins: { top: 50, bottom: 50, left: 100, right: 100 }, }), @@ -1007,10 +1183,26 @@ export class ReportController { columnSpan: 2, children: [new ParagraphRef({ children: [] })], borders: { - top: { style: BorderStyleRef.SINGLE, size: 8, color: borderColor }, - bottom: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, - left: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, - right: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, + top: { + style: BorderStyleRef.SINGLE, + size: 8, + color: borderColor, + }, + bottom: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, }, }), ], @@ -1035,12 +1227,31 @@ export class ReportController { ], }), ], - width: { size: pxToTwipFn(calcLabelWidth), type: WidthTypeRef.DXA }, + width: { + size: pxToTwipFn(calcLabelWidth), + type: WidthType.DXA, + }, borders: { - top: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, - bottom: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, - left: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, - right: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, + top: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, }, margins: { top: 50, bottom: 50, left: 100, right: 100 }, }), @@ -1060,10 +1271,26 @@ export class ReportController { }), ], borders: { - top: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, - bottom: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, - left: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, - right: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, + top: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, }, margins: { top: 50, bottom: 50, left: 100, right: 100 }, }), @@ -1074,21 +1301,36 @@ export class ReportController { result.push( new TableRef({ rows: calcTableRows, - width: { size: pxToTwipFn(component.width), type: WidthTypeRef.DXA }, + width: { size: pxToTwipFn(component.width), type: WidthType.DXA }, borders: { top: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, - bottom: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, + bottom: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, left: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, right: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, - insideHorizontal: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, - insideVertical: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, + insideHorizontal: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + insideVertical: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, }, }) ); } // Divider - 테이블 셀로 감싸서 정확한 너비 적용 - else if (component.type === "divider" && component.orientation === "horizontal") { + else if ( + component.type === "divider" && + component.orientation === "horizontal" + ) { result.push( new ParagraphRef({ border: { @@ -1113,185 +1355,351 @@ export class ReportController { }; // 섹션 생성 (페이지별) - const sortedPages = layoutConfig.pages.sort((a: any, b: any) => a.page_order - b.page_order); + 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; - const marginBottomMm = page.margins?.bottom || 10; - const marginLeftMm = page.margins?.left || 10; - const marginRightMm = page.margins?.right || 10; + const sections = sortedPages.map((page: any, pageIndex: number) => { + const pageWidthTwip = mmToTwip(page.width); + const pageHeightTwip = mmToTwip(page.height); + const marginTopMm = page.margins?.top || 10; + const marginBottomMm = page.margins?.bottom || 10; + const marginLeftMm = page.margins?.left || 10; + const marginRightMm = page.margins?.right || 10; - const marginTop = mmToTwip(marginTopMm); - const marginBottom = mmToTwip(marginBottomMm); - const marginLeft = mmToTwip(marginLeftMm); - const marginRight = mmToTwip(marginRightMm); + const marginTop = mmToTwip(marginTopMm); + const marginBottom = mmToTwip(marginBottomMm); + const marginLeft = mmToTwip(marginLeftMm); + const marginRight = mmToTwip(marginRightMm); - // 마진을 px로 변환 (1mm ≈ 3.78px at 96 DPI) - const marginLeftPx = marginLeftMm * 3.78; - const marginTopPx = marginTopMm * 3.78; + // 마진을 px로 변환 (1mm ≈ 3.78px at 96 DPI) + const marginLeftPx = marginLeftMm * 3.78; + const marginTopPx = marginTopMm * 3.78; - // 컴포넌트를 Y좌표순으로 정렬 - const sortedComponents = [...(page.components || [])].sort( - (a: any, b: any) => a.y - b.y + // 컴포넌트를 Y좌표순으로 정렬 + const sortedComponents = [...(page.components || [])].sort( + (a: any, b: any) => a.y - b.y + ); + + // 같은 Y좌표 범위(±30px)의 컴포넌트들을 그룹화 + const Y_GROUP_THRESHOLD = 30; // px + const componentGroups: any[][] = []; + let currentGroup: any[] = []; + let groupBaseY = -Infinity; + + for (const comp of sortedComponents) { + const compY = comp.y - marginTopPx; + if (currentGroup.length === 0) { + currentGroup.push(comp); + groupBaseY = compY; + } else if (Math.abs(compY - groupBaseY) <= Y_GROUP_THRESHOLD) { + currentGroup.push(comp); + } else { + componentGroups.push(currentGroup); + currentGroup = [comp]; + groupBaseY = compY; + } + } + if (currentGroup.length > 0) { + componentGroups.push(currentGroup); + } + + // 컴포넌트를 Paragraph/Table로 변환 + const children: (Paragraph | Table)[] = []; + + // Y좌표를 spacing으로 변환하기 위한 추적 변수 + let lastBottomY = 0; + + // 각 그룹 처리 + for (const group of componentGroups) { + // 그룹 내 컴포넌트들을 X좌표 순으로 정렬 + const sortedGroup = [...group].sort((a: any, b: any) => a.x - b.x); + + // 그룹의 Y 좌표 (첫 번째 컴포넌트 기준) + const groupY = Math.max(0, sortedGroup[0].y - marginTopPx); + const groupHeight = Math.max( + ...sortedGroup.map((c: any) => c.height) ); - // 같은 Y좌표 범위(±30px)의 컴포넌트들을 그룹화 - const Y_GROUP_THRESHOLD = 30; // px - const componentGroups: any[][] = []; - let currentGroup: any[] = []; - let groupBaseY = -Infinity; + // spacing 계산 + const gapFromPrevious = Math.max(0, groupY - lastBottomY); + const spacingBefore = pxToTwip(gapFromPrevious); - for (const comp of sortedComponents) { - const compY = comp.y - marginTopPx; - if (currentGroup.length === 0) { - currentGroup.push(comp); - groupBaseY = compY; - } else if (Math.abs(compY - groupBaseY) <= Y_GROUP_THRESHOLD) { - currentGroup.push(comp); - } else { - componentGroups.push(currentGroup); - currentGroup = [comp]; - groupBaseY = compY; + // 그룹에 컴포넌트가 여러 개면 하나의 테이블 행으로 배치 + if (sortedGroup.length > 1) { + // spacing을 위한 빈 paragraph + if (spacingBefore > 0) { + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + children: [], + }) + ); } - } - if (currentGroup.length > 0) { - componentGroups.push(currentGroup); - } - // 컴포넌트를 Paragraph/Table로 변환 - const children: (Paragraph | Table)[] = []; + // 각 컴포넌트를 셀로 변환 + const cells: TableCell[] = []; + let prevEndX = 0; - // Y좌표를 spacing으로 변환하기 위한 추적 변수 - let lastBottomY = 0; + for (const component of sortedGroup) { + const adjustedX = Math.max(0, component.x - marginLeftPx); + const displayValue = getComponentValue(component); - // 각 그룹 처리 - for (const group of componentGroups) { - // 그룹 내 컴포넌트들을 X좌표 순으로 정렬 - const sortedGroup = [...group].sort((a: any, b: any) => a.x - b.x); - - // 그룹의 Y 좌표 (첫 번째 컴포넌트 기준) - const groupY = Math.max(0, sortedGroup[0].y - marginTopPx); - const groupHeight = Math.max(...sortedGroup.map((c: any) => c.height)); - - // spacing 계산 - const gapFromPrevious = Math.max(0, groupY - lastBottomY); - const spacingBefore = pxToTwip(gapFromPrevious); - - // 그룹에 컴포넌트가 여러 개면 하나의 테이블 행으로 배치 - if (sortedGroup.length > 1) { - // spacing을 위한 빈 paragraph - if (spacingBefore > 0) { - children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, children: [] })); - } - - // 각 컴포넌트를 셀로 변환 - const cells: TableCell[] = []; - let prevEndX = 0; - - for (const component of sortedGroup) { - const adjustedX = Math.max(0, component.x - marginLeftPx); - const displayValue = getComponentValue(component); - - // 이전 셀과의 간격을 위한 빈 셀 추가 - if (adjustedX > prevEndX + 5) { - const gapWidth = adjustedX - prevEndX; - cells.push( - new TableCell({ - children: [new Paragraph({ children: [] })], - width: { size: pxToTwip(gapWidth), 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" }, - }, - }) - ); - } - - // 컴포넌트 셀 생성 - const cellContent = createCellContent(component, displayValue, pxToHalfPt, pxToTwip, queryResultsMap, AlignmentType, VerticalAlign, BorderStyle, Paragraph, TextRun, ImageRun, Table, TableRow, TableCell, pageIndex, totalPagesCount); + // 이전 셀과의 간격을 위한 빈 셀 추가 + if (adjustedX > prevEndX + 5) { + const gapWidth = adjustedX - prevEndX; cells.push( new TableCell({ - children: cellContent, - width: { size: pxToTwip(component.width), type: WidthType.DXA }, + children: [new Paragraph({ children: [] })], + width: { size: pxToTwip(gapWidth), 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" }, + 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, }) ); - prevEndX = adjustedX + component.width; } - // 테이블 행 생성 - const rowTable = new Table({ - rows: [new TableRow({ children: cells })], - width: { size: 100, type: WidthType.PERCENTAGE }, - 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" }, - }, - }); - children.push(rowTable); - lastBottomY = groupY + groupHeight; - continue; + // 컴포넌트 셀 생성 + 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, + 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, + }) + ); + prevEndX = adjustedX + component.width; } - // 단일 컴포넌트 처리 (기존 로직) - const component = sortedGroup[0]; - const displayValue = getComponentValue(component); - const adjustedX = Math.max(0, component.x - marginLeftPx); - const adjustedY = groupY; + // 테이블 행 생성 + const rowTable = new Table({ + rows: [new TableRow({ children: cells })], + width: { size: 100, type: WidthType.PERCENTAGE }, + 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", + }, + }, + }); + children.push(rowTable); + lastBottomY = groupY + groupHeight; + continue; + } - // X좌표를 indent로 변환 (마진 제외한 순수 들여쓰기) - const indentLeft = pxToTwip(adjustedX); + // 단일 컴포넌트 처리 (기존 로직) + const component = sortedGroup[0]; + const displayValue = getComponentValue(component); + const adjustedX = Math.max(0, component.x - marginLeftPx); + const adjustedY = groupY; - // Text/Label 컴포넌트 - 테이블 셀로 감싸서 width 내 줄바꿈 적용 - if (component.type === "text" || component.type === "label") { - const fontSizeHalfPt = pxToHalfPt(component.fontSize || 13); - const alignment = - component.textAlign === "center" - ? AlignmentType.CENTER - : component.textAlign === "right" - ? AlignmentType.RIGHT - : AlignmentType.LEFT; + // X좌표를 indent로 변환 (마진 제외한 순수 들여쓰기) + const indentLeft = pxToTwip(adjustedX); - // 줄바꿈 처리: \n으로 split하여 각 줄을 TextRun으로 생성 - const lines = displayValue.split("\n"); - const textChildren: TextRun[] = []; - lines.forEach((line: string, index: number) => { - if (index > 0) { - textChildren.push(new TextRun({ break: 1 })); - } - textChildren.push( - new TextRun({ - text: line, - size: fontSizeHalfPt, - color: (component.fontColor || "#000000").replace("#", ""), - bold: component.fontWeight === "bold" || component.fontWeight === "600", - font: "맑은 고딕", + // Text/Label 컴포넌트 - 테이블 셀로 감싸서 width 내 줄바꿈 적용 + if (component.type === "text" || component.type === "label") { + const fontSizeHalfPt = pxToHalfPt(component.fontSize || 13); + const alignment = + component.textAlign === "center" + ? AlignmentType.CENTER + : component.textAlign === "right" + ? AlignmentType.RIGHT + : AlignmentType.LEFT; + + // 줄바꿈 처리: \n으로 split하여 각 줄을 TextRun으로 생성 + const lines = displayValue.split("\n"); + const textChildren: TextRun[] = []; + lines.forEach((line: string, index: number) => { + if (index > 0) { + textChildren.push(new TextRun({ break: 1 })); + } + textChildren.push( + new TextRun({ + text: line, + size: fontSizeHalfPt, + color: (component.fontColor || "#000000").replace("#", ""), + bold: + component.fontWeight === "bold" || + component.fontWeight === "600", + font: "맑은 고딕", + }) + ); + }); + + // 테이블 셀로 감싸서 width 제한 → 자동 줄바꿈 + const textCell = new TableCell({ + children: [ + new Paragraph({ + alignment, + children: textChildren, + }), + ], + 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 textTable = new Table({ + rows: [new TableRow({ children: [textCell] })], + 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(textTable); + lastBottomY = adjustedY + component.height; + } + + // Image 컴포넌트 + else if (component.type === "image" && component.imageBase64) { + try { + const base64Data = + component.imageBase64.split(",")[1] || component.imageBase64; + const imageBuffer = Buffer.from(base64Data, "base64"); + + const paragraph = new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + indent: { left: indentLeft }, + children: [ + new ImageRun({ + data: imageBuffer, + transformation: { + width: Math.round(component.width * 0.75), + height: Math.round(component.height * 0.75), + }, + type: "png", + }), + ], + }); + children.push(paragraph); + lastBottomY = adjustedY + component.height; + } catch (imgError) { + console.error("이미지 처리 오류:", imgError); + } + } + + // Divider 컴포넌트 - 테이블 셀로 감싸서 정확한 위치와 너비 적용 + else if (component.type === "divider") { + if (component.orientation === "horizontal") { + // spacing을 위한 빈 paragraph + if (spacingBefore > 0) { + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + children: [], }) ); - }); + } - // 테이블 셀로 감싸서 width 제한 → 자동 줄바꿈 - const textCell = new TableCell({ + // 테이블 셀로 감싸서 너비 제한 + const dividerCell = new TableCell({ children: [ new Paragraph({ - alignment, - children: textChildren, + border: { + bottom: { + color: (component.lineColor || "#000000").replace( + "#", + "" + ), + space: 1, + style: BorderStyle.SINGLE, + size: (component.lineWidth || 1) * 8, + }, + }, + children: [], }), ], width: { size: pxToTwip(component.width), type: WidthType.DXA }, @@ -1301,11 +1709,10 @@ export class ReportController { left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, }, - verticalAlign: VerticalAlign.TOP, }); - const textTable = new Table({ - rows: [new TableRow({ children: [textCell] })], + const dividerTable = new Table({ + rows: [new TableRow({ children: [dividerCell] })], width: { size: pxToTwip(component.width), type: WidthType.DXA }, indent: { size: indentLeft, type: WidthType.DXA }, borders: { @@ -1313,29 +1720,51 @@ export class ReportController { 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" }, + 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(textTable); + children.push(dividerTable); lastBottomY = adjustedY + component.height; } + } - // Image 컴포넌트 - else if (component.type === "image" && component.imageBase64) { + // Signature 컴포넌트 + else if (component.type === "signature") { + const labelText = component.labelText || "서명:"; + const showLabel = component.showLabel !== false; + const sigFontSize = pxToHalfPt(component.fontSize || 12); + const textRuns: TextRun[] = []; + + if (showLabel) { + textRuns.push( + new TextRun({ + text: labelText + " ", + size: sigFontSize, + font: "맑은 고딕", + }) + ); + } + + if (component.imageBase64) { try { - const base64Data = component.imageBase64.split(",")[1] || component.imageBase64; + const base64Data = + component.imageBase64.split(",")[1] || component.imageBase64; const imageBuffer = Buffer.from(base64Data, "base64"); const paragraph = new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: [ + ...textRuns, new ImageRun({ data: imageBuffer, transformation: { @@ -1347,300 +1776,302 @@ export class ReportController { ], }); children.push(paragraph); - lastBottomY = adjustedY + component.height; } catch (imgError) { - console.error("이미지 처리 오류:", imgError); - } - } - - // Divider 컴포넌트 - 테이블 셀로 감싸서 정확한 위치와 너비 적용 - else if (component.type === "divider") { - if (component.orientation === "horizontal") { - // spacing을 위한 빈 paragraph - if (spacingBefore > 0) { - children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, children: [] })); - } - - // 테이블 셀로 감싸서 너비 제한 - const dividerCell = new TableCell({ - children: [ - new Paragraph({ - border: { - bottom: { - color: (component.lineColor || "#000000").replace("#", ""), - space: 1, - style: BorderStyle.SINGLE, - size: (component.lineWidth || 1) * 8, - }, - }, - children: [], - }), - ], - 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" }, - }, - }); - - const dividerTable = new Table({ - rows: [new TableRow({ children: [dividerCell] })], - 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" }, - }, - }); - children.push(dividerTable); - lastBottomY = adjustedY + component.height; - } - } - - // Signature 컴포넌트 - else if (component.type === "signature") { - const labelText = component.labelText || "서명:"; - const showLabel = component.showLabel !== false; - const sigFontSize = pxToHalfPt(component.fontSize || 12); - const textRuns: TextRun[] = []; - - if (showLabel) { + console.error("서명 이미지 오류:", imgError); textRuns.push( new TextRun({ - text: labelText + " ", + text: "_".repeat(20), size: sigFontSize, font: "맑은 고딕", }) ); - } - - if (component.imageBase64) { - try { - const base64Data = component.imageBase64.split(",")[1] || component.imageBase64; - const imageBuffer = Buffer.from(base64Data, "base64"); - - const paragraph = new Paragraph({ + children.push( + new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, - children: [ - ...textRuns, - new ImageRun({ - data: imageBuffer, - transformation: { - width: Math.round(component.width * 0.75), - height: Math.round(component.height * 0.75), - }, - type: "png", - }), - ], - }); - children.push(paragraph); - } catch (imgError) { - console.error("서명 이미지 오류:", imgError); - textRuns.push(new TextRun({ text: "_".repeat(20), size: sigFontSize, font: "맑은 고딕" })); - children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: textRuns })); - } - } else { - textRuns.push(new TextRun({ text: "_".repeat(20), size: sigFontSize, font: "맑은 고딕" })); - children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: textRuns })); + children: textRuns, + }) + ); } - lastBottomY = adjustedY + component.height; + } else { + textRuns.push( + new TextRun({ + text: "_".repeat(20), + size: sigFontSize, + font: "맑은 고딕", + }) + ); + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + indent: { left: indentLeft }, + children: textRuns, + }) + ); + } + lastBottomY = adjustedY + component.height; + } + + // Stamp 컴포넌트 + else if (component.type === "stamp") { + const personName = component.personName || ""; + const stampFontSize = pxToHalfPt(component.fontSize || 12); + const textRuns: TextRun[] = []; + + if (personName) { + textRuns.push( + new TextRun({ + text: personName + " ", + size: stampFontSize, + font: "맑은 고딕", + }) + ); } - // Stamp 컴포넌트 - else if (component.type === "stamp") { - const personName = component.personName || ""; - const stampFontSize = pxToHalfPt(component.fontSize || 12); - const textRuns: TextRun[] = []; + if (component.imageBase64) { + try { + const base64Data = + component.imageBase64.split(",")[1] || component.imageBase64; + const imageBuffer = Buffer.from(base64Data, "base64"); - if (personName) { + const paragraph = new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + indent: { left: indentLeft }, + children: [ + ...textRuns, + new ImageRun({ + data: imageBuffer, + transformation: { + width: Math.round( + Math.min(component.width, component.height) * 0.75 + ), + height: Math.round( + Math.min(component.width, component.height) * 0.75 + ), + }, + type: "png", + }), + ], + }); + children.push(paragraph); + } catch (imgError) { + console.error("도장 이미지 오류:", imgError); textRuns.push( new TextRun({ - text: personName + " ", + text: "(인)", + color: "DC2626", size: stampFontSize, font: "맑은 고딕", }) ); - } - - if (component.imageBase64) { - try { - const base64Data = component.imageBase64.split(",")[1] || component.imageBase64; - const imageBuffer = Buffer.from(base64Data, "base64"); - - const paragraph = new Paragraph({ + children.push( + new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, - children: [ - ...textRuns, - new ImageRun({ - data: imageBuffer, - transformation: { - width: Math.round(Math.min(component.width, component.height) * 0.75), - height: Math.round(Math.min(component.width, component.height) * 0.75), - }, - type: "png", - }), - ], - }); - children.push(paragraph); - } catch (imgError) { - console.error("도장 이미지 오류:", imgError); - textRuns.push(new TextRun({ text: "(인)", color: "DC2626", size: stampFontSize, font: "맑은 고딕" })); - children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: textRuns })); - } - } else { - textRuns.push(new TextRun({ text: "(인)", color: "DC2626", size: stampFontSize, font: "맑은 고딕" })); - children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: textRuns })); - } - 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; - } - - // Card 컴포넌트 - 테이블로 감싸서 정확한 위치 적용 - else if (component.type === "card") { - const cardTitle = component.cardTitle || "정보 카드"; - const cardItems = component.cardItems || []; - const labelWidthPx = component.labelWidth || 80; - const showCardTitle = component.showCardTitle !== false; - const titleFontSize = pxToHalfPt(component.titleFontSize || 14); - const labelFontSizeCard = pxToHalfPt(component.labelFontSize || 13); - const valueFontSizeCard = pxToHalfPt(component.valueFontSize || 13); - const titleColorCard = (component.titleColor || "#1e40af").replace("#", ""); - const labelColorCard = (component.labelColor || "#374151").replace("#", ""); - const valueColorCard = (component.valueColor || "#000000").replace("#", ""); - const borderColorCard = (component.borderColor || "#e5e7eb").replace("#", ""); - - // 쿼리 바인딩된 값 가져오기 - const getCardValueLocal = (item: { label: string; value: string; fieldName?: string }) => { - if (item.fieldName && component.queryId && queryResultsMap[component.queryId]) { - const qResult = queryResultsMap[component.queryId]; - if (qResult.rows && qResult.rows.length > 0) { - const row = qResult.rows[0]; - return row[item.fieldName] !== undefined ? String(row[item.fieldName]) : item.value; - } - } - return item.value; - }; - - const cardParagraphs: Paragraph[] = []; - - // 제목 - if (showCardTitle) { - cardParagraphs.push( - new Paragraph({ - children: [ - new TextRun({ - text: cardTitle, - size: titleFontSize, - color: titleColorCard, - bold: true, - font: "맑은 고딕", - }), - ], + children: textRuns, }) ); - // 구분선 - cardParagraphs.push( - new Paragraph({ - border: { - bottom: { - color: borderColorCard, - space: 1, - style: BorderStyle.SINGLE, - size: 8, - }, + } + } else { + textRuns.push( + new TextRun({ + text: "(인)", + color: "DC2626", + size: stampFontSize, + font: "맑은 고딕", + }) + ); + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + indent: { left: indentLeft }, + children: textRuns, + }) + ); + } + 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; + } + + // Card 컴포넌트 - 테이블로 감싸서 정확한 위치 적용 + else if (component.type === "card") { + const cardTitle = component.cardTitle || "정보 카드"; + const cardItems = component.cardItems || []; + const labelWidthPx = component.labelWidth || 80; + const showCardTitle = component.showCardTitle !== false; + const titleFontSize = pxToHalfPt(component.titleFontSize || 14); + const labelFontSizeCard = pxToHalfPt(component.labelFontSize || 13); + const valueFontSizeCard = pxToHalfPt(component.valueFontSize || 13); + const titleColorCard = (component.titleColor || "#1e40af").replace( + "#", + "" + ); + const labelColorCard = (component.labelColor || "#374151").replace( + "#", + "" + ); + const valueColorCard = (component.valueColor || "#000000").replace( + "#", + "" + ); + const borderColorCard = ( + component.borderColor || "#e5e7eb" + ).replace("#", ""); + + // 쿼리 바인딩된 값 가져오기 + const getCardValueLocal = (item: { + label: string; + value: string; + fieldName?: string; + }) => { + if ( + item.fieldName && + component.queryId && + queryResultsMap[component.queryId] + ) { + const qResult = queryResultsMap[component.queryId]; + if (qResult.rows && qResult.rows.length > 0) { + const row = qResult.rows[0]; + return row[item.fieldName] !== undefined + ? String(row[item.fieldName]) + : item.value; + } + } + return item.value; + }; + + const cardParagraphs: Paragraph[] = []; + + // 제목 + if (showCardTitle) { + cardParagraphs.push( + new Paragraph({ + children: [ + new TextRun({ + text: cardTitle, + size: titleFontSize, + color: titleColorCard, + bold: true, + font: "맑은 고딕", + }), + ], + }) + ); + // 구분선 + cardParagraphs.push( + new Paragraph({ + border: { + bottom: { + color: borderColorCard, + space: 1, + style: BorderStyle.SINGLE, + size: 8, }, - children: [], - }) - ); - } + }, + children: [], + }) + ); + } - // 항목들을 테이블로 구성 (라벨 + 값) - const itemRows = cardItems.map((item: { label: string; value: string; fieldName?: string }) => { + // 항목들을 테이블로 구성 (라벨 + 값) + const itemRows = cardItems.map( + (item: { label: string; value: string; fieldName?: string }) => { const itemValue = getCardValueLocal(item); return new TableRow({ children: [ new TableCell({ - width: { size: pxToTwip(labelWidthPx), type: WidthType.DXA }, + width: { + size: pxToTwip(labelWidthPx), + type: WidthType.DXA, + }, children: [ new Paragraph({ children: [ @@ -1655,14 +2086,33 @@ export class ReportController { }), ], 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" }, + 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", + }, }, }), new TableCell({ - width: { size: pxToTwip(component.width - labelWidthPx - 16), type: WidthType.DXA }, + width: { + size: pxToTwip(component.width - labelWidthPx - 16), + type: WidthType.DXA, + }, children: [ new Paragraph({ children: [ @@ -1676,211 +2126,259 @@ export class ReportController { }), ], 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" }, + 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", + }, }, }), ], }); - }); + } + ); - const itemsTable = new Table({ - rows: itemRows, - 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" }, - insideHorizontal: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, - insideVertical: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + const itemsTable = new Table({ + rows: itemRows, + 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" }, + insideHorizontal: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", }, - }); + insideVertical: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }); - // 전체를 하나의 테이블 셀로 감싸기 - const cardCell = new TableCell({ - children: [...cardParagraphs, itemsTable], - width: { size: pxToTwip(component.width), type: WidthType.DXA }, - borders: component.showCardBorder !== false + // 전체를 하나의 테이블 셀로 감싸기 + const cardCell = new TableCell({ + children: [...cardParagraphs, itemsTable], + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + borders: + component.showCardBorder !== false ? { - top: { style: BorderStyle.SINGLE, size: 4, color: borderColorCard }, - bottom: { style: BorderStyle.SINGLE, size: 4, color: borderColorCard }, - left: { style: BorderStyle.SINGLE, size: 4, color: borderColorCard }, - right: { style: BorderStyle.SINGLE, size: 4, color: borderColorCard }, + top: { + style: BorderStyle.SINGLE, + size: 4, + color: borderColorCard, + }, + bottom: { + style: BorderStyle.SINGLE, + size: 4, + color: borderColorCard, + }, + left: { + style: BorderStyle.SINGLE, + size: 4, + color: borderColorCard, + }, + right: { + style: BorderStyle.SINGLE, + size: 4, + color: borderColorCard, + }, } : { - 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 cardTable = new Table({ - rows: [new TableRow({ children: [cardCell] })], - 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(cardTable); - lastBottomY = adjustedY + component.height; - } - - // 계산 컴포넌트 - 테이블로 감싸서 정확한 위치 적용 - else if (component.type === "calculation") { - const calcItems = component.calcItems || []; - const resultLabel = component.resultLabel || "합계"; - const calcLabelWidth = component.labelWidth || 120; - const calcLabelFontSize = pxToHalfPt(component.labelFontSize || 13); - const calcValueFontSize = pxToHalfPt(component.valueFontSize || 13); - const calcResultFontSize = pxToHalfPt(component.resultFontSize || 16); - const calcLabelColor = (component.labelColor || "#374151").replace("#", ""); - const calcValueColor = (component.valueColor || "#000000").replace("#", ""); - const calcResultColor = (component.resultColor || "#2563eb").replace("#", ""); - const numberFormat = component.numberFormat || "currency"; - const currencySuffix = component.currencySuffix || "원"; - const borderColor = (component.borderColor || "#374151").replace("#", ""); - - // 숫자 포맷팅 함수 - const formatNumberFn = (num: number): string => { - if (numberFormat === "none") return String(num); - if (numberFormat === "comma") return num.toLocaleString(); - if (numberFormat === "currency") return num.toLocaleString() + currencySuffix; - return String(num); - }; - - // 쿼리 바인딩된 값 가져오기 - const getCalcItemValueFn = (item: { label: string; value: number | string; operator: string; fieldName?: string }): number => { - if (item.fieldName && component.queryId && queryResultsMap[component.queryId]) { - const qResult = queryResultsMap[component.queryId]; - if (qResult.rows && qResult.rows.length > 0) { - const row = qResult.rows[0]; - const val = row[item.fieldName]; - return typeof val === "number" ? val : parseFloat(String(val)) || 0; - } - } - return typeof item.value === "number" ? item.value : parseFloat(String(item.value)) || 0; - }; - - // 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용) - let calcResult = 0; - if (calcItems.length > 0) { - // 첫 번째 항목은 기준값 - calcResult = getCalcItemValueFn(calcItems[0] as { label: string; value: number | string; operator: string; fieldName?: string }); - - // 두 번째 항목부터 연산자 적용 - for (let i = 1; i < calcItems.length; i++) { - const calcItem = calcItems[i]; - const val = getCalcItemValueFn(calcItem as { label: string; value: number | string; operator: string; fieldName?: string }); - switch ((calcItem as { operator: string }).operator) { - case "+": - calcResult += val; - break; - case "-": - calcResult -= val; - break; - case "x": - calcResult *= val; - break; - case "÷": - calcResult = val !== 0 ? calcResult / val : calcResult; - break; - } - } - } - - // 테이블 행 생성 - const calcTableRows: TableRow[] = []; - - // 각 항목 행 - for (const calcItem of calcItems) { - const itemValue = getCalcItemValueFn(calcItem as { label: string; value: number | string; operator: string; fieldName?: string }); - calcTableRows.push( - new TableRow({ - children: [ - new TableCell({ - children: [ - new Paragraph({ - children: [ - new TextRun({ - text: calcItem.label, - size: calcLabelFontSize, - color: calcLabelColor, - font: "맑은 고딕", - }), - ], - }), - ], - width: { size: pxToTwip(calcLabelWidth), 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" }, - }, - margins: { top: 50, bottom: 50, left: 100, right: 100 }, - }), - new TableCell({ - children: [ - new Paragraph({ - alignment: AlignmentType.RIGHT, - children: [ - new TextRun({ - text: formatNumberFn(itemValue), - size: calcValueFontSize, - color: calcValueColor, - font: "맑은 고딕", - }), - ], - }), - ], - 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" }, - }, - margins: { top: 50, bottom: 50, left: 100, right: 100 }, - }), - ], - }) - ); - } - - // 구분선 행 - calcTableRows.push( - new TableRow({ - children: [ - new TableCell({ - columnSpan: 2, - children: [new Paragraph({ children: [] })], - borders: { - top: { style: BorderStyle.SINGLE, size: 8, color: borderColor }, - bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, - left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, - right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + 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 cardTable = new Table({ + rows: [new TableRow({ children: [cardCell] })], + 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(cardTable); + lastBottomY = adjustedY + component.height; + } - // 결과 행 + // 계산 컴포넌트 - 테이블로 감싸서 정확한 위치 적용 + else if (component.type === "calculation") { + const calcItems = component.calcItems || []; + const resultLabel = component.resultLabel || "합계"; + const calcLabelWidth = component.labelWidth || 120; + const calcLabelFontSize = pxToHalfPt(component.labelFontSize || 13); + const calcValueFontSize = pxToHalfPt(component.valueFontSize || 13); + const calcResultFontSize = pxToHalfPt( + component.resultFontSize || 16 + ); + const calcLabelColor = (component.labelColor || "#374151").replace( + "#", + "" + ); + const calcValueColor = (component.valueColor || "#000000").replace( + "#", + "" + ); + const calcResultColor = ( + component.resultColor || "#2563eb" + ).replace("#", ""); + const numberFormat = component.numberFormat || "currency"; + const currencySuffix = component.currencySuffix || "원"; + const borderColor = (component.borderColor || "#374151").replace( + "#", + "" + ); + + // 숫자 포맷팅 함수 + const formatNumberFn = (num: number): string => { + if (numberFormat === "none") return String(num); + if (numberFormat === "comma") return num.toLocaleString(); + if (numberFormat === "currency") + return num.toLocaleString() + currencySuffix; + return String(num); + }; + + // 쿼리 바인딩된 값 가져오기 + const getCalcItemValueFn = (item: { + label: string; + value: number | string; + operator: string; + fieldName?: string; + }): number => { + if ( + item.fieldName && + component.queryId && + queryResultsMap[component.queryId] + ) { + const qResult = queryResultsMap[component.queryId]; + if (qResult.rows && qResult.rows.length > 0) { + const row = qResult.rows[0]; + const val = row[item.fieldName]; + return typeof val === "number" + ? val + : parseFloat(String(val)) || 0; + } + } + return typeof item.value === "number" + ? item.value + : parseFloat(String(item.value)) || 0; + }; + + // 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용) + let calcResult = 0; + if (calcItems.length > 0) { + // 첫 번째 항목은 기준값 + calcResult = getCalcItemValueFn( + calcItems[0] as { + label: string; + value: number | string; + operator: string; + fieldName?: string; + } + ); + + // 두 번째 항목부터 연산자 적용 + for (let i = 1; i < calcItems.length; i++) { + const calcItem = calcItems[i]; + const val = getCalcItemValueFn( + calcItem as { + label: string; + value: number | string; + operator: string; + fieldName?: string; + } + ); + switch ((calcItem as { operator: string }).operator) { + case "+": + calcResult += val; + break; + case "-": + calcResult -= val; + break; + case "x": + calcResult *= val; + break; + case "÷": + calcResult = val !== 0 ? calcResult / val : calcResult; + break; + } + } + } + + // 테이블 행 생성 + const calcTableRows: TableRow[] = []; + + // 각 항목 행 + for (const calcItem of calcItems) { + const itemValue = getCalcItemValueFn( + calcItem as { + label: string; + value: number | string; + operator: string; + fieldName?: string; + } + ); calcTableRows.push( new TableRow({ children: [ @@ -1889,21 +2387,39 @@ export class ReportController { new Paragraph({ children: [ new TextRun({ - text: resultLabel, - size: calcResultFontSize, + text: calcItem.label, + size: calcLabelFontSize, color: calcLabelColor, - bold: true, font: "맑은 고딕", }), ], }), ], - width: { size: pxToTwip(calcLabelWidth), type: WidthType.DXA }, + width: { + size: pxToTwip(calcLabelWidth), + 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" }, + 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", + }, }, margins: { top: 50, bottom: 50, left: 100, right: 100 }, }), @@ -1913,167 +2429,333 @@ export class ReportController { alignment: AlignmentType.RIGHT, children: [ new TextRun({ - text: formatNumberFn(calcResult), - size: calcResultFontSize, - color: calcResultColor, - bold: true, + text: formatNumberFn(itemValue), + size: calcValueFontSize, + color: calcValueColor, font: "맑은 고딕", }), ], }), ], 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" }, + 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", + }, }, margins: { top: 50, bottom: 50, left: 100, right: 100 }, }), ], }) ); - - const calcTable = new Table({ - rows: calcTableRows, - 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(calcTable); - lastBottomY = adjustedY + component.height; } - // Table 컴포넌트 - else if (component.type === "table" && component.queryId) { - const queryResult = queryResultsMap[component.queryId]; - if (queryResult && queryResult.rows && queryResult.rows.length > 0) { - // 테이블 앞에 spacing과 indent를 위한 빈 paragraph 추가 - if (spacingBefore > 0 || indentLeft > 0) { - children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: [] })); - } - - const columns = - component.tableColumns && component.tableColumns.length > 0 - ? component.tableColumns - : queryResult.fields.map((field: string) => ({ - field, - header: field, - align: "left", - width: undefined, - })); - - // 테이블 폰트 사이즈 (기본 12px) - const tableFontSize = pxToHalfPt(component.fontSize || 12); - - // 헤더 행 - const headerCells = columns.map( - (col: { header: string; align?: string }) => - new TableCell({ - children: [ - new Paragraph({ - alignment: - col.align === "center" - ? AlignmentType.CENTER - : col.align === "right" - ? AlignmentType.RIGHT - : AlignmentType.LEFT, - children: [ - new TextRun({ - text: col.header, - bold: true, - size: tableFontSize, - font: "맑은 고딕", - }), - ], - }), - ], - shading: { - fill: (component.headerBackgroundColor || "#f3f4f6").replace("#", ""), + // 구분선 행 + calcTableRows.push( + new TableRow({ + children: [ + new TableCell({ + columnSpan: 2, + children: [new Paragraph({ children: [] })], + borders: { + top: { + style: BorderStyle.SINGLE, + size: 8, + color: borderColor, }, - verticalAlign: VerticalAlign.CENTER, - }) - ); - const headerRow = new TableRow({ children: headerCells }); + bottom: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }), + ], + }) + ); - // 데이터 행 - const dataRows = queryResult.rows.map( - (row: Record) => - new TableRow({ - children: columns.map( - (col: { field: string; align?: string }) => - new TableCell({ - children: [ - new Paragraph({ - alignment: - col.align === "center" - ? AlignmentType.CENTER - : col.align === "right" - ? AlignmentType.RIGHT - : AlignmentType.LEFT, - children: [ - new TextRun({ - text: String(row[col.field] ?? ""), - size: tableFontSize, - font: "맑은 고딕", - }), - ], - }), - ], - verticalAlign: VerticalAlign.CENTER, - }) - ), - }) - ); + // 결과 행 + calcTableRows.push( + new TableRow({ + children: [ + new TableCell({ + children: [ + new Paragraph({ + children: [ + new TextRun({ + text: resultLabel, + size: calcResultFontSize, + color: calcLabelColor, + bold: true, + font: "맑은 고딕", + }), + ], + }), + ], + width: { + size: pxToTwip(calcLabelWidth), + 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", + }, + }, + margins: { top: 50, bottom: 50, left: 100, right: 100 }, + }), + new TableCell({ + children: [ + new Paragraph({ + alignment: AlignmentType.RIGHT, + children: [ + new TextRun({ + text: formatNumberFn(calcResult), + size: calcResultFontSize, + color: calcResultColor, + bold: true, + font: "맑은 고딕", + }), + ], + }), + ], + 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", + }, + }, + margins: { top: 50, bottom: 50, left: 100, right: 100 }, + }), + ], + }) + ); - const table = new Table({ - width: { size: pxToTwip(component.width), type: WidthType.DXA }, - indent: { size: indentLeft, type: WidthType.DXA }, - rows: [headerRow, ...dataRows], - }); - children.push(table); - lastBottomY = adjustedY + component.height; - } - } - } - - // 빈 페이지 방지 - if (children.length === 0) { - children.push(new Paragraph({ children: [] })); - } - - return { - properties: { - page: { - size: { - width: pageWidthTwip, - height: pageHeightTwip, - orientation: - page.width > page.height ? PageOrientation.LANDSCAPE : PageOrientation.PORTRAIT, + const calcTable = new Table({ + rows: calcTableRows, + 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", }, - margin: { - top: marginTop, - bottom: marginBottom, - left: marginLeft, - right: marginRight, + 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(calcTable); + lastBottomY = adjustedY + component.height; + } + + // Table 컴포넌트 + else if (component.type === "table" && component.queryId) { + const queryResult = queryResultsMap[component.queryId]; + if ( + queryResult && + queryResult.rows && + queryResult.rows.length > 0 + ) { + // 테이블 앞에 spacing과 indent를 위한 빈 paragraph 추가 + if (spacingBefore > 0 || indentLeft > 0) { + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + indent: { left: indentLeft }, + children: [], + }) + ); + } + + const columns = + component.tableColumns && component.tableColumns.length > 0 + ? component.tableColumns + : queryResult.fields.map((field: string) => ({ + field, + header: field, + align: "left", + width: undefined, + })); + + // 테이블 폰트 사이즈 (기본 12px) + const tableFontSize = pxToHalfPt(component.fontSize || 12); + + // 헤더 행 + const headerCells = columns.map( + (col: { header: string; align?: string }) => + new TableCell({ + children: [ + new Paragraph({ + alignment: + col.align === "center" + ? AlignmentType.CENTER + : col.align === "right" + ? AlignmentType.RIGHT + : AlignmentType.LEFT, + children: [ + new TextRun({ + text: col.header, + bold: true, + size: tableFontSize, + font: "맑은 고딕", + }), + ], + }), + ], + shading: { + fill: ( + component.headerBackgroundColor || "#f3f4f6" + ).replace("#", ""), + }, + verticalAlign: VerticalAlign.CENTER, + }) + ); + const headerRow = new TableRow({ children: headerCells }); + + // 데이터 행 + const dataRows = queryResult.rows.map( + (row: Record) => + new TableRow({ + children: columns.map( + (col: { field: string; align?: string }) => + new TableCell({ + children: [ + new Paragraph({ + alignment: + col.align === "center" + ? AlignmentType.CENTER + : col.align === "right" + ? AlignmentType.RIGHT + : AlignmentType.LEFT, + children: [ + new TextRun({ + text: String(row[col.field] ?? ""), + size: tableFontSize, + font: "맑은 고딕", + }), + ], + }), + ], + verticalAlign: VerticalAlign.CENTER, + }) + ), + }) + ); + + const table = new Table({ + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + indent: { size: indentLeft, type: WidthType.DXA }, + rows: [headerRow, ...dataRows], + }); + children.push(table); + lastBottomY = adjustedY + component.height; + } + } + } + + // 빈 페이지 방지 + if (children.length === 0) { + children.push(new Paragraph({ children: [] })); + } + + return { + properties: { + page: { + size: { + width: pageWidthTwip, + height: pageHeightTwip, + orientation: + page.width > page.height + ? PageOrientation.LANDSCAPE + : PageOrientation.PORTRAIT, + }, + margin: { + top: marginTop, + bottom: marginBottom, + left: marginLeft, + right: marginRight, + }, }, - children, - }; - }); + }, + children, + }; + }); // Document 생성 const doc = new Document({