veza/apps/web/src/components/data/Table.tsx
2025-12-12 21:34:34 -05:00

313 lines
10 KiB
TypeScript

import { useState, useMemo, useCallback } from 'react';
import { Checkbox } from '@/components/ui/checkbox';
import { Pagination } from '@/components/navigation/Pagination';
import { cn } from '@/lib/utils';
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
export interface TableColumn<T> {
key: string;
header: string;
render?: (row: T, index: number) => React.ReactNode;
sortable?: boolean;
width?: string;
align?: 'left' | 'center' | 'right';
}
export interface TableProps<T> {
columns: TableColumn<T>[];
data: T[];
onSort?: (column: string, direction: 'asc' | 'desc') => void;
onRowClick?: (row: T, index: number) => void;
selectable?: boolean;
onSelectionChange?: (selectedRows: T[]) => void;
getRowId?: (row: T, index: number) => string;
paginated?: boolean;
itemsPerPage?: number;
emptyMessage?: string;
className?: string;
rowClassName?: (row: T, index: number) => string;
}
/**
* Composant Table avec tri, pagination, sélection, et actions.
*/
export function Table<T extends Record<string, any>>({
columns,
data,
onSort,
onRowClick,
selectable = false,
onSelectionChange,
getRowId,
paginated = false,
itemsPerPage = 10,
emptyMessage = 'Aucune donnée disponible',
className,
rowClassName,
}: TableProps<T>) {
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [currentPage, setCurrentPage] = useState(1);
const getRowKey = useCallback(
(row: T, index: number): string => {
if (getRowId) {
return getRowId(row, index);
}
return index.toString();
},
[getRowId],
);
const handleSort = useCallback(
(columnKey: string) => {
const column = columns.find((col) => col.key === columnKey);
if (!column?.sortable) return;
let newDirection: 'asc' | 'desc' = 'asc';
if (sortColumn === columnKey) {
newDirection = sortDirection === 'asc' ? 'desc' : 'asc';
}
setSortColumn(columnKey);
setSortDirection(newDirection);
onSort?.(columnKey, newDirection);
},
[columns, sortColumn, sortDirection, onSort],
);
const handleSelectAll = useCallback(
(checked: boolean) => {
const paginatedData = paginated ? paginatedDataMemo : data;
const newSelected = new Set<string>();
if (checked) {
paginatedData.forEach((row, index) => {
const absoluteIndex = paginated
? (currentPage - 1) * itemsPerPage + index
: index;
newSelected.add(getRowKey(row, absoluteIndex));
});
}
setSelectedRows(newSelected);
if (onSelectionChange) {
const selectedData = data.filter((row, index) =>
newSelected.has(getRowKey(row, index)),
);
onSelectionChange(selectedData);
}
},
[data, paginated, currentPage, itemsPerPage, getRowKey, onSelectionChange],
);
const handleSelectRow = useCallback(
(row: T, index: number, checked: boolean) => {
const rowKey = getRowKey(row, index);
const newSelected = new Set(selectedRows);
if (checked) {
newSelected.add(rowKey);
} else {
newSelected.delete(rowKey);
}
setSelectedRows(newSelected);
if (onSelectionChange) {
const selectedData = data.filter((r, i) =>
newSelected.has(getRowKey(r, i)),
);
onSelectionChange(selectedData);
}
},
[selectedRows, data, getRowKey, onSelectionChange],
);
const totalPages = useMemo(
() => Math.ceil(data.length / itemsPerPage),
[data.length, itemsPerPage],
);
const paginatedDataMemo = useMemo(() => {
if (!paginated) return data;
const start = (currentPage - 1) * itemsPerPage;
const end = start + itemsPerPage;
return data.slice(start, end);
}, [data, paginated, currentPage, itemsPerPage]);
const displayedData = paginated ? paginatedDataMemo : data;
const isAllSelected = useMemo(() => {
if (displayedData.length === 0) return false;
return displayedData.every((row, index) => {
const absoluteIndex = paginated
? (currentPage - 1) * itemsPerPage + index
: index;
return selectedRows.has(getRowKey(row, absoluteIndex));
});
}, [
displayedData,
selectedRows,
paginated,
currentPage,
itemsPerPage,
getRowKey,
]);
const isIndeterminate = useMemo(() => {
if (displayedData.length === 0) return false;
const selectedCount = displayedData.filter((row, index) => {
const absoluteIndex = paginated
? (currentPage - 1) * itemsPerPage + index
: index;
return selectedRows.has(getRowKey(row, absoluteIndex));
}).length;
return selectedCount > 0 && selectedCount < displayedData.length;
}, [
displayedData,
selectedRows,
paginated,
currentPage,
itemsPerPage,
getRowKey,
]);
const getSortIcon = (columnKey: string) => {
if (sortColumn !== columnKey) {
return <ArrowUpDown className="h-4 w-4 text-muted-foreground" />;
}
return sortDirection === 'asc' ? (
<ArrowUp className="h-4 w-4" />
) : (
<ArrowDown className="h-4 w-4" />
);
};
return (
<div className={cn('w-full', className)}>
<div className="rounded-md border">
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="border-b bg-muted/50">
{selectable && (
<th className="w-12 p-4">
<Checkbox
checked={isAllSelected}
onCheckedChange={(checked) =>
handleSelectAll(checked === true)
}
className={cn(
isIndeterminate &&
'data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
)}
/>
</th>
)}
{columns.map((column) => (
<th
key={column.key}
className={cn(
'p-4 text-left font-medium',
column.align === 'center' && 'text-center',
column.align === 'right' && 'text-right',
column.sortable && 'cursor-pointer hover:bg-muted/80',
column.width && `w-[${column.width}]`,
)}
style={column.width ? { width: column.width } : undefined}
onClick={() => column.sortable && handleSort(column.key)}
>
<div
className={cn(
'flex items-center gap-2',
column.align === 'center' && 'justify-center',
column.align === 'right' && 'justify-end',
)}
>
<span>{column.header}</span>
{column.sortable && getSortIcon(column.key)}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{displayedData.length === 0 ? (
<tr>
<td
colSpan={columns.length + (selectable ? 1 : 0)}
className="p-8 text-center text-muted-foreground"
>
{emptyMessage}
</td>
</tr>
) : (
displayedData.map((row, index) => {
const absoluteIndex = paginated
? (currentPage - 1) * itemsPerPage + index
: index;
const rowKey = getRowKey(row, absoluteIndex);
const isSelected = selectedRows.has(rowKey);
return (
<tr
key={rowKey}
className={cn(
'border-b transition-colors',
isSelected && 'bg-muted/50',
onRowClick && 'cursor-pointer hover:bg-muted/50',
rowClassName && rowClassName(row, absoluteIndex),
)}
onClick={() => onRowClick?.(row, absoluteIndex)}
>
{selectable && (
<td className="p-4">
<Checkbox
checked={isSelected}
onCheckedChange={(checked) =>
handleSelectRow(
row,
absoluteIndex,
checked === true,
)
}
onClick={(e) => e.stopPropagation()}
/>
</td>
)}
{columns.map((column) => (
<td
key={column.key}
className={cn(
'p-4',
column.align === 'center' && 'text-center',
column.align === 'right' && 'text-right',
)}
>
{column.render
? column.render(row, absoluteIndex)
: (row[column.key] ?? '')}
</td>
))}
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
{paginated && totalPages > 1 && (
<div className="mt-4 flex justify-center">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
)}
</div>
);
}