테이블리스트로 세금계산서 만들기

This commit is contained in:
leeheejin
2025-12-09 15:12:59 +09:00
parent d7e03d6b83
commit a8cbc289f6
4 changed files with 683 additions and 7 deletions

View File

@@ -1,11 +1,11 @@
"use client";
import React, { useEffect, useState } from "react";
import React, { useEffect, useState, useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Trash2, Loader2, X } from "lucide-react";
import { SimpleRepeaterTableProps, SimpleRepeaterColumnConfig } from "./types";
import { Trash2, Loader2, X, Plus } from "lucide-react";
import { SimpleRepeaterTableProps, SimpleRepeaterColumnConfig, SummaryFieldConfig } from "./types";
import { cn } from "@/lib/utils";
import { ComponentRendererProps } from "@/types/component";
import { useCalculation } from "./useCalculation";
@@ -21,6 +21,7 @@ export interface SimpleRepeaterTableComponentProps extends ComponentRendererProp
readOnly?: boolean;
showRowNumber?: boolean;
allowDelete?: boolean;
allowAdd?: boolean;
maxHeight?: string;
}
@@ -44,6 +45,7 @@ export function SimpleRepeaterTableComponent({
readOnly: propReadOnly,
showRowNumber: propShowRowNumber,
allowDelete: propAllowDelete,
allowAdd: propAllowAdd,
maxHeight: propMaxHeight,
...props
@@ -60,6 +62,13 @@ export function SimpleRepeaterTableComponent({
const readOnly = componentConfig?.readOnly ?? propReadOnly ?? false;
const showRowNumber = componentConfig?.showRowNumber ?? propShowRowNumber ?? true;
const allowDelete = componentConfig?.allowDelete ?? propAllowDelete ?? true;
const allowAdd = componentConfig?.allowAdd ?? propAllowAdd ?? false;
const addButtonText = componentConfig?.addButtonText || "행 추가";
const addButtonPosition = componentConfig?.addButtonPosition || "bottom";
const minRows = componentConfig?.minRows ?? 0;
const maxRows = componentConfig?.maxRows ?? Infinity;
const newRowDefaults = componentConfig?.newRowDefaults || {};
const summaryConfig = componentConfig?.summaryConfig;
const maxHeight = componentConfig?.maxHeight || propMaxHeight || "240px";
// value는 formData[columnName] 우선, 없으면 prop 사용
@@ -345,10 +354,137 @@ export function SimpleRepeaterTableComponent({
};
const handleRowDelete = (rowIndex: number) => {
// 최소 행 수 체크
if (value.length <= minRows) {
return;
}
const newData = value.filter((_, i) => i !== rowIndex);
handleChange(newData);
};
// 행 추가 함수
const handleAddRow = () => {
// 최대 행 수 체크
if (value.length >= maxRows) {
return;
}
// 새 행 생성 (기본값 적용)
const newRow: Record<string, any> = { ...newRowDefaults };
// 각 컬럼의 기본값 설정
columns.forEach((col) => {
if (newRow[col.field] === undefined) {
if (col.defaultValue !== undefined) {
newRow[col.field] = col.defaultValue;
} else if (col.type === "number") {
newRow[col.field] = 0;
} else if (col.type === "date") {
newRow[col.field] = new Date().toISOString().split("T")[0];
} else {
newRow[col.field] = "";
}
}
});
// 계산 필드 적용
const calculatedRow = calculateRow(newRow);
const newData = [...value, calculatedRow];
handleChange(newData);
};
// 합계 계산
const summaryValues = useMemo(() => {
if (!summaryConfig?.enabled || !summaryConfig.fields || value.length === 0) {
return null;
}
const result: Record<string, number> = {};
// 먼저 기본 집계 함수 계산
summaryConfig.fields.forEach((field) => {
if (field.formula) return; // 수식 필드는 나중에 처리
const values = value.map((row) => {
const val = row[field.field];
return typeof val === "number" ? val : parseFloat(val) || 0;
});
switch (field.type || "sum") {
case "sum":
result[field.field] = values.reduce((a, b) => a + b, 0);
break;
case "avg":
result[field.field] = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
break;
case "count":
result[field.field] = values.length;
break;
case "min":
result[field.field] = Math.min(...values);
break;
case "max":
result[field.field] = Math.max(...values);
break;
default:
result[field.field] = values.reduce((a, b) => a + b, 0);
}
});
// 수식 필드 계산 (다른 합계 필드 참조)
summaryConfig.fields.forEach((field) => {
if (!field.formula) return;
let formula = field.formula;
// 다른 필드 참조 치환
Object.keys(result).forEach((key) => {
formula = formula.replace(new RegExp(`\\b${key}\\b`, "g"), result[key].toString());
});
try {
result[field.field] = new Function(`return ${formula}`)();
} catch {
result[field.field] = 0;
}
});
return result;
}, [value, summaryConfig]);
// 합계 값 포맷팅
const formatSummaryValue = (field: SummaryFieldConfig, value: number): string => {
const decimals = field.decimals ?? 0;
const formatted = value.toFixed(decimals);
switch (field.format) {
case "currency":
return Number(formatted).toLocaleString() + "원";
case "percent":
return formatted + "%";
default:
return Number(formatted).toLocaleString();
}
};
// 행 추가 버튼 컴포넌트
const AddRowButton = () => {
if (!allowAdd || readOnly || value.length >= maxRows) return null;
return (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddRow}
className="h-8 text-xs"
>
<Plus className="h-3.5 w-3.5 mr-1" />
{addButtonText}
</Button>
);
};
const renderCell = (
row: any,
column: SimpleRepeaterColumnConfig,
@@ -457,8 +593,18 @@ export function SimpleRepeaterTableComponent({
);
}
// 테이블 컬럼 수 계산
const totalColumns = columns.length + (showRowNumber ? 1 : 0) + (allowDelete && !readOnly ? 1 : 0);
return (
<div className={cn("border rounded-md overflow-hidden bg-background", className)}>
{/* 상단 행 추가 버튼 */}
{allowAdd && addButtonPosition !== "bottom" && (
<div className="p-2 border-b bg-muted/50">
<AddRowButton />
</div>
)}
<div
className="overflow-x-auto overflow-y-auto"
style={{ maxHeight }}
@@ -492,10 +638,17 @@ export function SimpleRepeaterTableComponent({
{value.length === 0 ? (
<tr>
<td
colSpan={columns.length + (showRowNumber ? 1 : 0) + (allowDelete && !readOnly ? 1 : 0)}
colSpan={totalColumns}
className="px-4 py-8 text-center text-muted-foreground"
>
{allowAdd ? (
<div className="flex flex-col items-center gap-2">
<span> </span>
<AddRowButton />
</div>
) : (
"표시할 데이터가 없습니다"
)}
</td>
</tr>
) : (
@@ -517,7 +670,8 @@ export function SimpleRepeaterTableComponent({
variant="ghost"
size="sm"
onClick={() => handleRowDelete(rowIndex)}
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
disabled={value.length <= minRows}
className="h-7 w-7 p-0 text-destructive hover:text-destructive disabled:opacity-50"
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -529,6 +683,58 @@ export function SimpleRepeaterTableComponent({
</tbody>
</table>
</div>
{/* 합계 표시 */}
{summaryConfig?.enabled && summaryValues && (
<div className={cn(
"border-t bg-muted/30 p-3",
summaryConfig.position === "bottom-right" && "flex justify-end"
)}>
<div className={cn(
summaryConfig.position === "bottom-right" ? "w-auto min-w-[200px]" : "w-full"
)}>
{summaryConfig.title && (
<div className="text-xs font-medium text-muted-foreground mb-2">
{summaryConfig.title}
</div>
)}
<div className={cn(
"grid gap-2",
summaryConfig.position === "bottom-right" ? "grid-cols-1" : "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4"
)}>
{summaryConfig.fields.map((field) => (
<div
key={field.field}
className={cn(
"flex justify-between items-center px-3 py-1.5 rounded",
field.highlight ? "bg-primary/10 font-semibold" : "bg-background"
)}
>
<span className="text-xs text-muted-foreground">{field.label}</span>
<span className={cn(
"text-sm font-medium",
field.highlight && "text-primary"
)}>
{formatSummaryValue(field, summaryValues[field.field] || 0)}
</span>
</div>
))}
</div>
</div>
</div>
)}
{/* 하단 행 추가 버튼 */}
{allowAdd && addButtonPosition !== "top" && value.length > 0 && (
<div className="p-2 border-t bg-muted/50 flex justify-between items-center">
<AddRowButton />
{maxRows !== Infinity && (
<span className="text-xs text-muted-foreground">
{value.length} / {maxRows}
</span>
)}
</div>
)}
</div>
);
}