Files
core/src/shared/components/Table.js
T

352 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import React from 'react';
import Badge from './Badge';
import Button from './Button';
import { TorriGateIcon } from '../icons/index.js';
const ROW_SIZE = {
sm: { cell: 'px-4 py-[11px]', header: 'px-4 py-[9px]', mobile: 'p-4' },
md: { cell: 'px-4 py-[14px]', header: 'px-4 py-[12px]', mobile: 'p-5' },
lg: { cell: 'px-4 py-[18px]', header: 'px-4 py-[14px]', mobile: 'p-6' },
};
const Table = ({
columns = [],
data = [],
loading = false,
sortBy,
sortOrder,
onSort,
onRowClick,
getRowProps,
emptyMessage = 'Aucune donnée',
emptyDescription = 'Aucun élément à afficher',
size = 'sm',
className = '',
// Pagination props
currentPage = 1,
totalPages = 1,
onPageChange,
onLimitChange,
limit = 20,
total = 0,
...props
}) => {
const sizeClasses = ROW_SIZE[size] ?? ROW_SIZE.md;
const showPagination = total > 20;
const SortIcon = ({ column }) => {
const isActive = sortBy === column.key;
const isDesc = isActive && sortOrder === 'desc';
return (
<span className="ml-1">
<svg
className={`w-4 h-4 transition-transform ${isActive ? (isDesc ? 'rotate-180' : '') : 'text-neutral-500 dark:text-neutral-400'}`}
fill="currentColor"
viewBox="0 0 20 20"
>
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</span>
);
};
const Skeleton = ({ className: cx = '', width = '100%', height = 'h-4' }) => (
<div
className={`bg-gradient-to-r from-neutral-200 via-neutral-100 to-neutral-200 dark:from-neutral-700/30 dark:via-neutral-600/30 dark:to-neutral-700/30 bg-[length:200%_100%] animate-shimmer rounded ${height} ${cx}`}
style={{ width }}
/>
);
const SkeletonRow = () => (
<tr className="hover:bg-neutral-100 dark:hover:bg-neutral-900 transition-colors">
{columns.map((column, index) => (
<td key={index} className={`${sizeClasses.cell} ${column.noWrap !== false ? '' : 'whitespace-nowrap'}`}>
{column.skeleton ? (
column.skeleton.secondary ? (
<div className="space-y-2">
<Skeleton cx={column.skeleton.className} height={column.skeleton.height} width={column.skeleton.width} />
<Skeleton height={column.skeleton.secondary.height} width={column.skeleton.secondary.width} />
</div>
) : (
<Skeleton cx={column.skeleton.className} height={column.skeleton.height} width={column.skeleton.width} />
)
) : (
<Skeleton height="h-4" width="60%" />
)}
</td>
))}
</tr>
);
const EmptyState = () => (
<tr>
<td colSpan={columns.length} className="px-6 py-12 text-center">
<div className="text-neutral-500 dark:text-neutral-400 mb-2">
<TorriGateIcon className="mx-auto h-12 w-12 mb-4" />
</div>
<p className="text-lg font-medium text-neutral-700 dark:text-neutral-300 mb-1">{emptyMessage}</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400">{emptyDescription}</p>
</td>
</tr>
);
const renderCellContent = (item, column) => {
if (column.render) return column.render(item);
const value = column.key.split('.').reduce((obj, key) => obj?.[key], item);
if (column.type === 'badge') return <Badge variant={column.badgeVariant}>{value}</Badge>;
if (column.type === 'date') return new Date(value).toLocaleDateString();
if (column.type === 'currency') return `$${parseFloat(value || 0).toFixed(2)}`;
return value || '-';
};
const MobileCard = ({ item }) => (
<div className={`${sizeClasses.mobile} space-y-3`}>
<div className="flex items-start justify-between">
<div className="flex-1">
{columns.slice(0, 2).map((column) => (
<div key={column.key} className={column.key === columns[0]?.key ? 'mb-2' : ''}>
{renderCellContent(item, column)}
</div>
))}
</div>
<div className="flex flex-col gap-2 ml-4">
{columns.slice(2, 4).map((column) => (
<div key={column.key}>{renderCellContent(item, column)}</div>
))}
</div>
</div>
{columns.length > 4 && (
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{columns.slice(4).map((column) => (
<div key={column.key} className="mb-1">
<span className="font-medium">{column.label}:</span> {renderCellContent(item, column)}
</div>
))}
</div>
)}
</div>
);
const MobileSkeletonCard = () => (
<div className={`${sizeClasses.mobile} space-y-3`}>
<div className="flex items-start justify-between">
<div className="space-y-2 flex-1">
<Skeleton height="h-4" width="70%" />
<Skeleton height="h-3" width="40%" />
</div>
<div className="flex flex-col gap-2 ml-4">
<Skeleton cx="rounded-full" height="h-6" width="80px" />
<Skeleton cx="rounded-full" height="h-6" width="90px" />
</div>
</div>
<Skeleton height="h-3" width="50%" />
</div>
);
const getPageNumbers = () => {
const pages = [];
const delta = 2;
if (totalPages > 0) pages.push(1);
if (currentPage - delta > 2) pages.push('...');
for (let i = Math.max(2, currentPage - delta); i <= Math.min(totalPages - 1, currentPage + delta); i++) {
pages.push(i);
}
if (currentPage + delta < totalPages - 1) pages.push('...');
if (totalPages > 1) pages.push(totalPages);
return pages;
};
const PageButton = ({ onClick, disabled, children, isActive = false }) => (
<button
onClick={onClick}
disabled={disabled || loading}
className={`px-2 py-1 text-xs font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
isActive
? 'bg-neutral-900 text-white dark:bg-white dark:text-black'
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-700/30 hover:text-neutral-900 dark:hover:text-white'
}`}
>
{children}
</button>
);
const from = total > 0 ? (currentPage - 1) * limit + 1 : 0;
const to = Math.min(currentPage * limit, total);
const PaginationBar = () => (
<div className="px-4 py-3 border-t border-neutral-200 dark:border-[#1B1B1B]">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2 shrink-0">
{loading ? (
<Skeleton height="h-7" width="90px" />
) : (
<>
<span className="text-xs text-neutral-500 dark:text-neutral-400">Afficher</span>
<select
value={limit}
onChange={(e) => onLimitChange?.(Number(e.target.value))}
disabled={loading}
className="h-7 px-2 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700/50 rounded-md text-xs text-neutral-900 dark:text-white focus:outline-none focus:border-neutral-400 dark:focus:border-neutral-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</>
)}
</div>
<div className="flex items-center gap-1">
{loading ? (
<>
<Skeleton height="h-7" width="64px" />
<div className="hidden sm:flex items-center gap-1">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} height="h-7" width="28px" />
))}
</div>
<Skeleton height="h-7" width="56px" />
</>
) : (
<>
{totalPages > 1 && (
<Button
variant="ghost"
size="sm"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
>
Précédent
</Button>
)}
{totalPages > 1 && (
<div className="hidden sm:flex items-center gap-1">
{getPageNumbers().map((page, index) => (
<React.Fragment key={index}>
{page === '...' ? (
<span className="px-2 text-xs text-neutral-400 dark:text-neutral-500"></span>
) : (
<PageButton onClick={() => onPageChange(page)} isActive={currentPage === page}>
{page}
</PageButton>
)}
</React.Fragment>
))}
</div>
)}
{totalPages > 1 && (
<Button
variant="ghost"
size="sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
Suivant
</Button>
)}
</>
)}
</div>
<div className="text-xs text-neutral-400 dark:text-neutral-500 shrink-0 text-right">
{loading ? (
<Skeleton height="h-4" width="100px" />
) : (
`${from}${to} sur ${total}`
)}
</div>
</div>
</div>
);
return (
<div className={className} {...props}>
{/* Desktop Table */}
<div className="hidden lg:block overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-neutral-200 dark:border-[#1B1B1B]">
{columns.map((column) => (
<th
key={column.key}
className={`${sizeClasses.header} ${column.headerAlign === 'right' ? 'text-right' : 'text-left'} text-[11px] font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-[0.04em] ${
column.sortable ? 'cursor-pointer hover:text-neutral-900 dark:hover:text-white transition-colors' : ''
}`}
onClick={column.sortable && onSort ? () => onSort(column.key) : undefined}
>
<div className={`flex items-center ${column.headerAlign === 'right' ? 'justify-end' : ''}`}>
{column.label}
{column.sortable && onSort && <SortIcon column={column} />}
</div>
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-700/30 text-[13px]">
{loading ? (
Array.from({ length: 5 }).map((_, index) => <SkeletonRow key={index} />)
) : data.length === 0 ? (
<EmptyState />
) : (
data.map((item, index) => {
const isLast = index === data.length - 1;
const { className: extraClassName, ...rowExtraProps } = getRowProps ? getRowProps(item) : {};
return (
<tr
key={item.id || index}
className={`group transition-colors ${onRowClick ? 'cursor-pointer' : ''} ${extraClassName || ''}`}
onClick={onRowClick ? () => onRowClick(item) : undefined}
{...rowExtraProps}
>
{columns.map((column, colIdx) => (
<td key={column.key} className={`${sizeClasses.cell} ${column.noWrap !== false ? '' : 'whitespace-nowrap'} group-hover:bg-neutral-50 dark:group-hover:bg-neutral-950 transition-colors ${isLast && colIdx === 0 ? 'rounded-bl-xl' : ''} ${isLast && colIdx === columns.length - 1 ? 'rounded-br-xl' : ''}`}>
{renderCellContent(item, column)}
</td>
))}
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Mobile Cards */}
<div className="lg:hidden divide-y divide-neutral-200 dark:divide-neutral-700/30">
{loading ? (
Array.from({ length: 3 }).map((_, index) => <MobileSkeletonCard key={index} />)
) : data.length === 0 ? (
<div className={`${sizeClasses.mobile} py-12 text-center`}>
<div className="text-neutral-500 dark:text-neutral-400 mb-2">
<TorriGateIcon className="mx-auto h-12 w-12 mb-4" />
</div>
<p className="text-lg font-medium text-neutral-700 dark:text-neutral-300 mb-1">{emptyMessage}</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400">{emptyDescription}</p>
</div>
) : (
data.map((item, index) => {
const { className: extraClassName, ...rowExtraProps } = getRowProps ? getRowProps(item) : {};
return (
<div
key={item.id || index}
className={`${onRowClick ? 'cursor-pointer' : ''} ${extraClassName || ''} ${index === data.length - 1 ? 'rounded-b-xl' : ''}`}
onClick={onRowClick ? () => onRowClick(item) : undefined}
{...rowExtraProps}
>
<MobileCard item={item} />
</div>
);
})
)}
</div>
{showPagination && <PaginationBar />}
</div>
);
};
export default Table;