ERP-node/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponen...

536 lines
19 KiB
TypeScript

"use client";
import React, { useEffect, useState } 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 { 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;
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,
maxHeight: propMaxHeight,
...props
}: SimpleRepeaterTableComponentProps) {
// 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 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) {
console.warn("⚠️ [SimpleRepeaterTable] 저장할 데이터 없음");
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;
});
});
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]);
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) => {
const newData = value.filter((_, i) => i !== rowIndex);
handleChange(newData);
};
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?.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>
);
}
return (
<div className={cn("border rounded-md overflow-hidden bg-background", className)}>
<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={columns.length + (showRowNumber ? 1 : 0) + (allowDelete && !readOnly ? 1 : 0)}
className="px-4 py-8 text-center text-muted-foreground"
>
</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)}
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</td>
)}
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}