계산 컴포넌트 연산자 로직 개선

This commit is contained in:
dohyeons 2025-12-18 11:41:48 +09:00
parent 1fd428c016
commit 403bd0f8a1
7 changed files with 1157 additions and 1 deletions

View File

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

View File

@ -677,6 +677,133 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
</div>
);
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 (
<div className="flex h-full w-full flex-col overflow-hidden">
{/* 항목 목록 */}
<div className="flex-1 overflow-auto px-2 py-1">
{calcItems.map((item: { label: string; value: number | string; operator: string; fieldName?: string }, index: number) => {
const itemValue = getCalcItemValue(item);
return (
<div key={index} className="flex items-center justify-between py-1">
<span
className="flex-shrink-0"
style={{
width: `${calcLabelWidth}px`,
fontSize: `${calcLabelFontSize}px`,
color: calcLabelColor,
}}
>
{item.label}
</span>
<span
className="text-right"
style={{
fontSize: `${calcValueFontSize}px`,
color: calcValueColor,
}}
>
{formatNumber(itemValue)}
</span>
</div>
);
})}
</div>
{/* 구분선 */}
<div
className="mx-1 flex-shrink-0 border-t"
style={{ borderColor: component.borderColor || "#374151" }}
/>
{/* 결과 */}
<div className="flex items-center justify-between px-2 py-2">
<span
className="font-semibold"
style={{
width: `${calcLabelWidth}px`,
fontSize: `${calcResultFontSize}px`,
color: calcLabelColor,
}}
>
{resultLabel}
</span>
<span
className="text-right font-bold"
style={{
fontSize: `${calcResultFontSize}px`,
color: calcResultColor,
}}
>
{formatNumber(calcResult)}
</span>
</div>
</div>
);
default:
return <div> </div>;
}

View File

