Publish Fast Tabler Docker image / build-and-push-fast (push) Has been cancelled
254 lines
8.8 KiB
React
254 lines
8.8 KiB
React
import { useRef, useMemo } from 'react';
|
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
import { IconArrowUp, IconArrowDown, IconEdit, IconTrash, IconCopy } from '@tabler/icons-react';
|
|
import TableSkeleton from './TableSkeleton.jsx';
|
|
import EmptyState from './EmptyState.jsx';
|
|
import Tooltip from './Tooltip.jsx';
|
|
|
|
/**
|
|
* Виртуализированная таблица для больших списков данных (1000+ элементов)
|
|
* Рендерит только видимые строки для оптимальной производительности
|
|
*
|
|
* @param {Object} props
|
|
* @param {Array} props.columns - Массив описаний колонок
|
|
* @param {Array} props.items - Массив данных для отображения
|
|
* @param {string} props.itemKey - Ключ уникального идентификатора
|
|
* @param {boolean} props.loading - Состояние загрузки
|
|
* @param {string} props.sortField - Текущее поле сортировки
|
|
* @param {string} props.sortOrder - Направление сортировки
|
|
* @param {function} props.onSort - Обработчик сортировки
|
|
* @param {Set} props.selectedItems - Выбранные элементы
|
|
* @param {function} props.onSelectItem - Обработчик выбора
|
|
* @param {function} props.onSelectAll - Обработчик выбора всех
|
|
* @param {function} props.onEdit - Обработчик редактирования
|
|
* @param {function} props.onDelete - Обработчик удаления
|
|
* @param {function} props.onCopy - Обработчик копирования
|
|
* @param {number} props.rowHeight - Высота строки в пикселях (default: 48)
|
|
* @param {number} props.containerHeight - Высота контейнера (default: 600)
|
|
* @param {number} props.overscan - Количество дополнительных строк (default: 5)
|
|
* @param {Object} props.emptyState - Настройки пустого состояния
|
|
* @param {boolean} props.selectable - Включить чекбоксы (default: true)
|
|
*/
|
|
function VirtualizedTable({
|
|
columns = [],
|
|
items = [],
|
|
itemKey = 'id',
|
|
loading = false,
|
|
sortField,
|
|
sortOrder = 'asc',
|
|
onSort,
|
|
selectedItems = new Set(),
|
|
onSelectItem,
|
|
onSelectAll,
|
|
onDeselectAll,
|
|
onEdit,
|
|
onDelete,
|
|
onCopy,
|
|
rowHeight = 48,
|
|
containerHeight = 600,
|
|
overscan = 5,
|
|
emptyState,
|
|
selectable = true,
|
|
actions = [],
|
|
}) {
|
|
const parentRef = useRef(null);
|
|
const hasActions = onEdit || onDelete || onCopy || actions.length > 0;
|
|
|
|
const virtualizer = useVirtualizer({
|
|
count: items.length,
|
|
getScrollElement: () => parentRef.current,
|
|
estimateSize: () => rowHeight,
|
|
overscan,
|
|
});
|
|
|
|
const virtualItems = virtualizer.getVirtualItems();
|
|
const totalSize = virtualizer.getTotalSize();
|
|
|
|
// Рендер заголовка
|
|
const renderHeader = () => (
|
|
<thead className="sticky-top bg-white" style={{ zIndex: 1 }}>
|
|
<tr>
|
|
{selectable && (
|
|
<th style={{ width: '40px' }}>
|
|
<input
|
|
type="checkbox"
|
|
className="form-check-input"
|
|
checked={items.length > 0 && items.every(i => selectedItems.has(i[itemKey]))}
|
|
onChange={(e) => e.target.checked ? onSelectAll?.() : onDeselectAll?.()}
|
|
title="Выбрать все"
|
|
/>
|
|
</th>
|
|
)}
|
|
{columns.map((col) => {
|
|
const isSortable = col.sortable !== false && onSort;
|
|
const isActive = sortField === col.key;
|
|
|
|
return (
|
|
<th
|
|
key={col.key}
|
|
className={isSortable ? 'cursor-pointer user-select-none' : ''}
|
|
onClick={isSortable ? () => onSort(col.key) : undefined}
|
|
style={col.width ? { width: col.width } : undefined}
|
|
>
|
|
<div className="d-flex align-items-center">
|
|
{col.icon && <col.icon size={16} className="me-1 text-muted" />}
|
|
{col.title}
|
|
{isSortable && isActive && (
|
|
<span className="ms-1">
|
|
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</th>
|
|
);
|
|
})}
|
|
{hasActions && <th className="text-end">Действия</th>}
|
|
</tr>
|
|
</thead>
|
|
);
|
|
|
|
// Рендер действий
|
|
const renderActions = (item) => (
|
|
<>
|
|
{onEdit && (
|
|
<Tooltip content="Редактировать">
|
|
<button
|
|
className="btn btn-outline-primary btn-icon btn-sm me-1"
|
|
onClick={() => onEdit(item)}
|
|
>
|
|
<IconEdit size={16} />
|
|
</button>
|
|
</Tooltip>
|
|
)}
|
|
{onCopy && (
|
|
<Tooltip content="Копировать">
|
|
<button
|
|
className="btn btn-outline-secondary btn-icon btn-sm me-1"
|
|
onClick={() => onCopy(item)}
|
|
>
|
|
<IconCopy size={16} />
|
|
</button>
|
|
</Tooltip>
|
|
)}
|
|
{actions.map((action, idx) => (
|
|
<Tooltip key={idx} content={action.label}>
|
|
<button
|
|
className={`btn btn-outline-${action.variant || 'secondary'} btn-icon btn-sm me-1`}
|
|
onClick={() => action.onClick(item)}
|
|
>
|
|
<action.icon size={16} />
|
|
</button>
|
|
</Tooltip>
|
|
))}
|
|
{onDelete && (
|
|
<Tooltip content="Удалить">
|
|
<button
|
|
className="btn btn-outline-danger btn-icon btn-sm"
|
|
onClick={() => onDelete(item)}
|
|
>
|
|
<IconTrash size={16} />
|
|
</button>
|
|
</Tooltip>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
// Загрузка
|
|
if (loading) {
|
|
return <TableSkeleton rows={10} cols={columns.length + (hasActions ? 1 : 0)} hasCheckbox={selectable} />;
|
|
}
|
|
|
|
// Пустое состояние
|
|
if (items.length === 0) {
|
|
return (
|
|
<div className="p-4">
|
|
{emptyState ? (
|
|
<EmptyState
|
|
title={emptyState.title}
|
|
description={emptyState.description}
|
|
action={emptyState.action}
|
|
/>
|
|
) : (
|
|
<EmptyState title="Нет данных" description="Добавьте записи, чтобы начать." />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="table-responsive">
|
|
<table className="table card-table table-vcenter table-nowrap mb-0">
|
|
{renderHeader()}
|
|
</table>
|
|
|
|
<div
|
|
ref={parentRef}
|
|
style={{
|
|
height: `${Math.min(containerHeight, items.length * rowHeight + 20)}px`,
|
|
overflow: 'auto',
|
|
}}
|
|
>
|
|
<div style={{ height: `${totalSize}px`, position: 'relative' }}>
|
|
<table className="table card-table table-vcenter table-nowrap mb-0">
|
|
<tbody>
|
|
{virtualItems.map((virtualRow) => {
|
|
const item = items[virtualRow.index];
|
|
const id = item[itemKey];
|
|
const isSelected = selectedItems.has(id);
|
|
|
|
return (
|
|
<tr
|
|
key={id}
|
|
className={isSelected ? 'table-selected' : ''}
|
|
style={{
|
|
height: `${rowHeight}px`,
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
width: '100%',
|
|
transform: `translateY(${virtualRow.start}px)`,
|
|
}}
|
|
>
|
|
{selectable && (
|
|
<td style={{ width: '40px' }}>
|
|
<input
|
|
type="checkbox"
|
|
className="form-check-input"
|
|
checked={isSelected}
|
|
onChange={() => onSelectItem?.(id)}
|
|
/>
|
|
</td>
|
|
)}
|
|
{columns.map((col) => (
|
|
<td key={col.key} className={col.className} style={col.width ? { width: col.width } : undefined}>
|
|
{col.render ? col.render(item[col.key], item) : item[col.key]}
|
|
</td>
|
|
))}
|
|
{hasActions && (
|
|
<td className="text-end">
|
|
{renderActions(item)}
|
|
</td>
|
|
)}
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card-footer d-flex align-items-center justify-content-between">
|
|
<span className="text-muted">
|
|
Показано {items.length} записей
|
|
{items.length > 100 && (
|
|
<span className="badge bg-blue-lt text-blue ms-2">Виртуализация активна</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default VirtualizedTable;
|
|
|