From a8cbc289f65511de414a7c6165d50f15ea2a364c Mon Sep 17 00:00:00 2001
From: leeheejin
Date: Tue, 9 Dec 2025 15:12:59 +0900
Subject: [PATCH] =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=EB=A6=AC=EC=8A=A4?=
=?UTF-8?q?=ED=8A=B8=EB=A1=9C=20=EC=84=B8=EA=B8=88=EA=B3=84=EC=82=B0?=
=?UTF-8?q?=EC=84=9C=20=EB=A7=8C=EB=93=A4=EA=B8=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../SimpleRepeaterTableComponent.tsx | 218 ++++++++++-
.../SimpleRepeaterTableConfigPanel.tsx | 349 +++++++++++++++++-
.../components/simple-repeater-table/index.ts | 11 +
.../components/simple-repeater-table/types.ts | 112 ++++++
4 files changed, 683 insertions(+), 7 deletions(-)
diff --git a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx
index 3bb81986..ba47d13a 100644
--- a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx
+++ b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx
@@ -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 = { ...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 = {};
+
+ // 먼저 기본 집계 함수 계산
+ 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 (
+
+ );
+ };
+
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 (
+ {/* 상단 행 추가 버튼 */}
+ {allowAdd && addButtonPosition !== "bottom" && (
+
+ )}
+
|
- 표시할 데이터가 없습니다
+ {allowAdd ? (
+
+ ) : (
+ "표시할 데이터가 없습니다"
+ )}
|
) : (
@@ -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"
>
@@ -529,6 +683,58 @@ export function SimpleRepeaterTableComponent({
+
+ {/* 합계 표시 */}
+ {summaryConfig?.enabled && summaryValues && (
+
+
+ {summaryConfig.title && (
+
+ {summaryConfig.title}
+
+ )}
+
+ {summaryConfig.fields.map((field) => (
+
+ {field.label}
+
+ {formatSummaryValue(field, summaryValues[field.field] || 0)}
+
+
+ ))}
+
+
+
+ )}
+
+ {/* 하단 행 추가 버튼 */}
+ {allowAdd && addButtonPosition !== "top" && value.length > 0 && (
+
+
+ {maxRows !== Infinity && (
+
+ {value.length} / {maxRows}
+
+ )}
+
+ )}
);
}
diff --git a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel.tsx b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel.tsx
index 69b2b597..f0bdd7ac 100644
--- a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel.tsx
+++ b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel.tsx
@@ -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({
+
+
+
+ updateConfig({ allowAdd: checked })}
+ />
+
+
+ 사용자가 새 행을 추가할 수 있습니다
+
+
+
+ {localConfig.allowAdd && (
+ <>
+
+
+ updateConfig({ addButtonText: e.target.value })}
+ placeholder="행 추가"
+ className="h-8 text-xs sm:h-10 sm:text-sm"
+ />
+
+
+
+
+
+
+
+
+
+
+
updateConfig({ minRows: parseInt(e.target.value) || 0 })}
+ className="h-8 text-xs sm:h-10 sm:text-sm"
+ />
+
+ 0이면 제한 없음
+
+
+
+
+
+
updateConfig({ maxRows: e.target.value ? parseInt(e.target.value) : undefined })}
+ placeholder="무제한"
+ className="h-8 text-xs sm:h-10 sm:text-sm"
+ />
+
+ 비워두면 무제한
+
+
+
+ >
+ )}
+
+ {/* 합계 설정 */}
+
+
+
합계 설정
+
+ 테이블 하단에 합계를 표시합니다
+
+
+
+
+
+
+ updateConfig({
+ summaryConfig: {
+ ...localConfig.summaryConfig,
+ enabled: checked,
+ fields: localConfig.summaryConfig?.fields || [],
+ }
+ })}
+ />
+
+
+
+ {localConfig.summaryConfig?.enabled && (
+ <>
+
+
+ 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"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {localConfig.summaryConfig?.fields && localConfig.summaryConfig.fields.length > 0 ? (
+
+ {localConfig.summaryConfig.fields.map((field, index) => (
+
+
+ 합계 필드 {index + 1}
+
+
+
+
+
+
+
+
+
+
+
+ {
+ 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"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ const fields = [...(localConfig.summaryConfig?.fields || [])];
+ fields[index] = { ...fields[index], highlight: checked };
+ updateConfig({
+ summaryConfig: {
+ ...localConfig.summaryConfig,
+ enabled: true,
+ fields,
+ }
+ });
+ }}
+ />
+
+
+ ))}
+
+ ) : (
+
+ )}
+
+
+
+
사용 예시
+
+
• 공급가액 합계: supply_amount 필드의 SUM
+
• 세액 합계: tax_amount 필드의 SUM
+
• 총액: supply_amount + tax_amount (수식 필드)
+
+
+ >
+ )}
+
+
{/* 사용 안내 */}
SimpleRepeaterTable 사용법:
- 주어진 데이터를 표시하고 편집하는 경량 테이블입니다
- - 검색/추가 기능은 없으며, 상위 컴포넌트에서 데이터를 전달받습니다
+ - 행 추가 허용 옵션으로 사용자가 새 행을 추가할 수 있습니다
- 주로 EditModal과 함께 사용되며, 선택된 데이터를 일괄 수정할 때 유용합니다
- readOnly 옵션으로 전체 테이블을 읽기 전용으로 만들 수 있습니다
- 자동 계산 규칙을 통해 수량 * 단가 = 금액 같은 계산을 자동화할 수 있습니다
+ - 합계 설정으로 테이블 하단에 합계/평균 등을 표시할 수 있습니다
diff --git a/frontend/lib/registry/components/simple-repeater-table/index.ts b/frontend/lib/registry/components/simple-repeater-table/index.ts
index 0c6457ac..9cb2d3f2 100644
--- a/frontend/lib/registry/components/simple-repeater-table/index.ts
+++ b/frontend/lib/registry/components/simple-repeater-table/index.ts
@@ -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";
// 컴포넌트 내보내기
diff --git a/frontend/lib/registry/components/simple-repeater-table/types.ts b/frontend/lib/registry/components/simple-repeater-table/types.ts
index 8b137891..0ace80aa 100644
--- a/frontend/lib/registry/components/simple-repeater-table/types.ts
+++ b/frontend/lib/registry/components/simple-repeater-table/types.ts
@@ -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;
+
+ // 합계 설정
+ summaryConfig?: SummaryConfig;
+
+ // 데이터
+ value?: any[];
+ onChange?: (newData: any[]) => void;
+}