import React, { forwardRef, useMemo, useState, useEffect, TableHTMLAttributes } from 'react'; import { clsx } from 'clsx'; export interface TableColumn { key: string; title: string; dataIndex?: keyof T; render?: (value: any, record: T, index: number) => React.ReactNode; width?: string | number; align?: 'left' | 'center' | 'right'; sortable?: boolean; sortKey?: string; className?: string; headerClassName?: string; fixed?: 'left' | 'right'; } export interface TableProps extends Omit, 'onSelect'> { columns: TableColumn[]; data: T[]; loading?: boolean; size?: 'sm' | 'md' | 'lg'; variant?: 'default' | 'striped' | 'bordered' | 'borderless'; hover?: boolean; sticky?: boolean; rowSelection?: { type?: 'checkbox' | 'radio'; selectedRowKeys?: React.Key[]; onSelect?: (record: T, selected: boolean, selectedRows: T[], nativeEvent: Event) => void; onSelectAll?: (selected: boolean, selectedRows: T[], changeRows: T[]) => void; onSelectInvert?: (selectedRowKeys: React.Key[]) => void; getCheckboxProps?: (record: T) => { disabled?: boolean; name?: string }; hideSelectAll?: boolean; preserveSelectedRowKeys?: boolean; }; pagination?: { current?: number; pageSize?: number; total?: number; showSizeChanger?: boolean; showQuickJumper?: boolean; showTotal?: (total: number, range: [number, number]) => string; onChange?: (page: number, pageSize: number) => void; position?: 'top' | 'bottom' | 'both'; }; sort?: { field?: string; order?: 'asc' | 'desc'; multiple?: boolean; }; onSort?: (field: string, order: 'asc' | 'desc' | null) => void; expandable?: { expandedRowKeys?: React.Key[]; onExpand?: (expanded: boolean, record: T) => void; onExpandedRowsChange?: (expandedKeys: React.Key[]) => void; expandedRowRender?: (record: T, index: number) => React.ReactNode; rowExpandable?: (record: T) => boolean; expandRowByClick?: boolean; defaultExpandAllRows?: boolean; indentSize?: number; }; rowKey?: string | ((record: T) => React.Key); onRow?: (record: T, index: number) => React.HTMLAttributes; locale?: { emptyText?: string; selectAll?: string; selectRow?: string; expand?: string; collapse?: string; }; scroll?: { x?: string | number | true; y?: string | number; }; summary?: (data: T[]) => React.ReactNode; } export interface TablePaginationProps { current: number; pageSize: number; total: number; showSizeChanger?: boolean; showQuickJumper?: boolean; showTotal?: (total: number, range: [number, number]) => string; onChange?: (page: number, pageSize: number) => void; className?: string; } const Table = forwardRef(({ columns, data, loading = false, size = 'md', variant = 'default', hover = true, sticky = false, rowSelection, pagination, sort, onSort, expandable, rowKey = 'id', onRow, locale = { emptyText: 'No hay datos disponibles', selectAll: 'Seleccionar todo', selectRow: 'Seleccionar fila', expand: 'Expandir fila', collapse: 'Contraer fila', }, scroll, summary, className, ...props }, ref) => { const [sortState, setSortState] = useState<{ field?: string; order?: 'asc' | 'desc' }>({ field: sort?.field, order: sort?.order, }); const [selectedRowKeys, setSelectedRowKeys] = useState( rowSelection?.selectedRowKeys || [] ); const [expandedRowKeys, setExpandedRowKeys] = useState( expandable?.expandedRowKeys || (expandable?.defaultExpandAllRows ? data.map(getRowKey) : []) ); function getRowKey(record: any, index?: number): React.Key { if (typeof rowKey === 'function') { return rowKey(record); } return record[rowKey] || index || 0; } // Update local state when props change useEffect(() => { if (rowSelection?.selectedRowKeys) { setSelectedRowKeys(rowSelection.selectedRowKeys); } }, [rowSelection?.selectedRowKeys]); useEffect(() => { if (expandable?.expandedRowKeys) { setExpandedRowKeys(expandable.expandedRowKeys); } }, [expandable?.expandedRowKeys]); // Handle sorting const handleSort = (field: string) => { let newOrder: 'asc' | 'desc' | null = 'asc'; if (sortState.field === field) { newOrder = sortState.order === 'asc' ? 'desc' : sortState.order === 'desc' ? null : 'asc'; } setSortState({ field: newOrder ? field : undefined, order: newOrder || undefined }); onSort?.(field, newOrder); }; // Handle row selection const handleSelectRow = (record: any, selected: boolean, event: Event) => { const key = getRowKey(record); let newSelectedKeys = [...selectedRowKeys]; if (rowSelection?.type === 'radio') { newSelectedKeys = selected ? [key] : []; } else { if (selected) { newSelectedKeys.push(key); } else { newSelectedKeys = newSelectedKeys.filter(k => k !== key); } } setSelectedRowKeys(newSelectedKeys); const selectedRows = data.filter(item => newSelectedKeys.includes(getRowKey(item))); rowSelection?.onSelect?.(record, selected, selectedRows, event); }; const handleSelectAll = (selected: boolean) => { const selectableData = data.filter(record => { const checkboxProps = rowSelection?.getCheckboxProps?.(record); return !checkboxProps?.disabled; }); const newSelectedKeys = selected ? selectableData.map(getRowKey) : []; const changeRows = selected ? selectableData : selectedRowKeys.map(key => data.find(item => getRowKey(item) === key)).filter(Boolean); setSelectedRowKeys(newSelectedKeys); const selectedRows = data.filter(item => newSelectedKeys.includes(getRowKey(item))); rowSelection?.onSelectAll?.(selected, selectedRows, changeRows); }; // Handle row expansion const handleExpand = (record: any, expanded: boolean) => { const key = getRowKey(record); let newExpandedKeys = [...expandedRowKeys]; if (expanded) { newExpandedKeys.push(key); } else { newExpandedKeys = newExpandedKeys.filter(k => k !== key); } setExpandedRowKeys(newExpandedKeys); expandable?.onExpand?.(expanded, record); expandable?.onExpandedRowsChange?.(newExpandedKeys); }; // Processed data for current page const processedData = useMemo(() => { let result = [...data]; // Apply pagination if needed if (pagination) { const { current = 1, pageSize = 10 } = pagination; const start = (current - 1) * pageSize; const end = start + pageSize; result = result.slice(start, end); } return result; }, [data, pagination]); // Table classes const sizeClasses = { sm: 'text-xs', md: 'text-sm', lg: 'text-base', }; const variantClasses = { default: 'border-collapse', striped: 'border-collapse', bordered: 'border-collapse border border-[var(--border-primary)]', borderless: 'border-collapse', }; const tableClasses = clsx( 'w-full bg-[var(--bg-primary)]', sizeClasses[size], variantClasses[variant], { 'table-fixed': scroll?.x, }, className ); const containerClasses = clsx( 'overflow-auto', { 'max-h-96': scroll?.y, } ); // Render loading state if (loading) { return (
Cargando...
); } // Render empty state if (processedData.length === 0) { return (