@ -1,7 +1,7 @@
"use client";
import { useDrag } from "react-dnd";
import { Type, Table, Image, Minus, PenLine, Stamp as StampIcon, 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: <StampIcon className="h-4 w-4" /> },
{ type: "pageNumber", label: "페이지번호", icon: <Hash className="h-4 w-4" /> },
{ type: "card", label: "정보카드", icon: <CreditCard className="h-4 w-4" /> },
{ type: "calculation", label: "계산", icon: <Calculator className="h-4 w-4" /> },
];
function DraggableComponentItem({ type, label, icon }: ComponentItem) {

View File

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

View File

@ -1272,6 +1272,365 @@ export function ReportDesignerRightPanel() {
</Card>
)}
{/* 계산 컴포넌트 설정 */}
{selectedComponent.type === "calculation" && (
<Card className="mt-4 border-orange-200 bg-orange-50">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-orange-900"> </CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* 결과 라벨 */}
<div>
<Label className="text-xs"> </Label>
<Input
type="text"
value={selectedComponent.resultLabel || "합계"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
resultLabel: e.target.value,
})
}
placeholder="합계 금액"
className="h-8"
/>
</div>
{/* 라벨 너비 */}
<div>
<Label className="text-xs"> (px)</Label>
<Input
type="number"
value={selectedComponent.labelWidth || 120}
onChange={(e) =>
updateComponent(selectedComponent.id, {
labelWidth: Number(e.target.value),
})
}
min={60}
max={200}
className="h-8"
/>
</div>
{/* 숫자 포맷 */}
<div>
<Label className="text-xs"> </Label>
<Select
value={selectedComponent.numberFormat || "currency"}
onValueChange={(value) =>
updateComponent(selectedComponent.id, {
numberFormat: value as "none" | "comma" | "currency",
})
}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="comma"> </SelectItem>
<SelectItem value="currency"> ()</SelectItem>
</SelectContent>
</Select>
</div>
{/* 통화 접미사 */}
{selectedComponent.numberFormat === "currency" && (
<div>
<Label className="text-xs"> </Label>
<Input
type="text"
value={selectedComponent.currencySuffix || "원"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
currencySuffix: e.target.value,
})
}
placeholder="원"
className="h-8"
/>
</div>
)}
{/* 폰트 크기 설정 */}
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
value={selectedComponent.labelFontSize || 13}
onChange={(e) =>
updateComponent(selectedComponent.id, {
labelFontSize: Number(e.target.value),
})
}
min={10}
max={20}
className="h-8"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
value={selectedComponent.valueFontSize || 13}
onChange={(e) =>
updateComponent(selectedComponent.id, {
valueFontSize: Number(e.target.value),
})
}
min={10}
max={20}
className="h-8"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
value={selectedComponent.resultFontSize || 16}
onChange={(e) =>
updateComponent(selectedComponent.id, {
resultFontSize: Number(e.target.value),
})
}
min={12}
max={24}
className="h-8"
/>
</div>
</div>
{/* 색상 설정 */}
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-xs"> </Label>
<Input
type="color"
value={selectedComponent.labelColor || "#374151"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
labelColor: e.target.value,
})
}
className="h-8 w-full cursor-pointer p-1"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="color"
value={selectedComponent.valueColor || "#000000"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
valueColor: e.target.value,
})
}
className="h-8 w-full cursor-pointer p-1"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="color"
value={selectedComponent.resultColor || "#2563eb"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
resultColor: e.target.value,
})
}
className="h-8 w-full cursor-pointer p-1"
/>
</div>
</div>
{/* 계산 항목 목록 관리 */}
<div className="mt-4 border-t pt-3">
<div className="mb-2 flex items-center justify-between">
<Label className="text-xs font-semibold"> </Label>
<Button
size="sm"
variant="outline"
className="h-6 text-xs"
onClick={() => {
const currentItems = selectedComponent.calcItems || [];
updateComponent(selectedComponent.id, {
calcItems: [
...currentItems,
{
label: `항목${currentItems.length + 1}`,
value: 0,
operator: "+" as const,
fieldName: "",
},
],
});
}}
>
+
</Button>
</div>
{/* 쿼리 선택 (데이터 바인딩용) */}
<div className="mb-2">
<Label className="text-xs"> ()</Label>
<Select
value={selectedComponent.queryId || "none"}
onValueChange={(value) =>
updateComponent(selectedComponent.id, {
queryId: value === "none" ? undefined : value,
})
}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="쿼리 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{queries.map((q) => (
<SelectItem key={q.id} value={q.id}>
{q.name} ({q.type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 항목 리스트 */}
<div className="max-h-48 space-y-2 overflow-y-auto">
{(selectedComponent.calcItems || []).map((item, index: number) => (
<div key={index} className="rounded border bg-white p-2">
<div className="mb-1 flex items-center justify-between">
<span className="text-xs font-medium"> {index + 1}</span>
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
onClick={() => {
const currentItems = [...(selectedComponent.calcItems || [])];
currentItems.splice(index, 1);
updateComponent(selectedComponent.id, {
calcItems: currentItems,
});
}}
>
x
</Button>
</div>
<div className={`grid gap-1 ${index === 0 ? "grid-cols-1" : "grid-cols-3"}`}>
<div className={index === 0 ? "" : "col-span-2"}>
<Label className="text-[10px]"></Label>
<Input
type="text"
value={item.label}
onChange={(e) => {
const currentItems = [...(selectedComponent.calcItems || [])];
currentItems[index] = { ...currentItems[index], label: e.target.value };
updateComponent(selectedComponent.id, {
calcItems: currentItems,
});
}}
className="h-6 text-xs"
placeholder="항목명"
/>
</div>
{/* 두 번째 항목부터 연산자 표시 */}
{index > 0 && (
<div>
<Label className="text-[10px]"></Label>
<Select
value={item.operator}
onValueChange={(value) => {
const currentItems = [...(selectedComponent.calcItems || [])];
currentItems[index] = {
...currentItems[index],
operator: value as "+" | "-" | "x" | "÷",
};
updateComponent(selectedComponent.id, {
calcItems: currentItems,
});
}}
>
<SelectTrigger className="h-6 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="+">+</SelectItem>
<SelectItem value="-">-</SelectItem>
<SelectItem value="x">x</SelectItem>
<SelectItem value="÷">÷</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
<div className="mt-1">
{selectedComponent.queryId ? (
<div>
<Label className="text-[10px]"></Label>
<Select
value={item.fieldName || "none"}
onValueChange={(value) => {
const currentItems = [...(selectedComponent.calcItems || [])];
currentItems[index] = {
...currentItems[index],
fieldName: value === "none" ? "" : value,
};
updateComponent(selectedComponent.id, {
calcItems: currentItems,
});
}}
>
<SelectTrigger className="h-6 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{(() => {
const query = queries.find((q) => q.id === selectedComponent.queryId);
const result = query ? getQueryResult(query.id) : null;
if (result && result.fields) {
return result.fields.map((field: string) => (
<SelectItem key={field} value={field}>
{field}
</SelectItem>
));
}
return null;
})()}
</SelectContent>
</Select>
</div>
) : (
<div>
<Label className="text-[10px]"></Label>
<Input
type="number"
value={item.value}
onChange={(e) => {
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"
/>
</div>
)}
</div>
</div>
))}
</div>
</div>
</CardContent>
</Card>
)}
{/* 데이터 바인딩 (텍스트/라벨/테이블 컴포넌트) */}
{(selectedComponent.type === "text" ||
selectedComponent.type === "label" ||

View File

@ -213,6 +213,91 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
</div>`;
}
// 계산 컴포넌트
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 `
<div style="display: flex; justify-content: space-between; padding: 4px 8px;">
<span style="width: ${calcLabelWidth}px; font-size: ${calcLabelFontSize}px; color: ${calcLabelColor};">${item.label}</span>
<span style="font-size: ${calcValueFontSize}px; color: ${calcValueColor}; text-align: right;">${formatNumber(itemValue)}</span>
</div>
`;
})
.join("");
content = `
<div style="display: flex; flex-direction: column; height: 100%;">
<div style="flex: 1;">
${itemsHtml}
</div>
<div style="border-top: 1px solid ${borderColor}; margin: 4px 8px;"></div>
<div style="display: flex; justify-content: space-between; padding: 4px 8px;">
<span style="width: ${calcLabelWidth}px; font-size: ${calcResultFontSize}px; font-weight: 600; color: ${calcLabelColor};">${resultLabel}</span>
<span style="font-size: ${calcResultFontSize}px; font-weight: 700; color: ${calcResultColor}; text-align: right;">${formatNumber(calcResult)}</span>
</div>
</div>`;
}
// Table 컴포넌트
else if (component.type === "table" && queryResult && queryResult.rows.length > 0) {
const columns =
@ -903,6 +988,131 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
</div>
);
})()}
{/* 계산 컴포넌트 */}
{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 (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
}}
>
<div style={{ flex: 1 }}>
{calcItems.map((item: { label: string; value: number | string; operator: string; fieldName?: string }, idx: number) => {
const itemValue = getCalcItemValue(item);
return (
<div key={idx} style={{ display: "flex", justifyContent: "space-between", padding: "4px 8px" }}>
<span
style={{
width: `${calcLabelWidth}px`,
fontSize: `${calcLabelFontSize}px`,
color: calcLabelColor,
}}
>
{item.label}
</span>
<span
style={{
fontSize: `${calcValueFontSize}px`,
color: calcValueColor,
textAlign: "right",
}}
>
{formatNumber(itemValue)}
</span>
</div>
);
})}
</div>
<div style={{ borderTop: `1px solid ${borderColor}`, margin: "4px 8px" }} />
<div style={{ display: "flex", justifyContent: "space-between", padding: "4px 8px" }}>
<span
style={{
width: `${calcLabelWidth}px`,
fontSize: `${calcResultFontSize}px`,
fontWeight: 600,
color: calcLabelColor,
}}
>
{resultLabel}
</span>
<span
style={{
fontSize: `${calcResultFontSize}px`,
fontWeight: 700,
color: calcResultColor,
textAlign: "right",
}}
>
{formatNumber(calcResult)}
</span>
</div>
</div>
);
})()}
</div>
);
})}

View File

@ -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; // 통화 접미사 (예: "원")
}
// 리포트 상세