diff --git a/backend-node/src/controllers/reportController.ts b/backend-node/src/controllers/reportController.ts index d629864a..d6c77fcd 100644 --- a/backend-node/src/controllers/reportController.ts +++ b/backend-node/src/controllers/reportController.ts @@ -878,6 +878,215 @@ export class ReportController { } } + // 계산 컴포넌트 + else if (component.type === "calculation") { + const calcItems = component.calcItems || []; + const resultLabel = component.resultLabel || "합계"; + 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 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 && 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 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 item = calcItems[i]; + const val = getCalcItemValueFn(item as { label: string; value: number | string; operator: string; fieldName?: string }); + switch ((item 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 = []; + + // 각 항목 + for (const item of calcItems) { + const itemValue = getCalcItemValueFn(item as { label: string; value: number | string; operator: string; fieldName?: string }); + calcTableRows.push( + new TableRowRef({ + children: [ + new TableCellRef({ + children: [ + new ParagraphRef({ + children: [ + new TextRunRef({ + text: item.label, + size: calcLabelFontSize, + color: calcLabelColor, + font: "맑은 고딕", + }), + ], + }), + ], + width: { size: pxToTwipFn(calcLabelWidth), type: WidthTypeRef.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" }, + }, + margins: { top: 50, bottom: 50, left: 100, right: 100 }, + }), + new TableCellRef({ + children: [ + new ParagraphRef({ + alignment: AlignmentTypeRef.RIGHT, + children: [ + new TextRunRef({ + text: formatNumberFn(itemValue), + size: calcValueFontSize, + color: calcValueColor, + font: "맑은 고딕", + }), + ], + }), + ], + 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" }, + }, + margins: { top: 50, bottom: 50, left: 100, right: 100 }, + }), + ], + }) + ); + } + + // 구분선 행 + calcTableRows.push( + new TableRowRef({ + children: [ + new TableCellRef({ + 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" }, + }, + }), + ], + }) + ); + + // 결과 행 + calcTableRows.push( + new TableRowRef({ + children: [ + new TableCellRef({ + children: [ + new ParagraphRef({ + children: [ + new TextRunRef({ + text: resultLabel, + size: calcResultFontSize, + color: calcLabelColor, + bold: true, + font: "맑은 고딕", + }), + ], + }), + ], + width: { size: pxToTwipFn(calcLabelWidth), type: WidthTypeRef.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" }, + }, + margins: { top: 50, bottom: 50, left: 100, right: 100 }, + }), + new TableCellRef({ + children: [ + new ParagraphRef({ + alignment: AlignmentTypeRef.RIGHT, + children: [ + new TextRunRef({ + text: formatNumberFn(calcResult), + size: calcResultFontSize, + color: calcResultColor, + bold: true, + font: "맑은 고딕", + }), + ], + }), + ], + 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" }, + }, + margins: { top: 50, bottom: 50, left: 100, right: 100 }, + }), + ], + }) + ); + + result.push( + new TableRef({ + rows: calcTableRows, + width: { size: pxToTwipFn(component.width), type: WidthTypeRef.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" }, + 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") { result.push( @@ -1532,6 +1741,221 @@ export class ReportController { 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" }, + }, + }), + ], + }) + ); + + // 결과 행 + 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 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]; diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx index bd602582..01f0390b 100644 --- a/frontend/components/report/designer/CanvasComponent.tsx +++ b/frontend/components/report/designer/CanvasComponent.tsx @@ -677,6 +677,133 @@ export function CanvasComponent({ component }: CanvasComponentProps) { ); + case "calculation": + // 계산 컴포넌트 + const calcItems = component.calcItems || []; + const resultLabel = component.resultLabel || "합계"; + const calcLabelWidth = component.labelWidth || 120; + const calcLabelFontSize = component.labelFontSize || 13; + const calcValueFontSize = component.valueFontSize || 13; + const calcResultFontSize = component.resultFontSize || 16; + const calcLabelColor = component.labelColor || "#374151"; + const calcValueColor = component.valueColor || "#000000"; + const calcResultColor = component.resultColor || "#2563eb"; + const numberFormat = component.numberFormat || "currency"; + const currencySuffix = component.currencySuffix || "원"; + + // 숫자 포맷팅 함수 + const formatNumber = (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 getCalcItemValue = (item: { label: string; value: number | string; operator: string; fieldName?: string }): number => { + if (item.fieldName && component.queryId) { + const queryResult = getQueryResult(component.queryId); + if (queryResult && queryResult.rows && queryResult.rows.length > 0) { + const row = queryResult.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; + }; + + // 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용) + const calculateResult = (): number => { + if (calcItems.length === 0) return 0; + + // 첫 번째 항목은 기준값 + let result = getCalcItemValue(calcItems[0] as { label: string; value: number | string; operator: string; fieldName?: string }); + + // 두 번째 항목부터 연산자 적용 + for (let i = 1; i < calcItems.length; i++) { + const item = calcItems[i]; + const val = getCalcItemValue(item as { label: string; value: number | string; operator: string; fieldName?: string }); + switch (item.operator) { + case "+": + result += val; + break; + case "-": + result -= val; + break; + case "x": + result *= val; + break; + case "÷": + result = val !== 0 ? result / val : result; + break; + } + } + return result; + }; + + const calcResult = calculateResult(); + + return ( +