310277f5cd
- add ArrowDown01Icon svg component to shared icons index - update Table.js to use ArrowDown01Icon instead of ChevronDownIcon for sort indicator
348 lines
16 KiB
JavaScript
348 lines
16 KiB
JavaScript
'use client';
|
||
|
||
import React from 'react';
|
||
import Badge from './Badge';
|
||
import Button from './Button';
|
||
import { TorriGateIcon, ArrowDown01Icon } from '@zen/core/shared/icons';
|
||
|
||
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">
|
||
<ArrowDown01Icon className={`w-4 h-4 transition-transform ${isActive ? (isDesc ? 'rotate-180' : '') : 'text-neutral-500 dark:text-neutral-400'}`} />
|
||
</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 }) => {
|
||
const visible = columns.filter((col) => !col.mobileHidden);
|
||
const [primary, ...rest] = visible;
|
||
return (
|
||
<div className={`${sizeClasses.mobile} space-y-2.5`}>
|
||
{primary && (
|
||
<div className="text-sm font-medium text-neutral-900 dark:text-white">
|
||
{renderCellContent(item, primary)}
|
||
</div>
|
||
)}
|
||
{rest.length > 0 && (
|
||
<dl className="grid grid-cols-2 gap-x-4 gap-y-2.5">
|
||
{rest.map((column) => (
|
||
<div key={column.key}>
|
||
<dt className="text-[11px] font-medium uppercase tracking-[0.04em] text-neutral-400 dark:text-neutral-500">
|
||
{column.label}
|
||
</dt>
|
||
<dd className="mt-0.5 text-xs text-neutral-700 dark:text-neutral-300">
|
||
{renderCellContent(item, column)}
|
||
</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const MobileSkeletonCard = () => {
|
||
const visibleCount = columns.filter((col) => !col.mobileHidden).length;
|
||
const restCount = Math.min(Math.max(0, visibleCount - 1), 4);
|
||
return (
|
||
<div className={`${sizeClasses.mobile} space-y-2.5`}>
|
||
<Skeleton height="h-4" width="55%" />
|
||
{restCount > 0 && (
|
||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||
{Array.from({ length: restCount }).map((_, i) => (
|
||
<div key={i} className="space-y-1">
|
||
<Skeleton height="h-2.5" width="40%" />
|
||
<Skeleton height="h-3.5" width="70%" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</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"
|
||
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"
|
||
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.align === 'right' || 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.align === 'right' || 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'} ${column.align === 'right' ? 'text-right' : ''} 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;
|