378 lines
12 KiB
TypeScript
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>
|
|
),
|
|
};
|
|
}
|