Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
274
src/components/shared/data-table.tsx
Normal file
274
src/components/shared/data-table.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
type RowSelectionState,
|
||||
type PaginationState,
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user