686 lines
23 KiB
TypeScript
686 lines
23 KiB
TypeScript
|
|
import React, { forwardRef, useMemo, useState, useEffect, TableHTMLAttributes } from 'react';
|
||
|
|
import { clsx } from 'clsx';
|
||
|
|
|
||
|
|
export interface TableColumn<T = any> {
|
||
|
|
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<T = any> extends Omit<TableHTMLAttributes<HTMLTableElement>, 'onSelect'> {
|
||
|
|
columns: TableColumn<T>[];
|
||
|
|
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<HTMLTableRowElement>;
|
||
|
|
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<HTMLTableElement, TableProps>(({
|
||
|
|
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<React.Key[]>(
|
||
|
|
rowSelection?.selectedRowKeys || []
|
||
|
|
);
|
||
|
|
const [expandedRowKeys, setExpandedRowKeys] = useState<React.Key[]>(
|
||
|
|
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 border border-table-border',
|
||
|
|
striped: 'border-collapse',
|
||
|
|
bordered: 'border-collapse border border-table-border',
|
||
|
|
borderless: 'border-collapse',
|
||
|
|
};
|
||
|
|
|
||
|
|
const tableClasses = clsx(
|
||
|
|
'w-full bg-table-bg',
|
||
|
|
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 (
|
||
|
|
<div className="flex items-center justify-center p-8">
|
||
|
|
<div className="flex items-center space-x-2">
|
||
|
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-color-primary"></div>
|
||
|
|
<span className="text-text-secondary">Cargando...</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Render empty state
|
||
|
|
if (processedData.length === 0) {
|
||
|
|
return (
|
||
|
|
<div className="flex items-center justify-center p-8">
|
||
|
|
<div className="text-center">
|
||
|
|
<div className="text-text-tertiary mb-2">
|
||
|
|
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||
|
|
</svg>
|
||
|
|
</div>
|
||
|
|
<p className="text-text-secondary">{locale.emptyText}</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const renderHeader = () => {
|
||
|
|
const hasSelection = rowSelection && !rowSelection.hideSelectAll;
|
||
|
|
const hasExpansion = expandable;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<thead className={clsx('bg-table-header-bg', { 'sticky top-0 z-10': sticky })}>
|
||
|
|
<tr>
|
||
|
|
{hasSelection && (
|
||
|
|
<th className="px-4 py-3 text-left font-medium text-text-primary border-b border-table-border w-12">
|
||
|
|
{rowSelection.type !== 'radio' && (
|
||
|
|
<input
|
||
|
|
type="checkbox"
|
||
|
|
className="rounded border-input-border focus:ring-color-primary"
|
||
|
|
checked={selectedRowKeys.length === data.length && data.length > 0}
|
||
|
|
indeterminate={selectedRowKeys.length > 0 && selectedRowKeys.length < data.length}
|
||
|
|
onChange={(e) => handleSelectAll(e.target.checked)}
|
||
|
|
aria-label={locale.selectAll}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</th>
|
||
|
|
)}
|
||
|
|
{hasExpansion && (
|
||
|
|
<th className="px-4 py-3 text-left font-medium text-text-primary border-b border-table-border w-12"></th>
|
||
|
|
)}
|
||
|
|
{columns.map((column) => {
|
||
|
|
const isSorted = sortState.field === (column.sortKey || column.key);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<th
|
||
|
|
key={column.key}
|
||
|
|
className={clsx(
|
||
|
|
'px-4 py-3 font-medium text-text-primary border-b border-table-border',
|
||
|
|
{
|
||
|
|
'text-left': column.align === 'left' || !column.align,
|
||
|
|
'text-center': column.align === 'center',
|
||
|
|
'text-right': column.align === 'right',
|
||
|
|
'cursor-pointer hover:bg-table-row-hover': column.sortable,
|
||
|
|
},
|
||
|
|
column.headerClassName
|
||
|
|
)}
|
||
|
|
style={{ width: column.width }}
|
||
|
|
onClick={column.sortable ? () => handleSort(column.sortKey || column.key) : undefined}
|
||
|
|
>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<span>{column.title}</span>
|
||
|
|
{column.sortable && (
|
||
|
|
<span className="flex flex-col">
|
||
|
|
<svg
|
||
|
|
className={clsx('w-3 h-3', {
|
||
|
|
'text-color-primary': isSorted && sortState.order === 'asc',
|
||
|
|
'text-text-quaternary': !isSorted || sortState.order !== 'asc',
|
||
|
|
})}
|
||
|
|
fill="currentColor"
|
||
|
|
viewBox="0 0 20 20"
|
||
|
|
>
|
||
|
|
<path d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z" />
|
||
|
|
</svg>
|
||
|
|
<svg
|
||
|
|
className={clsx('w-3 h-3 -mt-1', {
|
||
|
|
'text-color-primary': isSorted && sortState.order === 'desc',
|
||
|
|
'text-text-quaternary': !isSorted || sortState.order !== 'desc',
|
||
|
|
})}
|
||
|
|
fill="currentColor"
|
||
|
|
viewBox="0 0 20 20"
|
||
|
|
>
|
||
|
|
<path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" />
|
||
|
|
</svg>
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</th>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
const renderBody = () => (
|
||
|
|
<tbody>
|
||
|
|
{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 (
|
||
|
|
<React.Fragment key={key}>
|
||
|
|
<tr
|
||
|
|
{...rowProps}
|
||
|
|
className={clsx(
|
||
|
|
'transition-colors duration-150',
|
||
|
|
{
|
||
|
|
'bg-table-row-hover': hover && !isSelected,
|
||
|
|
'bg-table-row-selected': isSelected,
|
||
|
|
'odd:bg-bg-secondary': variant === 'striped' && !isSelected,
|
||
|
|
'border-b border-table-border': variant !== 'borderless',
|
||
|
|
'cursor-pointer': expandable?.expandRowByClick,
|
||
|
|
},
|
||
|
|
rowProps.className
|
||
|
|
)}
|
||
|
|
onClick={expandable?.expandRowByClick ? () => handleExpand(record, !isExpanded) : rowProps.onClick}
|
||
|
|
>
|
||
|
|
{rowSelection && (
|
||
|
|
<td className="px-4 py-3 border-b border-table-border">
|
||
|
|
<input
|
||
|
|
type={rowSelection.type || 'checkbox'}
|
||
|
|
className="rounded border-input-border focus:ring-color-primary"
|
||
|
|
checked={isSelected}
|
||
|
|
onChange={(e) => handleSelectRow(record, e.target.checked, e.nativeEvent)}
|
||
|
|
disabled={rowSelection.getCheckboxProps?.(record)?.disabled}
|
||
|
|
name={rowSelection.getCheckboxProps?.(record)?.name}
|
||
|
|
aria-label={`${locale.selectRow} ${index + 1}`}
|
||
|
|
/>
|
||
|
|
</td>
|
||
|
|
)}
|
||
|
|
{expandable && (
|
||
|
|
<td className="px-4 py-3 border-b border-table-border">
|
||
|
|
{canExpand && (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="text-text-tertiary hover:text-text-primary transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-color-primary/20 rounded p-1"
|
||
|
|
onClick={(e) => {
|
||
|
|
e.stopPropagation();
|
||
|
|
handleExpand(record, !isExpanded);
|
||
|
|
}}
|
||
|
|
aria-label={isExpanded ? locale.collapse : locale.expand}
|
||
|
|
>
|
||
|
|
<svg
|
||
|
|
className={clsx('w-4 h-4 transform transition-transform duration-150', {
|
||
|
|
'rotate-90': isExpanded,
|
||
|
|
})}
|
||
|
|
fill="none"
|
||
|
|
stroke="currentColor"
|
||
|
|
viewBox="0 0 24 24"
|
||
|
|
>
|
||
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||
|
|
</svg>
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
</td>
|
||
|
|
)}
|
||
|
|
{columns.map((column) => {
|
||
|
|
const value = column.dataIndex ? record[column.dataIndex] : record;
|
||
|
|
const content = column.render ? column.render(value, record, index) : value;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<td
|
||
|
|
key={column.key}
|
||
|
|
className={clsx(
|
||
|
|
'px-4 py-3 border-b border-table-border',
|
||
|
|
{
|
||
|
|
'text-left': column.align === 'left' || !column.align,
|
||
|
|
'text-center': column.align === 'center',
|
||
|
|
'text-right': column.align === 'right',
|
||
|
|
},
|
||
|
|
column.className
|
||
|
|
)}
|
||
|
|
style={{ width: column.width }}
|
||
|
|
>
|
||
|
|
{content}
|
||
|
|
</td>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</tr>
|
||
|
|
{isExpanded && expandable?.expandedRowRender && (
|
||
|
|
<tr>
|
||
|
|
<td
|
||
|
|
colSpan={columns.length + (rowSelection ? 1 : 0) + (expandable ? 1 : 0)}
|
||
|
|
className="px-4 py-0 border-b border-table-border bg-bg-secondary"
|
||
|
|
>
|
||
|
|
<div className="py-4" style={{ paddingLeft: expandable.indentSize || 24 }}>
|
||
|
|
{expandable.expandedRowRender(record, index)}
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
)}
|
||
|
|
</React.Fragment>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
{summary && (
|
||
|
|
<tr className="bg-table-header-bg border-t-2 border-table-border font-medium">
|
||
|
|
<td colSpan={columns.length + (rowSelection ? 1 : 0) + (expandable ? 1 : 0)}>
|
||
|
|
{summary(data)}
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
)}
|
||
|
|
</tbody>
|
||
|
|
);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-4">
|
||
|
|
<div className={containerClasses}>
|
||
|
|
<table
|
||
|
|
ref={ref}
|
||
|
|
className={tableClasses}
|
||
|
|
{...props}
|
||
|
|
>
|
||
|
|
{renderHeader()}
|
||
|
|
{renderBody()}
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{pagination && (
|
||
|
|
<TablePagination
|
||
|
|
current={pagination.current || 1}
|
||
|
|
pageSize={pagination.pageSize || 10}
|
||
|
|
total={pagination.total || data.length}
|
||
|
|
showSizeChanger={pagination.showSizeChanger}
|
||
|
|
showQuickJumper={pagination.showQuickJumper}
|
||
|
|
showTotal={pagination.showTotal}
|
||
|
|
onChange={pagination.onChange}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
const TablePagination: React.FC<TablePaginationProps> = ({
|
||
|
|
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(
|
||
|
|
<button
|
||
|
|
key="prev"
|
||
|
|
type="button"
|
||
|
|
className="px-3 py-2 text-sm font-medium text-text-secondary border border-border-primary rounded-l-md hover:bg-bg-secondary disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-150"
|
||
|
|
disabled={current === 1}
|
||
|
|
onClick={() => handlePageChange(current - 1)}
|
||
|
|
>
|
||
|
|
Anterior
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
|
||
|
|
// First page and ellipsis
|
||
|
|
if (startPage > 1) {
|
||
|
|
buttons.push(
|
||
|
|
<button
|
||
|
|
key={1}
|
||
|
|
type="button"
|
||
|
|
className="px-3 py-2 text-sm font-medium text-text-secondary border-t border-b border-border-primary hover:bg-bg-secondary transition-colors duration-150"
|
||
|
|
onClick={() => handlePageChange(1)}
|
||
|
|
>
|
||
|
|
1
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
|
||
|
|
if (startPage > 2) {
|
||
|
|
buttons.push(
|
||
|
|
<span key="start-ellipsis" className="px-3 py-2 text-sm text-text-tertiary border-t border-b border-border-primary">
|
||
|
|
...
|
||
|
|
</span>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Page number buttons
|
||
|
|
for (let page = startPage; page <= endPage; page++) {
|
||
|
|
buttons.push(
|
||
|
|
<button
|
||
|
|
key={page}
|
||
|
|
type="button"
|
||
|
|
className={clsx(
|
||
|
|
'px-3 py-2 text-sm font-medium border-t border-b border-border-primary transition-colors duration-150',
|
||
|
|
{
|
||
|
|
'bg-color-primary text-text-inverse': page === current,
|
||
|
|
'text-text-secondary hover:bg-bg-secondary': page !== current,
|
||
|
|
}
|
||
|
|
)}
|
||
|
|
onClick={() => handlePageChange(page)}
|
||
|
|
>
|
||
|
|
{page}
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Last page and ellipsis
|
||
|
|
if (endPage < totalPages) {
|
||
|
|
if (endPage < totalPages - 1) {
|
||
|
|
buttons.push(
|
||
|
|
<span key="end-ellipsis" className="px-3 py-2 text-sm text-text-tertiary border-t border-b border-border-primary">
|
||
|
|
...
|
||
|
|
</span>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
buttons.push(
|
||
|
|
<button
|
||
|
|
key={totalPages}
|
||
|
|
type="button"
|
||
|
|
className="px-3 py-2 text-sm font-medium text-text-secondary border-t border-b border-border-primary hover:bg-bg-secondary transition-colors duration-150"
|
||
|
|
onClick={() => handlePageChange(totalPages)}
|
||
|
|
>
|
||
|
|
{totalPages}
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Next button
|
||
|
|
buttons.push(
|
||
|
|
<button
|
||
|
|
key="next"
|
||
|
|
type="button"
|
||
|
|
className="px-3 py-2 text-sm font-medium text-text-secondary border border-border-primary rounded-r-md hover:bg-bg-secondary disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-150"
|
||
|
|
disabled={current === totalPages}
|
||
|
|
onClick={() => handlePageChange(current + 1)}
|
||
|
|
>
|
||
|
|
Siguiente
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
|
||
|
|
return buttons;
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className={clsx('flex items-center justify-between', className)}>
|
||
|
|
<div className="flex items-center gap-4">
|
||
|
|
{showTotal && (
|
||
|
|
<span className="text-sm text-text-secondary">
|
||
|
|
{showTotal(total, [startRecord, endRecord])}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
{showSizeChanger && (
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<span className="text-sm text-text-secondary">Mostrar</span>
|
||
|
|
<select
|
||
|
|
className="px-2 py-1 text-sm border border-border-primary rounded-md bg-input-bg focus:outline-none focus:ring-2 focus:ring-color-primary/20"
|
||
|
|
value={pageSize}
|
||
|
|
onChange={(e) => handleSizeChange(Number(e.target.value))}
|
||
|
|
>
|
||
|
|
{[10, 20, 50, 100].map(size => (
|
||
|
|
<option key={size} value={size}>{size}</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
<span className="text-sm text-text-secondary">por página</span>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex items-center gap-4">
|
||
|
|
{showQuickJumper && (
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<span className="text-sm text-text-secondary">Ir a</span>
|
||
|
|
<input
|
||
|
|
type="number"
|
||
|
|
min={1}
|
||
|
|
max={totalPages}
|
||
|
|
className="w-16 px-2 py-1 text-sm text-center border border-border-primary rounded-md bg-input-bg focus:outline-none focus:ring-2 focus:ring-color-primary/20"
|
||
|
|
onKeyPress={(e) => {
|
||
|
|
if (e.key === 'Enter') {
|
||
|
|
const page = Number((e.target as HTMLInputElement).value);
|
||
|
|
handlePageChange(page);
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div className="flex">
|
||
|
|
{renderPageButtons()}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
Table.displayName = 'Table';
|
||
|
|
|
||
|
|
export default Table;
|
||
|
|
export { TablePagination };
|