313 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|