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