From 403bd0f8a19499a11c7cd204d3039d0c6c1b3a72 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 18 Dec 2025 11:41:48 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B3=84=EC=82=B0=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=97=B0=EC=82=B0=EC=9E=90=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/reportController.ts | 424 ++++++++++++++++++ .../report/designer/CanvasComponent.tsx | 127 ++++++ .../report/designer/ComponentPalette.tsx | 3 +- .../report/designer/ReportDesignerCanvas.tsx | 22 + .../designer/ReportDesignerRightPanel.tsx | 359 +++++++++++++++ .../report/designer/ReportPreviewModal.tsx | 210 +++++++++ frontend/types/report.ts | 13 + 7 files changed, 1157 insertions(+), 1 deletion(-) 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 ( +
+ {/* 항목 목록 */} +
+ {calcItems.map((item: { label: string; value: number | string; operator: string; fieldName?: string }, index: number) => { + const itemValue = getCalcItemValue(item); + return ( +
+ + {item.label} + + + {formatNumber(itemValue)} + +
+ ); + })} +
+ {/* 구분선 */} +
+ {/* 결과 */} +
+ + {resultLabel} + + + {formatNumber(calcResult)} + +
+
+ ); + default: return
알 수 없는 컴포넌트
; } diff --git a/frontend/components/report/designer/ComponentPalette.tsx b/frontend/components/report/designer/ComponentPalette.tsx index ed989a58..9dd0543f 100644 --- a/frontend/components/report/designer/ComponentPalette.tsx +++ b/frontend/components/report/designer/ComponentPalette.tsx @@ -1,7 +1,7 @@ "use client"; import { useDrag } from "react-dnd"; -import { Type, Table, Image, Minus, PenLine, Stamp as StampIcon, Hash, CreditCard } from "lucide-react"; +import { Type, Table, Image, Minus, PenLine, Stamp as StampIcon, Hash, CreditCard, Calculator } from "lucide-react"; interface ComponentItem { type: string; @@ -18,6 +18,7 @@ const COMPONENTS: ComponentItem[] = [ { type: "stamp", label: "도장란", icon: }, { type: "pageNumber", label: "페이지번호", icon: }, { type: "card", label: "정보카드", icon: }, + { type: "calculation", label: "계산", icon: }, ]; function DraggableComponentItem({ type, label, icon }: ComponentItem) { diff --git a/frontend/components/report/designer/ReportDesignerCanvas.tsx b/frontend/components/report/designer/ReportDesignerCanvas.tsx index 09b0fa42..bcf9d88f 100644 --- a/frontend/components/report/designer/ReportDesignerCanvas.tsx +++ b/frontend/components/report/designer/ReportDesignerCanvas.tsx @@ -173,6 +173,28 @@ export function ReportDesignerCanvas() { borderWidth: 1, borderColor: "#e5e7eb", }), + // 계산 컴포넌트 전용 + ...(item.componentType === "calculation" && { + width: 350, + height: 120, + calcItems: [ + { label: "공급가액", value: 0, operator: "+" as const, fieldName: "" }, + { label: "부가세 (10%)", value: 0, operator: "+" as const, fieldName: "" }, + ], + resultLabel: "합계 금액", + labelWidth: 120, + labelFontSize: 13, + valueFontSize: 13, + resultFontSize: 16, + labelColor: "#374151", + valueColor: "#000000", + resultColor: "#2563eb", + showCalcBorder: false, + numberFormat: "currency" as const, + currencySuffix: "원", + borderWidth: 0, + borderColor: "#e5e7eb", + }), // 테이블 전용 ...(item.componentType === "table" && { queryId: undefined, diff --git a/frontend/components/report/designer/ReportDesignerRightPanel.tsx b/frontend/components/report/designer/ReportDesignerRightPanel.tsx index fa4c72e5..ff832e21 100644 --- a/frontend/components/report/designer/ReportDesignerRightPanel.tsx +++ b/frontend/components/report/designer/ReportDesignerRightPanel.tsx @@ -1272,6 +1272,365 @@ export function ReportDesignerRightPanel() { )} + {/* 계산 컴포넌트 설정 */} + {selectedComponent.type === "calculation" && ( + + + 계산 설정 + + + {/* 결과 라벨 */} +
+ + + updateComponent(selectedComponent.id, { + resultLabel: e.target.value, + }) + } + placeholder="합계 금액" + className="h-8" + /> +
+ + {/* 라벨 너비 */} +
+ + + updateComponent(selectedComponent.id, { + labelWidth: Number(e.target.value), + }) + } + min={60} + max={200} + className="h-8" + /> +
+ + {/* 숫자 포맷 */} +
+ + +
+ + {/* 통화 접미사 */} + {selectedComponent.numberFormat === "currency" && ( +
+ + + updateComponent(selectedComponent.id, { + currencySuffix: e.target.value, + }) + } + placeholder="원" + className="h-8" + /> +
+ )} + + {/* 폰트 크기 설정 */} +
+
+ + + updateComponent(selectedComponent.id, { + labelFontSize: Number(e.target.value), + }) + } + min={10} + max={20} + className="h-8" + /> +
+
+ + + updateComponent(selectedComponent.id, { + valueFontSize: Number(e.target.value), + }) + } + min={10} + max={20} + className="h-8" + /> +
+
+ + + updateComponent(selectedComponent.id, { + resultFontSize: Number(e.target.value), + }) + } + min={12} + max={24} + className="h-8" + /> +
+
+ + {/* 색상 설정 */} +
+
+ + + updateComponent(selectedComponent.id, { + labelColor: e.target.value, + }) + } + className="h-8 w-full cursor-pointer p-1" + /> +
+
+ + + updateComponent(selectedComponent.id, { + valueColor: e.target.value, + }) + } + className="h-8 w-full cursor-pointer p-1" + /> +
+
+ + + updateComponent(selectedComponent.id, { + resultColor: e.target.value, + }) + } + className="h-8 w-full cursor-pointer p-1" + /> +
+
+ + {/* 계산 항목 목록 관리 */} +
+
+ + +
+ + {/* 쿼리 선택 (데이터 바인딩용) */} +
+ + +
+ + {/* 항목 리스트 */} +
+ {(selectedComponent.calcItems || []).map((item, index: number) => ( +
+
+ 항목 {index + 1} + +
+
+
+ + { + const currentItems = [...(selectedComponent.calcItems || [])]; + currentItems[index] = { ...currentItems[index], label: e.target.value }; + updateComponent(selectedComponent.id, { + calcItems: currentItems, + }); + }} + className="h-6 text-xs" + placeholder="항목명" + /> +
+ {/* 두 번째 항목부터 연산자 표시 */} + {index > 0 && ( +
+ + +
+ )} +
+
+ {selectedComponent.queryId ? ( +
+ + +
+ ) : ( +
+ + { + const currentItems = [...(selectedComponent.calcItems || [])]; + currentItems[index] = { + ...currentItems[index], + value: Number(e.target.value), + }; + updateComponent(selectedComponent.id, { + calcItems: currentItems, + }); + }} + className="h-6 text-xs" + placeholder="0" + /> +
+ )} +
+
+ ))} +
+
+
+
+ )} + {/* 데이터 바인딩 (텍스트/라벨/테이블 컴포넌트) */} {(selectedComponent.type === "text" || selectedComponent.type === "label" || diff --git a/frontend/components/report/designer/ReportPreviewModal.tsx b/frontend/components/report/designer/ReportPreviewModal.tsx index 6e0e80da..2ff70c73 100644 --- a/frontend/components/report/designer/ReportPreviewModal.tsx +++ b/frontend/components/report/designer/ReportPreviewModal.tsx @@ -213,6 +213,91 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
`; } + // 계산 컴포넌트 + else if (component.type === "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 borderColor = component.borderColor || "#374151"; + + // 숫자 포맷팅 함수 + 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 && 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; + }; + + // 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용) + let calcResult = 0; + if (calcItems.length > 0) { + // 첫 번째 항목은 기준값 + calcResult = 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 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 itemsHtml = calcItems + .map((item: { label: string; value: number | string; operator: string; fieldName?: string }) => { + const itemValue = getCalcItemValue(item); + return ` +
+ ${item.label} + ${formatNumber(itemValue)} +
+ `; + }) + .join(""); + + content = ` +
+
+ ${itemsHtml} +
+
+
+ ${resultLabel} + ${formatNumber(calcResult)} +
+
`; + } + // Table 컴포넌트 else if (component.type === "table" && queryResult && queryResult.rows.length > 0) { const columns = @@ -903,6 +988,131 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) ); })()} + + {/* 계산 컴포넌트 */} + {component.type === "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 borderColor = component.borderColor || "#374151"; + + // 숫자 포맷팅 함수 + 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 qResult = getQueryResult(component.queryId); + if (qResult && 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 = 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 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; + } + } + } + + return ( +
+
+ {calcItems.map((item: { label: string; value: number | string; operator: string; fieldName?: string }, idx: number) => { + const itemValue = getCalcItemValue(item); + return ( +
+ + {item.label} + + + {formatNumber(itemValue)} + +
+ ); + })} +
+
+
+ + {resultLabel} + + + {formatNumber(calcResult)} + +
+
+ ); + })()}
); })} diff --git a/frontend/types/report.ts b/frontend/types/report.ts index 1711bec6..46c8db89 100644 --- a/frontend/types/report.ts +++ b/frontend/types/report.ts @@ -176,6 +176,19 @@ export interface ComponentConfig { titleColor?: string; // 제목 색상 labelColor?: string; // 라벨 색상 valueColor?: string; // 값 색상 + // 계산 컴포넌트 전용 + calcItems?: Array<{ + label: string; // 항목 라벨 (예: "공급가액") + value: number | string; // 항목 값 또는 기본값 + operator: "+" | "-" | "x" | "÷"; // 연산자 + fieldName?: string; // 쿼리 필드명 (바인딩용) + }>; + resultLabel?: string; // 결과 라벨 (예: "합계 금액") + resultColor?: string; // 결과 색상 + resultFontSize?: number; // 결과 폰트 크기 + showCalcBorder?: boolean; // 테두리 표시 여부 + numberFormat?: "none" | "comma" | "currency"; // 숫자 포맷 (없음, 천단위, 원화) + currencySuffix?: string; // 통화 접미사 (예: "원") } // 리포트 상세