ADD new frontend

This commit is contained in:
Urtzi Alfaro
2025-08-28 10:41:04 +02:00
parent 9c247a5f99
commit 0fd273cfce
492 changed files with 114979 additions and 1632 deletions

View File

@@ -0,0 +1,686 @@
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 };