"use client"; import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { TableListConfig, ColumnConfig } from "./types"; import { WebType } from "@/types/common"; import { tableTypeApi } from "@/lib/api/screen"; import { entityJoinApi } from "@/lib/api/entityJoin"; import { codeCache } from "@/lib/caching/codeCache"; import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization"; import { Button } from "@/components/ui/button"; import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, RefreshCw, ArrowUp, ArrowDown, TableIcon, Settings, X, } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearchFilters"; import { SingleTableWithSticky } from "./SingleTableWithSticky"; import { CardModeRenderer } from "./CardModeRenderer"; // ======================================== // 캐시 및 유틸리티 // ======================================== const tableColumnCache = new Map(); const tableInfoCache = new Map(); const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분 const cleanupTableCache = () => { const now = Date.now(); for (const [key, entry] of tableColumnCache.entries()) { if (now - entry.timestamp > TABLE_CACHE_TTL) { tableColumnCache.delete(key); } } for (const [key, entry] of tableInfoCache.entries()) { if (now - entry.timestamp > TABLE_CACHE_TTL) { tableInfoCache.delete(key); } } }; if (typeof window !== "undefined") { setInterval(cleanupTableCache, 10 * 60 * 1000); } const debounceTimers = new Map(); const activeRequests = new Map>(); const debouncedApiCall = (key: string, fn: (...args: T) => Promise, delay: number = 300) => { return (...args: T): Promise => { const activeRequest = activeRequests.get(key); if (activeRequest) { return activeRequest as Promise; } return new Promise((resolve, reject) => { const existingTimer = debounceTimers.get(key); if (existingTimer) { clearTimeout(existingTimer); } const timer = setTimeout(async () => { try { const requestPromise = fn(...args); activeRequests.set(key, requestPromise); const result = await requestPromise; resolve(result); } catch (error) { reject(error); } finally { debounceTimers.delete(key); activeRequests.delete(key); } }, delay); debounceTimers.set(key, timer); }); }; }; // ======================================== // Props 인터페이스 // ======================================== export interface TableListComponentProps { component: any; isDesignMode?: boolean; isSelected?: boolean; isInteractive?: boolean; onClick?: () => void; onDragStart?: (e: React.DragEvent) => void; onDragEnd?: (e: React.DragEvent) => void; className?: string; style?: React.CSSProperties; formData?: Record; onFormDataChange?: (data: any) => void; config?: TableListConfig; size?: { width: number; height: number }; position?: { x: number; y: number; z?: number }; componentConfig?: any; selectedScreen?: any; onZoneComponentDrop?: any; onZoneClick?: any; tableName?: string; onRefresh?: () => void; onClose?: () => void; screenId?: string; onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void; onConfigChange?: (config: any) => void; refreshKey?: number; } // ======================================== // 메인 컴포넌트 // ======================================== export const TableListComponent: React.FC = ({ component, isDesignMode = false, isSelected = false, onClick, onDragStart, onDragEnd, config, className, style, onFormDataChange, componentConfig, onSelectedRowsChange, onConfigChange, refreshKey, tableName, }) => { // ======================================== // 설정 및 스타일 // ======================================== const tableConfig = { ...config, ...component.config, ...componentConfig, } as TableListConfig; // selectedTable 안전하게 추출 (문자열인지 확인) let finalSelectedTable = componentConfig?.selectedTable || component.config?.selectedTable || config?.selectedTable || tableName; console.log("🔍 TableListComponent 초기화:", { componentConfigSelectedTable: componentConfig?.selectedTable, componentConfigSelectedTableType: typeof componentConfig?.selectedTable, componentConfigSelectedTable2: component.config?.selectedTable, componentConfigSelectedTable2Type: typeof component.config?.selectedTable, configSelectedTable: config?.selectedTable, configSelectedTableType: typeof config?.selectedTable, screenTableName: tableName, screenTableNameType: typeof tableName, finalSelectedTable, finalSelectedTableType: typeof finalSelectedTable, }); // 객체인 경우 tableName 속성 추출 시도 if (typeof finalSelectedTable === "object" && finalSelectedTable !== null) { console.warn("⚠️ selectedTable이 객체입니다:", finalSelectedTable); finalSelectedTable = (finalSelectedTable as any).tableName || (finalSelectedTable as any).name || tableName; console.log("✅ 객체에서 추출한 테이블명:", finalSelectedTable); } tableConfig.selectedTable = finalSelectedTable; console.log( "✅ 최종 tableConfig.selectedTable:", tableConfig.selectedTable, "타입:", typeof tableConfig.selectedTable, ); const buttonColor = component.style?.labelColor || "#212121"; const buttonTextColor = component.config?.buttonTextColor || "#ffffff"; const gridColumns = component.gridColumns || 1; let calculatedWidth: string; if (isDesignMode) { if (gridColumns === 1) { calculatedWidth = "400px"; } else if (gridColumns === 2) { calculatedWidth = "800px"; } else { calculatedWidth = "100%"; } } else { calculatedWidth = "100%"; } const componentStyle: React.CSSProperties = { width: calculatedWidth, height: isDesignMode ? "auto" : "100%", minHeight: isDesignMode ? "300px" : "100%", display: "flex", flexDirection: "column", backgroundColor: "hsl(var(--background))", overflow: "hidden", ...style, }; // ======================================== // 상태 관리 // ======================================== const [data, setData] = useState[]>([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(0); const [totalItems, setTotalItems] = useState(0); const [searchTerm, setSearchTerm] = useState(""); const [sortColumn, setSortColumn] = useState(null); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); const [columnLabels, setColumnLabels] = useState>({}); const [tableLabel, setTableLabel] = useState(""); const [localPageSize, setLocalPageSize] = useState(tableConfig.pagination?.pageSize || 20); const [displayColumns, setDisplayColumns] = useState([]); const [joinColumnMapping, setJoinColumnMapping] = useState>({}); const [columnMeta, setColumnMeta] = useState>({}); const [searchValues, setSearchValues] = useState>({}); const [selectedRows, setSelectedRows] = useState>(new Set()); const [isDragging, setIsDragging] = useState(false); const [draggedRowIndex, setDraggedRowIndex] = useState(null); const [isAllSelected, setIsAllSelected] = useState(false); // 필터 설정 관련 상태 const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false); const [visibleFilterColumns, setVisibleFilterColumns] = useState>(new Set()); const { optimizedConvertCode } = useEntityJoinOptimization(columnMeta, { enableBatchLoading: true, preloadCommonCodes: true, maxBatchSize: 5, }); // ======================================== // 컬럼 라벨 가져오기 // ======================================== const fetchColumnLabels = async () => { if (!tableConfig.selectedTable) return; try { const cacheKey = `columns_${tableConfig.selectedTable}`; const cached = tableColumnCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) { const labels: Record = {}; const meta: Record = {}; cached.columns.forEach((col: any) => { labels[col.columnName] = col.displayName || col.comment || col.columnName; meta[col.columnName] = { webType: col.webType, codeCategory: col.codeCategory, }; }); setColumnLabels(labels); setColumnMeta(meta); return; } const columns = await tableTypeApi.getColumns(tableConfig.selectedTable); // 컬럼 입력 타입 정보 가져오기 const inputTypes = await tableTypeApi.getColumnInputTypes(tableConfig.selectedTable); const inputTypeMap: Record = {}; inputTypes.forEach((col: any) => { inputTypeMap[col.columnName] = col.inputType; }); tableColumnCache.set(cacheKey, { columns, inputTypes, timestamp: Date.now(), }); const labels: Record = {}; const meta: Record = {}; columns.forEach((col: any) => { labels[col.columnName] = col.displayName || col.comment || col.columnName; meta[col.columnName] = { webType: col.webType, codeCategory: col.codeCategory, inputType: inputTypeMap[col.columnName], }; }); setColumnLabels(labels); setColumnMeta(meta); } catch (error) { console.error("컬럼 라벨 가져오기 실패:", error); } }; // ======================================== // 테이블 라벨 가져오기 // ======================================== const fetchTableLabel = async () => { if (!tableConfig.selectedTable) return; try { const cacheKey = `table_info_${tableConfig.selectedTable}`; const cached = tableInfoCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) { const tables = cached.tables || []; const tableInfo = tables.find((t: any) => t.tableName === tableConfig.selectedTable); const label = tableInfo?.displayName || (tableInfo as any)?.comment || tableInfo?.description || tableConfig.selectedTable; setTableLabel(label); return; } const tables = await tableTypeApi.getTables(); tableInfoCache.set(cacheKey, { tables, timestamp: Date.now(), }); const tableInfo = tables.find((t: any) => t.tableName === tableConfig.selectedTable); const label = tableInfo?.displayName || (tableInfo as any)?.comment || tableInfo?.description || tableConfig.selectedTable; setTableLabel(label); } catch (error) { console.error("테이블 라벨 가져오기 실패:", error); } }; // ======================================== // 데이터 가져오기 // ======================================== const fetchTableDataInternal = useCallback(async () => { if (!tableConfig.selectedTable || isDesignMode) { setData([]); setTotalPages(0); setTotalItems(0); return; } // 테이블명 확인 로그 console.log("🔍 fetchTableDataInternal - selectedTable:", tableConfig.selectedTable); console.log("🔍 selectedTable 타입:", typeof tableConfig.selectedTable); console.log("🔍 전체 tableConfig:", tableConfig); setLoading(true); setError(null); try { const page = tableConfig.pagination?.currentPage || currentPage; const pageSize = localPageSize; const sortBy = sortColumn || undefined; const sortOrder = sortDirection; const search = searchTerm || undefined; const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined; const entityJoinColumns = (tableConfig.columns || []) .filter((col) => col.additionalJoinInfo) .map((col) => ({ sourceTable: col.additionalJoinInfo!.sourceTable, sourceColumn: col.additionalJoinInfo!.sourceColumn, joinAlias: col.additionalJoinInfo!.joinAlias, referenceTable: col.additionalJoinInfo!.referenceTable, })); const hasEntityJoins = entityJoinColumns.length > 0; let response; if (hasEntityJoins) { response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, { page, size: pageSize, sortBy, sortOrder, search: filters, enableEntityJoin: true, additionalJoinColumns: entityJoinColumns, }); } else { response = await tableTypeApi.getTableData(tableConfig.selectedTable, { page, size: pageSize, sortBy, sortOrder, search: filters, }); } setData(response.data || []); setTotalPages(response.totalPages || 0); setTotalItems(response.total || 0); setError(null); } catch (err: any) { console.error("데이터 가져오기 실패:", err); setData([]); setTotalPages(0); setTotalItems(0); setError(err.message || "데이터를 불러오지 못했습니다."); } finally { setLoading(false); } }, [ tableConfig.selectedTable, tableConfig.pagination?.currentPage, tableConfig.columns, currentPage, localPageSize, sortColumn, sortDirection, searchTerm, searchValues, isDesignMode, ]); const fetchTableDataDebounced = useCallback( (...args: Parameters) => { const key = `fetchData_${tableConfig.selectedTable}_${currentPage}_${sortColumn}_${sortDirection}`; return debouncedApiCall(key, fetchTableDataInternal, 300)(...args); }, [fetchTableDataInternal, tableConfig.selectedTable, currentPage, sortColumn, sortDirection], ); // ======================================== // 이벤트 핸들러 // ======================================== const handlePageChange = (newPage: number) => { if (newPage < 1 || newPage > totalPages) return; setCurrentPage(newPage); if (tableConfig.pagination) { tableConfig.pagination.currentPage = newPage; } if (onConfigChange) { onConfigChange({ ...tableConfig, pagination: { ...tableConfig.pagination, currentPage: newPage } }); } }; const handleSort = (column: string) => { if (sortColumn === column) { setSortDirection(sortDirection === "asc" ? "desc" : "asc"); } else { setSortColumn(column); setSortDirection("asc"); } }; const handleSearchValueChange = (columnName: string, value: any) => { setSearchValues((prev) => ({ ...prev, [columnName]: value })); }; const handleAdvancedSearch = () => { setCurrentPage(1); fetchTableDataDebounced(); }; const handleClearAdvancedFilters = () => { setSearchValues({}); setCurrentPage(1); fetchTableDataDebounced(); }; const handleRefresh = () => { fetchTableDataDebounced(); }; const getRowKey = (row: any, index: number) => { return row.id || row.uuid || `row-${index}`; }; const handleRowSelection = (rowKey: string, checked: boolean) => { const newSelectedRows = new Set(selectedRows); if (checked) { newSelectedRows.add(rowKey); } else { newSelectedRows.delete(rowKey); } setSelectedRows(newSelectedRows); const selectedRowsData = data.filter((row, index) => newSelectedRows.has(getRowKey(row, index))); if (onSelectedRowsChange) { onSelectedRowsChange(Array.from(newSelectedRows), selectedRowsData); } if (onFormDataChange) { onFormDataChange({ selectedRows: Array.from(newSelectedRows), selectedRowsData }); } const allRowsSelected = data.every((row, index) => newSelectedRows.has(getRowKey(row, index))); setIsAllSelected(allRowsSelected && data.length > 0); }; const handleSelectAll = (checked: boolean) => { if (checked) { const allKeys = data.map((row, index) => getRowKey(row, index)); const newSelectedRows = new Set(allKeys); setSelectedRows(newSelectedRows); setIsAllSelected(true); if (onSelectedRowsChange) { onSelectedRowsChange(Array.from(newSelectedRows), data); } if (onFormDataChange) { onFormDataChange({ selectedRows: Array.from(newSelectedRows), selectedRowsData: data }); } } else { setSelectedRows(new Set()); setIsAllSelected(false); if (onSelectedRowsChange) { onSelectedRowsChange([], []); } if (onFormDataChange) { onFormDataChange({ selectedRows: [], selectedRowsData: [] }); } } }; const handleRowClick = (row: any) => { console.log("행 클릭:", row); }; const handleRowDragStart = (e: React.DragEvent, row: any, index: number) => { setIsDragging(true); setDraggedRowIndex(index); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("application/json", JSON.stringify(row)); }; const handleRowDragEnd = (e: React.DragEvent) => { setIsDragging(false); setDraggedRowIndex(null); }; const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); onClick?.(); }; // ======================================== // 컬럼 관련 // ======================================== const visibleColumns = useMemo(() => { let cols = (tableConfig.columns || []).filter((col) => col.visible !== false); if (tableConfig.checkbox?.enabled) { const checkboxCol: ColumnConfig = { columnName: "__checkbox__", displayName: "", visible: true, sortable: false, searchable: false, width: 50, align: "center", order: -1, }; if (tableConfig.checkbox.position === "right") { cols = [...cols, checkboxCol]; } else { cols = [checkboxCol, ...cols]; } } return cols.sort((a, b) => (a.order || 0) - (b.order || 0)); }, [tableConfig.columns, tableConfig.checkbox]); const getColumnWidth = (column: ColumnConfig) => { if (column.columnName === "__checkbox__") return 50; if (column.width) return column.width; switch (column.format) { case "date": return 120; case "number": case "currency": return 100; case "boolean": return 80; default: return 150; } }; const renderCheckboxHeader = () => { if (!tableConfig.checkbox?.selectAll) return null; return ; }; const renderCheckboxCell = (row: any, index: number) => { const rowKey = getRowKey(row, index); const isChecked = selectedRows.has(rowKey); return ( handleRowSelection(rowKey, checked as boolean)} aria-label={`행 ${index + 1} 선택`} /> ); }; const formatCellValue = useCallback( (value: any, column: ColumnConfig, rowData?: Record) => { if (value === null || value === undefined) return "-"; // 🎯 엔티티 컬럼 표시 설정이 있는 경우 if (column.entityDisplayConfig && rowData) { // displayColumns 또는 selectedColumns 둘 다 체크 const displayColumns = column.entityDisplayConfig.displayColumns || column.entityDisplayConfig.selectedColumns; const separator = column.entityDisplayConfig.separator; if (displayColumns && displayColumns.length > 0) { // 선택된 컬럼들의 값을 구분자로 조합 const values = displayColumns .map((colName) => { const cellValue = rowData[colName]; if (cellValue === null || cellValue === undefined) return ""; return String(cellValue); }) .filter((v) => v !== ""); // 빈 값 제외 return values.join(separator || " - "); } } const meta = columnMeta[column.columnName]; if (meta?.webType && meta?.codeCategory) { const convertedValue = optimizedConvertCode(value, meta.codeCategory); if (convertedValue !== value) return convertedValue; } // inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선) const inputType = meta?.inputType || column.inputType; if (inputType === "number" || inputType === "decimal") { if (value !== null && value !== undefined && value !== "") { const numValue = typeof value === "string" ? parseFloat(value) : value; if (!isNaN(numValue)) { return numValue.toLocaleString("ko-KR"); } } return String(value); } switch (column.format) { case "number": if (value !== null && value !== undefined && value !== "") { const numValue = typeof value === "string" ? parseFloat(value) : value; if (!isNaN(numValue)) { return numValue.toLocaleString("ko-KR"); } } return String(value); case "date": if (value) { try { const date = new Date(value); return date.toLocaleDateString("ko-KR"); } catch { return value; } } return "-"; case "number": return typeof value === "number" ? value.toLocaleString() : value; case "currency": return typeof value === "number" ? `₩${value.toLocaleString()}` : value; case "boolean": return value ? "예" : "아니오"; default: return String(value); } }, [columnMeta, optimizedConvertCode], ); // ======================================== // useEffect 훅 // ======================================== // 필터 설정 localStorage 키 생성 const filterSettingKey = useMemo(() => { if (!tableConfig.selectedTable) return null; return `tableList_filterSettings_${tableConfig.selectedTable}`; }, [tableConfig.selectedTable]); // 저장된 필터 설정 불러오기 useEffect(() => { if (!filterSettingKey) return; try { const saved = localStorage.getItem(filterSettingKey); if (saved) { const savedFilters = JSON.parse(saved); setVisibleFilterColumns(new Set(savedFilters)); } else { // 초기값: 모든 필터 표시 const allFilters = (tableConfig.filter?.filters || []).map((f) => f.columnName); setVisibleFilterColumns(new Set(allFilters)); } } catch (error) { console.error("필터 설정 불러오기 실패:", error); // 기본값으로 모든 필터 표시 const allFilters = (tableConfig.filter?.filters || []).map((f) => f.columnName); setVisibleFilterColumns(new Set(allFilters)); } }, [filterSettingKey, tableConfig.filter?.filters]); // 필터 설정 저장 const saveFilterSettings = useCallback(() => { if (!filterSettingKey) return; try { localStorage.setItem(filterSettingKey, JSON.stringify(Array.from(visibleFilterColumns))); setIsFilterSettingOpen(false); } catch (error) { console.error("필터 설정 저장 실패:", error); } }, [filterSettingKey, visibleFilterColumns]); // 필터 토글 const toggleFilterVisibility = useCallback((columnName: string) => { setVisibleFilterColumns((prev) => { const newSet = new Set(prev); if (newSet.has(columnName)) { newSet.delete(columnName); } else { newSet.add(columnName); } return newSet; }); }, []); // 표시할 필터 목록 const activeFilters = useMemo(() => { return (tableConfig.filter?.filters || []).filter((f) => visibleFilterColumns.has(f.columnName)); }, [tableConfig.filter?.filters, visibleFilterColumns]); useEffect(() => { fetchColumnLabels(); fetchTableLabel(); }, [tableConfig.selectedTable]); useEffect(() => { if (!isDesignMode && tableConfig.selectedTable) { fetchTableDataDebounced(); } }, [ tableConfig.selectedTable, currentPage, localPageSize, sortColumn, sortDirection, searchTerm, refreshKey, isDesignMode, ]); useEffect(() => { if (tableConfig.refreshInterval && !isDesignMode) { const interval = setInterval(() => { fetchTableDataDebounced(); }, tableConfig.refreshInterval * 1000); return () => clearInterval(interval); } }, [tableConfig.refreshInterval, isDesignMode]); // ======================================== // 페이지네이션 JSX // ======================================== const paginationJSX = useMemo(() => { if (!tableConfig.pagination?.enabled || isDesignMode) return null; return (
{/* 중앙 페이지네이션 컨트롤 */}
{currentPage} / {totalPages || 1} 전체 {totalItems.toLocaleString()}개
{/* 우측 새로고침 버튼 */}
); }, [tableConfig.pagination, isDesignMode, currentPage, totalPages, totalItems, loading]); // ======================================== // 렌더링 // ======================================== const domProps = { onClick: handleClick, onDragStart: isDesignMode ? onDragStart : undefined, onDragEnd: isDesignMode ? onDragEnd : undefined, draggable: isDesignMode, className: cn(className, isDesignMode && "cursor-move"), style: componentStyle, }; // 카드 모드 if (tableConfig.displayMode === "card" && !isDesignMode) { return (
{paginationJSX}
); } // SingleTableWithSticky 모드 if (tableConfig.stickyHeader && !isDesignMode) { return (
{tableConfig.showHeader && (

{tableConfig.title || tableLabel || finalSelectedTable}

)} {tableConfig.filter?.enabled && (
)}
) => { const column = visibleColumns.find((c) => c.columnName === columnName); return column ? formatCellValue(value, column, rowData) : String(value); }} getColumnWidth={getColumnWidth} containerWidth={calculatedWidth} />
{paginationJSX}
); } // 일반 테이블 모드 (네이티브 HTML 테이블) return ( <>
{/* 헤더 */} {tableConfig.showHeader && (

{tableConfig.title || tableLabel || finalSelectedTable}

)} {/* 필터 */} {tableConfig.filter?.enabled && (
)} {/* 테이블 컨테이너 */}
{/* 스크롤 영역 */}
{/* 테이블 */} {/* 헤더 (sticky) */} {visibleColumns.map((column) => ( ))} {/* 바디 (스크롤) */} {loading ? ( ) : error ? ( ) : data.length === 0 ? ( ) : ( data.map((row, index) => ( handleRowDragStart(e, row, index)} onDragEnd={handleRowDragEnd} className={cn( "h-14 border-b transition-colors bg-background hover:bg-muted/50 cursor-pointer sm:h-16" )} onClick={() => handleRowClick(row)} > {visibleColumns.map((column) => { const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; const cellValue = row[mappedColumnName]; return ( ); })} )) )}
column.sortable && handleSort(column.columnName)} > {column.columnName === "__checkbox__" ? ( renderCheckboxHeader() ) : (
{columnLabels[column.columnName] || column.displayName} {column.sortable && sortColumn === column.columnName && ( {sortDirection === "asc" ? "↑" : "↓"} )}
)}
로딩 중...
오류 발생
{error}
데이터가 없습니다
조건을 변경하거나 새로운 데이터를 추가해보세요
{column.columnName === "__checkbox__" ? renderCheckboxCell(row, index) : formatCellValue(cellValue, column, row)}
{/* 페이지네이션 */} {paginationJSX}
{/* 필터 설정 다이얼로그 */} 검색 필터 설정 표시할 검색 필터를 선택하세요. 선택하지 않은 필터는 숨겨집니다.
{(tableConfig.filter?.filters || []).map((filter) => (
toggleFilterVisibility(filter.columnName)} />
))}
); }; export const TableListWrapper: React.FC = (props) => { return ; };