2025-11-28 11:48:46 +09:00
|
|
|
"use client";
|
|
|
|
|
|
2025-12-09 15:12:59 +09:00
|
|
|
import React, { useEffect, useState, useMemo } from "react";
|
2025-11-28 11:48:46 +09:00
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
2025-12-09 15:12:59 +09:00
|
|
|
import { Trash2, Loader2, X, Plus } from "lucide-react";
|
|
|
|
|
import { SimpleRepeaterTableProps, SimpleRepeaterColumnConfig, SummaryFieldConfig } from "./types";
|
2025-11-28 11:48:46 +09:00
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
import { ComponentRendererProps } from "@/types/component";
|
|
|
|
|
import { useCalculation } from "./useCalculation";
|
|
|
|
|
import { apiClient } from "@/lib/api/client";
|
|
|
|
|
|
|
|
|
|
export interface SimpleRepeaterTableComponentProps extends ComponentRendererProps {
|
|
|
|
|
config?: SimpleRepeaterTableProps;
|
|
|
|
|
// SimpleRepeaterTableProps의 개별 prop들도 지원 (호환성)
|
|
|
|
|
value?: any[];
|
|
|
|
|
onChange?: (newData: any[]) => void;
|
|
|
|
|
columns?: SimpleRepeaterColumnConfig[];
|
|
|
|
|
calculationRules?: any[];
|
|
|
|
|
readOnly?: boolean;
|
|
|
|
|
showRowNumber?: boolean;
|
|
|
|
|
allowDelete?: boolean;
|
2025-12-09 15:12:59 +09:00
|
|
|
allowAdd?: boolean;
|
2025-11-28 11:48:46 +09:00
|
|
|
maxHeight?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function SimpleRepeaterTableComponent({
|
|
|
|
|
// ComponentRendererProps (자동 전달)
|
|
|
|
|
component,
|
|
|
|
|
isDesignMode = false,
|
|
|
|
|
isSelected = false,
|
|
|
|
|
isInteractive = false,
|
|
|
|
|
onClick,
|
|
|
|
|
className,
|
|
|
|
|
formData,
|
|
|
|
|
onFormDataChange,
|
|
|
|
|
|
|
|
|
|
// SimpleRepeaterTable 전용 props
|
|
|
|
|
config,
|
|
|
|
|
value: propValue,
|
|
|
|
|
onChange: propOnChange,
|
|
|
|
|
columns: propColumns,
|
|
|
|
|
calculationRules: propCalculationRules,
|
|
|
|
|
readOnly: propReadOnly,
|
|
|
|
|
showRowNumber: propShowRowNumber,
|
|
|
|
|
allowDelete: propAllowDelete,
|
2025-12-09 15:12:59 +09:00
|
|
|
allowAdd: propAllowAdd,
|
2025-11-28 11:48:46 +09:00
|
|
|
maxHeight: propMaxHeight,
|
|
|
|
|
|
2025-12-10 16:47:48 +09:00
|
|
|
// DynamicComponentRenderer에서 전달되는 props (DOM 전달 방지를 위해 _ prefix 사용)
|
|
|
|
|
_initialData,
|
|
|
|
|
_originalData,
|
|
|
|
|
_groupedData,
|
|
|
|
|
// 레거시 호환성 (일부 컴포넌트에서 직접 전달할 수 있음)
|
|
|
|
|
initialData: legacyInitialData,
|
|
|
|
|
originalData: legacyOriginalData,
|
|
|
|
|
groupedData: legacyGroupedData,
|
2025-12-10 10:27:54 +09:00
|
|
|
|
2025-11-28 11:48:46 +09:00
|
|
|
...props
|
2025-12-10 10:27:54 +09:00
|
|
|
}: SimpleRepeaterTableComponentProps & {
|
2025-12-10 16:47:48 +09:00
|
|
|
_initialData?: any;
|
|
|
|
|
_originalData?: any;
|
|
|
|
|
_groupedData?: any;
|
2025-12-10 10:27:54 +09:00
|
|
|
initialData?: any;
|
|
|
|
|
originalData?: any;
|
|
|
|
|
groupedData?: any;
|
|
|
|
|
}) {
|
2025-12-10 16:47:48 +09:00
|
|
|
// 실제 사용할 데이터 (새 props 우선, 레거시 fallback)
|
|
|
|
|
const effectiveInitialData = _initialData || legacyInitialData;
|
|
|
|
|
const effectiveOriginalData = _originalData || legacyOriginalData;
|
|
|
|
|
const effectiveGroupedData = _groupedData || legacyGroupedData;
|
2025-11-28 11:48:46 +09:00
|
|
|
// config 또는 component.config 또는 개별 prop 우선순위로 병합
|
|
|
|
|
const componentConfig = {
|
|
|
|
|
...config,
|
|
|
|
|
...component?.config,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// config prop 우선, 없으면 개별 prop 사용
|
|
|
|
|
const columns = componentConfig?.columns || propColumns || [];
|
|
|
|
|
const calculationRules = componentConfig?.calculationRules || propCalculationRules || [];
|
|
|
|
|
const readOnly = componentConfig?.readOnly ?? propReadOnly ?? false;
|
|
|
|
|
const showRowNumber = componentConfig?.showRowNumber ?? propShowRowNumber ?? true;
|
|
|
|
|
const allowDelete = componentConfig?.allowDelete ?? propAllowDelete ?? true;
|
2025-12-09 15:12:59 +09:00
|
|
|
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;
|
2025-11-28 11:48:46 +09:00
|
|
|
const maxHeight = componentConfig?.maxHeight || propMaxHeight || "240px";
|
|
|
|
|
|
|
|
|
|
// value는 formData[columnName] 우선, 없으면 prop 사용
|
|
|
|
|
const columnName = component?.columnName;
|
|
|
|
|
const value = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
|
|
|
|
|
|
|
|
|
|
// 🆕 로딩 상태
|
|
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
// onChange 래퍼 (기존 onChange 콜백 + onFormDataChange 호출)
|
|
|
|
|
const handleChange = (newData: any[]) => {
|
|
|
|
|
// 기존 onChange 콜백 호출 (호환성)
|
|
|
|
|
const externalOnChange = componentConfig?.onChange || propOnChange;
|
|
|
|
|
if (externalOnChange) {
|
|
|
|
|
externalOnChange(newData);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// onFormDataChange 호출하여 EditModal의 groupData 업데이트
|
|
|
|
|
if (onFormDataChange && columnName) {
|
|
|
|
|
onFormDataChange(columnName, newData);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 계산 hook
|
|
|
|
|
const { calculateRow, calculateAll } = useCalculation(calculationRules);
|
|
|
|
|
|
|
|
|
|
// 🆕 초기 데이터 로드
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const loadInitialData = async () => {
|
|
|
|
|
const initialConfig = componentConfig?.initialDataConfig;
|
|
|
|
|
if (!initialConfig || !initialConfig.sourceTable) {
|
|
|
|
|
return; // 초기 데이터 설정이 없으면 로드하지 않음
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setIsLoading(true);
|
|
|
|
|
setLoadError(null);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// 필터 조건 생성
|
|
|
|
|
const filters: Record<string, any> = {};
|
|
|
|
|
|
|
|
|
|
if (initialConfig.filterConditions) {
|
|
|
|
|
for (const condition of initialConfig.filterConditions) {
|
|
|
|
|
let filterValue = condition.value;
|
|
|
|
|
|
|
|
|
|
// formData에서 값 가져오기
|
|
|
|
|
if (condition.valueFromField && formData) {
|
|
|
|
|
filterValue = formData[condition.valueFromField];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
filters[condition.field] = filterValue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// API 호출
|
|
|
|
|
const response = await apiClient.post(
|
|
|
|
|
`/table-management/tables/${initialConfig.sourceTable}/data`,
|
|
|
|
|
{
|
|
|
|
|
search: filters,
|
|
|
|
|
page: 1,
|
|
|
|
|
size: 1000, // 대량 조회
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (response.data.success && response.data.data?.data) {
|
|
|
|
|
const loadedData = response.data.data.data;
|
|
|
|
|
|
|
|
|
|
// 1. 기본 데이터 매핑 (Direct & Manual)
|
|
|
|
|
const baseMappedData = loadedData.map((row: any) => {
|
|
|
|
|
const mappedRow: any = { ...row }; // 원본 데이터 유지 (조인 키 참조용)
|
|
|
|
|
|
|
|
|
|
for (const col of columns) {
|
|
|
|
|
if (col.sourceConfig) {
|
|
|
|
|
if (col.sourceConfig.type === "direct" && col.sourceConfig.sourceColumn) {
|
|
|
|
|
mappedRow[col.field] = row[col.sourceConfig.sourceColumn];
|
|
|
|
|
} else if (col.sourceConfig.type === "manual") {
|
|
|
|
|
mappedRow[col.field] = col.defaultValue;
|
|
|
|
|
}
|
|
|
|
|
// Join은 2단계에서 처리
|
|
|
|
|
} else {
|
|
|
|
|
mappedRow[col.field] = row[col.field] ?? col.defaultValue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return mappedRow;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 2. 조인 데이터 처리
|
|
|
|
|
const joinColumns = columns.filter(
|
|
|
|
|
(col) => col.sourceConfig?.type === "join" && col.sourceConfig.joinTable && col.sourceConfig.joinKey
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (joinColumns.length > 0) {
|
|
|
|
|
// 조인 테이블별로 그룹화
|
|
|
|
|
const joinGroups = new Map<string, { key: string; refKey: string; cols: typeof columns }>();
|
|
|
|
|
|
|
|
|
|
joinColumns.forEach((col) => {
|
|
|
|
|
const table = col.sourceConfig!.joinTable!;
|
|
|
|
|
const key = col.sourceConfig!.joinKey!;
|
|
|
|
|
// refKey가 없으면 key와 동일하다고 가정 (하위 호환성)
|
|
|
|
|
const refKey = col.sourceConfig!.joinRefKey || key;
|
|
|
|
|
const groupKey = `${table}:${key}:${refKey}`;
|
|
|
|
|
|
|
|
|
|
if (!joinGroups.has(groupKey)) {
|
|
|
|
|
joinGroups.set(groupKey, { key, refKey, cols: [] });
|
|
|
|
|
}
|
|
|
|
|
joinGroups.get(groupKey)!.cols.push(col);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 각 그룹별로 데이터 조회 및 병합
|
|
|
|
|
await Promise.all(
|
|
|
|
|
Array.from(joinGroups.entries()).map(async ([groupKey, { key, refKey, cols }]) => {
|
|
|
|
|
const [tableName] = groupKey.split(":");
|
|
|
|
|
|
|
|
|
|
// 조인 키 값 수집 (중복 제거)
|
|
|
|
|
const keyValues = Array.from(new Set(
|
|
|
|
|
baseMappedData
|
|
|
|
|
.map((row: any) => row[key])
|
|
|
|
|
.filter((v: any) => v !== undefined && v !== null)
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
if (keyValues.length === 0) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// 조인 테이블 조회
|
|
|
|
|
// refKey(타겟 테이블 컬럼)로 검색
|
|
|
|
|
const response = await apiClient.post(
|
|
|
|
|
`/table-management/tables/${tableName}/data`,
|
|
|
|
|
{
|
|
|
|
|
search: { [refKey]: keyValues }, // { id: [1, 2, 3] }
|
|
|
|
|
page: 1,
|
|
|
|
|
size: 1000,
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (response.data.success && response.data.data?.data) {
|
|
|
|
|
const joinedRows = response.data.data.data;
|
|
|
|
|
// 조인 데이터 맵 생성 (refKey -> row)
|
|
|
|
|
const joinMap = new Map(joinedRows.map((r: any) => [r[refKey], r]));
|
|
|
|
|
|
|
|
|
|
// 데이터 병합
|
|
|
|
|
baseMappedData.forEach((row: any) => {
|
|
|
|
|
const keyValue = row[key];
|
|
|
|
|
const joinedRow = joinMap.get(keyValue);
|
|
|
|
|
|
|
|
|
|
if (joinedRow) {
|
|
|
|
|
cols.forEach((col) => {
|
|
|
|
|
if (col.sourceConfig?.joinColumn) {
|
|
|
|
|
row[col.field] = joinedRow[col.sourceConfig.joinColumn];
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error(`조인 실패 (${tableName}):`, error);
|
|
|
|
|
// 실패 시 무시하고 진행 (값은 undefined)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const mappedData = baseMappedData;
|
|
|
|
|
|
|
|
|
|
// 계산 필드 적용
|
|
|
|
|
const calculatedData = calculateAll(mappedData);
|
|
|
|
|
handleChange(calculatedData);
|
|
|
|
|
}
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
console.error("초기 데이터 로드 실패:", error);
|
|
|
|
|
setLoadError(error.message || "데이터를 불러올 수 없습니다");
|
|
|
|
|
} finally {
|
|
|
|
|
setIsLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
loadInitialData();
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [componentConfig?.initialDataConfig]);
|
|
|
|
|
|
|
|
|
|
// 초기 데이터에 계산 필드 적용
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (value.length > 0 && calculationRules.length > 0) {
|
|
|
|
|
const calculated = calculateAll(value);
|
|
|
|
|
// 값이 실제로 변경된 경우만 업데이트
|
|
|
|
|
if (JSON.stringify(calculated) !== JSON.stringify(value)) {
|
|
|
|
|
handleChange(calculated);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 🆕 저장 요청 시 테이블별로 데이터 그룹화 (beforeFormSave 이벤트 리스너)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleSaveRequest = async (event: Event) => {
|
|
|
|
|
if (value.length === 0) {
|
2025-12-10 10:27:54 +09:00
|
|
|
// console.warn("⚠️ [SimpleRepeaterTable] 저장할 데이터 없음");
|
2025-11-28 11:48:46 +09:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 🆕 테이블별로 데이터 그룹화
|
|
|
|
|
const dataByTable: Record<string, any[]> = {};
|
|
|
|
|
|
|
|
|
|
for (const row of value) {
|
|
|
|
|
// 각 행의 데이터를 테이블별로 분리
|
|
|
|
|
for (const col of columns) {
|
|
|
|
|
// 저장 설정이 있고 저장이 활성화된 경우에만
|
|
|
|
|
if (col.targetConfig && col.targetConfig.targetTable && col.targetConfig.saveEnabled !== false) {
|
|
|
|
|
const targetTable = col.targetConfig.targetTable;
|
|
|
|
|
const targetColumn = col.targetConfig.targetColumn || col.field;
|
|
|
|
|
|
|
|
|
|
// 테이블 그룹 초기화
|
|
|
|
|
if (!dataByTable[targetTable]) {
|
|
|
|
|
dataByTable[targetTable] = [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 해당 테이블의 데이터 찾기 또는 생성
|
|
|
|
|
let tableRow = dataByTable[targetTable].find((r: any) => r._rowIndex === row._rowIndex);
|
|
|
|
|
if (!tableRow) {
|
|
|
|
|
tableRow = { _rowIndex: row._rowIndex };
|
|
|
|
|
dataByTable[targetTable].push(tableRow);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 컬럼 값 저장
|
|
|
|
|
tableRow[targetColumn] = row[col.field];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// _rowIndex 제거
|
|
|
|
|
Object.keys(dataByTable).forEach((tableName) => {
|
|
|
|
|
dataByTable[tableName] = dataByTable[tableName].map((row: any) => {
|
|
|
|
|
const { _rowIndex, ...rest } = row;
|
|
|
|
|
return rest;
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-10 10:27:54 +09:00
|
|
|
// console.log("✅ [SimpleRepeaterTable] 테이블별 저장 데이터:", dataByTable);
|
2025-11-28 11:48:46 +09:00
|
|
|
|
|
|
|
|
// CustomEvent의 detail에 테이블별 데이터 추가
|
|
|
|
|
if (event instanceof CustomEvent && event.detail) {
|
|
|
|
|
// 각 테이블별로 데이터 전달
|
|
|
|
|
Object.entries(dataByTable).forEach(([tableName, rows]) => {
|
|
|
|
|
const key = `${columnName || component?.id}_${tableName}`;
|
|
|
|
|
event.detail.formData[key] = rows.map((row: any) => ({
|
|
|
|
|
...row,
|
|
|
|
|
_targetTable: tableName,
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-10 10:27:54 +09:00
|
|
|
// console.log("✅ [SimpleRepeaterTable] 저장 데이터 준비:", {
|
|
|
|
|
// tables: Object.keys(dataByTable),
|
|
|
|
|
// totalRows: Object.values(dataByTable).reduce((sum, rows) => sum + rows.length, 0),
|
|
|
|
|
// });
|
2025-11-28 11:48:46 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 기존 onFormDataChange도 호출 (호환성)
|
|
|
|
|
if (onFormDataChange && columnName) {
|
|
|
|
|
// 테이블별 데이터를 통합하여 전달
|
|
|
|
|
onFormDataChange(columnName, Object.entries(dataByTable).flatMap(([table, rows]) =>
|
|
|
|
|
rows.map((row: any) => ({ ...row, _targetTable: table }))
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 저장 버튼 클릭 시 데이터 수집
|
|
|
|
|
window.addEventListener("beforeFormSave", handleSaveRequest as EventListener);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener);
|
|
|
|
|
};
|
|
|
|
|
}, [value, columns, columnName, component?.id, onFormDataChange]);
|
|
|
|
|
|
|
|
|
|
const handleCellEdit = (rowIndex: number, field: string, cellValue: any) => {
|
|
|
|
|
const newRow = { ...value[rowIndex], [field]: cellValue };
|
|
|
|
|
|
|
|
|
|
// 계산 필드 업데이트
|
|
|
|
|
const calculatedRow = calculateRow(newRow);
|
|
|
|
|
|
|
|
|
|
const newData = [...value];
|
|
|
|
|
newData[rowIndex] = calculatedRow;
|
|
|
|
|
handleChange(newData);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleRowDelete = (rowIndex: number) => {
|
2025-12-09 15:12:59 +09:00
|
|
|
// 최소 행 수 체크
|
|
|
|
|
if (value.length <= minRows) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-11-28 11:48:46 +09:00
|
|
|
const newData = value.filter((_, i) => i !== rowIndex);
|
|
|
|
|
handleChange(newData);
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-09 15:12:59 +09:00
|
|
|
// 행 추가 함수
|
|
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-28 11:48:46 +09:00
|
|
|
const renderCell = (
|
|
|
|
|
row: any,
|
|
|
|
|
column: SimpleRepeaterColumnConfig,
|
|
|
|
|
rowIndex: number
|
|
|
|
|
) => {
|
|
|
|
|
const cellValue = row[column.field];
|
|
|
|
|
|
|
|
|
|
// 계산 필드는 편집 불가
|
|
|
|
|
if (column.calculated || !column.editable || readOnly) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="px-2 py-1">
|
|
|
|
|
{column.type === "number"
|
|
|
|
|
? typeof cellValue === "number"
|
|
|
|
|
? cellValue.toLocaleString()
|
|
|
|
|
: cellValue || "0"
|
|
|
|
|
: cellValue || "-"}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 편집 가능한 필드
|
|
|
|
|
switch (column.type) {
|
|
|
|
|
case "number":
|
|
|
|
|
return (
|
|
|
|
|
<Input
|
|
|
|
|
type="number"
|
|
|
|
|
value={cellValue || ""}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)
|
|
|
|
|
}
|
|
|
|
|
className="h-7 text-xs"
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "date":
|
|
|
|
|
return (
|
|
|
|
|
<Input
|
|
|
|
|
type="date"
|
|
|
|
|
value={cellValue || ""}
|
|
|
|
|
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
|
|
|
|
|
className="h-7 text-xs"
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "select":
|
|
|
|
|
return (
|
|
|
|
|
<Select
|
|
|
|
|
value={cellValue || ""}
|
|
|
|
|
onValueChange={(newValue) =>
|
|
|
|
|
handleCellEdit(rowIndex, column.field, newValue)
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-7 text-xs">
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
2025-12-29 17:42:30 +09:00
|
|
|
{column.selectOptions?.filter((option) => option.value && option.value !== "").map((option) => (
|
2025-11-28 11:48:46 +09:00
|
|
|
<SelectItem key={option.value} value={option.value}>
|
|
|
|
|
{option.label}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
default: // text
|
|
|
|
|
return (
|
|
|
|
|
<Input
|
|
|
|
|
type="text"
|
|
|
|
|
value={cellValue || ""}
|
|
|
|
|
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
|
|
|
|
|
className="h-7 text-xs"
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 로딩 중일 때
|
|
|
|
|
if (isLoading) {
|
|
|
|
|
return (
|
|
|
|
|
<div className={cn("border rounded-md overflow-hidden bg-background", className)}>
|
|
|
|
|
<div className="flex items-center justify-center py-12" style={{ minHeight: maxHeight }}>
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto mb-2" />
|
|
|
|
|
<p className="text-sm text-muted-foreground">데이터를 불러오는 중...</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 에러 발생 시
|
|
|
|
|
if (loadError) {
|
|
|
|
|
return (
|
|
|
|
|
<div className={cn("border rounded-md overflow-hidden bg-background", className)}>
|
|
|
|
|
<div className="flex items-center justify-center py-12" style={{ minHeight: maxHeight }}>
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center mx-auto mb-2">
|
|
|
|
|
<X className="h-6 w-6 text-destructive" />
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-sm font-medium text-destructive mb-1">데이터 로드 실패</p>
|
|
|
|
|
<p className="text-xs text-muted-foreground">{loadError}</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-09 15:12:59 +09:00
|
|
|
// 테이블 컬럼 수 계산
|
|
|
|
|
const totalColumns = columns.length + (showRowNumber ? 1 : 0) + (allowDelete && !readOnly ? 1 : 0);
|
|
|
|
|
|
2025-11-28 11:48:46 +09:00
|
|
|
return (
|
|
|
|
|
<div className={cn("border rounded-md overflow-hidden bg-background", className)}>
|
2025-12-09 15:12:59 +09:00
|
|
|
{/* 상단 행 추가 버튼 */}
|
|
|
|
|
{allowAdd && addButtonPosition !== "bottom" && (
|
|
|
|
|
<div className="p-2 border-b bg-muted/50">
|
|
|
|
|
<AddRowButton />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-11-28 11:48:46 +09:00
|
|
|
<div
|
|
|
|
|
className="overflow-x-auto overflow-y-auto"
|
|
|
|
|
style={{ maxHeight }}
|
|
|
|
|
>
|
|
|
|
|
<table className="w-full text-xs sm:text-sm">
|
|
|
|
|
<thead className="bg-muted sticky top-0 z-10">
|
|
|
|
|
<tr>
|
|
|
|
|
{showRowNumber && (
|
|
|
|
|
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
|
|
|
|
|
#
|
|
|
|
|
</th>
|
|
|
|
|
)}
|
|
|
|
|
{columns.map((col) => (
|
|
|
|
|
<th
|
|
|
|
|
key={col.field}
|
|
|
|
|
className="px-4 py-2 text-left font-medium text-muted-foreground"
|
|
|
|
|
style={{ width: col.width }}
|
|
|
|
|
>
|
|
|
|
|
{col.label}
|
|
|
|
|
{col.required && <span className="text-destructive ml-1">*</span>}
|
|
|
|
|
</th>
|
|
|
|
|
))}
|
|
|
|
|
{!readOnly && allowDelete && (
|
|
|
|
|
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-20">
|
|
|
|
|
삭제
|
|
|
|
|
</th>
|
|
|
|
|
)}
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody className="bg-background">
|
|
|
|
|
{value.length === 0 ? (
|
|
|
|
|
<tr>
|
|
|
|
|
<td
|
2025-12-09 15:12:59 +09:00
|
|
|
colSpan={totalColumns}
|
2025-11-28 11:48:46 +09:00
|
|
|
className="px-4 py-8 text-center text-muted-foreground"
|
|
|
|
|
>
|
2025-12-09 15:12:59 +09:00
|
|
|
{allowAdd ? (
|
|
|
|
|
<div className="flex flex-col items-center gap-2">
|
|
|
|
|
<span>표시할 데이터가 없습니다</span>
|
|
|
|
|
<AddRowButton />
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
"표시할 데이터가 없습니다"
|
|
|
|
|
)}
|
2025-11-28 11:48:46 +09:00
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
) : (
|
|
|
|
|
value.map((row, rowIndex) => (
|
|
|
|
|
<tr key={rowIndex} className="border-t hover:bg-accent/50">
|
|
|
|
|
{showRowNumber && (
|
|
|
|
|
<td className="px-4 py-2 text-center text-muted-foreground">
|
|
|
|
|
{rowIndex + 1}
|
|
|
|
|
</td>
|
|
|
|
|
)}
|
|
|
|
|
{columns.map((col) => (
|
|
|
|
|
<td key={col.field} className="px-2 py-1">
|
|
|
|
|
{renderCell(row, col, rowIndex)}
|
|
|
|
|
</td>
|
|
|
|
|
))}
|
|
|
|
|
{!readOnly && allowDelete && (
|
|
|
|
|
<td className="px-4 py-2 text-center">
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => handleRowDelete(rowIndex)}
|
2025-12-09 15:12:59 +09:00
|
|
|
disabled={value.length <= minRows}
|
|
|
|
|
className="h-7 w-7 p-0 text-destructive hover:text-destructive disabled:opacity-50"
|
2025-11-28 11:48:46 +09:00
|
|
|
>
|
|
|
|
|
<Trash2 className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</td>
|
|
|
|
|
)}
|
|
|
|
|
</tr>
|
|
|
|
|
))
|
|
|
|
|
)}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
2025-12-09 15:12:59 +09:00
|
|
|
|
|
|
|
|
{/* 합계 표시 */}
|
|
|
|
|
{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>
|
|
|
|
|
)}
|
2025-11-28 11:48:46 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|