-
+
- {String(message)}
- {details && (
-
- ΠΠΎΠΊΠ°Π·Π°ΡΡ Π΄Π΅ΡΠ°Π»ΠΈ
-
- {typeof details === 'string' ? details : JSON.stringify(details, null, 2)}
-
-
+
+ ΠΡΠΈΠ±ΠΊΠ°
+ {errorCode && (
+ {errorCode}
+ )}
+
+
{errorMessage}
+
+ {/* ΠΠ΅ΡΠ°Π»ΠΈ ΠΎΡΠΈΠ±ΠΊΠΈ (ΡΠ°ΡΠΊΡΡΠ²Π°ΡΡΠΈΠΉΡΡ Π±Π»ΠΎΠΊ) */}
+ {errorDetails && showDetails && (
+
+
+
+ {isExpanded && (
+
+ {typeof errorDetails === 'string' ? errorDetails : JSON.stringify(errorDetails, null, 2)}
+
+ )}
+
+ )}
+
+ {/* ΠΠ΅ΠΉΡΡΠ²ΠΈΡ */}
+ {(onRetry || handleCopy) && (
+
+ {onRetry && (
+
+ )}
+
+
)}
@@ -29,12 +105,12 @@ function ErrorAlert({ message, details, onClose }) {
)}
- );
+ )
}
-export default ErrorAlert;
+export default ErrorAlert
diff --git a/frontend/src/components/KeyboardShortcutHint.jsx b/frontend/src/components/KeyboardShortcutHint.jsx
new file mode 100644
index 0000000..eedbfb7
--- /dev/null
+++ b/frontend/src/components/KeyboardShortcutHint.jsx
@@ -0,0 +1,110 @@
+/**
+ * KeyboardShortcutHint - ΠΊΠΎΠΌΠΏΠΎΠ½Π΅Π½Ρ Π΄Π»Ρ ΠΎΡΠΎΠ±ΡΠ°ΠΆΠ΅Π½ΠΈΡ ΠΏΠΎΠ΄ΡΠΊΠ°Π·ΠΎΠΊ ΠΏΠΎ Π³ΠΎΡΡΡΠΈΠΌ ΠΊΠ»Π°Π²ΠΈΡΠ°ΠΌ
+ * ΠΡΠΏΠΎΠ»ΡΠ·ΡΠ΅ΡΡΡ Π²Π½ΡΡΡΠΈ ΠΊΠ½ΠΎΠΏΠΎΠΊ ΠΈ Π΄ΡΡΠ³ΠΈΡ
ΡΠ»Π΅ΠΌΠ΅Π½ΡΠΎΠ²
+ */
+function KeyboardShortcutHint({ shortcut, className = '' }) {
+ if (!shortcut) return null
+
+ // Π Π°Π·Π±ΠΈΠ²Π°Π΅ΠΌ ΠΊΠΎΠΌΠ±ΠΈΠ½Π°ΡΠΈΡ ΠΊΠ»Π°Π²ΠΈΡ Π½Π° ΡΠ°ΡΡΠΈ
+ const keys = shortcut.split('+').map(k => k.trim())
+
+ return (
+
+ {keys.map((key, index) => (
+
+ {key}
+
+ ))}
+
+
+
+ )
+}
+
+/**
+ * ShortcutsList - ΠΊΠΎΠΌΠΏΠΎΠ½Π΅Π½Ρ Π΄Π»Ρ ΠΎΡΠΎΠ±ΡΠ°ΠΆΠ΅Π½ΠΈΡ ΡΠΏΠΈΡΠΊΠ° Π³ΠΎΡΡΡΠΈΡ
ΠΊΠ»Π°Π²ΠΈΡ
+ * ΠΡΠΏΠΎΠ»ΡΠ·ΡΠ΅ΡΡΡ Π² ΠΌΠΎΠ΄Π°Π»ΡΠ½ΡΡ
ΠΎΠΊΠ½Π°Ρ
ΠΏΠΎΠΌΠΎΡΠΈ
+ */
+function ShortcutsList({ shortcuts }) {
+ return (
+
+ {shortcuts.map((shortcut, index) => (
+
+ {shortcut.description}
+
+
+ ))}
+
+
+
+ )
+}
+
+export { KeyboardShortcutHint, ShortcutsList }
+export default KeyboardShortcutHint
+
diff --git a/frontend/src/components/LastSaved.jsx b/frontend/src/components/LastSaved.jsx
new file mode 100644
index 0000000..0434c7d
--- /dev/null
+++ b/frontend/src/components/LastSaved.jsx
@@ -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 (
+
+
+ Π‘ΠΎΡ
ΡΠ°Π½Π΅Π½ΠΎ {relativeTime}
+
+ )
+ }
+
+ if (variant === 'compact') {
+ return (
+
+
+ {relativeTime}
+
+ )
+ }
+
+ // ΠΠ°ΡΠΈΠ°Π½Ρ ΠΏΠΎ ΡΠΌΠΎΠ»ΡΠ°Π½ΠΈΡ
+ return (
+
+
+ ΠΠΎΡΠ»Π΅Π΄Π½Π΅Π΅ ΡΠΎΡ
ΡΠ°Π½Π΅Π½ΠΈΠ΅: {relativeTime}
+
+ )
+}
+
+export default LastSaved
+
diff --git a/frontend/src/components/MobileCardView.jsx b/frontend/src/components/MobileCardView.jsx
new file mode 100644
index 0000000..d8b9f7e
--- /dev/null
+++ b/frontend/src/components/MobileCardView.jsx
@@ -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 (
+
+ {items.map((item, index) => {
+ const isSwiped = swipedItem === item
+
+ return (
+
onTouchStart(e, item)}
+ onTouchMove={(e) => onTouchMove(e, item)}
+ onTouchEnd={() => onTouchEnd(item)}
+ onClick={() => !isSwiped && onItemClick && onItemClick(item)}
+ >
+
+ {renderContent(item)}
+
+
+
+
+
+
+ {/* ΠΠ΅Π½Ρ Π΄Π΅ΠΉΡΡΠ²ΠΈΠΉ */}
+
+ ({
+ ...action,
+ onClick: () => action.onClick(item)
+ }))}>
+
+
+
+
+ {/* Swipe actions */}
+ {isSwiped && (
+
+ {actions.slice(0, 2).map((action, idx) => {
+ const Icon = action.icon
+ return (
+
+ )
+ })}
+
+ )}
+
+ )
+ })}
+
+
+
+ )
+}
+
+export default MobileCardView
+
diff --git a/frontend/src/components/Pagination.jsx b/frontend/src/components/Pagination.jsx
index da641fd..c5c138f 100644
--- a/frontend/src/components/Pagination.jsx
+++ b/frontend/src/components/Pagination.jsx
@@ -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 (
-
+
- ΠΠΎΠΊΠ°Π·Π°Π½ΠΎ {startItem} - {endItem} ΠΈΠ· {totalItems}
+ ΠΠΎΠΊΠ°Π·Π°Π½ΠΎ {startItem} - {endItem} ΠΈΠ· {totalItems}
-
);
}
diff --git a/frontend/src/components/ProgressBar.jsx b/frontend/src/components/ProgressBar.jsx
new file mode 100644
index 0000000..edccdfd
--- /dev/null
+++ b/frontend/src/components/ProgressBar.jsx
@@ -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 (
+
+
+ {status && (
+
{status}
+ )}
+
+ {estimatedTime !== null && estimatedTime > 0 && (
+
+ ΠΡΡΠ°Π»ΠΎΡΡ ~{formatTime(estimatedTime)}
+
+ )}
+ {showPercentage && (
+
+ {Math.round(progress)}%
+
+ )}
+
+
+
+
+
+ {size === 'lg' && showPercentage && (
+ {Math.round(progress)}%
+ )}
+
+
+
+
+
+ )
+}
+
+/**
+ * MultiStepProgress - ΠΊΠΎΠΌΠΏΠΎΠ½Π΅Π½Ρ Π΄Π»Ρ ΠΎΡΠΎΠ±ΡΠ°ΠΆΠ΅Π½ΠΈΡ ΠΌΠ½ΠΎΠ³ΠΎΡΠ°Π³ΠΎΠ²ΠΎΠ³ΠΎ ΠΏΡΠΎΠ³ΡΠ΅ΡΡΠ°
+ */
+function MultiStepProgress({ steps, currentStep, variant = 'primary' }) {
+ const progress = ((currentStep) / steps.length) * 100
+
+ return (
+
+ {/* ΠΠ½Π΄ΠΈΠΊΠ°ΡΠΎΡΡ ΡΠ°Π³ΠΎΠ² */}
+
+ {steps.map((step, index) => {
+ const isPast = index < currentStep
+ const isCurrent = index === currentStep
+ const status = isPast ? 'completed' : isCurrent ? 'current' : 'pending'
+
+ return (
+
+
+ {isPast ? (
+
+ ) : (
+ {index + 1}
+ )}
+
+
{step}
+ {index < steps.length - 1 && (
+
+ )}
+
+ )
+ })}
+
+
+ {/* ΠΡΠΎΠ³ΡΠ΅ΡΡ Π±Π°Ρ */}
+
+
+
+
+ )
+}
+
+export { ProgressBar, MultiStepProgress }
+export default ProgressBar
+
diff --git a/frontend/src/components/Tooltip.jsx b/frontend/src/components/Tooltip.jsx
new file mode 100644
index 0000000..f12c64c
--- /dev/null
+++ b/frontend/src/components/Tooltip.jsx
@@ -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 (
+ <>
+
+ {children}
+
+
+ {isVisible && (
+
+
+
{content}
+ {shortcut && (
+
{shortcut}
+ )}
+
+
+ )}
+
+
+ >
+ )
+}
+
+export default Tooltip
+
diff --git a/frontend/src/components/TrendIndicator.jsx b/frontend/src/components/TrendIndicator.jsx
new file mode 100644
index 0000000..90b0eb9
--- /dev/null
+++ b/frontend/src/components/TrendIndicator.jsx
@@ -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 (
+
+
+ β
+
+ )
+ }
+
+ 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 (
+
+
+ {isPositive ? '+' : ''}{displayValue}
+
+ )
+}
+
+export default TrendIndicator
+
diff --git a/frontend/src/components/ValidatedInput.jsx b/frontend/src/components/ValidatedInput.jsx
new file mode 100644
index 0000000..ffb22f4
--- /dev/null
+++ b/frontend/src/components/ValidatedInput.jsx
@@ -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 (
+
+ {label && (
+
+ )}
+
+
+
+
+ {/* ΠΠ½Π΄ΠΈΠΊΠ°ΡΠΎΡ Π²Π°Π»ΠΈΠ΄Π°ΡΠΈΠΈ */}
+ {showValidIcon && isTouched && !validationState.isValidating && (
+
+ {validationState.valid === true && (
+
+ )}
+ {validationState.valid === false && (
+
+ )}
+
+ )}
+
+ {/* ΠΠ½Π΄ΠΈΠΊΠ°ΡΠΎΡ ΠΏΡΠΎΡΠ΅ΡΡΠ° Π²Π°Π»ΠΈΠ΄Π°ΡΠΈΠΈ */}
+ {validationState.isValidating && (
+
+
+
+ )}
+
+
+ {/* ΠΠΎΠ΄ΡΠΊΠ°Π·ΠΊΠ° */}
+ {hint && !validationState.message && (
+
+ {hint}
+
+ )}
+
+ {/* Π‘ΠΎΠΎΠ±ΡΠ΅Π½ΠΈΠ΅ ΠΎΠ± ΠΎΡΠΈΠ±ΠΊΠ΅ */}
+ {isTouched && validationState.message && (
+
+
+ {validationState.message}
+
+ )}
+
+ )
+}
+
+export default ValidatedInput
+
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 233c0f4..6248b3c 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -10,6 +10,64 @@
--app-gap-sm: .5rem;
--app-gap-md: .75rem;
--app-gap-lg: 1rem;
+
+ /* Semantic colors for statuses */
+ --status-online: #2fb344;
+ --status-offline: #d63939;
+ --status-warning: #f59f00;
+ --status-unknown: #868e96;
+
+ /* Accent colors for important actions */
+ --accent-primary: #206bc4;
+ --accent-danger: #d63939;
+ --accent-success: #2fb344;
+ --accent-info: #4299e1;
+ --accent-warning: #f59f00;
+
+ /* Enhanced spacing scale */
+ --space-xs: 0.25rem;
+ --space-sm: 0.5rem;
+ --space-md: 1rem;
+ --space-lg: 1.5rem;
+ --space-xl: 2rem;
+ --space-xxl: 3rem;
+
+ /* Shadow scale */
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
+ --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.1);
+ --shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.15);
+ --shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.2);
+}
+
+/* ===== Improved Typography ===== */
+.page-title {
+ font-size: 1.75rem;
+ font-weight: 700;
+ letter-spacing: -0.025em;
+ line-height: 1.2;
+}
+
+.card-title {
+ font-size: 1.125rem;
+ font-weight: 600;
+ line-height: 1.4;
+}
+
+.table td {
+ line-height: 1.6;
+}
+
+/* ===== Improved Spacing ===== */
+.page-header {
+ margin-bottom: var(--space-xl);
+}
+
+.card + .card {
+ margin-top: var(--space-lg);
+}
+
+.table td, .table th {
+ padding: 0.75rem 1rem;
}
/* Header actions under page header */
@@ -53,4 +111,109 @@
/* ΠΡΡΡΡΠΏΡ ΠΌΠ΅ΠΆΠ΄Ρ Π±Π΅ΠΉΠ΄ΠΆΠ°ΠΌΠΈ ΠΈ Π² empty-action */
.badge + .badge { margin-left: .25rem; }
-.empty .empty-action .btn + .btn { margin-left: .5rem; }
\ No newline at end of file
+.empty .empty-action .btn + .btn { margin-left: .5rem; }
+
+/* ===== Enhanced Interactive Elements ===== */
+/* Better hover states */
+.btn:not(:disabled):hover {
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-md);
+ transition: all 0.2s ease;
+}
+
+.btn:not(:disabled):active {
+ transform: translateY(0);
+ transition: all 0.1s ease;
+}
+
+/* Card hover effects */
+.card.card-hover {
+ transition: all 0.2s ease;
+ cursor: pointer;
+}
+
+.card.card-hover:hover {
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-lg);
+}
+
+/* Better focus states for accessibility */
+*:focus-visible {
+ outline: 3px solid var(--accent-primary);
+ outline-offset: 3px;
+ border-radius: 3px;
+}
+
+button:focus-visible,
+.btn:focus-visible {
+ box-shadow: 0 0 0 3px rgba(32, 107, 196, 0.3);
+}
+
+/* Badge animations */
+.badge {
+ transition: all 0.2s ease;
+}
+
+.badge:hover {
+ transform: scale(1.05);
+}
+
+/* Minimum touch target size for mobile */
+@media (max-width: 768px) {
+ .btn, .page-link, .dropdown-item {
+ min-height: 44px;
+ min-width: 44px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .btn-icon {
+ width: 44px;
+ height: 44px;
+ }
+}
+
+/* Enhanced table row interactions */
+.table tbody tr {
+ transition: background-color 0.15s ease;
+}
+
+.table tbody tr:hover {
+ background-color: rgba(32, 107, 196, 0.04);
+}
+
+.table tbody tr:focus-within {
+ background-color: rgba(32, 107, 196, 0.08);
+ box-shadow: inset 3px 0 0 var(--accent-primary);
+}
+
+/* Loading state improvements */
+.btn.loading {
+ position: relative;
+ pointer-events: none;
+}
+
+.btn.loading::before {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 100%;
+ height: 100%;
+ border-radius: inherit;
+ background: rgba(255, 255, 255, 0.2);
+ transform: translate(-50%, -50%);
+ animation: pulse-button 1.5s infinite;
+}
+
+@keyframes pulse-button {
+ 0%, 100% {
+ opacity: 0;
+ transform: translate(-50%, -50%) scale(1);
+ }
+ 50% {
+ opacity: 1;
+ transform: translate(-50%, -50%) scale(1.05);
+ }
+}
\ No newline at end of file