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

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>
);
}

View File

@ -15,6 +15,8 @@ import {
ColumnTargetConfig,
InitialDataConfig,
DataFilterCondition,
SummaryConfig,
SummaryFieldConfig,
} from "./types";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@ -482,6 +484,81 @@ export function SimpleRepeaterTableConfigPanel({
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={localConfig.allowAdd ?? false}
onCheckedChange={(checked) => updateConfig({ allowAdd: checked })}
/>
</div>
<p className="text-xs text-muted-foreground">
</p>
</div>
{localConfig.allowAdd && (
<>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={localConfig.addButtonText || "행 추가"}
onChange={(e) => updateConfig({ addButtonText: e.target.value })}
placeholder="행 추가"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={localConfig.addButtonPosition || "bottom"}
onValueChange={(value) => updateConfig({ addButtonPosition: value as "top" | "bottom" | "both" })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="top"></SelectItem>
<SelectItem value="bottom"></SelectItem>
<SelectItem value="both"> + </SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Input
type="number"
min={0}
value={localConfig.minRows ?? 0}
onChange={(e) => updateConfig({ minRows: parseInt(e.target.value) || 0 })}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-xs text-muted-foreground">
0
</p>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Input
type="number"
min={1}
value={localConfig.maxRows ?? ""}
onChange={(e) => updateConfig({ maxRows: e.target.value ? parseInt(e.target.value) : undefined })}
placeholder="무제한"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
</>
)}
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Input
@ -1314,15 +1391,285 @@ export function SimpleRepeaterTableConfigPanel({
)}
</div>
{/* 합계 설정 */}
<div className="space-y-4 border rounded-lg p-4 bg-card">
<div>
<h3 className="text-sm font-semibold mb-1"> </h3>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={localConfig.summaryConfig?.enabled ?? false}
onCheckedChange={(checked) => updateConfig({
summaryConfig: {
...localConfig.summaryConfig,
enabled: checked,
fields: localConfig.summaryConfig?.fields || [],
}
})}
/>
</div>
</div>
{localConfig.summaryConfig?.enabled && (
<>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={localConfig.summaryConfig?.title || ""}
onChange={(e) => updateConfig({
summaryConfig: {
...localConfig.summaryConfig,
enabled: true,
title: e.target.value,
fields: localConfig.summaryConfig?.fields || [],
}
})}
placeholder="합계"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={localConfig.summaryConfig?.position || "bottom"}
onValueChange={(value) => updateConfig({
summaryConfig: {
...localConfig.summaryConfig,
enabled: true,
position: value as "bottom" | "bottom-right",
fields: localConfig.summaryConfig?.fields || [],
}
})}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bottom"> </SelectItem>
<SelectItem value="bottom-right"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Button
size="sm"
variant="outline"
onClick={() => {
const fields = localConfig.summaryConfig?.fields || [];
updateConfig({
summaryConfig: {
...localConfig.summaryConfig,
enabled: true,
fields: [...fields, { field: "", label: "", type: "sum", format: "number" }],
}
});
}}
className="h-7 text-xs"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
{localConfig.summaryConfig?.fields && localConfig.summaryConfig.fields.length > 0 ? (
<div className="space-y-3">
{localConfig.summaryConfig.fields.map((field, index) => (
<div key={`summary-${index}`} className="border rounded-md p-3 space-y-3 bg-background">
<div className="flex items-center justify-between">
<span className="text-xs font-medium"> {index + 1}</span>
<Button
size="sm"
variant="ghost"
onClick={() => {
const fields = [...(localConfig.summaryConfig?.fields || [])];
fields.splice(index, 1);
updateConfig({
summaryConfig: {
...localConfig.summaryConfig,
enabled: true,
fields,
}
});
}}
className="h-6 w-6 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Select
value={field.field}
onValueChange={(value) => {
const fields = [...(localConfig.summaryConfig?.fields || [])];
const selectedCol = localConfig.columns?.find(c => c.field === value);
fields[index] = {
...fields[index],
field: value,
label: fields[index].label || selectedCol?.label || value,
};
updateConfig({
summaryConfig: {
...localConfig.summaryConfig,
enabled: true,
fields,
}
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{(localConfig.columns || []).filter(c => c.type === "number").map((col) => (
<SelectItem key={col.field} value={col.field}>
{col.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Input
value={field.label}
onChange={(e) => {
const fields = [...(localConfig.summaryConfig?.fields || [])];
fields[index] = { ...fields[index], label: e.target.value };
updateConfig({
summaryConfig: {
...localConfig.summaryConfig,
enabled: true,
fields,
}
});
}}
placeholder="합계 라벨"
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={field.type || "sum"}
onValueChange={(value) => {
const fields = [...(localConfig.summaryConfig?.fields || [])];
fields[index] = { ...fields[index], type: value as SummaryFieldConfig["type"] };
updateConfig({
summaryConfig: {
...localConfig.summaryConfig,
enabled: true,
fields,
}
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sum"> (SUM)</SelectItem>
<SelectItem value="avg"> (AVG)</SelectItem>
<SelectItem value="count"> (COUNT)</SelectItem>
<SelectItem value="min"> (MIN)</SelectItem>
<SelectItem value="max"> (MAX)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={field.format || "number"}
onValueChange={(value) => {
const fields = [...(localConfig.summaryConfig?.fields || [])];
fields[index] = { ...fields[index], format: value as SummaryFieldConfig["format"] };
updateConfig({
summaryConfig: {
...localConfig.summaryConfig,
enabled: true,
fields,
}
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="number"></SelectItem>
<SelectItem value="currency"> ()</SelectItem>
<SelectItem value="percent"> (%)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center justify-between">
<Label className="text-[10px]"> </Label>
<Switch
checked={field.highlight ?? false}
onCheckedChange={(checked) => {
const fields = [...(localConfig.summaryConfig?.fields || [])];
fields[index] = { ...fields[index], highlight: checked };
updateConfig({
summaryConfig: {
...localConfig.summaryConfig,
enabled: true,
fields,
}
});
}}
/>
</div>
</div>
))}
</div>
) : (
<div className="p-4 border-2 border-dashed rounded-lg text-center">
<p className="text-xs text-muted-foreground">
</p>
</div>
)}
</div>
<div className="p-3 bg-blue-50 dark:bg-blue-950 rounded-md border border-blue-200 dark:border-blue-800">
<p className="text-xs font-medium mb-1"> </p>
<div className="space-y-1 text-[10px] text-muted-foreground">
<p> 합계: supply_amount SUM</p>
<p> 합계: tax_amount SUM</p>
<p> 총액: supply_amount + tax_amount ( )</p>
</div>
</div>
</>
)}
</div>
{/* 사용 안내 */}
<div className="p-4 bg-muted rounded-md text-xs text-muted-foreground">
<p className="font-medium mb-2">SimpleRepeaterTable :</p>
<ul className="space-y-1 list-disc list-inside">
<li> </li>
<li>/ , </li>
<li><strong> </strong> </li>
<li> EditModal과 , </li>
<li>readOnly </li>
<li> * = </li>
<li><strong> </strong> / </li>
</ul>
</div>
</div>

View File

@ -31,6 +31,15 @@ export const SimpleRepeaterTableDefinition = createComponentDefinition({
readOnly: false,
showRowNumber: true,
allowDelete: true,
allowAdd: false,
addButtonText: "행 추가",
addButtonPosition: "bottom",
minRows: 0,
maxRows: undefined,
summaryConfig: {
enabled: false,
fields: [],
},
maxHeight: "240px",
},
defaultSize: { width: 800, height: 400 },
@ -51,6 +60,8 @@ export type {
InitialDataConfig,
DataFilterCondition,
SourceJoinCondition,
SummaryConfig,
SummaryFieldConfig,
} from "./types";
// 컴포넌트 내보내기

View File

@ -1 +1,113 @@
/**
* SimpleRepeaterTable
*/
// 컬럼 데이터 소스 설정
export interface ColumnSourceConfig {
type: "direct" | "join" | "manual";
sourceTable?: string;
sourceColumn?: string;
joinTable?: string;
joinColumn?: string;
joinKey?: string;
joinRefKey?: string;
}
// 컬럼 데이터 타겟 설정
export interface ColumnTargetConfig {
targetTable?: string;
targetColumn?: string;
saveEnabled?: boolean;
}
// 컬럼 설정
export interface SimpleRepeaterColumnConfig {
field: string;
label: string;
type?: "text" | "number" | "date" | "select";
width?: string;
editable?: boolean;
required?: boolean;
calculated?: boolean;
defaultValue?: any;
placeholder?: string;
selectOptions?: { value: string; label: string }[];
sourceConfig?: ColumnSourceConfig;
targetConfig?: ColumnTargetConfig;
}
// 계산 규칙
export interface CalculationRule {
result: string;
formula: string;
dependencies?: string[];
}
// 초기 데이터 필터 조건
export interface DataFilterCondition {
field: string;
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN";
value?: any;
valueFromField?: string;
}
// 소스 조인 조건
export interface SourceJoinCondition {
sourceKey: string;
referenceKey: string;
}
// 초기 데이터 설정
export interface InitialDataConfig {
sourceTable: string;
filterConditions?: DataFilterCondition[];
joinConditions?: SourceJoinCondition[];
}
// 합계 필드 설정
export interface SummaryFieldConfig {
field: string;
label: string;
type?: "sum" | "avg" | "count" | "min" | "max";
formula?: string; // 다른 합계 필드를 참조하는 계산식 (예: "supply_amount + tax_amount")
format?: "number" | "currency" | "percent";
decimals?: number;
highlight?: boolean; // 강조 표시 (합계 행)
}
// 합계 설정
export interface SummaryConfig {
enabled: boolean;
position?: "bottom" | "bottom-right";
title?: string;
fields: SummaryFieldConfig[];
}
// 메인 Props
export interface SimpleRepeaterTableProps {
// 기본 설정
columns?: SimpleRepeaterColumnConfig[];
calculationRules?: CalculationRule[];
initialDataConfig?: InitialDataConfig;
// 표시 설정
readOnly?: boolean;
showRowNumber?: boolean;
allowDelete?: boolean;
maxHeight?: string;
// 행 추가 설정
allowAdd?: boolean;
addButtonText?: string;
addButtonPosition?: "top" | "bottom" | "both";
minRows?: number;
maxRows?: number;
newRowDefaults?: Record<string, any>;
// 합계 설정
summaryConfig?: SummaryConfig;
// 데이터
value?: any[];
onChange?: (newData: any[]) => void;
}