upd
This commit is contained in:
267
src/components/common/DataTable.tsx
Normal file
267
src/components/common/DataTable.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user