273 lines
8.9 KiB
TypeScript
273 lines
8.9 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useState } from 'react';
|
||
|
|
import {
|
||
|
|
flexRender,
|
||
|
|
getCoreRowModel,
|
||
|
|
useReactTable,
|
||
|
|
type ColumnDef,
|
||
|
|
type RowSelectionState,
|
||
|
|
} from '@tanstack/react-table';
|
||
|
|
import { ArrowDown, ArrowUp, ArrowUpDown, Loader2 } from 'lucide-react';
|
||
|
|
|
||
|
|
import {
|
||
|
|
Table,
|
||
|
|
TableBody,
|
||
|
|
TableCell,
|
||
|
|
TableHead,
|
||
|
|
TableHeader,
|
||
|
|
TableRow,
|
||
|
|
} from '@/components/ui/table';
|
||
|
|
import { Button } from '@/components/ui/button';
|
||
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
||
|
|
import { cn } from '@/lib/utils';
|
||
|
|
|
||
|
|
export interface DataTablePagination {
|
||
|
|
page: number;
|
||
|
|
pageSize: number;
|
||
|
|
total: number;
|
||
|
|
totalPages: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface BulkAction {
|
||
|
|
label: string;
|
||
|
|
icon?: React.ElementType;
|
||
|
|
variant?: 'default' | 'destructive';
|
||
|
|
onClick: (selectedIds: string[]) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface DataTableProps<TData> {
|
||
|
|
columns: ColumnDef<TData, unknown>[];
|
||
|
|
data: TData[];
|
||
|
|
pagination?: DataTablePagination;
|
||
|
|
onPaginationChange?: (page: number, pageSize: number) => void;
|
||
|
|
sort?: { field: string; direction: 'asc' | 'desc' };
|
||
|
|
onSortChange?: (field: string, direction: 'asc' | 'desc') => void;
|
||
|
|
rowSelection?: RowSelectionState;
|
||
|
|
onRowSelectionChange?: (selection: RowSelectionState) => void;
|
||
|
|
bulkActions?: BulkAction[];
|
||
|
|
emptyState?: React.ReactNode;
|
||
|
|
isLoading?: boolean;
|
||
|
|
getRowId?: (row: TData) => string;
|
||
|
|
onRowClick?: (row: TData) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function DataTable<TData>({
|
||
|
|
columns,
|
||
|
|
data,
|
||
|
|
pagination,
|
||
|
|
onPaginationChange,
|
||
|
|
sort,
|
||
|
|
onSortChange,
|
||
|
|
rowSelection: externalSelection,
|
||
|
|
onRowSelectionChange,
|
||
|
|
bulkActions,
|
||
|
|
emptyState,
|
||
|
|
isLoading,
|
||
|
|
getRowId,
|
||
|
|
onRowClick,
|
||
|
|
}: DataTableProps<TData>) {
|
||
|
|
const [internalSelection, setInternalSelection] = useState<RowSelectionState>({});
|
||
|
|
const rowSelectionState = externalSelection ?? internalSelection;
|
||
|
|
const setRowSelection = onRowSelectionChange ?? setInternalSelection;
|
||
|
|
|
||
|
|
const allColumns: ColumnDef<TData, unknown>[] = [];
|
||
|
|
if (bulkActions && bulkActions.length > 0) {
|
||
|
|
allColumns.push({
|
||
|
|
id: 'select',
|
||
|
|
header: ({ table }) => (
|
||
|
|
<Checkbox
|
||
|
|
checked={
|
||
|
|
table.getIsAllPageRowsSelected() ||
|
||
|
|
(table.getIsSomePageRowsSelected() && 'indeterminate')
|
||
|
|
}
|
||
|
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||
|
|
aria-label="Select all"
|
||
|
|
className="translate-y-[2px]"
|
||
|
|
/>
|
||
|
|
),
|
||
|
|
cell: ({ row }) => (
|
||
|
|
<Checkbox
|
||
|
|
checked={row.getIsSelected()}
|
||
|
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||
|
|
aria-label="Select row"
|
||
|
|
className="translate-y-[2px]"
|
||
|
|
onClick={(e) => e.stopPropagation()}
|
||
|
|
/>
|
||
|
|
),
|
||
|
|
enableSorting: false,
|
||
|
|
size: 40,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
allColumns.push(...columns);
|
||
|
|
|
||
|
|
const table = useReactTable({
|
||
|
|
data,
|
||
|
|
columns: allColumns,
|
||
|
|
getCoreRowModel: getCoreRowModel(),
|
||
|
|
manualPagination: true,
|
||
|
|
manualSorting: true,
|
||
|
|
rowCount: pagination?.total ?? data.length,
|
||
|
|
state: {
|
||
|
|
rowSelection: rowSelectionState,
|
||
|
|
pagination: pagination
|
||
|
|
? { pageIndex: pagination.page - 1, pageSize: pagination.pageSize }
|
||
|
|
: undefined,
|
||
|
|
},
|
||
|
|
onRowSelectionChange: (updater) => {
|
||
|
|
const newSelection =
|
||
|
|
typeof updater === 'function' ? updater(rowSelectionState) : updater;
|
||
|
|
setRowSelection(newSelection);
|
||
|
|
},
|
||
|
|
getRowId: getRowId as (row: TData, index: number) => string,
|
||
|
|
enableRowSelection: !!bulkActions?.length,
|
||
|
|
});
|
||
|
|
|
||
|
|
const selectedIds = Object.keys(rowSelectionState).filter(
|
||
|
|
(k) => rowSelectionState[k],
|
||
|
|
);
|
||
|
|
|
||
|
|
function handleSort(columnId: string) {
|
||
|
|
if (!onSortChange) return;
|
||
|
|
if (sort?.field === columnId) {
|
||
|
|
onSortChange(columnId, sort.direction === 'asc' ? 'desc' : 'asc');
|
||
|
|
} else {
|
||
|
|
onSortChange(columnId, 'asc');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function getSortIcon(columnId: string) {
|
||
|
|
if (sort?.field !== columnId) return <ArrowUpDown className="ml-1 h-3.5 w-3.5" />;
|
||
|
|
return sort.direction === 'asc' ? (
|
||
|
|
<ArrowUp className="ml-1 h-3.5 w-3.5" />
|
||
|
|
) : (
|
||
|
|
<ArrowDown className="ml-1 h-3.5 w-3.5" />
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-2">
|
||
|
|
<div className="rounded-md border">
|
||
|
|
<Table>
|
||
|
|
<TableHeader className="sticky top-0 z-10 bg-muted/50">
|
||
|
|
{table.getHeaderGroups().map((headerGroup) => (
|
||
|
|
<TableRow key={headerGroup.id}>
|
||
|
|
{headerGroup.headers.map((header) => (
|
||
|
|
<TableHead
|
||
|
|
key={header.id}
|
||
|
|
style={{ width: header.getSize() !== 150 ? header.getSize() : undefined }}
|
||
|
|
className={cn(
|
||
|
|
header.column.getCanSort() && onSortChange && 'cursor-pointer select-none',
|
||
|
|
)}
|
||
|
|
onClick={() => {
|
||
|
|
if (header.column.getCanSort() && onSortChange && header.column.id !== 'select') {
|
||
|
|
handleSort(header.column.id);
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<div className="flex items-center">
|
||
|
|
{header.isPlaceholder
|
||
|
|
? null
|
||
|
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
||
|
|
{header.column.getCanSort() &&
|
||
|
|
onSortChange &&
|
||
|
|
header.column.id !== 'select' &&
|
||
|
|
header.column.id !== 'actions' &&
|
||
|
|
getSortIcon(header.column.id)}
|
||
|
|
</div>
|
||
|
|
</TableHead>
|
||
|
|
))}
|
||
|
|
</TableRow>
|
||
|
|
))}
|
||
|
|
</TableHeader>
|
||
|
|
<TableBody>
|
||
|
|
{isLoading ? (
|
||
|
|
<TableRow>
|
||
|
|
<TableCell colSpan={allColumns.length} className="h-40 text-center">
|
||
|
|
<div className="flex items-center justify-center gap-2">
|
||
|
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||
|
|
<span className="text-muted-foreground">Loading...</span>
|
||
|
|
</div>
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
) : table.getRowModel().rows.length === 0 ? (
|
||
|
|
<TableRow>
|
||
|
|
<TableCell colSpan={allColumns.length} className="h-40">
|
||
|
|
{emptyState ?? (
|
||
|
|
<div className="text-center text-muted-foreground">No results.</div>
|
||
|
|
)}
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
) : (
|
||
|
|
table.getRowModel().rows.map((row) => (
|
||
|
|
<TableRow
|
||
|
|
key={row.id}
|
||
|
|
data-state={row.getIsSelected() && 'selected'}
|
||
|
|
className={cn(onRowClick && 'cursor-pointer')}
|
||
|
|
onClick={() => onRowClick?.(row.original)}
|
||
|
|
>
|
||
|
|
{row.getVisibleCells().map((cell) => (
|
||
|
|
<TableCell key={cell.id}>
|
||
|
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||
|
|
</TableCell>
|
||
|
|
))}
|
||
|
|
</TableRow>
|
||
|
|
))
|
||
|
|
)}
|
||
|
|
</TableBody>
|
||
|
|
</Table>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Pagination */}
|
||
|
|
{pagination && pagination.totalPages > 1 && (
|
||
|
|
<div className="flex items-center justify-between px-2">
|
||
|
|
<div className="text-sm text-muted-foreground">
|
||
|
|
{selectedIds.length > 0
|
||
|
|
? `${selectedIds.length} of ${pagination.total} row(s) selected`
|
||
|
|
: `${pagination.total} row(s) total`}
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
disabled={pagination.page <= 1}
|
||
|
|
onClick={() => onPaginationChange?.(pagination.page - 1, pagination.pageSize)}
|
||
|
|
>
|
||
|
|
Previous
|
||
|
|
</Button>
|
||
|
|
<span className="text-sm text-muted-foreground">
|
||
|
|
Page {pagination.page} of {pagination.totalPages}
|
||
|
|
</span>
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
disabled={pagination.page >= pagination.totalPages}
|
||
|
|
onClick={() => onPaginationChange?.(pagination.page + 1, pagination.pageSize)}
|
||
|
|
>
|
||
|
|
Next
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Bulk actions bar */}
|
||
|
|
{bulkActions && selectedIds.length > 0 && (
|
||
|
|
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 rounded-lg border bg-background px-4 py-3 shadow-lg animate-in slide-in-from-bottom-4">
|
||
|
|
<span className="text-sm font-medium">{selectedIds.length} selected</span>
|
||
|
|
{bulkActions.map((action) => (
|
||
|
|
<Button
|
||
|
|
key={action.label}
|
||
|
|
variant={action.variant ?? 'default'}
|
||
|
|
size="sm"
|
||
|
|
onClick={() => action.onClick(selectedIds)}
|
||
|
|
>
|
||
|
|
{action.icon && <action.icon className="mr-1.5 h-4 w-4" />}
|
||
|
|
{action.label}
|
||
|
|
</Button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|