feat: Улучшение компонентов Dashboard, EmptyState, ErrorAlert и Pagination. Добавлены новые функции, такие как индикаторы тренда, возможность перехода на страницу и улучшенные действия с ошибками. Обновлены стили для повышения удобства использования и доступности.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m54s

This commit is contained in:
2025-10-03 19:11:10 +07:00
parent 72fbb7bde5
commit 18f3a5312a
16 changed files with 2697 additions and 96 deletions
+141
View File
@@ -0,0 +1,141 @@
import { useState, useEffect, useRef } from 'react'
import { IconDotsVertical } from '@tabler/icons-react'
/**
* ContextMenu - компонент контекстного меню для действий
* Поддерживает правый клик и dropdown меню
*/
function ContextMenu({
items, // Array of { label, icon, onClick, variant, divider }
children, // Trigger element
align = 'right' // 'left' | 'right'
}) {
const [isOpen, setIsOpen] = useState(false)
const [position, setPosition] = useState({ x: 0, y: 0 })
const [useContextPosition, setUseContextPosition] = useState(false)
const menuRef = useRef(null)
const triggerRef = useRef(null)
const handleContextMenu = (e) => {
e.preventDefault()
setPosition({ x: e.clientX, y: e.clientY })
setUseContextPosition(true)
setIsOpen(true)
}
const handleClick = () => {
if (triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect()
setPosition({
x: align === 'right' ? rect.right : rect.left,
y: rect.bottom
})
setUseContextPosition(false)
setIsOpen(!isOpen)
}
}
const handleItemClick = (onClick) => {
setIsOpen(false)
if (onClick) {
onClick()
}
}
// Закрытие при клике вне меню
useEffect(() => {
const handleClickOutside = (e) => {
if (menuRef.current && !menuRef.current.contains(e.target) &&
triggerRef.current && !triggerRef.current.contains(e.target)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}
}, [isOpen])
return (
<div
ref={triggerRef}
className="context-menu-trigger"
onContextMenu={handleContextMenu}
style={{ display: 'inline-block' }}
>
{children ? (
<div onClick={handleClick}>
{children}
</div>
) : (
<button
className="btn btn-ghost-secondary btn-icon btn-sm"
onClick={handleClick}
aria-label="Открыть меню"
title="Показать действия"
>
<IconDotsVertical size={18} />
</button>
)}
{isOpen && (
<>
<div
ref={menuRef}
className="dropdown-menu show"
style={{
position: 'fixed',
left: useContextPosition ? `${position.x}px` : align === 'right' ? 'auto' : `${position.x}px`,
right: useContextPosition ? 'auto' : align === 'right' ? `${window.innerWidth - position.x}px` : 'auto',
top: `${position.y}px`,
zIndex: 9999,
minWidth: '180px'
}}
>
{items.map((item, index) => {
if (item.divider) {
return <div key={index} className="dropdown-divider"></div>
}
const Icon = item.icon
const variantClass = item.variant === 'danger' ? 'text-danger' : ''
return (
<button
key={index}
className={`dropdown-item ${variantClass}`}
onClick={() => handleItemClick(item.onClick)}
disabled={item.disabled}
>
{Icon && <Icon size={18} className="me-2" />}
{item.label}
{item.shortcut && (
<kbd className="ms-auto">{item.shortcut}</kbd>
)}
</button>
)
})}
</div>
{/* Backdrop для мобильных */}
<div
className="dropdown-backdrop"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 9998
}}
onClick={() => setIsOpen(false)}
></div>
</>
)}
</div>
)
}
export default ContextMenu
+70 -4
View File
@@ -1,6 +1,6 @@
/**
* EmptyState - компонент для отображения пустого состояния
* Оптимизирован: убран неиспользуемый импорт React
* EmptyState - улучшенный компонент для отображения пустого состояния
* Добавлены: иллюстрации, улучшенные CTA, варианты размеров
*/
function EmptyState({
icon: Icon,
@@ -8,11 +8,41 @@ function EmptyState({
description,
action,
secondaryAction,
size = 'default', // 'small', 'default', 'large'
illustration, // URL иллюстрации
variant = 'default' // 'default', 'success', 'info', 'warning'
}) {
const sizeClasses = {
small: 'empty-sm',
default: '',
large: 'empty-lg'
}
const variantColors = {
default: 'text-muted',
success: 'text-success',
info: 'text-info',
warning: 'text-warning'
}
return (
<div className="empty">
<div className={`empty ${sizeClasses[size]}`}>
<div className="empty-icon">
{Icon ? <Icon size={48} className="text-muted" /> : null}
{illustration ? (
<img
src={illustration}
alt={title}
style={{
maxWidth: size === 'large' ? '200px' : size === 'small' ? '80px' : '120px',
opacity: 0.6
}}
/>
) : Icon ? (
<Icon
size={size === 'large' ? 64 : size === 'small' ? 32 : 48}
className={variantColors[variant]}
/>
) : null}
</div>
<p className="empty-title">{title}</p>
{description && (
@@ -28,6 +58,42 @@ function EmptyState({
)}
</div>
)}
<style jsx>{`
.empty-sm .empty-icon {
margin-bottom: 0.5rem;
}
.empty-sm .empty-title {
font-size: 1rem;
}
.empty-sm .empty-subtitle {
font-size: 0.875rem;
}
.empty-lg .empty-icon {
margin-bottom: 2rem;
}
.empty-lg .empty-title {
font-size: 1.5rem;
font-weight: 600;
}
.empty-lg .empty-subtitle {
font-size: 1.125rem;
}
.empty-icon img {
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
`}</style>
</div>
)
}
+95 -19
View File
@@ -1,27 +1,103 @@
import { IconAlertTriangle } from '@tabler/icons-react';
import { IconAlertTriangle, IconCopy, IconRefresh, IconChevronDown, IconChevronUp } from '@tabler/icons-react'
import { useState } from 'react'
/**
* ErrorAlert - компонент для отображения ошибок
* Оптимизирован: убрано дублирование кода
* ErrorAlert - улучшенный компонент для отображения ошибок
* Добавлены: Copy error, Retry, Show details
*/
function ErrorAlert({ message, details, onClose }) {
if (!message) return null;
function ErrorAlert({
message,
details,
error, // альтернативный формат - объект error
onClose,
onRetry,
className = '',
showDetails = true
}) {
const [isExpanded, setIsExpanded] = useState(false)
const [copied, setCopied] = useState(false)
// Поддержка обоих форматов: { message, details } или { error }
const errorMessage = message || (typeof error === 'string' ? error : error?.message) || 'Произошла неизвестная ошибка'
const errorDetails = details || error?.details || error?.stack || null
const errorCode = error?.code || error?.status || null
if (!errorMessage) return null
const handleCopy = () => {
const errorText = `
Ошибка: ${errorMessage}
${errorCode ? `Код: ${errorCode}` : ''}
${errorDetails ? `Детали:\n${typeof errorDetails === 'string' ? errorDetails : JSON.stringify(errorDetails, null, 2)}` : ''}
Время: ${new Date().toLocaleString('ru-RU')}
`.trim()
navigator.clipboard.writeText(errorText).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}).catch(err => {
console.error('Не удалось скопировать:', err)
})
}
return (
<div className="alert alert-danger alert-dismissible" role="alert">
<div className={`alert alert-danger alert-dismissible ${className}`} role="alert">
<div className="d-flex">
<div>
<div className="me-2">
<IconAlertTriangle className="icon alert-icon" />
</div>
<div className="flex-grow-1">
{String(message)}
{details && (
<details className="small mt-1">
<summary>Показать детали</summary>
<pre className="mb-0 mt-1" style={{ whiteSpace: 'pre-wrap' }}>
{typeof details === 'string' ? details : JSON.stringify(details, null, 2)}
</pre>
</details>
<h4 className="alert-title">
Ошибка
{errorCode && (
<span className="badge bg-danger-lt text-danger ms-2">{errorCode}</span>
)}
</h4>
<div className="text-secondary mb-2">{errorMessage}</div>
{/* Детали ошибки (раскрывающийся блок) */}
{errorDetails && showDetails && (
<div className="mt-2">
<button
className="btn btn-sm btn-ghost-secondary"
onClick={() => setIsExpanded(!isExpanded)}
type="button"
>
{isExpanded ? <IconChevronUp size={16} /> : <IconChevronDown size={16} />}
<span className="ms-1">{isExpanded ? 'Скрыть детали' : 'Показать детали'}</span>
</button>
{isExpanded && (
<pre className="mt-2 p-2 bg-dark text-white rounded" style={{
fontSize: '0.75rem',
maxHeight: '200px',
overflow: 'auto'
}}>
{typeof errorDetails === 'string' ? errorDetails : JSON.stringify(errorDetails, null, 2)}
</pre>
)}
</div>
)}
{/* Действия */}
{(onRetry || handleCopy) && (
<div className="btn-list mt-2">
{onRetry && (
<button className="btn btn-sm btn-primary" onClick={onRetry} type="button">
<IconRefresh size={16} className="me-1" />
Повторить
</button>
)}
<button
className="btn btn-sm btn-outline-secondary"
onClick={handleCopy}
title="Скопировать информацию об ошибке"
type="button"
>
<IconCopy size={16} className="me-1" />
{copied ? 'Скопировано!' : 'Копировать ошибку'}
</button>
</div>
)}
</div>
</div>
@@ -29,12 +105,12 @@ function ErrorAlert({ message, details, onClose }) {
<button
type="button"
className="btn-close"
onClick={onClose}
onClick={onClose}
aria-label="Закрыть"
/>
)}
</div>
);
)
}
export default ErrorAlert;
export default ErrorAlert
@@ -0,0 +1,110 @@
/**
* KeyboardShortcutHint - компонент для отображения подсказок по горячим клавишам
* Используется внутри кнопок и других элементов
*/
function KeyboardShortcutHint({ shortcut, className = '' }) {
if (!shortcut) return null
// Разбиваем комбинацию клавиш на части
const keys = shortcut.split('+').map(k => k.trim())
return (
<span className={`keyboard-shortcut-hint ${className}`}>
{keys.map((key, index) => (
<kbd key={index} className="kbd">
{key}
</kbd>
))}
<style jsx>{`
.keyboard-shortcut-hint {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-left: 0.5rem;
opacity: 0.7;
}
.kbd {
background: rgba(0, 0, 0, 0.05);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0.25rem;
padding: 0.125rem 0.375rem;
font-size: 0.75rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-weight: 500;
line-height: 1;
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.1);
}
@media (prefers-color-scheme: dark) {
.kbd {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.1);
}
}
/* Скрываем на мобильных */
@media (max-width: 768px) {
.keyboard-shortcut-hint {
display: none;
}
}
`}</style>
</span>
)
}
/**
* ShortcutsList - компонент для отображения списка горячих клавиш
* Используется в модальных окнах помощи
*/
function ShortcutsList({ shortcuts }) {
return (
<div className="shortcuts-list">
{shortcuts.map((shortcut, index) => (
<div key={index} className="shortcut-item">
<span className="shortcut-description">{shortcut.description}</span>
<KeyboardShortcutHint shortcut={shortcut.keys} />
</div>
))}
<style jsx>{`
.shortcuts-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.shortcut-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
border-radius: 0.25rem;
transition: background-color 0.15s ease;
}
.shortcut-item:hover {
background-color: rgba(0, 0, 0, 0.02);
}
.shortcut-description {
flex: 1;
color: var(--tblr-body-color);
}
@media (prefers-color-scheme: dark) {
.shortcut-item:hover {
background-color: rgba(255, 255, 255, 0.05);
}
}
`}</style>
</div>
)
}
export { KeyboardShortcutHint, ShortcutsList }
export default KeyboardShortcutHint
+93
View File
@@ -0,0 +1,93 @@
import { useState, useEffect } from 'react'
import { IconClock, IconCheck } from '@tabler/icons-react'
/**
* LastSaved - компонент для отображения времени последнего сохранения
* Показывает относительное время ("2 минуты назад") с автообновлением
*/
function LastSaved({ timestamp, variant = 'default' }) {
const [relativeTime, setRelativeTime] = useState('')
useEffect(() => {
const updateRelativeTime = () => {
if (!timestamp) {
setRelativeTime('')
return
}
const now = Date.now()
const time = new Date(timestamp).getTime()
const diffMs = now - time
const diffSec = Math.floor(diffMs / 1000)
const diffMin = Math.floor(diffSec / 60)
const diffHour = Math.floor(diffMin / 60)
const diffDay = Math.floor(diffHour / 24)
if (diffSec < 10) {
setRelativeTime('только что')
} else if (diffSec < 60) {
setRelativeTime(`${diffSec} сек назад`)
} else if (diffMin < 60) {
setRelativeTime(`${diffMin} мин назад`)
} else if (diffHour < 24) {
setRelativeTime(`${diffHour} ч назад`)
} else {
setRelativeTime(`${diffDay} дн назад`)
}
}
updateRelativeTime()
const interval = setInterval(updateRelativeTime, 10000) // обновляем каждые 10 секунд
return () => clearInterval(interval)
}, [timestamp])
if (!timestamp) return null
const absoluteTime = new Date(timestamp).toLocaleString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
// Варианты отображения
if (variant === 'badge') {
return (
<span
className="badge bg-success-lt text-success"
title={`Последнее сохранение: ${absoluteTime}`}
>
<IconCheck size={14} className="me-1" />
Сохранено {relativeTime}
</span>
)
}
if (variant === 'compact') {
return (
<span
className="text-muted small"
title={`Последнее сохранение: ${absoluteTime}`}
>
<IconClock size={14} className="me-1" style={{ verticalAlign: 'middle' }} />
{relativeTime}
</span>
)
}
// Вариант по умолчанию
return (
<div
className="text-muted small d-flex align-items-center"
title={absoluteTime}
>
<IconClock size={16} className="me-1" />
<span>Последнее сохранение: {relativeTime}</span>
</div>
)
}
export default LastSaved
+175
View File
@@ -0,0 +1,175 @@
import { useState } from 'react'
import { IconChevronRight, IconDotsVertical } from '@tabler/icons-react'
import ContextMenu from './ContextMenu.jsx'
/**
* MobileCardView - компонент для отображения табличных данных в виде карточек на мобильных
* Поддерживает swipe gestures для действий
*/
function MobileCardView({
items,
onItemClick,
renderContent, // функция для рендера содержимого карточки
actions // массив действий { label, icon, onClick, variant }
}) {
const [swipedItem, setSwipedItem] = useState(null)
const [touchStart, setTouchStart] = useState(null)
const [touchEnd, setTouchEnd] = useState(null)
// минимальная дистанция свайпа в px
const minSwipeDistance = 50
const onTouchStart = (e, item) => {
setTouchEnd(null)
setTouchStart(e.targetTouches[0].clientX)
}
const onTouchMove = (e, item) => {
setTouchEnd(e.targetTouches[0].clientX)
}
const onTouchEnd = (item) => {
if (!touchStart || !touchEnd) return
const distance = touchStart - touchEnd
const isLeftSwipe = distance > minSwipeDistance
const isRightSwipe = distance < -minSwipeDistance
if (isLeftSwipe) {
setSwipedItem(item)
} else if (isRightSwipe) {
setSwipedItem(null)
}
}
return (
<div className="mobile-card-view d-md-none">
{items.map((item, index) => {
const isSwiped = swipedItem === item
return (
<div
key={item.id || index}
className={`mobile-card ${isSwiped ? 'swiped' : ''}`}
onTouchStart={(e) => onTouchStart(e, item)}
onTouchMove={(e) => onTouchMove(e, item)}
onTouchEnd={() => onTouchEnd(item)}
onClick={() => !isSwiped && onItemClick && onItemClick(item)}
>
<div className="mobile-card-content">
{renderContent(item)}
<div className="mobile-card-arrow">
<IconChevronRight size={20} />
</div>
</div>
{/* Меню действий */}
<div className="mobile-card-actions">
<ContextMenu items={actions.map(action => ({
...action,
onClick: () => action.onClick(item)
}))}>
<button className="btn btn-icon btn-ghost-secondary">
<IconDotsVertical size={20} />
</button>
</ContextMenu>
</div>
{/* Swipe actions */}
{isSwiped && (
<div className="mobile-card-swipe-actions">
{actions.slice(0, 2).map((action, idx) => {
const Icon = action.icon
return (
<button
key={idx}
className={`btn btn-${action.variant || 'primary'} btn-icon`}
onClick={() => action.onClick(item)}
title={action.label}
>
{Icon && <Icon size={20} />}
</button>
)
})}
</div>
)}
</div>
)
})}
<style jsx>{`
.mobile-card-view {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem;
}
.mobile-card {
position: relative;
background: var(--tblr-bg-surface, white);
border: 1px solid var(--tblr-border-color, #e9ecef);
border-radius: 0.5rem;
overflow: hidden;
transition: all 0.3s ease;
}
.mobile-card.swiped .mobile-card-content {
transform: translateX(-80px);
}
.mobile-card-content {
display: flex;
align-items: center;
padding: 1rem;
transition: transform 0.3s ease;
background: var(--tblr-bg-surface, white);
position: relative;
z-index: 2;
}
.mobile-card-content > :first-child {
flex: 1;
}
.mobile-card-arrow {
color: var(--tblr-muted, #6c757d);
margin-left: 0.5rem;
}
.mobile-card-actions {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 3;
}
.mobile-card-swipe-actions {
position: absolute;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 0.5rem;
z-index: 1;
}
.mobile-card:active {
transform: scale(0.98);
}
@media (min-width: 768px) {
.mobile-card-view {
display: none;
}
}
`}</style>
</div>
)
}
export default MobileCardView
+106 -65
View File
@@ -1,10 +1,13 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { IconChevronLeft, IconChevronRight, IconChevronsLeft, IconChevronsRight } from '@tabler/icons-react';
/**
* Универсальный компонент пагинации
* Оптимизирован: useMemo для вычисления страниц
* Улучшенный компонент пагинации
* Добавлено: Jump to Page, иконки, улучшенная доступность
*/
function Pagination({ currentPage, totalPages, totalItems, pageSize, onPageChange }) {
const [jumpToPage, setJumpToPage] = useState('');
if (totalPages <= 1) return null;
const pages = useMemo(() => {
@@ -31,74 +34,112 @@ function Pagination({ currentPage, totalPages, totalItems, pageSize, onPageChang
const startItem = (currentPage - 1) * pageSize + 1;
const endItem = Math.min(currentPage * pageSize, totalItems);
const handleJumpToPage = (e) => {
e.preventDefault();
const pageNum = parseInt(jumpToPage, 10);
if (pageNum >= 1 && pageNum <= totalPages) {
onPageChange(pageNum);
setJumpToPage('');
}
};
return (
<div className="card-footer d-flex align-items-center justify-content-between">
<div className="card-footer d-flex align-items-center justify-content-between flex-wrap gap-2">
<div className="text-muted">
Показано {startItem} - {endItem} из {totalItems}
Показано <strong>{startItem} - {endItem}</strong> из <strong>{totalItems}</strong>
</div>
<ul className="pagination m-0">
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
<button
className="page-link"
onClick={() => onPageChange(1)}
disabled={currentPage === 1}
aria-label="Первая страница"
>
Первая
</button>
</li>
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
<button
className="page-link"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
aria-label="Предыдущая страница"
>
Назад
</button>
</li>
{pages.map((item) => {
if (item.type === 'ellipsis') {
<div className="d-flex align-items-center gap-2 flex-wrap">
{/* Jump to page */}
{totalPages > 10 && (
<form onSubmit={handleJumpToPage} className="d-flex align-items-center gap-1">
<label htmlFor="jump-to-page" className="text-muted small mb-0">
Стр:
</label>
<input
id="jump-to-page"
type="number"
min="1"
max={totalPages}
value={jumpToPage}
onChange={(e) => setJumpToPage(e.target.value)}
placeholder={currentPage.toString()}
className="form-control form-control-sm"
style={{ width: '60px' }}
aria-label="Перейти на страницу"
/>
</form>
)}
{/* Pagination controls */}
<ul className="pagination m-0">
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
<button
className="page-link"
onClick={() => onPageChange(1)}
disabled={currentPage === 1}
aria-label="Первая страница"
title="Первая страница"
>
<IconChevronsLeft size={16} />
</button>
</li>
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
<button
className="page-link"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
aria-label="Предыдущая страница"
title="Предыдущая страница"
>
<IconChevronLeft size={16} />
</button>
</li>
{pages.map((item) => {
if (item.type === 'ellipsis') {
return (
<li key={item.key} className="page-item disabled">
<span className="page-link"></span>
</li>
);
}
return (
<li key={item.key} className="page-item disabled">
<span className="page-link"></span>
<li key={item.key} className={`page-item${currentPage === item.page ? ' active' : ''}`}>
<button
className="page-link"
onClick={() => onPageChange(item.page)}
aria-label={`Страница ${item.page}`}
aria-current={currentPage === item.page ? 'page' : undefined}
>
{item.page}
</button>
</li>
);
}
return (
<li key={item.key} className={`page-item${currentPage === item.page ? ' active' : ''}`}>
<button
className="page-link"
onClick={() => onPageChange(item.page)}
aria-label={`Страница ${item.page}`}
aria-current={currentPage === item.page ? 'page' : undefined}
>
{item.page}
</button>
</li>
);
})}
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
<button
className="page-link"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
aria-label="Следующая страница"
>
Вперед
</button>
</li>
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
<button
className="page-link"
onClick={() => onPageChange(totalPages)}
disabled={currentPage === totalPages}
aria-label="Последняя страница"
>
Последняя
</button>
</li>
</ul>
})}
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
<button
className="page-link"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
aria-label="Следующая страница"
title="Следующая страница"
>
<IconChevronRight size={16} />
</button>
</li>
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
<button
className="page-link"
onClick={() => onPageChange(totalPages)}
disabled={currentPage === totalPages}
aria-label="Последняя страница"
title="Последняя страница (стр. {totalPages})"
>
<IconChevronsRight size={16} />
</button>
</li>
</ul>
</div>
</div>
);
}
+195
View File
@@ -0,0 +1,195 @@
import { IconCheck, IconX } from '@tabler/icons-react'
/**
* ProgressBar - компонент прогресс бара с estimated time
* @param {number} progress - прогресс от 0 до 100
* @param {string} status - статус операции
* @param {number} estimatedTime - оставшееся время в секундах
* @param {string} variant - цвет: 'primary', 'success', 'danger', 'warning'
*/
function ProgressBar({
progress = 0,
status = '',
estimatedTime = null,
variant = 'primary',
showPercentage = true,
striped = false,
animated = false,
size = 'default' // 'sm', 'default', 'lg'
}) {
const sizeClass = size === 'sm' ? 'progress-sm' : size === 'lg' ? 'progress-lg' : ''
const stripedClass = striped ? 'progress-bar-striped' : ''
const animatedClass = animated ? 'progress-bar-animated' : ''
const formatTime = (seconds) => {
if (seconds < 60) {
return `${Math.round(seconds)} сек`
} else if (seconds < 3600) {
const minutes = Math.floor(seconds / 60)
const secs = Math.round(seconds % 60)
return `${minutes} мин ${secs} сек`
} else {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
return `${hours} ч ${minutes} мин`
}
}
return (
<div className="progress-container">
<div className="d-flex justify-content-between align-items-center mb-2">
{status && (
<div className="text-muted small">{status}</div>
)}
<div className="d-flex align-items-center gap-2">
{estimatedTime !== null && estimatedTime > 0 && (
<span className="text-muted small">
Осталось ~{formatTime(estimatedTime)}
</span>
)}
{showPercentage && (
<span className="text-muted small fw-bold">
{Math.round(progress)}%
</span>
)}
</div>
</div>
<div className={`progress ${sizeClass}`}>
<div
className={`progress-bar bg-${variant} ${stripedClass} ${animatedClass}`}
role="progressbar"
style={{ width: `${progress}%` }}
aria-valuenow={progress}
aria-valuemin="0"
aria-valuemax="100"
>
{size === 'lg' && showPercentage && (
<span className="progress-bar-label">{Math.round(progress)}%</span>
)}
</div>
</div>
<style jsx>{`
.progress-container {
width: 100%;
}
.progress-sm {
height: 0.5rem;
}
.progress-lg {
height: 2rem;
font-size: 1rem;
}
.progress-bar-label {
line-height: 2rem;
color: white;
}
`}</style>
</div>
)
}
/**
* MultiStepProgress - компонент для отображения многошагового прогресса
*/
function MultiStepProgress({ steps, currentStep, variant = 'primary' }) {
const progress = ((currentStep) / steps.length) * 100
return (
<div className="multi-step-progress">
{/* Индикаторы шагов */}
<div className="steps-indicator mb-3">
{steps.map((step, index) => {
const isPast = index < currentStep
const isCurrent = index === currentStep
const status = isPast ? 'completed' : isCurrent ? 'current' : 'pending'
return (
<div key={index} className={`step-item step-${status}`}>
<div className={`step-circle bg-${isPast ? 'success' : isCurrent ? variant : 'secondary'}`}>
{isPast ? (
<IconCheck size={16} className="text-white" />
) : (
<span className="text-white">{index + 1}</span>
)}
</div>
<div className="step-label text-muted small mt-1">{step}</div>
{index < steps.length - 1 && (
<div className={`step-line ${isPast ? 'completed' : ''}`}></div>
)}
</div>
)
})}
</div>
{/* Прогресс бар */}
<ProgressBar
progress={progress}
variant={variant}
showPercentage={false}
animated
/>
<style jsx>{`
.steps-indicator {
display: flex;
justify-content: space-between;
align-items: flex-start;
position: relative;
}
.step-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
position: relative;
}
.step-circle {
width: 2rem;
height: 2rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
z-index: 2;
position: relative;
}
.step-line {
position: absolute;
top: 1rem;
left: 50%;
right: -50%;
height: 2px;
background: var(--tblr-border-color, #e9ecef);
z-index: 1;
}
.step-line.completed {
background: var(--tblr-success, #2fb344);
}
.step-label {
text-align: center;
max-width: 100px;
}
.step-current .step-label {
font-weight: 600;
color: var(--tblr-body-color) !important;
}
`}</style>
</div>
)
}
export { ProgressBar, MultiStepProgress }
export default ProgressBar
+169
View File
@@ -0,0 +1,169 @@
import { useState, useRef, useEffect } from 'react'
/**
* Tooltip компонент для отображения подсказок
* Поддерживает keyboard shortcuts и различные позиции
*/
function Tooltip({
children,
content,
position = 'top', // top, bottom, left, right
shortcut, // keyboard shortcut to display
delay = 200,
className = ''
}) {
const [isVisible, setIsVisible] = useState(false)
const [coords, setCoords] = useState({ x: 0, y: 0 })
const timeoutRef = useRef(null)
const triggerRef = useRef(null)
const showTooltip = (e) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
timeoutRef.current = setTimeout(() => {
if (triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect()
let x = 0, y = 0
switch (position) {
case 'top':
x = rect.left + rect.width / 2
y = rect.top
break
case 'bottom':
x = rect.left + rect.width / 2
y = rect.bottom
break
case 'left':
x = rect.left
y = rect.top + rect.height / 2
break
case 'right':
x = rect.right
y = rect.top + rect.height / 2
break
default:
x = rect.left + rect.width / 2
y = rect.top
}
setCoords({ x, y })
setIsVisible(true)
}
}, delay)
}
const hideTooltip = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
setIsVisible(false)
}
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [])
return (
<>
<span
ref={triggerRef}
onMouseEnter={showTooltip}
onMouseLeave={hideTooltip}
onFocus={showTooltip}
onBlur={hideTooltip}
className={`tooltip-trigger ${className}`}
style={{ position: 'relative', display: 'inline-block' }}
>
{children}
</span>
{isVisible && (
<div
className={`tooltip-content tooltip-${position}`}
style={{
position: 'fixed',
left: `${coords.x}px`,
top: `${coords.y}px`,
zIndex: 9999,
pointerEvents: 'none'
}}
>
<div className="tooltip-inner">
<div className="tooltip-text">{content}</div>
{shortcut && (
<kbd className="tooltip-shortcut ms-2">{shortcut}</kbd>
)}
</div>
</div>
)}
<style jsx>{`
.tooltip-content {
animation: tooltipFadeIn 0.15s ease-out;
}
.tooltip-top {
transform: translate(-50%, calc(-100% - 8px));
}
.tooltip-bottom {
transform: translate(-50%, 8px);
}
.tooltip-left {
transform: translate(calc(-100% - 8px), -50%);
}
.tooltip-right {
transform: translate(8px, -50%);
}
.tooltip-inner {
background: var(--tblr-dark, #1e293b);
color: white;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.875rem;
box-shadow: var(--shadow-lg);
white-space: nowrap;
display: flex;
align-items: center;
max-width: 300px;
}
.tooltip-shortcut {
background: rgba(255, 255, 255, 0.2);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-family: monospace;
}
@keyframes tooltipFadeIn {
from {
opacity: 0;
transform: translate(-50%, calc(-100% - 4px)) scale(0.95);
}
to {
opacity: 1;
transform: translate(-50%, calc(-100% - 8px)) scale(1);
}
}
@media (prefers-color-scheme: dark) {
.tooltip-inner {
background: var(--tblr-gray-800, #1e293b);
}
}
`}</style>
</>
)
}
export default Tooltip
@@ -0,0 +1,54 @@
import { IconTrendingUp, IconTrendingDown, IconMinus } from '@tabler/icons-react'
/**
* TrendIndicator - компонент для отображения тренда изменения метрик
* @param {number} value - текущее значение
* @param {number} previousValue - предыдущее значение
* @param {string} format - формат отображения: 'number', 'percent'
* @param {boolean} inverse - если true, рост = плохо (красный), падение = хорошо (зеленый)
*/
function TrendIndicator({ value, previousValue, format = 'number', inverse = false }) {
if (previousValue === null || previousValue === undefined || value === previousValue) {
return (
<span className="text-muted d-inline-flex align-items-center" title="Без изменений">
<IconMinus size={16} className="me-1" />
<span className="small"></span>
</span>
)
}
const diff = value - previousValue
const percentChange = previousValue !== 0 ? (diff / previousValue) * 100 : 0
const isPositive = diff > 0
const isNegative = diff < 0
// Определяем цвет в зависимости от направления и inverse флага
let colorClass = 'text-muted'
if (isPositive) {
colorClass = inverse ? 'text-danger' : 'text-success'
} else if (isNegative) {
colorClass = inverse ? 'text-success' : 'text-danger'
}
const Icon = isPositive ? IconTrendingUp : IconTrendingDown
const displayValue = format === 'percent'
? `${Math.abs(percentChange).toFixed(1)}%`
: `${Math.abs(diff)}`
const title = `${isPositive ? '+' : ''}${diff} (${isPositive ? '+' : ''}${percentChange.toFixed(1)}%) от предыдущего значения ${previousValue}`
return (
<span
className={`${colorClass} d-inline-flex align-items-center`}
title={title}
style={{ fontSize: '0.875rem', fontWeight: 500 }}
>
<Icon size={16} className="me-1" />
<span>{isPositive ? '+' : ''}{displayValue}</span>
</span>
)
}
export default TrendIndicator
+177
View File
@@ -0,0 +1,177 @@
import { useState, useEffect, useRef } from 'react'
import { IconCheck, IconX, IconAlertCircle } from '@tabler/icons-react'
/**
* ValidatedInput - input с real-time валидацией
* @param {function} validate - функция валидации, возвращает { valid: boolean, message: string }
* @param {number} debounce - задержка перед валидацией в ms
*/
function ValidatedInput({
value,
onChange,
validate,
debounce = 300,
label,
placeholder,
hint,
required = false,
type = 'text',
className = '',
showValidIcon = true,
validateOnChange = true,
...props
}) {
const [validationState, setValidationState] = useState({
valid: null,
message: '',
isValidating: false
})
const [isTouched, setIsTouched] = useState(false)
const timeoutRef = useRef(null)
useEffect(() => {
// Не валидируем, пока пользователь не начал вводить
if (!isTouched || !validateOnChange) return
if (!validate) {
setValidationState({ valid: null, message: '', isValidating: false })
return
}
// Очищаем предыдущий таймер
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
// Показываем индикатор валидации
setValidationState(prev => ({ ...prev, isValidating: true }))
// Запускаем валидацию с debounce
timeoutRef.current = setTimeout(async () => {
try {
const result = validate(value)
// Поддержка как синхронной, так и асинхронной валидации
const validationResult = result instanceof Promise ? await result : result
setValidationState({
valid: validationResult.valid,
message: validationResult.message || '',
isValidating: false
})
} catch (error) {
setValidationState({
valid: false,
message: 'Ошибка валидации',
isValidating: false
})
}
}, debounce)
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [value, validate, debounce, isTouched, validateOnChange])
const handleChange = (e) => {
if (!isTouched) setIsTouched(true)
onChange(e)
}
const handleBlur = () => {
setIsTouched(true)
// Запускаем валидацию сразу при потере фокуса
if (validate && !validateOnChange) {
const result = validate(value)
const validationResult = result instanceof Promise ? result.then(r => {
setValidationState({
valid: r.valid,
message: r.message || '',
isValidating: false
})
}) : setValidationState({
valid: result.valid,
message: result.message || '',
isValidating: false
})
}
}
const getInputClass = () => {
const classes = ['form-control']
if (isTouched && validationState.valid === true) {
classes.push('is-valid')
} else if (isTouched && validationState.valid === false) {
classes.push('is-invalid')
}
return classes.join(' ')
}
return (
<div className={`mb-3 ${className}`}>
{label && (
<label className="form-label">
{label}
{required && <span className="text-danger ms-1">*</span>}
</label>
)}
<div className="input-group">
<input
type={type}
className={getInputClass()}
value={value}
onChange={handleChange}
onBlur={handleBlur}
placeholder={placeholder}
aria-invalid={validationState.valid === false}
aria-describedby={`${props.id}-feedback ${props.id}-hint`}
{...props}
/>
{/* Индикатор валидации */}
{showValidIcon && isTouched && !validationState.isValidating && (
<span className="input-group-text">
{validationState.valid === true && (
<IconCheck size={18} className="text-success" />
)}
{validationState.valid === false && (
<IconX size={18} className="text-danger" />
)}
</span>
)}
{/* Индикатор процесса валидации */}
{validationState.isValidating && (
<span className="input-group-text">
<span className="spinner-border spinner-border-sm" role="status"></span>
</span>
)}
</div>
{/* Подсказка */}
{hint && !validationState.message && (
<small id={`${props.id}-hint`} className="form-hint text-muted">
{hint}
</small>
)}
{/* Сообщение об ошибке */}
{isTouched && validationState.message && (
<div
id={`${props.id}-feedback`}
className={`invalid-feedback d-block ${validationState.valid ? 'text-success' : 'text-danger'}`}
>
<IconAlertCircle size={14} className="me-1" />
{validationState.message}
</div>
)}
</div>
)
}
export default ValidatedInput