{locale.emptyText}

); } const renderHeader = () => { const hasSelection = rowSelection && !rowSelection.hideSelectAll; const hasExpansion = expandable; return ( {hasSelection && ( {rowSelection.type !== 'radio' && ( 0} indeterminate={selectedRowKeys.length > 0 && selectedRowKeys.length < data.length} onChange={(e) => handleSelectAll(e.target.checked)} aria-label={locale.selectAll} /> )} )} {hasExpansion && ( )} {columns.map((column) => { const isSorted = sortState.field === (column.sortKey || column.key); return ( handleSort(column.sortKey || column.key) : undefined} >
{column.title} {column.sortable && ( )}
); })} ); }; const renderBody = () => ( {processedData.map((record, index) => { const key = getRowKey(record, index); const isSelected = selectedRowKeys.includes(key); const isExpanded = expandedRowKeys.includes(key); const canExpand = expandable?.rowExpandable?.(record) !== false; const rowProps = onRow?.(record, index) || {}; return ( handleExpand(record, !isExpanded) : rowProps.onClick} > {rowSelection && ( handleSelectRow(record, e.target.checked, e.nativeEvent)} disabled={rowSelection.getCheckboxProps?.(record)?.disabled} name={rowSelection.getCheckboxProps?.(record)?.name} aria-label={`${locale.selectRow} ${index + 1}`} /> )} {expandable && ( {canExpand && ( )} )} {columns.map((column) => { const value = column.dataIndex ? record[column.dataIndex] : record; const content = column.render ? column.render(value, record, index) : value; return ( {content} ); })} {isExpanded && expandable?.expandedRowRender && (
{expandable.expandedRowRender(record, index)}
)}
); })} {summary && ( {summary(data)} )} ); return (
{renderHeader()} {renderBody()}
{pagination && ( )}
); }); const TablePagination: React.FC = ({ current, pageSize, total, showSizeChanger = false, showQuickJumper = false, showTotal, onChange, className, }) => { const totalPages = Math.ceil(total / pageSize); const startRecord = (current - 1) * pageSize + 1; const endRecord = Math.min(current * pageSize, total); const handlePageChange = (page: number) => { if (page >= 1 && page <= totalPages && page !== current) { onChange?.(page, pageSize); } }; const handleSizeChange = (newSize: number) => { const newTotalPages = Math.ceil(total / newSize); const newPage = current > newTotalPages ? newTotalPages : current; onChange?.(newPage || 1, newSize); }; const renderPageButtons = () => { const buttons = []; const maxVisible = 7; let startPage = Math.max(1, current - Math.floor(maxVisible / 2)); let endPage = Math.min(totalPages, startPage + maxVisible - 1); if (endPage - startPage + 1 < maxVisible) { startPage = Math.max(1, endPage - maxVisible + 1); } // Previous button buttons.push( ); // First page and ellipsis if (startPage > 1) { buttons.push( ); if (startPage > 2) { buttons.push( ... ); } } // Page number buttons for (let page = startPage; page <= endPage; page++) { buttons.push( ); } // Last page and ellipsis if (endPage < totalPages) { if (endPage < totalPages - 1) { buttons.push( ... ); } buttons.push( ); } // Next button buttons.push( ); return buttons; }; return (
{showTotal && ( {showTotal(total, [startRecord, endRecord])} )} {showSizeChanger && (
Mostrar por página
)}
{showQuickJumper && (
Ir a { if (e.key === 'Enter') { const page = Number((e.target as HTMLInputElement).value); handlePageChange(page); } }} />
)}
{renderPageButtons()}
); }; Table.displayName = 'Table'; export default Table; export { TablePagination };