xpeditis2.0/apps/frontend/src/components/data/data-table.tsx
2026-08-13 17:43:11 +02:00

378 lines
12 KiB
TypeScript

'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<TData extends unknown, TValue> {
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<TData> {
columns: ColumnDef<TData, any>[];
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<TData>) => React.ReactNode;
/** Palier de bascule table/cartes. */
cardBreakpoint?: 'md' | 'lg';
caption?: string;
className?: string;
}
export function DataTable<TData>({
columns,
data,
loading = false,
error = null,
empty,
density = 'compact',
getRowId,
onRowClick,
sorting,
onSortingChange,
rowSelection,
onRowSelectionChange,
pagination,
renderCard,
cardBreakpoint = 'md',
caption,
className,
}: DataTableProps<TData>) {
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) => (
<div className={cn('overflow-hidden rounded-lg border border-border bg-white', className)}>
{children}
</div>
);
if (loading) return shell(<TableSkeleton columns={Math.min(columns.length, 6)} />);
if (error) return shell(<ErrorState {...error} />);
if (data.length === 0) return shell(<EmptyState {...empty} />);
return (
<div className={cn('overflow-hidden rounded-lg border border-border bg-white', className)}>
{/* Table, a partir du palier de bascule */}
<div className={cn(tableVisibility, 'overflow-x-auto')}>
<table className="w-full border-collapse">
{caption && <caption className="sr-only">{caption}</caption>}
<thead>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id} className="border-b border-border bg-neutral-50">
{headerGroup.headers.map(header => {
const meta = header.column.columnDef.meta;
const sortable = header.column.getCanSort();
const sorted = header.column.getIsSorted();
return (
<th
key={header.id}
scope="col"
style={header.getSize() !== 150 ? { width: header.getSize() } : undefined}
className={cn(
'px-4 py-2.5 text-label uppercase text-neutral-500',
alignClasses[meta?.align ?? 'left'],
meta?.hideBelow && hideBelowClasses[meta.hideBelow]
)}
>
{header.isPlaceholder ? null : sortable ? (
<button
type="button"
onClick={header.column.getToggleSortingHandler()}
aria-label={`Trier par ${meta?.headerLabel ?? header.column.id}`}
className={cn(
'inline-flex items-center gap-1 rounded-md transition-colors hover:text-brand-navy',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
)}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{sorted === 'asc' ? (
<ArrowUp className="size-3 text-brand-blue" />
) : sorted === 'desc' ? (
<ArrowDown className="size-3 text-brand-blue" />
) : (
<ChevronsUpDown className="size-3 text-neutral-300" />
)}
</button>
) : (
flexRender(header.column.columnDef.header, header.getContext())
)}
</th>
);
})}
</tr>
))}
</thead>
<tbody className="divide-y divide-border">
{table.getRowModel().rows.map(row => (
<tr
key={row.id}
data-state={row.getIsSelected() ? 'selected' : undefined}
onClick={onRowClick ? () => 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 (
<td
key={cell.id}
className={cn(
cellPadding,
'text-body-sm text-neutral-700 align-middle',
alignClasses[meta?.align ?? 'left'],
meta?.hideBelow && hideBelowClasses[meta.hideBelow]
)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
{/* Cartes, en deca du palier de bascule */}
<div className={cn(cardVisibility, 'divide-y divide-border')}>
{table.getRowModel().rows.map(row => (
<div key={row.id}>{renderCard ? renderCard(row) : <AutoCard row={row} onRowClick={onRowClick} />}</div>
))}
</div>
{pagination && <Pagination {...pagination} className="border-t-0 bg-white" />}
</div>
);
}
/**
* Carte mobile deduite des metadonnees de colonne, pour ne pas obliger chaque
* appelant a ecrire un rendu mobile.
*/
function AutoCard<TData>({
row,
onRowClick,
}: {
row: Row<TData>;
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 (
<div
onClick={onRowClick ? () => onRowClick(row.original) : undefined}
className={cn('flex gap-3 p-4', onRowClick && 'cursor-pointer active:bg-neutral-50')}
>
{selectCell && (
<div className="pt-0.5">
{flexRender(selectCell.column.columnDef.cell, selectCell.getContext())}
</div>
)}
<div className="min-w-0 flex-1">
{titles.length > 0 && (
<div className="text-body-sm font-medium text-brand-navy">
{titles.map(c => (
<React.Fragment key={c.id}>
{flexRender(c.column.columnDef.cell, c.getContext())}
</React.Fragment>
))}
</div>
)}
{subtitles.length > 0 && (
<div className="mt-0.5 text-body-xs text-neutral-500">
{subtitles.map(c => (
<React.Fragment key={c.id}>
{flexRender(c.column.columnDef.cell, c.getContext())}
</React.Fragment>
))}
</div>
)}
{fields.length > 0 && (
<dl className="mt-2 grid grid-cols-2 gap-x-3 gap-y-1">
{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 (
<div key={c.id} className="min-w-0">
<dt className="truncate text-label uppercase text-neutral-400">{label}</dt>
<dd className="truncate text-body-xs text-neutral-700">
{flexRender(c.column.columnDef.cell, c.getContext())}
</dd>
</div>
);
})}
</dl>
)}
</div>
{actions.length > 0 && (
<div className="shrink-0" onClick={e => e.stopPropagation()}>
{actions.map(c => (
<React.Fragment key={c.id}>
{flexRender(c.column.columnDef.cell, c.getContext())}
</React.Fragment>
))}
</div>
)}
</div>
);
}
/**
* Colonne de selection prete a l'emploi, avec case d'en-tete a trois etats.
*/
export function selectionColumn<TData>(labels: {
selectAll: string;
selectRow: string;
}): ColumnDef<TData, unknown> {
return {
id: 'select',
size: 40,
enableSorting: false,
meta: { cardRole: 'hidden' },
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected()
? true
: table.getIsSomePageRowsSelected()
? 'indeterminate'
: false
}
onCheckedChange={value => table.toggleAllPageRowsSelected(Boolean(value))}
aria-label={labels.selectAll}
/>
),
cell: ({ row }) => (
<span onClick={e => e.stopPropagation()}>
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={value => row.toggleSelected(Boolean(value))}
aria-label={labels.selectRow}
/>
</span>
),
};
}