268 lines
8.6 KiB
TypeScript
268 lines
8.6 KiB
TypeScript
import { useState } from 'react';
|
||
import { ChevronUp, ChevronDown, ChevronsUpDown, ChevronLeft, ChevronRight } from 'lucide-react';
|
||
import { cn } from '../../utils/helpers';
|
||
import { Skeleton } from './LoadingSkeleton';
|
||
import { EmptyState } from './EmptyState';
|
||
|
||
// Simplified column definition
|
||
export interface ColumnDef<T> {
|
||
id: string;
|
||
header: string;
|
||
accessorKey?: keyof T;
|
||
accessorFn?: (row: T) => unknown;
|
||
cell?: (info: { row: T; getValue: () => unknown }) => React.ReactNode;
|
||
sortable?: boolean;
|
||
width?: string;
|
||
}
|
||
|
||
interface DataTableProps<T> {
|
||
columns: ColumnDef<T>[];
|
||
data: T[];
|
||
isLoading?: boolean;
|
||
emptyMessage?: string;
|
||
emptyDescription?: string;
|
||
pageSize?: number;
|
||
showPagination?: boolean;
|
||
enableSorting?: boolean;
|
||
className?: string;
|
||
striped?: boolean;
|
||
compact?: boolean;
|
||
onRowClick?: (row: T) => void;
|
||
getRowId?: (row: T) => string;
|
||
}
|
||
|
||
type SortDirection = 'asc' | 'desc' | null;
|
||
|
||
interface SortState {
|
||
columnId: string | null;
|
||
direction: SortDirection;
|
||
}
|
||
|
||
export function DataTable<T>({
|
||
columns,
|
||
data,
|
||
isLoading = false,
|
||
emptyMessage = 'Нет данных',
|
||
emptyDescription,
|
||
pageSize = 10,
|
||
showPagination = true,
|
||
enableSorting = true,
|
||
className,
|
||
striped = false,
|
||
compact = false,
|
||
onRowClick,
|
||
getRowId,
|
||
}: DataTableProps<T>) {
|
||
const [sort, setSort] = useState<SortState>({ columnId: null, direction: null });
|
||
const [currentPage, setCurrentPage] = useState(0);
|
||
|
||
// Get value from row
|
||
const getValue = (row: T, column: ColumnDef<T>): unknown => {
|
||
if (column.accessorFn) return column.accessorFn(row);
|
||
if (column.accessorKey) return row[column.accessorKey];
|
||
return null;
|
||
};
|
||
|
||
// Sort data
|
||
const sortedData = [...data].sort((a, b) => {
|
||
if (!sort.columnId || !sort.direction) return 0;
|
||
|
||
const column = columns.find((c) => c.id === sort.columnId);
|
||
if (!column) return 0;
|
||
|
||
const aVal = getValue(a, column);
|
||
const bVal = getValue(b, column);
|
||
|
||
if (aVal === bVal) return 0;
|
||
if (aVal === null || aVal === undefined) return 1;
|
||
if (bVal === null || bVal === undefined) return -1;
|
||
|
||
const comparison = aVal < bVal ? -1 : 1;
|
||
return sort.direction === 'asc' ? comparison : -comparison;
|
||
});
|
||
|
||
// Paginate data
|
||
const totalPages = Math.ceil(sortedData.length / pageSize);
|
||
const paginatedData = showPagination
|
||
? sortedData.slice(currentPage * pageSize, (currentPage + 1) * pageSize)
|
||
: sortedData;
|
||
|
||
// Toggle sort
|
||
const toggleSort = (columnId: string) => {
|
||
if (!enableSorting) return;
|
||
|
||
setSort((prev) => {
|
||
if (prev.columnId !== columnId) {
|
||
return { columnId, direction: 'asc' };
|
||
}
|
||
if (prev.direction === 'asc') {
|
||
return { columnId, direction: 'desc' };
|
||
}
|
||
return { columnId: null, direction: null };
|
||
});
|
||
};
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className={cn('overflow-hidden rounded-lg border border-border', className)}>
|
||
<table className="w-full">
|
||
<thead className="bg-muted/50">
|
||
<tr>
|
||
{columns.map((col) => (
|
||
<th key={col.id} className={cn('px-4 text-left', compact ? 'py-2' : 'py-3')}>
|
||
<Skeleton className="h-4 w-20" />
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{Array.from({ length: 5 }).map((_, i) => (
|
||
<tr key={i} className="border-b border-border">
|
||
{columns.map((col) => (
|
||
<td key={col.id} className={cn('px-4', compact ? 'py-2' : 'py-3')}>
|
||
<Skeleton className="h-4 w-full" />
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (data.length === 0) {
|
||
return (
|
||
<EmptyState
|
||
title={emptyMessage}
|
||
description={emptyDescription}
|
||
className={className}
|
||
/>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className={cn('space-y-4', className)}>
|
||
<div className="overflow-hidden rounded-lg border border-border">
|
||
<table className="w-full">
|
||
<thead className="bg-muted/50">
|
||
<tr>
|
||
{columns.map((column) => (
|
||
<th
|
||
key={column.id}
|
||
style={{ width: column.width }}
|
||
className={cn(
|
||
'text-left text-sm font-medium text-muted-foreground',
|
||
compact ? 'px-3 py-2' : 'px-4 py-3'
|
||
)}
|
||
>
|
||
<div
|
||
className={cn(
|
||
'flex items-center gap-2',
|
||
column.sortable !== false && enableSorting && 'cursor-pointer select-none hover:text-foreground'
|
||
)}
|
||
onClick={() => column.sortable !== false && toggleSort(column.id)}
|
||
>
|
||
{column.header}
|
||
{column.sortable !== false && enableSorting && (
|
||
<span className="text-muted-foreground">
|
||
{sort.columnId === column.id && sort.direction === 'asc' ? (
|
||
<ChevronUp className="h-4 w-4" />
|
||
) : sort.columnId === column.id && sort.direction === 'desc' ? (
|
||
<ChevronDown className="h-4 w-4" />
|
||
) : (
|
||
<ChevronsUpDown className="h-4 w-4 opacity-50" />
|
||
)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{paginatedData.map((row, index) => (
|
||
<tr
|
||
key={getRowId ? getRowId(row) : index}
|
||
className={cn(
|
||
'border-b border-border transition-colors',
|
||
striped && index % 2 === 1 && 'bg-muted/30',
|
||
'hover:bg-muted/50',
|
||
onRowClick && 'cursor-pointer'
|
||
)}
|
||
onClick={() => onRowClick?.(row)}
|
||
>
|
||
{columns.map((column) => {
|
||
const value = getValue(row, column);
|
||
return (
|
||
<td
|
||
key={column.id}
|
||
className={cn(compact ? 'px-3 py-2' : 'px-4 py-3')}
|
||
>
|
||
{column.cell
|
||
? column.cell({ row, getValue: () => value })
|
||
: String(value ?? '')}
|
||
</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{showPagination && totalPages > 1 && (
|
||
<div className="flex items-center justify-between text-sm">
|
||
<div className="text-muted-foreground">
|
||
Страница {currentPage + 1} из {totalPages}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
|
||
disabled={currentPage === 0}
|
||
className="flex h-8 w-8 items-center justify-center rounded border border-border transition-colors hover:bg-accent disabled:opacity-50"
|
||
>
|
||
<ChevronLeft className="h-4 w-4" />
|
||
</button>
|
||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||
let pageNum: number;
|
||
if (totalPages <= 5) {
|
||
pageNum = i;
|
||
} else if (currentPage < 3) {
|
||
pageNum = i;
|
||
} else if (currentPage > totalPages - 4) {
|
||
pageNum = totalPages - 5 + i;
|
||
} else {
|
||
pageNum = currentPage - 2 + i;
|
||
}
|
||
return (
|
||
<button
|
||
key={pageNum}
|
||
onClick={() => setCurrentPage(pageNum)}
|
||
className={cn(
|
||
'flex h-8 w-8 items-center justify-center rounded border transition-colors',
|
||
currentPage === pageNum
|
||
? 'border-primary bg-primary text-primary-foreground'
|
||
: 'border-border hover:bg-accent'
|
||
)}
|
||
>
|
||
{pageNum + 1}
|
||
</button>
|
||
);
|
||
})}
|
||
<button
|
||
onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
|
||
disabled={currentPage === totalPages - 1}
|
||
className="flex h-8 w-8 items-center justify-center rounded border border-border transition-colors hover:bg-accent disabled:opacity-50"
|
||
>
|
||
<ChevronRight className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default DataTable;
|