307 lines
11 KiB
React
307 lines
11 KiB
React
import { useState } from 'react';
|
|
import { IconArrowUp, IconArrowDown, IconEdit, IconTrash, IconCopy, IconCheck, IconX } from '@tabler/icons-react';
|
|
import TableSkeleton, { TableEmpty } from './TableSkeleton.jsx';
|
|
import EmptyState from './EmptyState.jsx';
|
|
import Pagination from './Pagination.jsx';
|
|
import Tooltip from './Tooltip.jsx';
|
|
|
|
/**
|
|
* Универсальный компонент таблицы данных
|
|
*
|
|
* @param {Object} props
|
|
* @param {Array} props.columns - Массив описаний колонок
|
|
* - { key: string, title: string, sortable?: boolean, icon?: Component, render?: (value, item) => ReactNode }
|
|
* @param {Array} props.items - Массив данных для отображения
|
|
* @param {string} props.itemKey - Ключ уникального идентификатора элемента
|
|
* @param {boolean} props.loading - Состояние загрузки
|
|
* @param {string} props.sortField - Текущее поле сортировки
|
|
* @param {string} props.sortOrder - Направление сортировки ('asc' | 'desc')
|
|
* @param {function} props.onSort - Обработчик изменения сортировки
|
|
* @param {Set} props.selectedItems - Набор выбранных элементов
|
|
* @param {function} props.onSelectItem - Обработчик выбора элемента
|
|
* @param {function} props.onSelectAll - Обработчик выбора всех
|
|
* @param {function} props.onDeselectAll - Обработчик снятия выбора
|
|
* @param {function} props.onEdit - Обработчик редактирования (item) => void
|
|
* @param {function} props.onDelete - Обработчик удаления (item) => void
|
|
* @param {function} props.onCopy - Обработчик копирования (item) => void
|
|
* @param {Object} props.inlineEdit - Настройки инлайн-редактирования
|
|
* - { editingKey: string, editingValue: string, onSave: () => void, onCancel: () => void, onChange: (value) => void, renderEditor?: () => ReactNode }
|
|
* @param {Object} props.pagination - Настройки пагинации
|
|
* - { currentPage, totalPages, totalItems, pageSize, onPageChange }
|
|
* @param {Object} props.emptyState - Настройки пустого состояния
|
|
* - { title, description, action, secondaryAction }
|
|
* @param {Array} props.actions - Дополнительные действия в строке
|
|
* - [{ icon: Component, label: string, onClick: (item) => void, variant?: string }]
|
|
* @param {boolean} props.selectable - Включить чекбоксы выбора (default: true)
|
|
* @param {number} props.skeletonRows - Количество строк скелетона (default: 10)
|
|
* @param {boolean} props.striped - Чередование фона строк (Tabler UX, default: true)
|
|
* @param {boolean} props.compact - Уменьшенные отступы ячеек (default: false)
|
|
* @param {boolean} props.flat - Таблица без карточной подложки: только горизонтальные разделители (default: false)
|
|
*/
|
|
function DataTable({
|
|
columns = [],
|
|
items = [],
|
|
itemKey = 'id',
|
|
loading = false,
|
|
sortField,
|
|
sortOrder = 'asc',
|
|
onSort,
|
|
selectedItems = new Set(),
|
|
onSelectItem,
|
|
onSelectAll,
|
|
onDeselectAll,
|
|
onEdit,
|
|
onDelete,
|
|
onCopy,
|
|
inlineEdit,
|
|
pagination,
|
|
emptyState,
|
|
actions = [],
|
|
selectable = true,
|
|
skeletonRows = 10,
|
|
striped = true,
|
|
compact = false,
|
|
flat = false,
|
|
}) {
|
|
const hasActions = onEdit || onDelete || onCopy || actions.length > 0;
|
|
const isEditing = (item) => inlineEdit && item[itemKey] === inlineEdit.editingKey;
|
|
|
|
// Рендер заголовка колонки (col.title — подпись, col.tooltip — подсказка для UX)
|
|
const renderColumnHeader = (col) => {
|
|
const isSortable = col.sortable !== false && onSort;
|
|
const isActive = sortField === col.key;
|
|
|
|
const content = (
|
|
<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>
|
|
);
|
|
|
|
const thProps = {
|
|
key: col.key,
|
|
scope: 'col',
|
|
style: col.width ? { width: col.width } : undefined,
|
|
};
|
|
|
|
if (isSortable) {
|
|
return (
|
|
<th
|
|
{...thProps}
|
|
className="cursor-pointer user-select-none"
|
|
onClick={() => onSort(col.key)}
|
|
>
|
|
{col.tooltip ? <Tooltip content={col.tooltip} position="top">{content}</Tooltip> : content}
|
|
</th>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<th {...thProps}>
|
|
{col.tooltip ? <Tooltip content={col.tooltip} position="top">{content}</Tooltip> : content}
|
|
</th>
|
|
);
|
|
};
|
|
|
|
// Рендер ячейки
|
|
const renderCell = (col, item) => {
|
|
const value = item[col.key];
|
|
|
|
if (col.render) {
|
|
return col.render(value, item);
|
|
}
|
|
|
|
return value;
|
|
};
|
|
|
|
// Рендер действий
|
|
const renderActions = (item) => {
|
|
if (isEditing(item)) {
|
|
return (
|
|
<>
|
|
{inlineEdit.renderEditor && inlineEdit.renderEditor(item)}
|
|
<Tooltip content="Сохранить (Enter)">
|
|
<button
|
|
className="btn btn-success btn-icon btn-sm me-1"
|
|
onClick={inlineEdit.onSave}
|
|
aria-label="Сохранить"
|
|
>
|
|
<IconCheck size={16} />
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip content="Отмена (Esc)">
|
|
<button
|
|
className="btn btn-secondary btn-icon btn-sm"
|
|
onClick={inlineEdit.onCancel}
|
|
aria-label="Отмена"
|
|
>
|
|
<IconX size={16} />
|
|
</button>
|
|
</Tooltip>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{onEdit && (
|
|
<Tooltip content="Редактировать" position="top">
|
|
<button
|
|
className="btn btn-outline-primary btn-icon btn-sm me-1"
|
|
onClick={() => onEdit(item)}
|
|
aria-label="Редактировать"
|
|
>
|
|
<IconEdit size={16} />
|
|
</button>
|
|
</Tooltip>
|
|
)}
|
|
{onCopy && (
|
|
<Tooltip content="Копировать" position="top">
|
|
<button
|
|
className="btn btn-outline-secondary btn-icon btn-sm me-1"
|
|
onClick={() => onCopy(item)}
|
|
aria-label="Копировать"
|
|
>
|
|
<IconCopy size={16} />
|
|
</button>
|
|
</Tooltip>
|
|
)}
|
|
{actions.map((action, idx) => (
|
|
<Tooltip key={idx} content={action.label} position="top">
|
|
<button
|
|
className={`btn btn-outline-${action.variant || 'secondary'} btn-icon btn-sm me-1`}
|
|
onClick={() => action.onClick(item)}
|
|
aria-label={action.label}
|
|
>
|
|
<action.icon size={16} />
|
|
</button>
|
|
</Tooltip>
|
|
))}
|
|
{onDelete && (
|
|
<Tooltip content="Удалить" position="top">
|
|
<button
|
|
className="btn btn-outline-danger btn-icon btn-sm"
|
|
onClick={() => onDelete(item)}
|
|
aria-label="Удалить"
|
|
>
|
|
<IconTrash size={16} />
|
|
</button>
|
|
</Tooltip>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
// Загрузка
|
|
if (loading) {
|
|
return <TableSkeleton rows={skeletonRows} cols={columns.length + (hasActions ? 1 : 0)} hasCheckbox={selectable} />;
|
|
}
|
|
|
|
// Пустое состояние
|
|
if (items.length === 0) {
|
|
return (
|
|
<TableEmpty cols={columns.length + (hasActions ? 1 : 0) + (selectable ? 1 : 0)}>
|
|
{emptyState ? (
|
|
<EmptyState
|
|
title={emptyState.title}
|
|
description={emptyState.description}
|
|
action={emptyState.action}
|
|
secondaryAction={emptyState.secondaryAction}
|
|
/>
|
|
) : (
|
|
<EmptyState title="Нет данных" description="Добавьте записи, чтобы начать." />
|
|
)}
|
|
</TableEmpty>
|
|
);
|
|
}
|
|
|
|
const allSelected = items.length > 0 && items.every(i => selectedItems.has(i[itemKey]));
|
|
const tableClass = [
|
|
'table table-vcenter table-nowrap mb-0',
|
|
flat ? 'table-flat' : 'card-table',
|
|
!flat && striped && 'table-striped',
|
|
compact && 'table-sm',
|
|
].filter(Boolean).join(' ');
|
|
|
|
return (
|
|
<>
|
|
<div className="table-responsive">
|
|
<table className={tableClass} role="grid">
|
|
<thead>
|
|
<tr>
|
|
{selectable && (
|
|
<th scope="col" style={{ width: '40px' }}>
|
|
<input
|
|
type="checkbox"
|
|
className="form-check-input"
|
|
checked={allSelected}
|
|
onChange={(e) => e.target.checked ? onSelectAll?.() : onDeselectAll?.()}
|
|
title="Выбрать все на странице"
|
|
/>
|
|
</th>
|
|
)}
|
|
{columns.map(renderColumnHeader)}
|
|
{hasActions && <th scope="col" className="text-end td-actions">Действия</th>}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items.map((item, idx) => {
|
|
const id = item[itemKey];
|
|
const isSelected = selectedItems.has(id);
|
|
const editing = isEditing(item);
|
|
|
|
return (
|
|
<tr
|
|
key={id}
|
|
className={editing ? 'table-active' : (isSelected ? 'table-selected' : '')}
|
|
style={{ animation: `fadeIn 0.3s ease ${idx * 0.02}s both` }}
|
|
>
|
|
{selectable && (
|
|
<td>
|
|
<input
|
|
type="checkbox"
|
|
className="form-check-input"
|
|
checked={isSelected}
|
|
onChange={() => onSelectItem?.(id)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
</td>
|
|
)}
|
|
{columns.map((col) => (
|
|
<td key={col.key} className={col.className}>
|
|
{renderCell(col, item)}
|
|
</td>
|
|
))}
|
|
{hasActions && (
|
|
<td className="text-end td-actions">
|
|
{renderActions(item)}
|
|
</td>
|
|
)}
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{pagination && (
|
|
<Pagination
|
|
currentPage={pagination.currentPage}
|
|
totalPages={pagination.totalPages}
|
|
totalItems={pagination.totalItems}
|
|
pageSize={pagination.pageSize}
|
|
onPageChange={pagination.onPageChange}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default DataTable;
|
|
|