Files
router-lists-ui/frontend/src/components/DataTable.jsx
T

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;