테이블리스트로 세금계산서 만들기
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user