테이블리스트로 세금계산서 만들기
This commit is contained in:
parent
d7e03d6b83
commit
a8cbc289f6
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue