import React, { useState, useMemo, useCallback } from 'react'; import { ChevronUp, ChevronDown, ChevronsUpDown, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Search, X, Filter, } from 'lucide-react'; import { cn } from '../utils/cn'; import { SkeletonTable } from './Skeleton'; // ============================================================================ // Types // ============================================================================ export type SortDirection = 'asc' | 'desc' | null; export type ColumnAlign = 'left' | 'center' | 'right'; export interface ColumnDef { /** Unique column identifier */ id: string; /** Column header label */ header: string; /** Data accessor key or function */ accessorKey?: keyof T; accessorFn?: (row: T) => unknown; /** Custom cell renderer */ cell?: (value: unknown, row: T, rowIndex: number) => React.ReactNode; /** Column alignment */ align?: ColumnAlign; /** Whether column is sortable */ sortable?: boolean; /** Whether column is filterable */ filterable?: boolean; /** Column width */ width?: string | number; /** Minimum column width */ minWidth?: string | number; /** Whether to hide on mobile */ hideOnMobile?: boolean; /** Custom sort function */ sortFn?: (a: T, b: T, direction: SortDirection) => number; /** Custom filter function */ filterFn?: (row: T, filterValue: string) => boolean; } export interface PaginationConfig { /** Current page (1-indexed) */ page: number; /** Items per page */ pageSize: number; /** Total number of items (for server-side pagination) */ totalItems?: number; /** Available page sizes */ pageSizeOptions?: number[]; /** Callback when page changes */ onPageChange?: (page: number) => void; /** Callback when page size changes */ onPageSizeChange?: (pageSize: number) => void; } export interface DataTableProps> { /** Column definitions */ columns: ColumnDef[]; /** Table data */ data: T[]; /** Row key extractor */ getRowId?: (row: T, index: number) => string; /** Pagination configuration */ pagination?: PaginationConfig; /** Enable global search */ enableSearch?: boolean; /** Search placeholder */ searchPlaceholder?: string; /** Enable column filters */ enableFilters?: boolean; /** Default sort column */ defaultSortColumn?: string; /** Default sort direction */ defaultSortDirection?: SortDirection; /** Loading state */ isLoading?: boolean; /** Empty state message */ emptyMessage?: string; /** Table title */ title?: string; /** Table subtitle */ subtitle?: string; /** Row click handler */ onRowClick?: (row: T, index: number) => void; /** Selected rows (controlled) */ selectedRows?: Set; /** Row selection handler */ onRowSelect?: (rowId: string, selected: boolean) => void; /** Enable row selection */ enableRowSelection?: boolean; /** Striped rows */ striped?: boolean; /** Hover effect on rows */ hoverable?: boolean; /** Compact mode */ compact?: boolean; /** Additional CSS classes */ className?: string; } // ============================================================================ // Utility Functions // ============================================================================ function getCellValue(row: T, column: ColumnDef): unknown { if (column.accessorFn) { return column.accessorFn(row); } if (column.accessorKey) { return row[column.accessorKey]; } return null; } function defaultSort( a: T, b: T, column: ColumnDef, direction: SortDirection ): number { if (!direction) return 0; const aVal = getCellValue(a, column); const bVal = getCellValue(b, column); let comparison = 0; if (aVal === null || aVal === undefined) comparison = 1; else if (bVal === null || bVal === undefined) comparison = -1; else if (typeof aVal === 'number' && typeof bVal === 'number') { comparison = aVal - bVal; } else if (typeof aVal === 'string' && typeof bVal === 'string') { comparison = aVal.localeCompare(bVal, 'es-MX'); } else if (aVal instanceof Date && bVal instanceof Date) { comparison = aVal.getTime() - bVal.getTime(); } else { comparison = String(aVal).localeCompare(String(bVal), 'es-MX'); } return direction === 'asc' ? comparison : -comparison; } function defaultFilter(row: T, column: ColumnDef, filterValue: string): boolean { const value = getCellValue(row, column); if (value === null || value === undefined) return false; return String(value).toLowerCase().includes(filterValue.toLowerCase()); } // ============================================================================ // Sub-Components // ============================================================================ interface SortIconProps { direction: SortDirection; } function SortIcon({ direction }: SortIconProps): React.ReactElement { if (direction === 'asc') { return ; } if (direction === 'desc') { return ; } return ; } interface PaginationProps { currentPage: number; pageSize: number; totalItems: number; pageSizeOptions: number[]; onPageChange: (page: number) => void; onPageSizeChange: (pageSize: number) => void; } function Pagination({ currentPage, pageSize, totalItems, pageSizeOptions, onPageChange, onPageSizeChange, }: PaginationProps): React.ReactElement { const totalPages = Math.ceil(totalItems / pageSize); const startItem = (currentPage - 1) * pageSize + 1; const endItem = Math.min(currentPage * pageSize, totalItems); const canGoPrev = currentPage > 1; const canGoNext = currentPage < totalPages; return (
{/* Page size selector */}
Mostrar por pagina
{/* Info and controls */}
{startItem}-{endItem} de {totalItems}
{currentPage} / {totalPages}
); } // ============================================================================ // Main Component // ============================================================================ export function DataTable>({ columns, data, getRowId, pagination, enableSearch = false, searchPlaceholder = 'Buscar...', enableFilters = false, defaultSortColumn, defaultSortDirection = null, isLoading = false, emptyMessage = 'No hay datos disponibles', title, subtitle, onRowClick, selectedRows, onRowSelect, enableRowSelection = false, striped = false, hoverable = true, compact = false, className, }: DataTableProps): React.ReactElement { // State const [searchQuery, setSearchQuery] = useState(''); const [sortColumn, setSortColumn] = useState(defaultSortColumn ?? null); const [sortDirection, setSortDirection] = useState(defaultSortDirection); const [columnFilters, setColumnFilters] = useState>({}); const [showFilters, setShowFilters] = useState(false); // Internal pagination state (for client-side pagination) const [internalPage, setInternalPage] = useState(pagination?.page ?? 1); const [internalPageSize, setInternalPageSize] = useState(pagination?.pageSize ?? 10); // Effective pagination values const currentPage = pagination?.page ?? internalPage; const pageSize = pagination?.pageSize ?? internalPageSize; const pageSizeOptions = pagination?.pageSizeOptions ?? [10, 25, 50, 100]; // Row ID helper const getRowIdFn = useCallback( (row: T, index: number): string => { if (getRowId) return getRowId(row, index); if ('id' in row) return String(row.id); return String(index); }, [getRowId] ); // Filter and sort data const processedData = useMemo(() => { let result = [...data]; // Apply global search if (searchQuery) { const query = searchQuery.toLowerCase(); result = result.filter((row) => columns.some((col) => { const value = getCellValue(row, col); return value !== null && String(value).toLowerCase().includes(query); }) ); } // Apply column filters if (Object.keys(columnFilters).length > 0) { result = result.filter((row) => Object.entries(columnFilters).every(([colId, filterValue]) => { if (!filterValue) return true; const column = columns.find((c) => c.id === colId); if (!column) return true; if (column.filterFn) return column.filterFn(row, filterValue); return defaultFilter(row, column, filterValue); }) ); } // Apply sorting if (sortColumn && sortDirection) { const column = columns.find((c) => c.id === sortColumn); if (column) { result.sort((a, b) => { if (column.sortFn) return column.sortFn(a, b, sortDirection); return defaultSort(a, b, column, sortDirection); }); } } return result; }, [data, columns, searchQuery, columnFilters, sortColumn, sortDirection]); // Calculate total items const totalItems = pagination?.totalItems ?? processedData.length; // Apply pagination (client-side only if not server-side) const paginatedData = useMemo(() => { if (pagination?.totalItems !== undefined) { // Server-side pagination - data is already paginated return processedData; } // Client-side pagination const start = (currentPage - 1) * pageSize; return processedData.slice(start, start + pageSize); }, [processedData, pagination?.totalItems, currentPage, pageSize]); // Handlers const handleSort = useCallback((columnId: string) => { setSortColumn((prev) => { if (prev !== columnId) { setSortDirection('asc'); return columnId; } setSortDirection((dir) => { if (dir === 'asc') return 'desc'; if (dir === 'desc') return null; return 'asc'; }); return columnId; }); }, []); const handlePageChange = useCallback( (page: number) => { if (pagination?.onPageChange) { pagination.onPageChange(page); } else { setInternalPage(page); } }, [pagination] ); const handlePageSizeChange = useCallback( (size: number) => { if (pagination?.onPageSizeChange) { pagination.onPageSizeChange(size); } else { setInternalPageSize(size); setInternalPage(1); } }, [pagination] ); const handleColumnFilterChange = useCallback((columnId: string, value: string) => { setColumnFilters((prev) => ({ ...prev, [columnId]: value, })); setInternalPage(1); }, []); const clearFilters = useCallback(() => { setColumnFilters({}); setSearchQuery(''); setInternalPage(1); }, []); const hasActiveFilters = searchQuery || Object.values(columnFilters).some(Boolean); if (isLoading) { return ( ); } return (
{/* Header */} {(title || subtitle || enableSearch || enableFilters) && (
{/* Title */} {(title || subtitle) && (
{title && (

{title}

)} {subtitle && (

{subtitle}

)}
)} {/* Search and filters */}
{enableSearch && (
{ setSearchQuery(e.target.value); setInternalPage(1); }} placeholder={searchPlaceholder} className="w-full sm:w-64 rounded-md border border-gray-300 bg-white py-2 pl-9 pr-3 text-sm placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" />
)} {enableFilters && ( )} {hasActiveFilters && ( )}
{/* Column filters */} {showFilters && enableFilters && (
{columns .filter((col) => col.filterable !== false) .map((column) => (
handleColumnFilterChange(column.id, e.target.value) } placeholder={column.header} className="w-32 rounded border border-gray-300 bg-white px-2 py-1 text-sm placeholder:text-gray-400 focus:border-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
))}
)}
)} {/* Table */}
{enableRowSelection && ( )} {columns.map((column) => ( ))} {paginatedData.length === 0 ? ( ) : ( paginatedData.map((row, rowIndex) => { const rowId = getRowIdFn(row, rowIndex); const isSelected = selectedRows?.has(rowId); return ( onRowClick?.(row, rowIndex)} > {enableRowSelection && ( )} {columns.map((column) => { const value = getCellValue(row, column); const displayValue = column.cell ? column.cell(value, row, rowIndex) : value !== null && value !== undefined ? String(value) : '-'; return ( ); })} ); }) )}
{ paginatedData.forEach((row, index) => { const rowId = getRowIdFn(row, index); onRowSelect?.(rowId, e.target.checked); }); }} /> column.sortable !== false && handleSort(column.id)} >
{column.header} {column.sortable !== false && ( )}
{emptyMessage}
{ e.stopPropagation(); onRowSelect?.(rowId, e.target.checked); }} className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" /> {displayValue}
{/* Pagination */} {pagination !== undefined && totalItems > 0 && ( )}
); }