Files
pn-new-crm/src/components/shared/data-table.tsx

274 lines
9.0 KiB
TypeScript
Raw Normal View History

'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 overflow-x-auto">
<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>
);
}