feat: Улучшение компонентов Dashboard, EmptyState, ErrorAlert и Pagination. Добавлены новые функции, такие как индикаторы тренда, возможность перехода на страницу и улучшенные действия с ошибками. Обновлены стили для повышения удобства использования и доступности.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m54s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m54s
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user