turash/bugulma/frontend/components/admin/DataTable.tsx

315 lines
9.2 KiB
TypeScript

import React, { useState, useMemo } from 'react';
import { clsx } from 'clsx';
import { ResponsiveTable, Pagination, SearchBar, Checkbox, Button } from '@/components/ui';
import { MoreVertical, Download, Trash2, Edit, Eye } from 'lucide-react';
import { DropdownMenu } from '@/components/ui';
export interface DataTableColumn<T> {
key: string;
header: string;
render: (item: T, index: number) => React.ReactNode;
width?: string | number;
minWidth?: string | number;
sortable?: boolean;
align?: 'left' | 'center' | 'right';
}
export interface DataTableAction<T> {
label: string;
icon?: React.ReactNode;
onClick: (item: T) => void;
variant?: 'default' | 'destructive';
disabled?: (item: T) => boolean;
}
export interface DataTableProps<T> {
data: T[];
columns: DataTableColumn<T>[];
getRowId: (item: T) => string;
isLoading?: boolean;
// Pagination
pagination?: {
currentPage: number;
totalPages: number;
pageSize: number;
totalItems: number;
onPageChange: (page: number) => void;
onPageSizeChange?: (size: number) => void;
};
// Sorting
sorting?: {
column: string | null;
direction: 'asc' | 'desc' | null;
onSort: (column: string, direction: 'asc' | 'desc') => void;
};
// Search
search?: {
value: string;
onChange: (value: string) => void;
placeholder?: string;
};
// Selection
selection?: {
selectedRows: Set<string>;
onSelectionChange: (selected: Set<string>) => void;
};
// Actions
actions?: DataTableAction<T>[];
bulkActions?: Array<{
label: string;
icon?: React.ReactNode;
onClick: (selectedIds: string[]) => void;
variant?: 'default' | 'destructive';
}>;
// Empty state
emptyMessage?: string;
emptyDescription?: string;
emptyAction?: React.ReactNode;
// Mobile card renderer
renderMobileCard?: (item: T, index: number) => React.ReactNode;
className?: string;
}
/**
* Enhanced DataTable component with built-in pagination, sorting, filtering, and bulk actions
*/
export function DataTable<T>({
data,
columns,
getRowId,
isLoading = false,
pagination,
sorting,
search,
selection,
actions,
bulkActions,
emptyMessage = 'No data available',
emptyDescription,
emptyAction,
renderMobileCard,
className,
}: DataTableProps<T>) {
const [showBulkActions, setShowBulkActions] = useState(false);
const selectedCount = selection?.selectedRows.size || 0;
const hasSelection = selectedCount > 0;
const handleSelectAll = (checked: boolean) => {
if (!selection) return;
if (checked) {
const allIds = new Set(data.map(getRowId));
selection.onSelectionChange(allIds);
} else {
selection.onSelectionChange(new Set());
}
};
const handleSelectRow = (id: string, checked: boolean) => {
if (!selection) return;
const newSelection = new Set(selection.selectedRows);
if (checked) {
newSelection.add(id);
} else {
newSelection.delete(id);
}
selection.onSelectionChange(newSelection);
};
const allSelected = data.length > 0 && data.every((item) => selection?.selectedRows.has(getRowId(item)));
const someSelected = data.some((item) => selection?.selectedRows.has(getRowId(item)));
// Add selection column if selection is enabled
const tableColumns = useMemo(() => {
if (!selection) return columns;
return [
{
key: '__select__',
header: '',
render: (_: T, index: number) => {
const id = getRowId(data[index]);
const isSelected = selection.selectedRows.has(id);
return (
<Checkbox
checked={isSelected}
onChange={(e) => handleSelectRow(id, e.target.checked)}
onClick={(e) => e.stopPropagation()}
/>
);
},
width: 40,
sortable: false,
align: 'center' as const,
},
...columns,
];
}, [columns, selection, data, getRowId]);
// Add actions column if actions are provided
const finalColumns = useMemo(() => {
if (!actions || actions.length === 0) return tableColumns;
return [
...tableColumns,
{
key: '__actions__',
header: 'Actions',
render: (item: T) => {
const enabledActions = actions.filter(
(action) => !action.disabled || !action.disabled(item)
);
if (enabledActions.length === 0) return null;
return (
<DropdownMenu
trigger={
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" />
</Button>
}
items={enabledActions.map((action) => ({
label: action.label,
value: action.label,
icon: action.icon,
onClick: () => action.onClick(item),
disabled: action.disabled?.(item),
}))}
align="right"
/>
);
},
width: 80,
sortable: false,
align: 'right' as const,
},
];
}, [tableColumns, actions]);
const handleSort = (key: string) => {
if (!sorting) return;
const newDirection =
sorting.column === key && sorting.direction === 'asc' ? 'desc' : 'asc';
sorting.onSort(key, newDirection);
};
return (
<div className={clsx('space-y-4', className)}>
{/* Toolbar */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
{/* Search */}
{search && (
<div className="w-full sm:w-auto sm:min-w-[300px]">
<SearchBar
value={search.value}
onChange={search.onChange}
placeholder={search.placeholder || 'Search...'}
/>
</div>
)}
{/* Bulk Actions */}
{hasSelection && bulkActions && bulkActions.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{selectedCount} selected
</span>
{bulkActions.map((action, index) => (
<Button
key={index}
variant={action.variant === 'destructive' ? 'destructive' : 'outline'}
size="sm"
onClick={() => {
const selectedIds = Array.from(selection!.selectedRows);
action.onClick(selectedIds);
}}
>
{action.icon && <span className="mr-2">{action.icon}</span>}
{action.label}
</Button>
))}
<Button
variant="ghost"
size="sm"
onClick={() => selection?.onSelectionChange(new Set())}
>
Clear
</Button>
</div>
)}
</div>
{/* Select All Checkbox (if selection enabled) */}
{selection && data.length > 0 && (
<div className="flex items-center gap-2 px-2">
<Checkbox
checked={allSelected}
ref={(el) => {
if (el) {
el.indeterminate = someSelected && !allSelected;
}
}}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
<span className="text-sm text-muted-foreground">
Select all ({data.length} items)
</span>
</div>
)}
{/* Table */}
<ResponsiveTable
data={data}
columns={finalColumns}
onSort={handleSort}
sortedColumn={sorting?.column || null}
sortDirection={sorting?.direction || null}
emptyMessage={emptyMessage}
emptyDescription={emptyDescription}
emptyAction={emptyAction}
selectedRows={selection?.selectedRows}
onRowSelect={(id) => {
const item = data.find((d) => getRowId(d) === id);
if (item && selection) {
const isSelected = selection.selectedRows.has(id);
handleSelectRow(id, !isSelected);
}
}}
getRowId={getRowId}
renderMobileCard={renderMobileCard}
isLoading={isLoading}
/>
{/* Pagination */}
{pagination && (
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<Pagination
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
onPageChange={pagination.onPageChange}
pageSize={pagination.pageSize}
totalItems={pagination.totalItems}
/>
{pagination.onPageSizeChange && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Items per page:</span>
<select
value={pagination.pageSize}
onChange={(e) => pagination.onPageSizeChange?.(Number(e.target.value))}
className="rounded-md border bg-background px-2 py-1 text-sm"
>
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
)}
</div>
)}
</div>
);
}