'use client'; import * as React from 'react'; import { flexRender, getCoreRowModel, getSortedRowModel, useReactTable, type ColumnDef, type Row, type SortingState, type RowSelectionState, } from '@tanstack/react-table'; import { ArrowDown, ArrowUp, ChevronsUpDown } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Checkbox } from '@/components/ui/checkbox'; import { EmptyState, ErrorState, TableSkeleton, type EmptyStateProps } from '@/components/ui/states'; import { Pagination, type PaginationProps } from '@/components/ui/pagination'; /** * Table de donnees unique de l'application. * * Absorbe les 26 tables ecrites a la main, leurs 7 paginations locales et leurs * 21 conteneurs `overflow-x-auto`. Sous `md`, elle bascule en liste de cartes * plutot que d'imposer un defilement horizontal. * * Metadonnees de colonne reconnues (`column.meta`) : * - `align` : alignement du contenu * - `hideBelow` : palier en deca duquel la colonne est masquee * - `cardRole` : role de la colonne dans la carte mobile * - `headerLabel`: libelle court, utilise comme etiquette dans la carte mobile */ declare module '@tanstack/react-table' { // Les parametres generiques sont imposes par la signature d'origine de // ColumnMeta ; ils ne sont pas utilises par nos metadonnees. interface ColumnMeta { align?: 'left' | 'center' | 'right'; hideBelow?: 'sm' | 'md' | 'lg' | 'xl'; cardRole?: 'title' | 'subtitle' | 'field' | 'actions' | 'hidden'; headerLabel?: string; } } const hideBelowClasses = { sm: 'hidden sm:table-cell', md: 'hidden md:table-cell', lg: 'hidden lg:table-cell', xl: 'hidden xl:table-cell', } as const; const alignClasses = { left: 'text-left', center: 'text-center', right: 'text-right', } as const; export interface DataTableProps { columns: ColumnDef[]; data: TData[]; loading?: boolean; error?: { title: string; description?: string; detail?: string; retryLabel?: string; onRetry?: () => void; } | null; empty: EmptyStateProps; density?: 'compact' | 'comfortable'; getRowId?: (row: TData, index: number) => string; onRowClick?: (row: TData) => void; sorting?: SortingState; onSortingChange?: (sorting: SortingState) => void; rowSelection?: RowSelectionState; onRowSelectionChange?: (selection: RowSelectionState) => void; pagination?: PaginationProps; /** Rendu mobile personnalise. A defaut, la carte est deduite des `meta.cardRole`. */ renderCard?: (row: Row) => React.ReactNode; /** Palier de bascule table/cartes. */ cardBreakpoint?: 'md' | 'lg'; caption?: string; className?: string; } export function DataTable({ columns, data, loading = false, error = null, empty, density = 'compact', getRowId, onRowClick, sorting, onSortingChange, rowSelection, onRowSelectionChange, pagination, renderCard, cardBreakpoint = 'md', caption, className, }: DataTableProps) { const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), manualPagination: true, getRowId, state: { ...(sorting ? { sorting } : {}), ...(rowSelection ? { rowSelection } : {}), }, onSortingChange: onSortingChange ? updater => onSortingChange(typeof updater === 'function' ? updater(sorting ?? []) : updater) : undefined, onRowSelectionChange: onRowSelectionChange ? updater => onRowSelectionChange( typeof updater === 'function' ? updater(rowSelection ?? {}) : updater ) : undefined, enableRowSelection: Boolean(onRowSelectionChange), }); const cellPadding = density === 'compact' ? 'px-4 py-2.5' : 'px-4 py-4'; const tableVisibility = cardBreakpoint === 'md' ? 'hidden md:block' : 'hidden lg:block'; const cardVisibility = cardBreakpoint === 'md' ? 'md:hidden' : 'lg:hidden'; const shell = (children: React.ReactNode) => (
{children}
); if (loading) return shell(); if (error) return shell(); if (data.length === 0) return shell(); return (
{/* Table, a partir du palier de bascule */}
{caption && } {table.getHeaderGroups().map(headerGroup => ( {headerGroup.headers.map(header => { const meta = header.column.columnDef.meta; const sortable = header.column.getCanSort(); const sorted = header.column.getIsSorted(); return ( ); })} ))} {table.getRowModel().rows.map(row => ( onRowClick(row.original) : undefined} className={cn( 'transition-colors', row.getIsSelected() ? 'bg-neutral-100' : 'hover:bg-neutral-50', onRowClick && 'cursor-pointer' )} > {row.getVisibleCells().map(cell => { const meta = cell.column.columnDef.meta; return ( ); })} ))}
{caption}
{header.isPlaceholder ? null : sortable ? ( ) : ( flexRender(header.column.columnDef.header, header.getContext()) )}
{flexRender(cell.column.columnDef.cell, cell.getContext())}
{/* Cartes, en deca du palier de bascule */}
{table.getRowModel().rows.map(row => (
{renderCard ? renderCard(row) : }
))}
{pagination && }
); } /** * Carte mobile deduite des metadonnees de colonne, pour ne pas obliger chaque * appelant a ecrire un rendu mobile. */ function AutoCard({ row, onRowClick, }: { row: Row; onRowClick?: (row: TData) => void; }) { const cells = row.getVisibleCells(); const byRole = (role: string) => cells.filter(c => c.column.columnDef.meta?.cardRole === role); const titles = byRole('title'); const subtitles = byRole('subtitle'); const actions = byRole('actions'); // Sans annotation explicite, on affiche les colonnes non structurelles. const fields = cells.filter(c => { const role = c.column.columnDef.meta?.cardRole; if (role) return role === 'field'; return c.column.id !== 'select'; }); const selectCell = cells.find(c => c.column.id === 'select'); return (
onRowClick(row.original) : undefined} className={cn('flex gap-3 p-4', onRowClick && 'cursor-pointer active:bg-neutral-50')} > {selectCell && (
{flexRender(selectCell.column.columnDef.cell, selectCell.getContext())}
)}
{titles.length > 0 && (
{titles.map(c => ( {flexRender(c.column.columnDef.cell, c.getContext())} ))}
)} {subtitles.length > 0 && (
{subtitles.map(c => ( {flexRender(c.column.columnDef.cell, c.getContext())} ))}
)} {fields.length > 0 && (
{fields.map(c => { const meta = c.column.columnDef.meta; const label = meta?.headerLabel ?? (typeof c.column.columnDef.header === 'string' ? c.column.columnDef.header : c.column.id); return (
{label}
{flexRender(c.column.columnDef.cell, c.getContext())}
); })}
)}
{actions.length > 0 && (
e.stopPropagation()}> {actions.map(c => ( {flexRender(c.column.columnDef.cell, c.getContext())} ))}
)}
); } /** * Colonne de selection prete a l'emploi, avec case d'en-tete a trois etats. */ export function selectionColumn(labels: { selectAll: string; selectRow: string; }): ColumnDef { return { id: 'select', size: 40, enableSorting: false, meta: { cardRole: 'hidden' }, header: ({ table }) => ( table.toggleAllPageRowsSelected(Boolean(value))} aria-label={labels.selectAll} /> ), cell: ({ row }) => ( e.stopPropagation()}> row.toggleSelected(Boolean(value))} aria-label={labels.selectRow} /> ), }; }