- EditModal, InteractiveScreenViewer, SaveModal 컴포넌트에서 리피터 데이터(배열)를 마스터 저장에서 제외하고, 별도로 저장하는 로직을 추가하였습니다. - 리피터 데이터 저장 이벤트를 발생시켜 UnifiedRepeater 컴포넌트가 이를 리스닝하도록 개선하였습니다. - 각 컴포넌트에서 최종 저장 데이터 로그를 업데이트하여, 저장 과정에서의 데이터 흐름을 명확히 하였습니다. 이로 인해 데이터 저장의 효율성과 리피터 관리의 일관성이 향상되었습니다.
813 lines
28 KiB
TypeScript
813 lines
28 KiB
TypeScript
"use client";
|
|
|
|
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, Plus } from "lucide-react";
|
|
import { SimpleRepeaterTableProps, SimpleRepeaterColumnConfig, SummaryFieldConfig } from "./types";
|
|
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;
|
|
allowAdd?: boolean;
|
|
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,
|
|
allowAdd: propAllowAdd,
|
|
maxHeight: propMaxHeight,
|
|
|
|
// DynamicComponentRenderer에서 전달되는 props (DOM 전달 방지를 위해 _ prefix 사용)
|
|
_initialData,
|
|
_originalData,
|
|
_groupedData,
|
|
// 레거시 호환성 (일부 컴포넌트에서 직접 전달할 수 있음)
|
|
initialData: legacyInitialData,
|
|
originalData: legacyOriginalData,
|
|
groupedData: legacyGroupedData,
|
|
|
|
...props
|
|
}: SimpleRepeaterTableComponentProps & {
|
|
_initialData?: any;
|
|
_originalData?: any;
|
|
_groupedData?: any;
|
|
initialData?: any;
|
|
originalData?: any;
|
|
groupedData?: any;
|
|
}) {
|
|
// 실제 사용할 데이터 (새 props 우선, 레거시 fallback)
|
|
const effectiveInitialData = _initialData || legacyInitialData;
|
|
const effectiveOriginalData = _originalData || legacyOriginalData;
|
|
const effectiveGroupedData = _groupedData || legacyGroupedData;
|
|
// 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;
|
|
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";
|
|
|
|
// 🆕 컴포넌트 레벨의 저장 테이블 설정
|
|
const componentTargetTable = componentConfig?.targetTable || componentConfig?.saveTable;
|
|
const componentFkColumn = componentConfig?.fkColumn;
|
|
|
|
// 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) {
|
|
// console.warn("⚠️ [SimpleRepeaterTable] 저장할 데이터 없음");
|
|
return;
|
|
}
|
|
|
|
// 🆕 컴포넌트 레벨의 targetTable이 설정되어 있으면 우선 사용
|
|
if (componentTargetTable) {
|
|
console.log("✅ [SimpleRepeaterTable] 컴포넌트 레벨 저장 테이블 사용:", componentTargetTable);
|
|
|
|
// 모든 행을 해당 테이블에 저장
|
|
const dataToSave = value.map((row: any) => {
|
|
// 메타데이터 필드 제외 (_, _rowIndex 등)
|
|
const cleanRow: Record<string, any> = {};
|
|
Object.keys(row).forEach((key) => {
|
|
if (!key.startsWith("_")) {
|
|
cleanRow[key] = row[key];
|
|
}
|
|
});
|
|
return {
|
|
...cleanRow,
|
|
_targetTable: componentTargetTable,
|
|
};
|
|
});
|
|
|
|
// CustomEvent의 detail에 데이터 추가
|
|
if (event instanceof CustomEvent && event.detail) {
|
|
const key = columnName || component?.id || "repeater_data";
|
|
event.detail.formData[key] = dataToSave;
|
|
console.log("✅ [SimpleRepeaterTable] 저장 데이터 준비:", {
|
|
key,
|
|
targetTable: componentTargetTable,
|
|
itemCount: dataToSave.length,
|
|
});
|
|
}
|
|
|
|
// 기존 onFormDataChange도 호출 (호환성)
|
|
if (onFormDataChange && columnName) {
|
|
onFormDataChange(columnName, dataToSave);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 🆕 컬럼별 targetConfig가 있는 경우 기존 로직 사용
|
|
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];
|
|
}
|
|
}
|
|
}
|
|
|
|
// 컬럼별 설정도 없으면 기본 동작 (formData에 직접 추가)
|
|
if (Object.keys(dataByTable).length === 0) {
|
|
console.log("⚠️ [SimpleRepeaterTable] targetTable 설정 없음 - 기본 저장");
|
|
if (event instanceof CustomEvent && event.detail) {
|
|
const key = columnName || component?.id || "repeater_data";
|
|
event.detail.formData[key] = value;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// _rowIndex 제거
|
|
Object.keys(dataByTable).forEach((tableName) => {
|
|
dataByTable[tableName] = dataByTable[tableName].map((row: any) => {
|
|
const { _rowIndex, ...rest } = row;
|
|
return rest;
|
|
});
|
|
});
|
|
|
|
// console.log("✅ [SimpleRepeaterTable] 테이블별 저장 데이터:", dataByTable);
|
|
|
|
// 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,
|
|
}));
|
|
});
|
|
|
|
// console.log("✅ [SimpleRepeaterTable] 저장 데이터 준비:", {
|
|
// tables: Object.keys(dataByTable),
|
|
// totalRows: Object.values(dataByTable).reduce((sum, rows) => sum + rows.length, 0),
|
|
// });
|
|
}
|
|
|
|
// 기존 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, componentTargetTable]);
|
|
|
|
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) => {
|
|
// 최소 행 수 체크
|
|
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,
|
|
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>
|
|
{column.selectOptions?.filter((option) => option.value && option.value !== "").map((option) => (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
// 테이블 컬럼 수 계산
|
|
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 }}
|
|
>
|
|
<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
|
|
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>
|
|
) : (
|
|
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)}
|
|
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>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
))
|
|
)}
|
|
</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>
|
|
);
|
|
}
|
|
|