Files
front/src/components/common/DataTable.tsx
2026-08-12 19:30:17 +03:00

268 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;