feat: Implement date formatting utility functions and update components to use formatted timestamps for improved readability and consistency across the application.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m47s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m47s
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
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)
|
||||
*/
|
||||
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,
|
||||
}) {
|
||||
const hasActions = onEdit || onDelete || onCopy || actions.length > 0;
|
||||
const isEditing = (item) => inlineEdit && item[itemKey] === inlineEdit.editingKey;
|
||||
|
||||
// Рендер заголовка колонки
|
||||
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>
|
||||
);
|
||||
|
||||
if (isSortable) {
|
||||
return (
|
||||
<th
|
||||
key={col.key}
|
||||
className="cursor-pointer user-select-none"
|
||||
onClick={() => onSort(col.key)}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
>
|
||||
{content}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<th key={col.key} style={col.width ? { width: col.width } : undefined}>
|
||||
{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]));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="table-responsive">
|
||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
{selectable && (
|
||||
<th 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 className="text-end">Действия</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">
|
||||
{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;
|
||||
|
||||
Reference in New Issue
Block a user