This commit is contained in:
2026-08-12 19:30:17 +03:00
parent 40c324d7fe
commit b0b7196518
152 changed files with 24657 additions and 0 deletions

View 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;