"use client"; /** * UnifiedRepeater 컴포넌트 * * 렌더링 모드: * - inline: 현재 테이블 컬럼 직접 입력 * - modal: 엔티티 선택 (FK 저장) + 추가 입력 컬럼 * * RepeaterTable 및 ItemSelectionModal 재사용 */ import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Plus, Columns } from "lucide-react"; import { cn } from "@/lib/utils"; import { UnifiedRepeaterConfig, UnifiedRepeaterProps, RepeaterColumnConfig as UnifiedColumnConfig, DEFAULT_REPEATER_CONFIG, } from "@/types/unified-repeater"; import { apiClient } from "@/lib/api/client"; // modal-repeater-table 컴포넌트 재사용 import { RepeaterTable } from "@/lib/registry/components/modal-repeater-table/RepeaterTable"; import { ItemSelectionModal } from "@/lib/registry/components/modal-repeater-table/ItemSelectionModal"; import { RepeaterColumnConfig } from "@/lib/registry/components/modal-repeater-table/types"; // 전역 UnifiedRepeater 등록 (buttonActions에서 사용) declare global { interface Window { __unifiedRepeaterInstances?: Set; } } export const UnifiedRepeater: React.FC = ({ config: propConfig, parentId, data: initialData, onDataChange, onRowClick, className, }) => { // 설정 병합 const config: UnifiedRepeaterConfig = useMemo( () => ({ ...DEFAULT_REPEATER_CONFIG, ...propConfig, dataSource: { ...DEFAULT_REPEATER_CONFIG.dataSource, ...propConfig.dataSource }, features: { ...DEFAULT_REPEATER_CONFIG.features, ...propConfig.features }, modal: { ...DEFAULT_REPEATER_CONFIG.modal, ...propConfig.modal }, }), [propConfig], ); // 상태 const [data, setData] = useState(initialData || []); const [selectedRows, setSelectedRows] = useState>(new Set()); const [modalOpen, setModalOpen] = useState(false); const [equalizeWidthsTrigger, setEqualizeWidthsTrigger] = useState(0); // 소스 테이블 컬럼 라벨 매핑 const [sourceColumnLabels, setSourceColumnLabels] = useState>({}); // 현재 테이블 컬럼 정보 (inputType 매핑용) const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState>({}); // 동적 데이터 소스 상태 const [activeDataSources, setActiveDataSources] = useState>({}); const isModalMode = config.renderMode === "modal"; // 전역 리피터 등록 useEffect(() => { const tableName = config.dataSource?.tableName; if (tableName) { if (!window.__unifiedRepeaterInstances) { window.__unifiedRepeaterInstances = new Set(); } window.__unifiedRepeaterInstances.add(tableName); } return () => { if (tableName && window.__unifiedRepeaterInstances) { window.__unifiedRepeaterInstances.delete(tableName); } }; }, [config.dataSource?.tableName]); // 저장 이벤트 리스너 useEffect(() => { const handleSaveEvent = async (event: CustomEvent) => { const tableName = config.dataSource?.tableName; const eventParentId = event.detail?.parentId; const mainFormData = event.detail?.mainFormData; if (!tableName || data.length === 0) { return; } try { // 테이블 유효 컬럼 조회 let validColumns: Set = new Set(); try { const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`); const columns = columnsResponse.data?.data?.columns || columnsResponse.data?.columns || columnsResponse.data || []; validColumns = new Set(columns.map((col: any) => col.columnName || col.column_name || col.name)); } catch { console.warn("테이블 컬럼 정보 조회 실패"); } for (let i = 0; i < data.length; i++) { const row = data[i]; // 내부 필드 제거 const cleanRow = Object.fromEntries( Object.entries(row).filter(([key]) => !key.startsWith("_")) ); // 메인 폼 데이터 병합 const { id: _mainId, ...mainFormDataWithoutId } = mainFormData || {}; const mergedData = { ...mainFormDataWithoutId, ...cleanRow, }; // 유효하지 않은 컬럼 제거 const filteredData: Record = {}; for (const [key, value] of Object.entries(mergedData)) { if (validColumns.size === 0 || validColumns.has(key)) { filteredData[key] = value; } } await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData); } console.log("UnifiedRepeater 저장 완료:", data.length, "건"); } catch (error) { console.error("UnifiedRepeater 저장 실패:", error); throw error; } }; window.addEventListener("repeaterSave" as any, handleSaveEvent); return () => { window.removeEventListener("repeaterSave" as any, handleSaveEvent); }; }, [data, config.dataSource?.tableName, parentId]); // 현재 테이블 컬럼 정보 로드 useEffect(() => { const loadCurrentTableColumnInfo = async () => { const tableName = config.dataSource?.tableName; if (!tableName) return; try { const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); const columns = response.data?.data?.columns || response.data?.columns || response.data || []; const columnMap: Record = {}; columns.forEach((col: any) => { const name = col.columnName || col.column_name || col.name; columnMap[name] = { inputType: col.inputType || col.input_type || col.webType || "text", displayName: col.displayName || col.display_name || col.label || name, detailSettings: col.detailSettings || col.detail_settings, }; }); setCurrentTableColumnInfo(columnMap); } catch (error) { console.error("컬럼 정보 로드 실패:", error); } }; loadCurrentTableColumnInfo(); }, [config.dataSource?.tableName]); // 소스 테이블 컬럼 라벨 로드 (modal 모드) useEffect(() => { const loadSourceColumnLabels = async () => { const sourceTable = config.dataSource?.sourceTable; if (!isModalMode || !sourceTable) return; try { const response = await apiClient.get(`/table-management/tables/${sourceTable}/columns`); const columns = response.data?.data?.columns || response.data?.columns || response.data || []; const labels: Record = {}; columns.forEach((col: any) => { const name = col.columnName || col.column_name || col.name; labels[name] = col.displayName || col.display_name || col.label || name; }); setSourceColumnLabels(labels); } catch (error) { console.error("소스 컬럼 라벨 로드 실패:", error); } }; loadSourceColumnLabels(); }, [config.dataSource?.sourceTable, isModalMode]); // UnifiedColumnConfig → RepeaterColumnConfig 변환 const repeaterColumns: RepeaterColumnConfig[] = useMemo(() => { const displayColumns: RepeaterColumnConfig[] = []; // 모달 표시 컬럼 추가 (읽기 전용) if (isModalMode && config.modal?.sourceDisplayColumns) { config.modal.sourceDisplayColumns.forEach((col) => { const key = typeof col === "string" ? col : col.key; const label = typeof col === "string" ? sourceColumnLabels[col] || col : col.label || sourceColumnLabels[key] || key; if (key && key !== "none") { displayColumns.push({ field: `_display_${key}`, label, type: "text", editable: false, calculated: true, }); } }); } // 입력 컬럼 추가 const inputColumns = config.columns.map((col: UnifiedColumnConfig): RepeaterColumnConfig => { const colInfo = currentTableColumnInfo[col.key]; const inputType = col.inputType || colInfo?.inputType || "text"; let type: "text" | "number" | "date" | "select" = "text"; if (inputType === "number" || inputType === "decimal") type = "number"; else if (inputType === "date" || inputType === "datetime") type = "date"; else if (inputType === "code") type = "select"; return { field: col.key, label: col.title || colInfo?.displayName || col.key, type, editable: col.editable !== false, width: col.width === "auto" ? undefined : col.width, required: false, }; }); return [...displayColumns, ...inputColumns]; }, [config.columns, config.modal?.sourceDisplayColumns, isModalMode, sourceColumnLabels, currentTableColumnInfo]); // 데이터 변경 핸들러 const handleDataChange = useCallback((newData: any[]) => { setData(newData); onDataChange?.(newData); }, [onDataChange]); // 행 변경 핸들러 const handleRowChange = useCallback((index: number, newRow: any) => { const newData = [...data]; newData[index] = newRow; setData(newData); onDataChange?.(newData); }, [data, onDataChange]); // 행 삭제 핸들러 const handleRowDelete = useCallback((index: number) => { const newData = data.filter((_, i) => i !== index); setData(newData); onDataChange?.(newData); // 선택 상태 업데이트 const newSelected = new Set(); selectedRows.forEach((i) => { if (i < index) newSelected.add(i); else if (i > index) newSelected.add(i - 1); }); setSelectedRows(newSelected); }, [data, selectedRows, onDataChange]); // 일괄 삭제 핸들러 const handleBulkDelete = useCallback(() => { const newData = data.filter((_, index) => !selectedRows.has(index)); setData(newData); onDataChange?.(newData); setSelectedRows(new Set()); }, [data, selectedRows, onDataChange]); // 행 추가 (inline 모드) const handleAddRow = useCallback(() => { if (isModalMode) { setModalOpen(true); } else { const newRow: any = { _id: `new_${Date.now()}` }; config.columns.forEach((col) => { newRow[col.key] = ""; }); const newData = [...data, newRow]; setData(newData); onDataChange?.(newData); } }, [isModalMode, config.columns, data, onDataChange]); // 모달에서 항목 선택 const handleSelectItems = useCallback((items: Record[]) => { const fkColumn = config.dataSource?.foreignKey; const refKey = config.dataSource?.referenceKey || "id"; const newRows = items.map((item) => { const row: any = { _id: `new_${Date.now()}_${Math.random()}` }; // FK 값 저장 if (fkColumn && item[refKey]) { row[fkColumn] = item[refKey]; } // 표시용 데이터 저장 if (config.modal?.sourceDisplayColumns) { config.modal.sourceDisplayColumns.forEach((col) => { const key = typeof col === "string" ? col : col.key; if (key && key !== "none") { row[`_display_${key}`] = item[key] || ""; } }); } // 입력 컬럼 초기화 config.columns.forEach((col) => { if (row[col.key] === undefined) { row[col.key] = ""; } }); return row; }); const newData = [...data, ...newRows]; setData(newData); onDataChange?.(newData); setModalOpen(false); }, [config.dataSource?.foreignKey, config.dataSource?.referenceKey, config.modal?.sourceDisplayColumns, config.columns, data, onDataChange]); // 소스 컬럼 목록 (모달용) const sourceColumns = useMemo(() => { if (!config.modal?.sourceDisplayColumns) return []; return config.modal.sourceDisplayColumns .map((col) => typeof col === "string" ? col : col.key) .filter((key) => key && key !== "none"); }, [config.modal?.sourceDisplayColumns]); return (
{/* 헤더 영역 */}
{data.length > 0 && `${data.length}개 항목`} {selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`} {repeaterColumns.length > 0 && ( )}
{selectedRows.size > 0 && ( )}
{/* Repeater 테이블 */} { setActiveDataSources((prev) => ({ ...prev, [field]: optionId })); }} selectedRows={selectedRows} onSelectionChange={setSelectedRows} equalizeWidthsTrigger={equalizeWidthsTrigger} /> {/* 항목 선택 모달 (modal 모드) */} {isModalMode && ( )}
); }; UnifiedRepeater.displayName = "UnifiedRepeater"; export default UnifiedRepeater;