first
This commit is contained in:
@@ -36,7 +36,9 @@ class DetectorSerializer(serializers.ModelSerializer):
|
|||||||
fields = ('detector_id', 'type', 'detector_type', 'serial_number', 'name', 'object', 'status', 'zone', 'floor', 'notifications')
|
fields = ('detector_id', 'type', 'detector_type', 'serial_number', 'name', 'object', 'status', 'zone', 'floor', 'notifications')
|
||||||
|
|
||||||
def get_detector_id(self, obj):
|
def get_detector_id(self, obj):
|
||||||
return obj.name or f"{obj.sensor_type.code}-{obj.id}"
|
# Используем serial_number для совместимости с 3D моделью
|
||||||
|
# Если serial_number нет, используем ID с префиксом
|
||||||
|
return obj.serial_number or f"sensor_{obj.id}"
|
||||||
|
|
||||||
def get_type(self, obj):
|
def get_type(self, obj):
|
||||||
sensor_type_mapping = {
|
sensor_type_mapping = {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from api.utils.decorators import handle_exceptions
|
|||||||
class SensorView(APIView):
|
class SensorView(APIView):
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
serializer_class = DetectorsResponseSerializer
|
serializer_class = DetectorsResponseSerializer
|
||||||
|
pagination_class = None # Отключаем пагинацию для получения всех датчиков
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
summary="Получение всех датчиков",
|
summary="Получение всех датчиков",
|
||||||
@@ -54,7 +55,25 @@ class SensorView(APIView):
|
|||||||
'alerts'
|
'alerts'
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
total_count = sensors.count()
|
||||||
|
print(f"[SensorView] Total sensors in DB: {total_count}")
|
||||||
|
|
||||||
|
# Проверяем уникальность serial_number
|
||||||
|
serial_numbers = [s.serial_number for s in sensors if s.serial_number]
|
||||||
|
unique_serials = set(serial_numbers)
|
||||||
|
print(f"[SensorView] Unique serial_numbers: {len(unique_serials)} out of {len(serial_numbers)}")
|
||||||
|
|
||||||
|
if len(serial_numbers) != len(unique_serials):
|
||||||
|
from collections import Counter
|
||||||
|
duplicates = {k: v for k, v in Counter(serial_numbers).items() if v > 1}
|
||||||
|
print(f"[SensorView] WARNING: Found duplicate serial_numbers: {duplicates}")
|
||||||
|
|
||||||
serializer = DetectorsResponseSerializer(sensors)
|
serializer = DetectorsResponseSerializer(sensors)
|
||||||
|
detectors_dict = serializer.data.get('detectors', {})
|
||||||
|
|
||||||
|
print(f"[SensorView] Serialized detectors count: {len(detectors_dict)}")
|
||||||
|
print(f"[SensorView] Sample detector_ids: {list(detectors_dict.keys())[:5]}")
|
||||||
|
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
except Sensor.DoesNotExist:
|
except Sensor.DoesNotExist:
|
||||||
return Response(
|
return Response(
|
||||||
|
|||||||
@@ -71,18 +71,98 @@ const LoginPage = () => {
|
|||||||
return <Loader />
|
return <Loader />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const interSemiboldStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 600 }
|
||||||
|
const interRegularStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 400 }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-screen flex-col items-center justify-center gap-8 py-8">
|
<div className="relative min-h-screen w-full flex flex-col items-center justify-center gap-8 py-8 overflow-hidden">
|
||||||
<div className="mb-4 flex items-center justify-center gap-4">
|
<style>{`
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% { transform: translateY(0px) rotate(0deg); }
|
||||||
|
50% { transform: translateY(-20px) rotate(180deg); }
|
||||||
|
}
|
||||||
|
@keyframes glow {
|
||||||
|
0%, 100% { opacity: 0.3; }
|
||||||
|
50% { opacity: 0.8; }
|
||||||
|
}
|
||||||
|
@keyframes rotate {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
@keyframes slideIn {
|
||||||
|
0% { transform: translateX(-100%); opacity: 0; }
|
||||||
|
100% { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
.float-animation {
|
||||||
|
animation: float 6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.glow-animation {
|
||||||
|
animation: glow 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.rotate-animation {
|
||||||
|
animation: rotate 20s linear infinite;
|
||||||
|
}
|
||||||
|
.slide-in {
|
||||||
|
animation: slideIn 0.8s ease-out;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
{/* Фоновый градиент - многоуровневый */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-[#050810] via-[#0f1729] to-[#1a1f28] z-0"></div>
|
||||||
|
|
||||||
|
{/* Второй слой градиента */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-tr from-transparent via-[#1a3a52]/20 to-transparent z-0"></div>
|
||||||
|
|
||||||
|
{/* Основные светящиеся орбиты */}
|
||||||
|
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-gradient-to-br from-blue-600/20 to-cyan-500/10 rounded-full blur-3xl glow-animation" style={{ animationDelay: '0s' }}></div>
|
||||||
|
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-gradient-to-tl from-blue-500/20 to-cyan-500/10 rounded-full blur-3xl glow-animation" style={{ animationDelay: '1s' }}></div>
|
||||||
|
<div className="absolute top-1/2 right-1/3 w-80 h-80 bg-gradient-to-bl from-cyan-500/15 to-blue-400/10 rounded-full blur-3xl glow-animation" style={{ animationDelay: '2s' }}></div>
|
||||||
|
|
||||||
|
{/* Дополнительные акцентные элементы */}
|
||||||
|
<div className="absolute top-0 right-0 w-72 h-72 bg-blue-500/5 rounded-full blur-2xl float-animation"></div>
|
||||||
|
<div className="absolute bottom-0 left-0 w-72 h-72 bg-cyan-500/5 rounded-full blur-2xl float-animation" style={{ animationDelay: '3s' }}></div>
|
||||||
|
|
||||||
|
{/* Сетка с градиентом */}
|
||||||
|
<div className="absolute inset-0 opacity-5 z-0" style={{
|
||||||
|
backgroundImage: `
|
||||||
|
linear-gradient(0deg, transparent 24%, rgba(59, 147, 245, 0.08) 25%, rgba(59, 147, 245, 0.08) 26%, transparent 27%, transparent 74%, rgba(59, 147, 245, 0.08) 75%, rgba(59, 147, 245, 0.08) 76%, transparent 77%, transparent),
|
||||||
|
linear-gradient(90deg, transparent 24%, rgba(59, 147, 245, 0.08) 25%, rgba(59, 147, 245, 0.08) 26%, transparent 27%, transparent 74%, rgba(59, 147, 245, 0.08) 75%, rgba(59, 147, 245, 0.08) 76%, transparent 77%, transparent)
|
||||||
|
`,
|
||||||
|
backgroundSize: '60px 60px'
|
||||||
|
}}></div>
|
||||||
|
|
||||||
|
{/* Диагональные линии */}
|
||||||
|
<div className="absolute inset-0 opacity-3 z-0" style={{
|
||||||
|
backgroundImage: `
|
||||||
|
repeating-linear-gradient(45deg, transparent, transparent 35px, rgba(59, 147, 245, 0.1) 35px, rgba(59, 147, 245, 0.1) 70px)
|
||||||
|
`
|
||||||
|
}}></div>
|
||||||
|
|
||||||
|
{/* Центральный светящийся элемент */}
|
||||||
|
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-1 h-1 bg-cyan-400 rounded-full shadow-2xl" style={{
|
||||||
|
boxShadow: '0 0 60px 20px rgba(34, 211, 238, 0.15), 0 0 100px 40px rgba(59, 147, 245, 0.08)'
|
||||||
|
}}></div>
|
||||||
|
|
||||||
|
{/* Верхний логотип */}
|
||||||
|
<div className="relative z-10 mb-4 flex items-center justify-center gap-4 slide-in">
|
||||||
<Image src="/icons/logo.png" alt="AerBIM Logo" width={438} height={60} />
|
<Image src="/icons/logo.png" alt="AerBIM Logo" width={438} height={60} />
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-cards z-10 mx-4 flex w-full max-w-xl flex-col gap-4 rounded-2xl p-6 shadow-lg md:mx-8">
|
|
||||||
|
{/* Карточка формы с улучшенным стилем */}
|
||||||
|
<div className="relative z-10 mx-4 flex w-full max-w-md flex-col gap-6 rounded-[20px] bg-[#161824]/80 p-8 shadow-2xl border border-cyan-500/20 backdrop-blur-xl slide-in" style={{ animationDelay: '0.2s' }}>
|
||||||
|
{/* Верхний градиент на карточке */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-cyan-500/50 to-transparent rounded-t-[20px]"></div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
className="flex flex-col items-center justify-between gap-8 md:flex-row md:gap-4"
|
className="flex flex-col gap-6"
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
>
|
>
|
||||||
<div className="flex w-full flex-col gap-4">
|
<div className="flex flex-col gap-6">
|
||||||
<h1 className="text-2xl font-bold">Авторизация</h1>
|
<h1 style={interSemiboldStyle} className="text-3xl text-white">
|
||||||
|
Авторизация
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
<TextInput
|
<TextInput
|
||||||
value={values.login}
|
value={values.login}
|
||||||
name="login"
|
name="login"
|
||||||
@@ -103,6 +183,7 @@ const LoginPage = () => {
|
|||||||
isVisible={isVisible}
|
isVisible={isVisible}
|
||||||
togglePasswordVisibility={togglePasswordVisibility}
|
togglePasswordVisibility={togglePasswordVisibility}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RoleSelector
|
<RoleSelector
|
||||||
value={values.role}
|
value={values.role}
|
||||||
name="role"
|
name="role"
|
||||||
@@ -110,24 +191,27 @@ const LoginPage = () => {
|
|||||||
label="Ваша роль"
|
label="Ваша роль"
|
||||||
placeholder="Выберите вашу роль"
|
placeholder="Выберите вашу роль"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex w-full items-center justify-center pt-6">
|
|
||||||
<Button
|
|
||||||
text="Войти"
|
|
||||||
className="bg-blue mt-3 flex w-full items-center justify-center py-4 text-base font-semibold text-white shadow-lg transition-all duration-200 hover:opacity-90"
|
|
||||||
type="submit"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="mt-4 w-full py-3 px-4 bg-gradient-to-r from-[#3193f5] to-[#1e7ce8] hover:from-[#2563eb] hover:to-[#1a5fd6] text-white font-semibold rounded-xl transition-all duration-200 shadow-lg hover:shadow-2xl hover:shadow-blue-500/50"
|
||||||
|
style={interSemiboldStyle}
|
||||||
|
>
|
||||||
|
Войти
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="text-center text-base font-medium">
|
<div className="border-t border-cyan-500/20 pt-4">
|
||||||
<span className="hover:text-blue transition-colors duration-200 hover:underline">
|
<p style={interRegularStyle} className="text-center text-sm text-gray-400">
|
||||||
|
<span className="hover:text-cyan-400 transition-colors duration-200 hover:underline cursor-pointer">
|
||||||
<Link href="/password-recovery">Забыли логин/пароль?</Link>
|
<Link href="/password-recovery">Забыли логин/пароль?</Link>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import Sidebar from '../../../components/ui/Sidebar'
|
import Sidebar from '../../../components/ui/Sidebar'
|
||||||
|
import AnimatedBackground from '../../../components/ui/AnimatedBackground'
|
||||||
import useNavigationStore from '../../store/navigationStore'
|
import useNavigationStore from '../../store/navigationStore'
|
||||||
import DetectorList from '../../../components/alerts/DetectorList'
|
import DetectorList from '../../../components/alerts/DetectorList'
|
||||||
import AlertsList from '../../../components/alerts/AlertsList'
|
import AlertsList from '../../../components/alerts/AlertsList'
|
||||||
@@ -141,12 +142,15 @@ const AlertsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-[#0e111a]">
|
<div className="relative flex h-screen bg-[#0e111a] overflow-hidden">
|
||||||
|
<AnimatedBackground />
|
||||||
|
<div className="relative z-20">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
activeItem={8} // История тревог
|
activeItem={8} // История тревог
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="relative z-10 flex-1 flex flex-col">
|
||||||
<header className="bg-[#161824] border-b border-gray-700 px-6 py-4">
|
<header className="bg-[#161824] border-b border-gray-700 px-6 py-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
@@ -171,7 +175,7 @@ const AlertsPage: React.FC = () => {
|
|||||||
<div className="flex-1 p-6 overflow-auto">
|
<div className="flex-1 p-6 overflow-auto">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-white text-2xl font-semibold">Уведомления и тревоги</h1>
|
<h1 style={{ fontFamily: 'Inter, sans-serif', fontWeight: 600 }} className="text-white text-2xl">Уведомления и тревоги</h1>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{selectedDetectors.length > 0 && (
|
{selectedDetectors.length > 0 && (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import React, { useEffect, useCallback, useState } from 'react'
|
import React, { useEffect, useCallback, useState } from 'react'
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import Sidebar from '../../../components/ui/Sidebar'
|
import Sidebar from '../../../components/ui/Sidebar'
|
||||||
|
import AnimatedBackground from '../../../components/ui/AnimatedBackground'
|
||||||
import useNavigationStore from '../../store/navigationStore'
|
import useNavigationStore from '../../store/navigationStore'
|
||||||
import Monitoring from '../../../components/navigation/Monitoring'
|
import Monitoring from '../../../components/navigation/Monitoring'
|
||||||
import FloorNavigation from '../../../components/navigation/FloorNavigation'
|
import FloorNavigation from '../../../components/navigation/FloorNavigation'
|
||||||
@@ -117,6 +118,8 @@ const NavigationPage: React.FC = () => {
|
|||||||
map[String(d.serial_number).trim()] = d.status
|
map[String(d.serial_number).trim()] = d.status
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
console.log('[NavigationPage] sensorStatusMap created with', Object.keys(map).length, 'sensors')
|
||||||
|
console.log('[NavigationPage] Sample sensor IDs in map:', Object.keys(map).slice(0, 5))
|
||||||
return map
|
return map
|
||||||
}, [detectorsData])
|
}, [detectorsData])
|
||||||
|
|
||||||
@@ -127,21 +130,22 @@ const NavigationPage: React.FC = () => {
|
|||||||
}, [selectedDetector, selectedAlert]);
|
}, [selectedDetector, selectedAlert]);
|
||||||
|
|
||||||
// Управление выделением всех сенсоров при открытии/закрытии меню Sensors
|
// Управление выделением всех сенсоров при открытии/закрытии меню Sensors
|
||||||
|
// ИСПРАВЛЕНО: Подсветка датчиков остается включенной всегда, независимо от состояния панели Sensors
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('[NavigationPage] showSensors changed:', showSensors, 'modelReady:', isModelReady)
|
console.log('[NavigationPage] showSensors changed:', showSensors, 'modelReady:', isModelReady)
|
||||||
if (showSensors && isModelReady) {
|
if (isModelReady) {
|
||||||
// При открытии меню Sensors - выделяем все сенсоры (только если модель готова)
|
// Всегда включаем подсветку всех сенсоров когда модель готова
|
||||||
console.log('[NavigationPage] Setting highlightAllSensors to TRUE')
|
console.log('[NavigationPage] Setting highlightAllSensors to TRUE (always enabled)')
|
||||||
setHighlightAllSensors(true)
|
setHighlightAllSensors(true)
|
||||||
|
// Сбрасываем фокус только если панель Sensors закрыта
|
||||||
|
if (!showSensors) {
|
||||||
setFocusedSensorId(null)
|
setFocusedSensorId(null)
|
||||||
} else if (!showSensors) {
|
}
|
||||||
// При закрытии меню Sensors - сбрасываем выделение
|
|
||||||
console.log('[NavigationPage] Setting highlightAllSensors to FALSE')
|
|
||||||
setHighlightAllSensors(false)
|
|
||||||
}
|
}
|
||||||
}, [showSensors, isModelReady])
|
}, [showSensors, isModelReady])
|
||||||
|
|
||||||
// Дополнительный эффект для задержки выделения сенсоров при открытии меню
|
// Дополнительный эффект для задержки выделения сенсоров при открытии меню
|
||||||
|
// ИСПРАВЛЕНО: Задержка применяется только при открытии панели Sensors
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showSensors && isModelReady) {
|
if (showSensors && isModelReady) {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -155,9 +159,10 @@ const NavigationPage: React.FC = () => {
|
|||||||
|
|
||||||
const urlObjectId = searchParams.get('objectId')
|
const urlObjectId = searchParams.get('objectId')
|
||||||
const urlObjectTitle = searchParams.get('objectTitle')
|
const urlObjectTitle = searchParams.get('objectTitle')
|
||||||
|
const urlModelPath = searchParams.get('modelPath')
|
||||||
const objectId = currentObject.id || urlObjectId
|
const objectId = currentObject.id || urlObjectId
|
||||||
const objectTitle = currentObject.title || urlObjectTitle
|
const objectTitle = currentObject.title || urlObjectTitle
|
||||||
const [selectedModelPath, setSelectedModelPath] = useState<string>('')
|
const [selectedModelPath, setSelectedModelPath] = useState<string>(urlModelPath || '')
|
||||||
|
|
||||||
const handleModelLoaded = useCallback(() => {
|
const handleModelLoaded = useCallback(() => {
|
||||||
setIsModelReady(true)
|
setIsModelReady(true)
|
||||||
@@ -174,8 +179,12 @@ const NavigationPage: React.FC = () => {
|
|||||||
if (selectedModelPath) {
|
if (selectedModelPath) {
|
||||||
setIsModelReady(false);
|
setIsModelReady(false);
|
||||||
setModelError(null);
|
setModelError(null);
|
||||||
|
// Сохраняем выбранную модель в URL для восстановления при возврате
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.set('modelPath', selectedModelPath);
|
||||||
|
window.history.replaceState(null, '', `?${params.toString()}`);
|
||||||
}
|
}
|
||||||
}, [selectedModelPath]);
|
}, [selectedModelPath, searchParams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (urlObjectId && (!currentObject.id || currentObject.id !== urlObjectId)) {
|
if (urlObjectId && (!currentObject.id || currentObject.id !== urlObjectId)) {
|
||||||
@@ -183,6 +192,13 @@ const NavigationPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [urlObjectId, urlObjectTitle, currentObject.id, currentObject.title, setCurrentObject])
|
}, [urlObjectId, urlObjectTitle, currentObject.id, currentObject.title, setCurrentObject])
|
||||||
|
|
||||||
|
// Восстановление выбранной модели из URL при загрузке страницы
|
||||||
|
useEffect(() => {
|
||||||
|
if (urlModelPath && !selectedModelPath) {
|
||||||
|
setSelectedModelPath(urlModelPath);
|
||||||
|
}
|
||||||
|
}, [urlModelPath, selectedModelPath])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadDetectors = async () => {
|
const loadDetectors = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -195,6 +211,8 @@ const NavigationPage: React.FC = () => {
|
|||||||
if (!res.ok) throw new Error(typeof payload === 'string' ? payload : (payload?.error || 'Не удалось получить детекторов'))
|
if (!res.ok) throw new Error(typeof payload === 'string' ? payload : (payload?.error || 'Не удалось получить детекторов'))
|
||||||
const data = payload?.data ?? payload
|
const data = payload?.data ?? payload
|
||||||
const detectors = (data?.detectors ?? {}) as Record<string, DetectorType>
|
const detectors = (data?.detectors ?? {}) as Record<string, DetectorType>
|
||||||
|
console.log('[NavigationPage] Received detectors count:', Object.keys(detectors).length)
|
||||||
|
console.log('[NavigationPage] Sample detector keys:', Object.keys(detectors).slice(0, 5))
|
||||||
setDetectorsData({ detectors })
|
setDetectorsData({ detectors })
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('Ошибка загрузки детекторов:', e)
|
console.error('Ошибка загрузки детекторов:', e)
|
||||||
@@ -240,11 +258,9 @@ const NavigationPage: React.FC = () => {
|
|||||||
setSelectedDetector(null)
|
setSelectedDetector(null)
|
||||||
setFocusedSensorId(null)
|
setFocusedSensorId(null)
|
||||||
setSelectedAlert(null)
|
setSelectedAlert(null)
|
||||||
// При закрытии меню детектора из Sensors - выделяем все сенсоры снова
|
// При закрытии меню детектора - выделяем все сенсоры снова
|
||||||
if (showSensors) {
|
|
||||||
setHighlightAllSensors(true)
|
setHighlightAllSensors(true)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const handleNotificationClick = (notification: NotificationType) => {
|
const handleNotificationClick = (notification: NotificationType) => {
|
||||||
if (selectedNotification?.id === notification.id && showNotificationDetectorInfo) {
|
if (selectedNotification?.id === notification.id && showNotificationDetectorInfo) {
|
||||||
@@ -266,11 +282,9 @@ const NavigationPage: React.FC = () => {
|
|||||||
setSelectedAlert(null)
|
setSelectedAlert(null)
|
||||||
setFocusedSensorId(null)
|
setFocusedSensorId(null)
|
||||||
setSelectedDetector(null)
|
setSelectedDetector(null)
|
||||||
// При закрытии меню алерта из Sensors - выделяем все сенсоры снова
|
// При закрытии меню алерта - выделяем все сенсоры снова
|
||||||
if (showSensors) {
|
|
||||||
setHighlightAllSensors(true)
|
setHighlightAllSensors(true)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const handleAlertClick = (alert: AlertType) => {
|
const handleAlertClick = (alert: AlertType) => {
|
||||||
console.log('[NavigationPage] Alert clicked, focusing on detector in 3D scene:', alert)
|
console.log('[NavigationPage] Alert clicked, focusing on detector in 3D scene:', alert)
|
||||||
@@ -378,12 +392,15 @@ const NavigationPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-[#0e111a]">
|
<div className="relative flex h-screen bg-[#0e111a] overflow-hidden">
|
||||||
|
<AnimatedBackground />
|
||||||
|
<div className="relative z-20">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
activeItem={2}
|
activeItem={2}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col relative">
|
<div className="relative z-10 flex-1 flex flex-col">
|
||||||
|
|
||||||
{showMonitoring && (
|
{showMonitoring && (
|
||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||||
@@ -439,7 +456,7 @@ const NavigationPage: React.FC = () => {
|
|||||||
detectorsData={detectorsData}
|
detectorsData={detectorsData}
|
||||||
onDetectorMenuClick={handleDetectorMenuClick}
|
onDetectorMenuClick={handleDetectorMenuClick}
|
||||||
onClose={closeListOfDetectors}
|
onClose={closeListOfDetectors}
|
||||||
is3DReady={isModelReady && !modelError}
|
is3DReady={selectedModelPath ? !modelError : false}
|
||||||
/>
|
/>
|
||||||
{detectorsError && (
|
{detectorsError && (
|
||||||
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
||||||
@@ -456,7 +473,7 @@ const NavigationPage: React.FC = () => {
|
|||||||
detectorsData={detectorsData}
|
detectorsData={detectorsData}
|
||||||
onAlertClick={handleAlertClick}
|
onAlertClick={handleAlertClick}
|
||||||
onClose={closeSensors}
|
onClose={closeSensors}
|
||||||
is3DReady={isModelReady && !modelError}
|
is3DReady={selectedModelPath ? !modelError : false}
|
||||||
/>
|
/>
|
||||||
{detectorsError && (
|
{detectorsError && (
|
||||||
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import React, { useState, useEffect } from 'react'
|
|||||||
import ObjectGallery from '../../../components/objects/ObjectGallery'
|
import ObjectGallery from '../../../components/objects/ObjectGallery'
|
||||||
import { ObjectData } from '../../../components/objects/ObjectCard'
|
import { ObjectData } from '../../../components/objects/ObjectCard'
|
||||||
import Sidebar from '../../../components/ui/Sidebar'
|
import Sidebar from '../../../components/ui/Sidebar'
|
||||||
|
import AnimatedBackground from '../../../components/ui/AnimatedBackground'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
import Image from 'next/image'
|
||||||
|
|
||||||
// Универсальная функция для преобразования объекта из бэкенда в ObjectData
|
// Универсальная функция для преобразования объекта из бэкенда в ObjectData
|
||||||
const transformRawToObjectData = (raw: any): ObjectData => {
|
const transformRawToObjectData = (raw: any): ObjectData => {
|
||||||
@@ -126,18 +128,82 @@ const ObjectsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-[#0e111a]">
|
<div className="relative flex h-screen bg-[#0e111a] overflow-hidden">
|
||||||
|
<AnimatedBackground />
|
||||||
|
|
||||||
|
<div className="relative z-20">
|
||||||
<Sidebar activeItem={null} />
|
<Sidebar activeItem={null} />
|
||||||
<div className="flex-1 overflow-hidden">
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex-1 overflow-y-auto">
|
||||||
|
{/* Приветствие и информация */}
|
||||||
|
<div className="min-h-screen flex flex-col items-center justify-start pt-20 px-8">
|
||||||
|
{/* Логотип */}
|
||||||
|
<div className="mb-8 flex justify-center">
|
||||||
|
<div className="relative w-64 h-20">
|
||||||
|
<Image
|
||||||
|
src="/icons/logo.png"
|
||||||
|
alt="AerBIM Logo"
|
||||||
|
width={438}
|
||||||
|
height={60}
|
||||||
|
className="object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Приветствие */}
|
||||||
|
<h1 className="text-5xl font-bold text-white mb-4 text-center animate-fade-in" style={{ fontFamily: 'Inter, sans-serif' }}>
|
||||||
|
Добро пожаловать!
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-xl text-gray-300 mb-8 text-center animate-fade-in" style={{ animationDelay: '0.2s', fontFamily: 'Inter, sans-serif' }}>
|
||||||
|
Система мониторинга AerBIM Monitor
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Версия системы */}
|
||||||
|
<div className="mb-16 p-4 rounded-lg bg-gradient-to-r from-blue-500/10 to-cyan-500/10 border border-blue-500/20 inline-block animate-fade-in" style={{ animationDelay: '0.4s' }}>
|
||||||
|
<p className="text-sm text-gray-400" style={{ fontFamily: 'Inter, sans-serif' }}>
|
||||||
|
Версия системы: <span className="text-cyan-400 font-semibold">3.0.0</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Блок с галереей объектов */}
|
||||||
|
<div className="w-full max-w-6xl p-8 rounded-xl bg-gradient-to-r from-blue-500/10 to-cyan-500/10 border border-blue-500/20 backdrop-blur-sm">
|
||||||
|
{/* Заголовок галереи */}
|
||||||
|
<h2 className="text-3xl font-bold text-white mb-8 text-center" style={{ fontFamily: 'Inter, sans-serif' }}>
|
||||||
|
Выберите объект для работы
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Галерея объектов */}
|
||||||
<ObjectGallery
|
<ObjectGallery
|
||||||
objects={objects}
|
objects={objects}
|
||||||
title="Объекты"
|
title=""
|
||||||
onObjectSelect={handleObjectSelect}
|
onObjectSelect={handleObjectSelect}
|
||||||
selectedObjectId={selectedObjectId}
|
selectedObjectId={selectedObjectId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style jsx>{`
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fade-in 0.8s ease-out forwards;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import Sidebar from '../../../components/ui/Sidebar'
|
import Sidebar from '../../../components/ui/Sidebar'
|
||||||
|
import AnimatedBackground from '../../../components/ui/AnimatedBackground'
|
||||||
import useNavigationStore from '../../store/navigationStore'
|
import useNavigationStore from '../../store/navigationStore'
|
||||||
import ReportsList from '../../../components/reports/ReportsList'
|
import ReportsList from '../../../components/reports/ReportsList'
|
||||||
import ExportMenu from '../../../components/ui/ExportMenu'
|
import ExportMenu from '../../../components/ui/ExportMenu'
|
||||||
@@ -107,12 +108,15 @@ const ReportsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-[#0e111a]">
|
<div className="relative flex h-screen bg-[#0e111a] overflow-hidden">
|
||||||
|
<AnimatedBackground />
|
||||||
|
<div className="relative z-20">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
activeItem={9} // Отчёты
|
activeItem={9} // Отчёты
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="relative z-10 flex-1 flex flex-col">
|
||||||
<header className="border-b border-gray-700 bg-[#161824] px-6 py-4">
|
<header className="border-b border-gray-700 bg-[#161824] px-6 py-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
@@ -142,7 +146,7 @@ const ReportsPage: React.FC = () => {
|
|||||||
<div className="flex-1 overflow-auto p-6">
|
<div className="flex-1 overflow-auto p-6">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-semibold text-white">Отчеты по датчикам</h1>
|
<h1 style={{ fontFamily: 'Inter, sans-serif', fontWeight: 600 }} className="text-2xl text-white">Отчеты по датчикам</h1>
|
||||||
|
|
||||||
<ExportMenu onExport={handleExport} />
|
<ExportMenu onExport={handleExport} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, in
|
|||||||
})
|
})
|
||||||
}, [alerts, searchTerm])
|
}, [alerts, searchTerm])
|
||||||
|
|
||||||
|
const interSemiboldStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 600 }
|
||||||
|
const interRegularStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 400 }
|
||||||
|
|
||||||
const getStatusColor = (type: string) => {
|
const getStatusColor = (type: string) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'critical':
|
case 'critical':
|
||||||
@@ -64,29 +67,29 @@ const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, in
|
|||||||
{/* Таблица алертов */}
|
{/* Таблица алертов */}
|
||||||
<div className="bg-[#161824] rounded-[20px] p-6">
|
<div className="bg-[#161824] rounded-[20px] p-6">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="text-xl font-semibold text-white">История тревог</h2>
|
<h2 style={interSemiboldStyle} className="text-xl text-white">История тревог</h2>
|
||||||
<span className="text-sm text-gray-400">Всего: {filteredAlerts.length}</span>
|
<span style={interRegularStyle} className="text-sm text-gray-400">Всего: {filteredAlerts.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-700">
|
<tr className="border-b border-gray-700">
|
||||||
<th className="text-left text-white font-medium py-3">Детектор</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Детектор</th>
|
||||||
<th className="text-left text-white font-medium py-3">Статус</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Статус</th>
|
||||||
<th className="text-left text-white font-medium py-3">Сообщение</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Сообщение</th>
|
||||||
<th className="text-left text-white font-medium py-3">Местоположение</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Местоположение</th>
|
||||||
<th className="text-left text-white font-medium py-3">Приоритет</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Приоритет</th>
|
||||||
<th className="text-left text-white font-medium py-3">Подтверждено</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Подтверждено</th>
|
||||||
<th className="text-left text-white font-medium py-3">Время</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Время</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredAlerts.map((item) => (
|
{filteredAlerts.map((item) => (
|
||||||
<tr key={item.id} className="border-b border-gray-700 hover:bg-gray-800/50 transition-colors">
|
<tr key={item.id} className="border-b border-gray-700 hover:bg-gray-800/50 transition-colors">
|
||||||
<td className="py-4">
|
<td style={interRegularStyle} className="py-4 text-sm text-white">
|
||||||
<div className="text-sm font-medium text-white">{item.detector_name || 'Детектор'}</div>
|
<div>{item.detector_name || 'Детектор'}</div>
|
||||||
{item.detector_id ? (
|
{item.detector_id ? (
|
||||||
<div className="text-sm text-gray-400">ID: {item.detector_id}</div>
|
<div className="text-gray-400">ID: {item.detector_id}</div>
|
||||||
) : null}
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4">
|
<td className="py-4">
|
||||||
@@ -95,16 +98,16 @@ const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, in
|
|||||||
className="w-3 h-3 rounded-full"
|
className="w-3 h-3 rounded-full"
|
||||||
style={{ backgroundColor: getStatusColor(item.type) }}
|
style={{ backgroundColor: getStatusColor(item.type) }}
|
||||||
></div>
|
></div>
|
||||||
<span className="text-sm text-gray-300">
|
<span style={interRegularStyle} className="text-sm text-gray-300">
|
||||||
{item.type === 'critical' ? 'Критический' : item.type === 'warning' ? 'Предупреждение' : 'Информация'}
|
{item.type === 'critical' ? 'Критический' : item.type === 'warning' ? 'Предупреждение' : 'Информация'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4">
|
<td style={interRegularStyle} className="py-4 text-sm text-white">
|
||||||
<div className="text-sm text-white">{item.message}</div>
|
{item.message}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4">
|
<td style={interRegularStyle} className="py-4 text-sm text-white">
|
||||||
<div className="text-sm text-white">{item.location || '-'}</div>
|
{item.location || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4">
|
<td className="py-4">
|
||||||
<span
|
<span
|
||||||
@@ -122,20 +125,21 @@ const AlertsList: React.FC<AlertsListProps> = ({ alerts, onAcknowledgeToggle, in
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4">
|
<td className="py-4">
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
<span style={interRegularStyle} className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs ${
|
||||||
item.acknowledged ? 'bg-green-600/20 text-green-300 ring-1 ring-green-600/40' : 'bg-red-600/20 text-red-300 ring-1 ring-red-600/40'
|
item.acknowledged ? 'bg-green-600/20 text-green-300 ring-1 ring-green-600/40' : 'bg-red-600/20 text-red-300 ring-1 ring-red-600/40'
|
||||||
}`}>
|
}`}>
|
||||||
{item.acknowledged ? 'Да' : 'Нет'}
|
{item.acknowledged ? 'Да' : 'Нет'}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => onAcknowledgeToggle(item.id)}
|
onClick={() => onAcknowledgeToggle(item.id)}
|
||||||
className="ml-2 inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-[#2a2e3e] text-white hover:bg-[#353a4d]"
|
style={interRegularStyle}
|
||||||
|
className="ml-2 inline-flex items-center px-2 py-1 rounded text-xs bg-[#2a2e3e] text-white hover:bg-[#353a4d]"
|
||||||
>
|
>
|
||||||
{item.acknowledged ? 'Снять' : 'Подтвердить'}
|
{item.acknowledged ? 'Снять' : 'Подтвердить'}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4">
|
<td style={interRegularStyle} className="py-4 text-sm text-gray-300">
|
||||||
<div className="text-sm text-gray-300">{new Date(item.timestamp).toLocaleString('ru-RU')}</div>
|
{new Date(item.timestamp).toLocaleString('ru-RU')}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -90,7 +90,8 @@ const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors
|
|||||||
placeholder="Поиск по ID детектора..."
|
placeholder="Поиск по ID детектора..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="bg-[#161824] text-white placeholder-gray-400 px-4 py-2 rounded-lg border border-gray-600 focus:border-blue-500 focus:outline-none w-64"
|
className="bg-[#161824] text-white placeholder-gray-400 px-4 py-2 rounded-lg border border-gray-600 focus:border-blue-500 focus:outline-none w-64 text-sm font-medium"
|
||||||
|
style={{ fontFamily: 'Inter, sans-serif' }}
|
||||||
/>
|
/>
|
||||||
<svg className="absolute right-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="absolute right-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
|||||||
@@ -15,40 +15,178 @@ interface AreaChartProps {
|
|||||||
|
|
||||||
const AreaChart: React.FC<AreaChartProps> = ({ className = '', data }) => {
|
const AreaChart: React.FC<AreaChartProps> = ({ className = '', data }) => {
|
||||||
const width = 635
|
const width = 635
|
||||||
const height = 200
|
const height = 280
|
||||||
const paddingBottom = 20
|
const margin = { top: 20, right: 30, bottom: 50, left: 60 }
|
||||||
const baselineY = height - paddingBottom
|
const plotWidth = width - margin.left - margin.right
|
||||||
const maxPlotHeight = height - 40
|
const plotHeight = height - margin.top - margin.bottom
|
||||||
|
const baselineY = margin.top + plotHeight
|
||||||
|
|
||||||
const safeData = (Array.isArray(data) && data.length > 0)
|
const safeData = (Array.isArray(data) && data.length > 0)
|
||||||
? data
|
? data
|
||||||
: Array.from({ length: 7 }, () => ({ value: 0 }))
|
: Array.from({ length: 7 }, () => ({ value: 0 }))
|
||||||
|
|
||||||
const maxVal = Math.max(...safeData.map(d => d.value || 0), 1)
|
const maxVal = Math.max(...safeData.map(d => d.value || 0), 1)
|
||||||
const stepX = safeData.length > 1 ? width / (safeData.length - 1) : width
|
const stepX = safeData.length > 1 ? plotWidth / (safeData.length - 1) : plotWidth
|
||||||
|
|
||||||
const points = safeData.map((d, i) => {
|
const points = safeData.map((d, i) => {
|
||||||
const x = i * stepX
|
const x = margin.left + i * stepX
|
||||||
const y = baselineY - (Math.min(d.value || 0, maxVal) / maxVal) * maxPlotHeight
|
const y = baselineY - (Math.min(d.value || 0, maxVal) / maxVal) * plotHeight
|
||||||
return { x, y }
|
return { x, y }
|
||||||
})
|
})
|
||||||
|
|
||||||
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
|
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
|
||||||
const areaPath = `${linePath} L${width},${baselineY} L0,${baselineY} Z`
|
const areaPath = `${linePath} L${width - margin.right},${baselineY} L${margin.left},${baselineY} Z`
|
||||||
|
|
||||||
|
// Генерируем Y-оси метки
|
||||||
|
const ySteps = 4
|
||||||
|
const yLabels = Array.from({ length: ySteps + 1 }, (_, i) => {
|
||||||
|
const value = (maxVal / ySteps) * (ySteps - i)
|
||||||
|
const y = margin.top + (i * plotHeight) / ySteps
|
||||||
|
return { value: value.toFixed(1), y }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Генерируем X-оси метки (показываем каждую 2-ю или 3-ю точку)
|
||||||
|
const xLabelStep = Math.ceil(safeData.length / 5)
|
||||||
|
const xLabels = safeData
|
||||||
|
.map((d, i) => {
|
||||||
|
const x = margin.left + i * stepX
|
||||||
|
const label = d.label || d.timestamp || `${i + 1}`
|
||||||
|
return { label, x, index: i }
|
||||||
|
})
|
||||||
|
.filter((_, i) => i % xLabelStep === 0 || i === safeData.length - 1)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`w-full h-full ${className}`}>
|
<div className={`w-full h-full ${className}`}>
|
||||||
<svg className="w-full h-full" viewBox={`0 0 ${width} ${height}`}>
|
<svg className="w-full h-full" viewBox={`0 0 ${width} ${height}`}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="areaGradient" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="areaGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="0%" stopColor="rgb(42, 157, 144)" stopOpacity="0.3" />
|
<stop offset="0%" stopColor="rgb(37, 99, 235)" stopOpacity="0.3" />
|
||||||
<stop offset="100%" stopColor="rgb(42, 157, 144)" stopOpacity="0" />
|
<stop offset="100%" stopColor="rgb(37, 99, 235)" stopOpacity="0" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
|
{/* Сетка Y */}
|
||||||
|
{yLabels.map((label, i) => (
|
||||||
|
<line
|
||||||
|
key={`grid-y-${i}`}
|
||||||
|
x1={margin.left}
|
||||||
|
y1={label.y}
|
||||||
|
x2={width - margin.right}
|
||||||
|
y2={label.y}
|
||||||
|
stroke="rgba(148, 163, 184, 0.2)"
|
||||||
|
strokeWidth="1"
|
||||||
|
strokeDasharray="4,4"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Ось X */}
|
||||||
|
<line
|
||||||
|
x1={margin.left}
|
||||||
|
y1={baselineY}
|
||||||
|
x2={width - margin.right}
|
||||||
|
y2={baselineY}
|
||||||
|
stroke="rgb(148, 163, 184)"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Ось Y */}
|
||||||
|
<line
|
||||||
|
x1={margin.left}
|
||||||
|
y1={margin.top}
|
||||||
|
x2={margin.left}
|
||||||
|
y2={baselineY}
|
||||||
|
stroke="rgb(148, 163, 184)"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Y-оси метки и подписи */}
|
||||||
|
{yLabels.map((label, i) => (
|
||||||
|
<g key={`y-label-${i}`}>
|
||||||
|
<line
|
||||||
|
x1={margin.left - 5}
|
||||||
|
y1={label.y}
|
||||||
|
x2={margin.left}
|
||||||
|
y2={label.y}
|
||||||
|
stroke="rgb(148, 163, 184)"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={margin.left - 10}
|
||||||
|
y={label.y + 4}
|
||||||
|
textAnchor="end"
|
||||||
|
fontSize="12"
|
||||||
|
fill="rgb(148, 163, 184)"
|
||||||
|
fontFamily="Arial, sans-serif"
|
||||||
|
>
|
||||||
|
{label.value}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* X-оси метки и подписи */}
|
||||||
|
{xLabels.map((label, i) => (
|
||||||
|
<g key={`x-label-${i}`}>
|
||||||
|
<line
|
||||||
|
x1={label.x}
|
||||||
|
y1={baselineY}
|
||||||
|
x2={label.x}
|
||||||
|
y2={baselineY + 5}
|
||||||
|
stroke="rgb(148, 163, 184)"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={label.x}
|
||||||
|
y={baselineY + 20}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize="11"
|
||||||
|
fill="rgb(148, 163, 184)"
|
||||||
|
fontFamily="Arial, sans-serif"
|
||||||
|
>
|
||||||
|
{typeof label.label === 'string' ? label.label.substring(0, 10) : `${label.index + 1}`}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Подпись оси Y */}
|
||||||
|
<text
|
||||||
|
x={20}
|
||||||
|
y={margin.top + plotHeight / 2}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize="13"
|
||||||
|
fill="rgb(148, 163, 184)"
|
||||||
|
fontFamily="Arial, sans-serif"
|
||||||
|
transform={`rotate(-90, 20, ${margin.top + plotHeight / 2})`}
|
||||||
|
>
|
||||||
|
Значение
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Подпись оси X */}
|
||||||
|
<text
|
||||||
|
x={margin.left + plotWidth / 2}
|
||||||
|
y={height - 10}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize="13"
|
||||||
|
fill="rgb(148, 163, 184)"
|
||||||
|
fontFamily="Arial, sans-serif"
|
||||||
|
>
|
||||||
|
Время
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* График */}
|
||||||
<path d={areaPath} fill="url(#areaGradient)" />
|
<path d={areaPath} fill="url(#areaGradient)" />
|
||||||
<path d={linePath} stroke="rgb(42, 157, 144)" strokeWidth="2" fill="none" />
|
<path d={linePath} stroke="rgb(37, 99, 235)" strokeWidth="2.5" fill="none" />
|
||||||
|
|
||||||
|
{/* Точки данных */}
|
||||||
{points.map((p, i) => (
|
{points.map((p, i) => (
|
||||||
<circle key={i} cx={p.x} cy={p.y} r="3" fill="rgb(42, 157, 144)" />
|
<circle
|
||||||
|
key={i}
|
||||||
|
cx={p.x}
|
||||||
|
cy={p.y}
|
||||||
|
r="4"
|
||||||
|
fill="rgb(37, 99, 235)"
|
||||||
|
stroke="rgb(15, 23, 42)"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,26 +14,159 @@ interface BarChartProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const BarChart: React.FC<BarChartProps> = ({ className = '', data }) => {
|
const BarChart: React.FC<BarChartProps> = ({ className = '', data }) => {
|
||||||
|
const width = 635
|
||||||
|
const height = 280
|
||||||
|
const margin = { top: 20, right: 30, bottom: 50, left: 60 }
|
||||||
|
const plotWidth = width - margin.left - margin.right
|
||||||
|
const plotHeight = height - margin.top - margin.bottom
|
||||||
|
const baselineY = margin.top + plotHeight
|
||||||
|
|
||||||
const barData = (Array.isArray(data) && data.length > 0)
|
const barData = (Array.isArray(data) && data.length > 0)
|
||||||
? data.map(d => ({ value: d.value, color: d.color || 'rgb(42, 157, 144)' }))
|
? data.map(d => ({ value: d.value, label: d.label || '', color: d.color || 'rgb(37, 99, 235)' }))
|
||||||
: Array.from({ length: 12 }, () => ({ value: 0, color: 'rgb(42, 157, 144)' }))
|
: Array.from({ length: 12 }, (_, i) => ({ value: 0, label: `${i + 1}`, color: 'rgb(37, 99, 235)' }))
|
||||||
|
|
||||||
const maxVal = Math.max(...barData.map(b => b.value || 0), 1)
|
const maxVal = Math.max(...barData.map(b => b.value || 0), 1)
|
||||||
|
|
||||||
return (
|
// Генерируем Y-оси метки
|
||||||
<div className={`w-full h-full ${className}`}>
|
const ySteps = 4
|
||||||
<svg className="w-full h-full" viewBox="0 0 635 200">
|
const yLabels = Array.from({ length: ySteps + 1 }, (_, i) => {
|
||||||
<g>
|
const value = (maxVal / ySteps) * (ySteps - i)
|
||||||
{barData.map((bar, index) => {
|
const y = margin.top + (i * plotHeight) / ySteps
|
||||||
const barWidth = 40
|
return { value: value.toFixed(1), y }
|
||||||
const barSpacing = 12
|
})
|
||||||
const x = index * (barWidth + barSpacing) + 20
|
|
||||||
const barHeight = (bar.value / maxVal) * 160
|
// Генерируем X-оси метки (показываем каждую 2-ю или 3-ю)
|
||||||
const y = 180 - barHeight
|
const xLabelStep = Math.ceil(barData.length / 8)
|
||||||
|
const xLabels = barData
|
||||||
|
.map((d, i) => {
|
||||||
|
const barWidth = Math.max(30, plotWidth / barData.length - 8)
|
||||||
|
const barSpacing = (plotWidth - barWidth * barData.length) / (barData.length - 1 || 1)
|
||||||
|
const x = margin.left + i * (barWidth + barSpacing) + barWidth / 2
|
||||||
|
return { label: d.label || `${i + 1}`, x, index: i }
|
||||||
|
})
|
||||||
|
.filter((_, i) => i % xLabelStep === 0 || i === barData.length - 1)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className={`w-full h-full ${className}`}>
|
||||||
|
<svg className="w-full h-full" viewBox={`0 0 ${width} ${height}`}>
|
||||||
|
{/* Сетка Y */}
|
||||||
|
{yLabels.map((label, i) => (
|
||||||
|
<line
|
||||||
|
key={`grid-y-${i}`}
|
||||||
|
x1={margin.left}
|
||||||
|
y1={label.y}
|
||||||
|
x2={width - margin.right}
|
||||||
|
y2={label.y}
|
||||||
|
stroke="rgba(148, 163, 184, 0.2)"
|
||||||
|
strokeWidth="1"
|
||||||
|
strokeDasharray="4,4"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Ось X */}
|
||||||
|
<line
|
||||||
|
x1={margin.left}
|
||||||
|
y1={baselineY}
|
||||||
|
x2={width - margin.right}
|
||||||
|
y2={baselineY}
|
||||||
|
stroke="rgb(148, 163, 184)"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Ось Y */}
|
||||||
|
<line
|
||||||
|
x1={margin.left}
|
||||||
|
y1={margin.top}
|
||||||
|
x2={margin.left}
|
||||||
|
y2={baselineY}
|
||||||
|
stroke="rgb(148, 163, 184)"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Y-оси метки и подписи */}
|
||||||
|
{yLabels.map((label, i) => (
|
||||||
|
<g key={`y-label-${i}`}>
|
||||||
|
<line
|
||||||
|
x1={margin.left - 5}
|
||||||
|
y1={label.y}
|
||||||
|
x2={margin.left}
|
||||||
|
y2={label.y}
|
||||||
|
stroke="rgb(148, 163, 184)"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={margin.left - 10}
|
||||||
|
y={label.y + 4}
|
||||||
|
textAnchor="end"
|
||||||
|
fontSize="12"
|
||||||
|
fill="rgb(148, 163, 184)"
|
||||||
|
fontFamily="Arial, sans-serif"
|
||||||
|
>
|
||||||
|
{label.value}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* X-оси метки и подписи */}
|
||||||
|
{xLabels.map((label, i) => (
|
||||||
|
<g key={`x-label-${i}`}>
|
||||||
|
<line
|
||||||
|
x1={label.x}
|
||||||
|
y1={baselineY}
|
||||||
|
x2={label.x}
|
||||||
|
y2={baselineY + 5}
|
||||||
|
stroke="rgb(148, 163, 184)"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={label.x}
|
||||||
|
y={baselineY + 20}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize="11"
|
||||||
|
fill="rgb(148, 163, 184)"
|
||||||
|
fontFamily="Arial, sans-serif"
|
||||||
|
>
|
||||||
|
{typeof label.label === 'string' ? label.label.substring(0, 8) : `${label.index + 1}`}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Подпись оси Y */}
|
||||||
|
<text
|
||||||
|
x={20}
|
||||||
|
y={margin.top + plotHeight / 2}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize="13"
|
||||||
|
fill="rgb(148, 163, 184)"
|
||||||
|
fontFamily="Arial, sans-serif"
|
||||||
|
transform={`rotate(-90, 20, ${margin.top + plotHeight / 2})`}
|
||||||
|
>
|
||||||
|
Значение
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Подпись оси X */}
|
||||||
|
<text
|
||||||
|
x={margin.left + plotWidth / 2}
|
||||||
|
y={height - 10}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize="13"
|
||||||
|
fill="rgb(148, 163, 184)"
|
||||||
|
fontFamily="Arial, sans-serif"
|
||||||
|
>
|
||||||
|
Период
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Столбцы */}
|
||||||
|
{barData.map((bar, index) => {
|
||||||
|
const barWidth = Math.max(30, plotWidth / barData.length - 8)
|
||||||
|
const barSpacing = (plotWidth - barWidth * barData.length) / (barData.length - 1 || 1)
|
||||||
|
const x = margin.left + index * (barWidth + barSpacing)
|
||||||
|
const barHeight = (bar.value / maxVal) * plotHeight
|
||||||
|
const y = baselineY - barHeight
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={`bar-${index}`}>
|
||||||
<rect
|
<rect
|
||||||
key={index}
|
|
||||||
x={x}
|
x={x}
|
||||||
y={y}
|
y={y}
|
||||||
width={barWidth}
|
width={barWidth}
|
||||||
@@ -41,10 +174,24 @@ const BarChart: React.FC<BarChartProps> = ({ className = '', data }) => {
|
|||||||
fill={bar.color}
|
fill={bar.color}
|
||||||
rx="4"
|
rx="4"
|
||||||
ry="4"
|
ry="4"
|
||||||
|
opacity="0.9"
|
||||||
/>
|
/>
|
||||||
|
{/* Тень для глубины */}
|
||||||
|
<rect
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
width={barWidth}
|
||||||
|
height={barHeight}
|
||||||
|
fill="none"
|
||||||
|
stroke={bar.color}
|
||||||
|
strokeWidth="1"
|
||||||
|
rx="4"
|
||||||
|
ry="4"
|
||||||
|
opacity="0.3"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</g>
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,13 +15,16 @@ const ChartCard: React.FC<ChartCardProps> = ({
|
|||||||
children,
|
children,
|
||||||
className = ''
|
className = ''
|
||||||
}) => {
|
}) => {
|
||||||
|
const interSemiboldStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 600 }
|
||||||
|
const interRegularStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 400 }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`bg-[#161824] rounded-[20px] p-6 ${className}`}>
|
<div className={`bg-[#161824] rounded-[20px] p-6 ${className}`}>
|
||||||
<div className="flex items-start justify-between mb-6">
|
<div className="flex items-start justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-white text-base font-semibold mb-1">{title}</h3>
|
<h3 style={interSemiboldStyle} className="text-white text-sm mb-1">{title}</h3>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<p className="text-[#71717a] text-sm">{subtitle}</p>
|
<p style={interRegularStyle} className="text-[#71717a] text-xs">{subtitle}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-4 h-4">
|
<div className="w-4 h-4">
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState, useMemo } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Sidebar from '../ui/Sidebar'
|
import Sidebar from '../ui/Sidebar'
|
||||||
|
import AnimatedBackground from '../ui/AnimatedBackground'
|
||||||
import useNavigationStore from '../../app/store/navigationStore'
|
import useNavigationStore from '../../app/store/navigationStore'
|
||||||
import ChartCard from './ChartCard'
|
import ChartCard from './ChartCard'
|
||||||
import AreaChart from './AreaChart'
|
import AreaChart from './AreaChart'
|
||||||
import BarChart from './BarChart'
|
import BarChart from './BarChart'
|
||||||
|
import { aggregateChartDataByDays } from '../../lib/chartDataAggregator'
|
||||||
|
|
||||||
const Dashboard: React.FC = () => {
|
const Dashboard: React.FC = () => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -14,7 +16,7 @@ const Dashboard: React.FC = () => {
|
|||||||
const objectTitle = currentObject?.title
|
const objectTitle = currentObject?.title
|
||||||
|
|
||||||
const [dashboardAlerts, setDashboardAlerts] = useState<any[]>([])
|
const [dashboardAlerts, setDashboardAlerts] = useState<any[]>([])
|
||||||
const [chartData, setChartData] = useState<{ timestamp: string; value: number }[]>([])
|
const [rawChartData, setRawChartData] = useState<{ timestamp: string; value: number }[]>([])
|
||||||
const [sensorTypes] = useState<Array<{code: string, name: string}>>([
|
const [sensorTypes] = useState<Array<{code: string, name: string}>>([
|
||||||
{ code: '', name: 'Все датчики' },
|
{ code: '', name: 'Все датчики' },
|
||||||
{ code: 'GA', name: 'Инклинометр' },
|
{ code: 'GA', name: 'Инклинометр' },
|
||||||
@@ -52,7 +54,7 @@ const Dashboard: React.FC = () => {
|
|||||||
setDashboardAlerts(tableData as any[])
|
setDashboardAlerts(tableData as any[])
|
||||||
|
|
||||||
const cd = Array.isArray(payload?.data?.chart_data) ? payload.data.chart_data : []
|
const cd = Array.isArray(payload?.data?.chart_data) ? payload.data.chart_data : []
|
||||||
setChartData(cd as any[])
|
setRawChartData(cd as any[])
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load dashboard:', e)
|
console.error('Failed to load dashboard:', e)
|
||||||
}
|
}
|
||||||
@@ -130,13 +132,24 @@ const Dashboard: React.FC = () => {
|
|||||||
setSelectedTablePeriod(period)
|
setSelectedTablePeriod(period)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Агрегируем данные графика в зависимости от периода
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
return aggregateChartDataByDays(rawChartData, selectedChartPeriod)
|
||||||
|
}, [rawChartData, selectedChartPeriod])
|
||||||
|
|
||||||
|
const interSemiboldStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 600 }
|
||||||
|
const interRegularStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 400 }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-[#0e111a]">
|
<div className="relative flex h-screen bg-[#0e111a] overflow-hidden">
|
||||||
|
<AnimatedBackground />
|
||||||
|
<div className="relative z-20">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
activeItem={1} // Dashboard
|
activeItem={1} // Dashboard
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="relative z-10 flex-1 flex flex-col">
|
||||||
<header className="bg-[#161824] border-b border-gray-700 px-6 py-4">
|
<header className="bg-[#161824] border-b border-gray-700 px-6 py-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
@@ -158,7 +171,7 @@ const Dashboard: React.FC = () => {
|
|||||||
|
|
||||||
<div className="flex-1 p-6 overflow-auto">
|
<div className="flex-1 p-6 overflow-auto">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-white text-2xl font-semibold mb-6">{objectTitle || 'Объект'}</h1>
|
<h1 style={interSemiboldStyle} className="text-white text-2xl mb-6">{objectTitle || 'Объект'}</h1>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -215,7 +228,7 @@ const Dashboard: React.FC = () => {
|
|||||||
<ChartCard
|
<ChartCard
|
||||||
title="Статистика"
|
title="Статистика"
|
||||||
>
|
>
|
||||||
<BarChart data={chartData?.map((d: any) => ({ value: d.value }))} />
|
<BarChart data={chartData?.map((d: any) => ({ value: d.value, label: d.label }))} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -224,7 +237,7 @@ const Dashboard: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h2 className="text-white text-2xl font-semibold">Тренды</h2>
|
<h2 style={interSemiboldStyle} className="text-white text-2xl">Тренды</h2>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<select
|
<select
|
||||||
value={selectedTablePeriod}
|
value={selectedTablePeriod}
|
||||||
@@ -248,55 +261,56 @@ const Dashboard: React.FC = () => {
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-700">
|
<tr className="border-b border-gray-700">
|
||||||
<th className="text-left text-white font-medium py-3">Детектор</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Детектор</th>
|
||||||
<th className="text-left text-white font-medium py-3">Сообщение</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Сообщение</th>
|
||||||
<th className="text-left text-white font-medium py-3">Серьезность</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Серьезность</th>
|
||||||
<th className="text-left text-white font-medium py-3">Дата</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Дата</th>
|
||||||
<th className="text-left text-white font-medium py-3">Решен</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Решен</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredAlerts.map((alert: any) => (
|
{filteredAlerts.map((alert: any) => (
|
||||||
<tr key={alert.id} className="border-b border-gray-800">
|
<tr key={alert.id} className="border-b border-gray-800">
|
||||||
<td className="py-3 text-white text-sm">{alert.name}</td>
|
<td style={interRegularStyle} className="py-3 text-white text-sm">{alert.name}</td>
|
||||||
<td className="py-3 text-gray-300 text-sm">{alert.message}</td>
|
<td style={interRegularStyle} className="py-3 text-gray-300 text-sm">{alert.message}</td>
|
||||||
<td className="py-3">
|
<td className="py-3">
|
||||||
<span className={`text-sm ${alert.severity === 'critical' ? 'text-red-500' : alert.severity === 'warning' ? 'text-orange-500' : 'text-green-500'}`}>
|
<span style={interRegularStyle} className={`text-sm ${alert.severity === 'critical' ? 'text-red-500' : alert.severity === 'warning' ? 'text-orange-500' : 'text-green-500'}`}>
|
||||||
{alert.severity === 'critical' ? 'Критическое' : alert.severity === 'warning' ? 'Предупреждение' : 'Норма'}
|
{alert.severity === 'critical' ? 'Критическое' : alert.severity === 'warning' ? 'Предупреждение' : 'Норма'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 text-gray-400 text-sm">{new Date(alert.created_at).toLocaleString()}</td>
|
<td style={interRegularStyle} className="py-3 text-gray-400 text-sm">{new Date(alert.created_at).toLocaleString()}</td>
|
||||||
<td className="py-3">
|
<td className="py-3">
|
||||||
{alert.resolved ? (
|
{alert.resolved ? (
|
||||||
<span className="text-sm text-green-500">Да</span>
|
<span style={interRegularStyle} className="text-sm text-green-500">Да</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-gray-500">Нет</span>
|
<span style={interRegularStyle} className="text-sm text-gray-500">Нет</span>
|
||||||
)}
|
)
|
||||||
|
}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Статы */}
|
{/* Статистика */}
|
||||||
<div className="mt-6 grid grid-cols-4 gap-4">
|
<div className="mt-6 grid grid-cols-4 gap-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold text-white">{filteredAlerts.length}</div>
|
<div style={interSemiboldStyle} className="text-2xl text-white">{filteredAlerts.length}</div>
|
||||||
<div className="text-sm text-gray-400">Всего</div>
|
<div style={interRegularStyle} className="text-sm text-gray-400">Всего</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold text-green-500">{statusCounts.normal}</div>
|
<div style={interSemiboldStyle} className="text-2xl text-green-500">{statusCounts.normal}</div>
|
||||||
<div className="text-sm text-gray-400">Норма</div>
|
<div style={interRegularStyle} className="text-sm text-gray-400">Норма</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold text-orange-500">{statusCounts.warning}</div>
|
<div style={interSemiboldStyle} className="text-2xl text-orange-500">{statusCounts.warning}</div>
|
||||||
<div className="text-sm text-gray-400">Предупреждения</div>
|
<div style={interRegularStyle} className="text-sm text-gray-400">Предупреждения</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold text-red-500">{statusCounts.critical}</div>
|
<div style={interSemiboldStyle} className="text-2xl text-red-500">{statusCounts.critical}</div>
|
||||||
<div className="text-sm text-gray-400">Критические</div>
|
<div style={interRegularStyle} className="text-sm text-gray-400">Критические</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
const [allSensorsOverlayCircles, setAllSensorsOverlayCircles] = useState<
|
const [allSensorsOverlayCircles, setAllSensorsOverlayCircles] = useState<
|
||||||
{ sensorId: string; left: number; top: number; colorHex: string }[]
|
{ sensorId: string; left: number; top: number; colorHex: string }[]
|
||||||
>([])
|
>([])
|
||||||
|
// NEW: State for tracking hovered sensor in overlay circles
|
||||||
|
const [hoveredSensorId, setHoveredSensorId] = useState<string | null>(null)
|
||||||
|
|
||||||
const handlePan = () => setPanActive(!panActive);
|
const handlePan = () => setPanActive(!panActive);
|
||||||
|
|
||||||
@@ -224,6 +226,82 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// NEW: Function to handle overlay circle click
|
||||||
|
const handleOverlayCircleClick = (sensorId: string) => {
|
||||||
|
console.log('[ModelViewer] Overlay circle clicked:', sensorId)
|
||||||
|
|
||||||
|
// Find the mesh for this sensor
|
||||||
|
const allMeshes = importedMeshesRef.current || []
|
||||||
|
const sensorMeshes = collectSensorMeshes(allMeshes)
|
||||||
|
const targetMesh = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId)
|
||||||
|
|
||||||
|
if (!targetMesh) {
|
||||||
|
console.warn(`[ModelViewer] Mesh not found for sensor: ${sensorId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const scene = sceneRef.current
|
||||||
|
const camera = scene?.activeCamera as ArcRotateCamera
|
||||||
|
if (!scene || !camera) return
|
||||||
|
|
||||||
|
// Calculate bounding box of the sensor mesh
|
||||||
|
const bbox = (typeof targetMesh.getHierarchyBoundingVectors === 'function')
|
||||||
|
? targetMesh.getHierarchyBoundingVectors()
|
||||||
|
: {
|
||||||
|
min: targetMesh.getBoundingInfo().boundingBox.minimumWorld,
|
||||||
|
max: targetMesh.getBoundingInfo().boundingBox.maximumWorld
|
||||||
|
}
|
||||||
|
|
||||||
|
const center = bbox.min.add(bbox.max).scale(0.5)
|
||||||
|
const size = bbox.max.subtract(bbox.min)
|
||||||
|
const maxDimension = Math.max(size.x, size.y, size.z)
|
||||||
|
|
||||||
|
// Calculate optimal camera distance
|
||||||
|
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5)
|
||||||
|
|
||||||
|
// Stop any current animations
|
||||||
|
scene.stopAnimation(camera)
|
||||||
|
|
||||||
|
// Setup easing
|
||||||
|
const ease = new CubicEase()
|
||||||
|
ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT)
|
||||||
|
|
||||||
|
const frameRate = 60
|
||||||
|
const durationMs = 600 // 0.6 seconds for smooth animation
|
||||||
|
const totalFrames = Math.round((durationMs / 1000) * frameRate)
|
||||||
|
|
||||||
|
// Animate camera target position
|
||||||
|
Animation.CreateAndStartAnimation(
|
||||||
|
'camTarget',
|
||||||
|
camera,
|
||||||
|
'target',
|
||||||
|
frameRate,
|
||||||
|
totalFrames,
|
||||||
|
camera.target.clone(),
|
||||||
|
center.clone(),
|
||||||
|
Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||||
|
ease
|
||||||
|
)
|
||||||
|
|
||||||
|
// Animate camera radius (zoom)
|
||||||
|
Animation.CreateAndStartAnimation(
|
||||||
|
'camRadius',
|
||||||
|
camera,
|
||||||
|
'radius',
|
||||||
|
frameRate,
|
||||||
|
totalFrames,
|
||||||
|
camera.radius,
|
||||||
|
targetRadius,
|
||||||
|
Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||||
|
ease
|
||||||
|
)
|
||||||
|
|
||||||
|
// Call callback to display tooltip
|
||||||
|
onSensorPick?.(sensorId)
|
||||||
|
|
||||||
|
console.log('[ModelViewer] Camera animation started for sensor:', sensorId)
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isDisposedRef.current = false
|
isDisposedRef.current = false
|
||||||
isInitializedRef.current = false
|
isInitializedRef.current = false
|
||||||
@@ -343,41 +421,18 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
}, [onError])
|
}, [onError])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isInitializedRef.current || isDisposedRef.current) {
|
if (!modelPath || !sceneRef.current || !engineRef.current) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!modelPath || modelPath.trim() === '') {
|
const scene = sceneRef.current
|
||||||
console.warn('[ModelViewer] No model path provided')
|
|
||||||
// Не вызываем onError для пустого пути - это нормальное состояние при инициализации
|
|
||||||
setIsLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadModel = async () => {
|
|
||||||
if (!sceneRef.current || isDisposedRef.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentModelPath = modelPath;
|
|
||||||
console.log('[ModelViewer] Starting model load:', currentModelPath);
|
|
||||||
|
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setLoadingProgress(0)
|
setLoadingProgress(0)
|
||||||
setShowModel(false)
|
setShowModel(false)
|
||||||
setModelReady(false)
|
setModelReady(false)
|
||||||
setPanActive(false)
|
|
||||||
|
|
||||||
const oldMeshes = sceneRef.current.meshes.slice();
|
const loadModel = async () => {
|
||||||
const activeCameraId = sceneRef.current.activeCamera?.uniqueId;
|
try {
|
||||||
console.log('[ModelViewer] Cleaning up old meshes. Total:', oldMeshes.length);
|
console.log('[ModelViewer] Starting to load model:', modelPath)
|
||||||
oldMeshes.forEach(m => {
|
|
||||||
if (m.uniqueId !== activeCameraId) {
|
|
||||||
m.dispose();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[ModelViewer] Loading GLTF model:', currentModelPath)
|
|
||||||
|
|
||||||
// UI элемент загрузчика (есть эффект замедленности)
|
// UI элемент загрузчика (есть эффект замедленности)
|
||||||
const progressInterval = setInterval(() => {
|
const progressInterval = setInterval(() => {
|
||||||
@@ -390,103 +445,127 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
})
|
})
|
||||||
}, 100)
|
}, 100)
|
||||||
|
|
||||||
try {
|
// Use the correct ImportMeshAsync signature: (url, scene, onProgress)
|
||||||
console.log('[ModelViewer] Calling ImportMeshAsync with path:', currentModelPath);
|
const result = await ImportMeshAsync(modelPath, scene, (evt) => {
|
||||||
|
if (evt.lengthComputable) {
|
||||||
|
const progress = (evt.loaded / evt.total) * 100
|
||||||
|
setLoadingProgress(progress)
|
||||||
|
console.log('[ModelViewer] Loading progress:', progress)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Проверим доступность файла через fetch
|
clearInterval(progressInterval)
|
||||||
try {
|
|
||||||
const testResponse = await fetch(currentModelPath, { method: 'HEAD' });
|
if (isDisposedRef.current) {
|
||||||
console.log('[ModelViewer] File availability check:', {
|
console.log('[ModelViewer] Component disposed during load')
|
||||||
url: currentModelPath,
|
return
|
||||||
status: testResponse.status,
|
|
||||||
statusText: testResponse.statusText,
|
|
||||||
ok: testResponse.ok
|
|
||||||
});
|
|
||||||
} catch (fetchError) {
|
|
||||||
console.error('[ModelViewer] File fetch error:', fetchError);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await ImportMeshAsync(currentModelPath, sceneRef.current)
|
console.log('[ModelViewer] Model loaded successfully:', {
|
||||||
console.log('[ModelViewer] ImportMeshAsync completed successfully');
|
|
||||||
console.log('[ModelViewer] Import result:', {
|
|
||||||
meshesCount: result.meshes.length,
|
meshesCount: result.meshes.length,
|
||||||
particleSystemsCount: result.particleSystems.length,
|
particleSystemsCount: result.particleSystems.length,
|
||||||
skeletonsCount: result.skeletons.length,
|
skeletonsCount: result.skeletons.length,
|
||||||
animationGroupsCount: result.animationGroups.length
|
animationGroupsCount: result.animationGroups.length
|
||||||
});
|
})
|
||||||
|
|
||||||
if (isDisposedRef.current || modelPath !== currentModelPath) {
|
|
||||||
console.log('[ModelViewer] Model loading aborted - model changed during load')
|
|
||||||
clearInterval(progressInterval)
|
|
||||||
setIsLoading(false)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
importedMeshesRef.current = result.meshes
|
importedMeshesRef.current = result.meshes
|
||||||
|
|
||||||
clearInterval(progressInterval)
|
|
||||||
setLoadingProgress(100)
|
|
||||||
|
|
||||||
console.log('[ModelViewer] GLTF Model loaded successfully!', result)
|
|
||||||
|
|
||||||
if (result.meshes.length > 0) {
|
if (result.meshes.length > 0) {
|
||||||
|
|
||||||
const boundingBox = result.meshes[0].getHierarchyBoundingVectors()
|
const boundingBox = result.meshes[0].getHierarchyBoundingVectors()
|
||||||
const size = boundingBox.max.subtract(boundingBox.min)
|
|
||||||
const maxDimension = Math.max(size.x, size.y, size.z)
|
|
||||||
|
|
||||||
const camera = sceneRef.current!.activeCamera as ArcRotateCamera
|
|
||||||
camera.radius = maxDimension * 2
|
|
||||||
camera.target = result.meshes[0].position
|
|
||||||
|
|
||||||
importedMeshesRef.current = result.meshes
|
|
||||||
setModelReady(true)
|
|
||||||
|
|
||||||
onModelLoaded?.({
|
onModelLoaded?.({
|
||||||
meshes: result.meshes,
|
meshes: result.meshes,
|
||||||
boundingBox: {
|
boundingBox: {
|
||||||
min: boundingBox.min,
|
min: { x: boundingBox.min.x, y: boundingBox.min.y, z: boundingBox.min.z },
|
||||||
max: boundingBox.max
|
max: { x: boundingBox.max.x, y: boundingBox.max.y, z: boundingBox.max.z },
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Плавное появление модели
|
setLoadingProgress(100)
|
||||||
setTimeout(() => {
|
|
||||||
if (!isDisposedRef.current && modelPath === currentModelPath) {
|
|
||||||
setShowModel(true)
|
setShowModel(true)
|
||||||
|
setModelReady(true)
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
} else {
|
|
||||||
console.log('Model display aborted - model changed during animation')
|
|
||||||
}
|
|
||||||
}, 500)
|
|
||||||
} else {
|
|
||||||
console.warn('No meshes found in model')
|
|
||||||
onError?.('В модели не найдена геометрия')
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearInterval(progressInterval)
|
if (isDisposedRef.current) return
|
||||||
if (!isDisposedRef.current && modelPath === currentModelPath) {
|
const errorMessage = error instanceof Error ? error.message : 'Неизвестная ошибка'
|
||||||
console.error('Error loading GLTF model:', error)
|
console.error('[ModelViewer] Error loading model:', errorMessage)
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
const message = `Ошибка при загрузке модели: ${errorMessage}`
|
||||||
onError?.(`Ошибка загрузки модели: ${errorMessage}`)
|
onError?.(message)
|
||||||
} else {
|
|
||||||
console.log('Error occurred but loading was aborted - model changed')
|
|
||||||
}
|
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
|
setModelReady(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загрузка модлеи начинается после появления спиннера
|
loadModel()
|
||||||
requestIdleCallback(() => loadModel(), { timeout: 50 })
|
}, [modelPath, onModelLoaded, onError])
|
||||||
}, [modelPath, onError, onModelLoaded])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sceneRef.current || isDisposedRef.current || !modelReady) return
|
if (!highlightAllSensors || focusSensorId || !modelReady) {
|
||||||
|
setAllSensorsOverlayCircles([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const scene = sceneRef.current
|
||||||
|
const engine = engineRef.current
|
||||||
|
if (!scene || !engine) {
|
||||||
|
setAllSensorsOverlayCircles([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (highlightAllSensors) {
|
|
||||||
const allMeshes = importedMeshesRef.current || []
|
const allMeshes = importedMeshesRef.current || []
|
||||||
const sensorMeshes = collectSensorMeshes(allMeshes)
|
const sensorMeshes = collectSensorMeshes(allMeshes)
|
||||||
|
if (sensorMeshes.length === 0) {
|
||||||
|
setAllSensorsOverlayCircles([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const engineTyped = engine as Engine
|
||||||
|
const updateCircles = () => {
|
||||||
|
const circles = computeSensorOverlayCircles({
|
||||||
|
scene,
|
||||||
|
engine: engineTyped,
|
||||||
|
meshes: sensorMeshes,
|
||||||
|
sensorStatusMap,
|
||||||
|
})
|
||||||
|
setAllSensorsOverlayCircles(circles)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCircles()
|
||||||
|
const observer = scene.onBeforeRenderObservable.add(updateCircles)
|
||||||
|
return () => {
|
||||||
|
scene.onBeforeRenderObservable.remove(observer)
|
||||||
|
setAllSensorsOverlayCircles([])
|
||||||
|
}
|
||||||
|
}, [highlightAllSensors, focusSensorId, modelReady, sensorStatusMap])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!highlightAllSensors || focusSensorId || !modelReady) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const scene = sceneRef.current
|
||||||
|
if (!scene) return
|
||||||
|
|
||||||
|
const allMeshes = importedMeshesRef.current || []
|
||||||
|
if (allMeshes.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sensorMeshes = collectSensorMeshes(allMeshes)
|
||||||
|
|
||||||
|
console.log('[ModelViewer] Total meshes in model:', allMeshes.length)
|
||||||
|
console.log('[ModelViewer] Sensor meshes found:', sensorMeshes.length)
|
||||||
|
|
||||||
|
// Log first 5 sensor IDs found in meshes
|
||||||
|
const sensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m)).filter(Boolean).slice(0, 5)
|
||||||
|
console.log('[ModelViewer] Sample sensor IDs from meshes:', sensorIds)
|
||||||
|
|
||||||
|
if (sensorMeshes.length === 0) {
|
||||||
|
console.warn('[ModelViewer] No sensor meshes found in 3D model!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
applyHighlightToMeshes(
|
applyHighlightToMeshes(
|
||||||
highlightLayerRef.current,
|
highlightLayerRef.current,
|
||||||
highlightedMeshesRef,
|
highlightedMeshesRef,
|
||||||
@@ -497,6 +576,13 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
return statusToColor3(status ?? null)
|
return statusToColor3(status ?? null)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
}, [highlightAllSensors, focusSensorId, modelReady, sensorStatusMap])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusSensorId || !modelReady) {
|
||||||
|
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
|
||||||
|
highlightedMeshesRef.current = []
|
||||||
|
highlightLayerRef.current?.removeAllMeshes()
|
||||||
chosenMeshRef.current = null
|
chosenMeshRef.current = null
|
||||||
setOverlayPos(null)
|
setOverlayPos(null)
|
||||||
setOverlayData(null)
|
setOverlayData(null)
|
||||||
@@ -528,12 +614,14 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sensorMeshes = collectSensorMeshes(allMeshes)
|
const sensorMeshes = collectSensorMeshes(allMeshes)
|
||||||
|
const allSensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m))
|
||||||
const chosen = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId)
|
const chosen = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId)
|
||||||
|
|
||||||
console.log('[ModelViewer] Sensor focus', {
|
console.log('[ModelViewer] Sensor focus', {
|
||||||
requested: sensorId,
|
requested: sensorId,
|
||||||
totalImportedMeshes: allMeshes.length,
|
totalImportedMeshes: allMeshes.length,
|
||||||
totalSensorMeshes: sensorMeshes.length,
|
totalSensorMeshes: sensorMeshes.length,
|
||||||
|
allSensorIds: allSensorIds,
|
||||||
chosen: chosen ? { id: chosen.id, name: chosen.name, uniqueId: chosen.uniqueId, parent: chosen.parent?.name } : null,
|
chosen: chosen ? { id: chosen.id, name: chosen.name, uniqueId: chosen.uniqueId, parent: chosen.parent?.name } : null,
|
||||||
source: 'result.meshes',
|
source: 'result.meshes',
|
||||||
})
|
})
|
||||||
@@ -593,7 +681,6 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [focusSensorId, modelReady, highlightAllSensors])
|
}, [focusSensorId, modelReady, highlightAllSensors])
|
||||||
|
|
||||||
// Включение выбора на основе взаимодействия с моделью только при готовности модели и включении выбора сенсоров
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scene = sceneRef.current
|
const scene = sceneRef.current
|
||||||
if (!scene || !modelReady || !isSensorSelectionEnabled) return
|
if (!scene || !modelReady || !isSensorSelectionEnabled) return
|
||||||
@@ -621,7 +708,6 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
}
|
}
|
||||||
}, [modelReady, isSensorSelectionEnabled, onSensorPick])
|
}, [modelReady, isSensorSelectionEnabled, onSensorPick])
|
||||||
|
|
||||||
// Расчет позиции оверлея
|
|
||||||
const computeOverlayPosition = React.useCallback((mesh: AbstractMesh | null) => {
|
const computeOverlayPosition = React.useCallback((mesh: AbstractMesh | null) => {
|
||||||
if (!sceneRef.current || !mesh) return null
|
if (!sceneRef.current || !mesh) return null
|
||||||
const scene = sceneRef.current
|
const scene = sceneRef.current
|
||||||
@@ -644,49 +730,12 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Позиция оверлея изначально
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chosenMeshRef.current || !overlayData) return
|
if (!chosenMeshRef.current || !overlayData) return
|
||||||
const pos = computeOverlayPosition(chosenMeshRef.current)
|
const pos = computeOverlayPosition(chosenMeshRef.current)
|
||||||
setOverlayPos(pos)
|
setOverlayPos(pos)
|
||||||
}, [overlayData, computeOverlayPosition])
|
}, [overlayData, computeOverlayPosition])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const scene = sceneRef.current
|
|
||||||
const engine = engineRef.current
|
|
||||||
if (!scene || !engine || !modelReady) {
|
|
||||||
setAllSensorsOverlayCircles([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!highlightAllSensors || focusSensorId || !sensorStatusMap) {
|
|
||||||
setAllSensorsOverlayCircles([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const allMeshes = importedMeshesRef.current || []
|
|
||||||
const sensorMeshes = collectSensorMeshes(allMeshes)
|
|
||||||
if (sensorMeshes.length === 0) {
|
|
||||||
setAllSensorsOverlayCircles([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const engineTyped = engine as Engine
|
|
||||||
const updateCircles = () => {
|
|
||||||
const circles = computeSensorOverlayCircles({
|
|
||||||
scene,
|
|
||||||
engine: engineTyped,
|
|
||||||
meshes: sensorMeshes,
|
|
||||||
sensorStatusMap,
|
|
||||||
})
|
|
||||||
setAllSensorsOverlayCircles(circles)
|
|
||||||
}
|
|
||||||
updateCircles()
|
|
||||||
const observer = scene.onBeforeRenderObservable.add(updateCircles)
|
|
||||||
return () => {
|
|
||||||
scene.onBeforeRenderObservable.remove(observer)
|
|
||||||
setAllSensorsOverlayCircles([])
|
|
||||||
}
|
|
||||||
}, [highlightAllSensors, focusSensorId, modelReady, sensorStatusMap])
|
|
||||||
|
|
||||||
// Позиция оверлея при движущейся камере
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sceneRef.current || !chosenMeshRef.current || !overlayData) return
|
if (!sceneRef.current || !chosenMeshRef.current || !overlayData) return
|
||||||
const scene = sceneRef.current
|
const scene = sceneRef.current
|
||||||
@@ -767,13 +816,19 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{/* UPDATED: Interactive overlay circles with hover effects */}
|
||||||
{allSensorsOverlayCircles.map(circle => {
|
{allSensorsOverlayCircles.map(circle => {
|
||||||
const size = 36
|
const size = 36
|
||||||
const radius = size / 2
|
const radius = size / 2
|
||||||
const fill = hexWithAlpha(circle.colorHex, 0.2)
|
const fill = hexWithAlpha(circle.colorHex, 0.2)
|
||||||
|
const isHovered = hoveredSensorId === circle.sensorId
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${circle.sensorId}-${Math.round(circle.left)}-${Math.round(circle.top)}`}
|
key={`${circle.sensorId}-${Math.round(circle.left)}-${Math.round(circle.top)}`}
|
||||||
|
onClick={() => handleOverlayCircleClick(circle.sensorId)}
|
||||||
|
onMouseEnter={() => setHoveredSensorId(circle.sensorId)}
|
||||||
|
onMouseLeave={() => setHoveredSensorId(null)}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: circle.left - radius,
|
left: circle.left - radius,
|
||||||
@@ -783,8 +838,16 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
borderRadius: '9999px',
|
borderRadius: '9999px',
|
||||||
border: `2px solid ${circle.colorHex}`,
|
border: `2px solid ${circle.colorHex}`,
|
||||||
backgroundColor: fill,
|
backgroundColor: fill,
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'auto',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||||
|
transform: isHovered ? 'scale(1.4)' : 'scale(1)',
|
||||||
|
boxShadow: isHovered
|
||||||
|
? `0 0 25px ${circle.colorHex}, inset 0 0 10px ${circle.colorHex}`
|
||||||
|
: `0 0 8px ${circle.colorHex}`,
|
||||||
|
zIndex: isHovered ? 50 : 10,
|
||||||
}}
|
}}
|
||||||
|
title={`Датчик: ${circle.sensorId}`}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -181,13 +181,13 @@ const AlertMenu: React.FC<AlertMenuProps> = ({ alert, isOpen, onClose, getStatus
|
|||||||
<div className="font-semibold truncate text-base">{alert.detector_name}</div>
|
<div className="font-semibold truncate text-base">{alert.detector_name}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={handleReportsClick} className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-4 py-2 rounded-[6px] text-sm font-medium transition-colors flex items-center gap-2 flex-1">
|
<button onClick={handleReportsClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-4 py-2 rounded-[6px] text-sm font-medium transition-colors flex items-center gap-2 flex-1">
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
Отчет
|
Отчет
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleHistoryClick} className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-4 py-2 rounded-[6px] text-sm font-medium transition-colors flex items-center gap-2 flex-1">
|
<button onClick={handleHistoryClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-4 py-2 rounded-[6px] text-sm font-medium transition-colors flex items-center gap-2 flex-1">
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -230,13 +230,13 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||||
<button onClick={handleReportsClick} className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-2 py-1 rounded-[8px] text-xs font-medium transition-colors flex items-center gap-1">
|
<button onClick={handleReportsClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-2 py-1 rounded-[8px] text-xs font-medium transition-colors flex items-center gap-1">
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
Отчет
|
Отчет
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleHistoryClick} className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-2 py-1 rounded-[8px] text-xs font-medium transition-colors flex items-center gap-1">
|
<button onClick={handleHistoryClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-2 py-1 rounded-[8px] text-xs font-medium transition-colors flex items-center gap-1">
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -261,13 +261,13 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
</h3>
|
</h3>
|
||||||
{/* Кнопки действий: Отчет и История */}
|
{/* Кнопки действий: Отчет и История */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button onClick={handleReportsClick} className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
<button onClick={handleReportsClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
Отчет
|
Отчет
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleHistoryClick} className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
<button onClick={handleHistoryClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -42,12 +42,15 @@ const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
|||||||
onSelectModel?.(modelPath);
|
onSelectModel?.(modelPath);
|
||||||
}, [onSelectModel]);
|
}, [onSelectModel]);
|
||||||
|
|
||||||
|
// Загрузка зон при изменении объекта
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const objId = currentObject?.id;
|
const objId = currentObject?.id;
|
||||||
if (!objId) return;
|
if (!objId) return;
|
||||||
loadZones(objId);
|
loadZones(objId);
|
||||||
}, [currentObject?.id, loadZones]);
|
}, [currentObject?.id, loadZones]);
|
||||||
|
|
||||||
|
// Автоматический выбор первой зоны при загрузке
|
||||||
|
useEffect(() => {
|
||||||
const sortedZones: Zone[] = (currentZones || []).slice().sort((a: Zone, b: Zone) => {
|
const sortedZones: Zone[] = (currentZones || []).slice().sort((a: Zone, b: Zone) => {
|
||||||
const oa = typeof a.order === 'number' ? a.order : 0;
|
const oa = typeof a.order === 'number' ? a.order : 0;
|
||||||
const ob = typeof b.order === 'number' ? b.order : 0;
|
const ob = typeof b.order === 'number' ? b.order : 0;
|
||||||
@@ -55,6 +58,20 @@ const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
|||||||
return (a.name || '').localeCompare(b.name || '');
|
return (a.name || '').localeCompare(b.name || '');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (sortedZones.length > 0 && sortedZones[0].model_path && !zonesLoading) {
|
||||||
|
handleSelectModel(sortedZones[0].model_path);
|
||||||
|
}
|
||||||
|
}, [currentZones, zonesLoading, handleSelectModel]);
|
||||||
|
|
||||||
|
const sortedZones: Zone[] = React.useMemo(() => {
|
||||||
|
return (currentZones || []).slice().sort((a: Zone, b: Zone) => {
|
||||||
|
const oa = typeof a.order === 'number' ? a.order : 0;
|
||||||
|
const ob = typeof b.order === 'number' ? b.order : 0;
|
||||||
|
if (oa !== ob) return oa - ob;
|
||||||
|
return (a.name || '').localeCompare(b.name || '');
|
||||||
|
});
|
||||||
|
}, [currentZones]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="bg-[rgb(22,24,36)] rounded-[12px] p-4 space-y-4">
|
<div className="bg-[rgb(22,24,36)] rounded-[12px] p-4 space-y-4">
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import useNavigationStore from '@/app/store/navigationStore'
|
||||||
import * as statusColors from '../../lib/statusColors'
|
import * as statusColors from '../../lib/statusColors'
|
||||||
|
|
||||||
interface DetectorInfoType {
|
interface DetectorInfoType {
|
||||||
@@ -29,6 +31,8 @@ interface NotificationDetectorInfoProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const NotificationDetectorInfo: React.FC<NotificationDetectorInfoProps> = ({ detectorData, onClose }) => {
|
const NotificationDetectorInfo: React.FC<NotificationDetectorInfoProps> = ({ detectorData, onClose }) => {
|
||||||
|
const router = useRouter()
|
||||||
|
const { setSelectedDetector, currentObject } = useNavigationStore()
|
||||||
const detectorInfo = detectorData
|
const detectorInfo = detectorData
|
||||||
|
|
||||||
if (!detectorInfo) {
|
if (!detectorInfo) {
|
||||||
@@ -77,6 +81,72 @@ const NotificationDetectorInfo: React.FC<NotificationDetectorInfoProps> = ({ det
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleReportsClick = () => {
|
||||||
|
const currentUrl = new URL(window.location.href)
|
||||||
|
const objectId = currentUrl.searchParams.get('objectId') || currentObject.id
|
||||||
|
const objectTitle = currentUrl.searchParams.get('objectTitle') || currentObject.title
|
||||||
|
|
||||||
|
const detectorDataToSet = {
|
||||||
|
detector_id: detectorInfo.detector_id,
|
||||||
|
name: detectorInfo.name,
|
||||||
|
serial_number: '',
|
||||||
|
object: detectorInfo.object,
|
||||||
|
status: detectorInfo.status,
|
||||||
|
checked: detectorInfo.checked,
|
||||||
|
type: detectorInfo.type,
|
||||||
|
detector_type: detectorInfo.detector_type,
|
||||||
|
location: detectorInfo.location,
|
||||||
|
floor: detectorInfo.floor,
|
||||||
|
notifications: detectorInfo.notifications || []
|
||||||
|
}
|
||||||
|
setSelectedDetector(detectorDataToSet)
|
||||||
|
|
||||||
|
let reportsUrl = '/reports'
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
|
||||||
|
if (objectId) params.set('objectId', objectId)
|
||||||
|
if (objectTitle) params.set('objectTitle', objectTitle)
|
||||||
|
|
||||||
|
if (params.toString()) {
|
||||||
|
reportsUrl += `?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(reportsUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHistoryClick = () => {
|
||||||
|
const currentUrl = new URL(window.location.href)
|
||||||
|
const objectId = currentUrl.searchParams.get('objectId') || currentObject.id
|
||||||
|
const objectTitle = currentUrl.searchParams.get('objectTitle') || currentObject.title
|
||||||
|
|
||||||
|
const detectorDataToSet = {
|
||||||
|
detector_id: detectorInfo.detector_id,
|
||||||
|
name: detectorInfo.name,
|
||||||
|
serial_number: '',
|
||||||
|
object: detectorInfo.object,
|
||||||
|
status: detectorInfo.status,
|
||||||
|
checked: detectorInfo.checked,
|
||||||
|
type: detectorInfo.type,
|
||||||
|
detector_type: detectorInfo.detector_type,
|
||||||
|
location: detectorInfo.location,
|
||||||
|
floor: detectorInfo.floor,
|
||||||
|
notifications: detectorInfo.notifications || []
|
||||||
|
}
|
||||||
|
setSelectedDetector(detectorDataToSet)
|
||||||
|
|
||||||
|
let historyUrl = '/history'
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
|
||||||
|
if (objectId) params.set('objectId', objectId)
|
||||||
|
if (objectTitle) params.set('objectTitle', objectTitle)
|
||||||
|
|
||||||
|
if (params.toString()) {
|
||||||
|
historyUrl += `?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(historyUrl)
|
||||||
|
}
|
||||||
|
|
||||||
const latestNotification = detectorInfo.notifications && detectorInfo.notifications.length > 0
|
const latestNotification = detectorInfo.notifications && detectorInfo.notifications.length > 0
|
||||||
? detectorInfo.notifications.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())[0]
|
? detectorInfo.notifications.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())[0]
|
||||||
: null
|
: null
|
||||||
@@ -100,13 +170,13 @@ const NotificationDetectorInfo: React.FC<NotificationDetectorInfoProps> = ({ det
|
|||||||
{detectorInfo.name}
|
{detectorInfo.name}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
<button onClick={handleReportsClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
Отчет
|
Отчет
|
||||||
</button>
|
</button>
|
||||||
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
<button onClick={handleHistoryClick} className="bg-[#3193f5] hover:bg-[#2563eb] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -90,6 +90,9 @@ const ReportsList: React.FC<ReportsListProps> = ({ detectorsData, initialSearchT
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const interSemiboldStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 600 }
|
||||||
|
const interRegularStyle = { fontFamily: 'Inter, sans-serif', fontWeight: 400 }
|
||||||
|
|
||||||
const getPriorityColor = (priority: string) => {
|
const getPriorityColor = (priority: string) => {
|
||||||
switch (priority) {
|
switch (priority) {
|
||||||
case 'high':
|
case 'high':
|
||||||
@@ -186,21 +189,21 @@ const ReportsList: React.FC<ReportsListProps> = ({ detectorsData, initialSearchT
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-700">
|
<tr className="border-b border-gray-700">
|
||||||
<th className="text-left text-white font-medium py-3">Детектор</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Детектор</th>
|
||||||
<th className="text-left text-white font-medium py-3">Статус</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Статус</th>
|
||||||
<th className="text-left text-white font-medium py-3">Сообщение</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Сообщение</th>
|
||||||
<th className="text-left text-white font-medium py-3">Местоположение</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Местоположение</th>
|
||||||
<th className="text-left text-white font-medium py-3">Приоритет</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Приоритет</th>
|
||||||
<th className="text-left text-white font-medium py-3">Подтверждено</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Подтверждено</th>
|
||||||
<th className="text-left text-white font-medium py-3">Время</th>
|
<th style={interSemiboldStyle} className="text-left text-white text-sm py-3">Время</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredDetectors.map((detector) => (
|
{filteredDetectors.map((detector) => (
|
||||||
<tr key={detector.id} className="border-b border-gray-700 hover:bg-gray-800/50 transition-colors">
|
<tr key={detector.id} className="border-b border-gray-700 hover:bg-gray-800/50 transition-colors">
|
||||||
<td className="py-4">
|
<td style={interRegularStyle} className="py-4 text-sm text-white">
|
||||||
<div className="text-sm font-medium text-white">{detector.detector_name}</div>
|
<div>{detector.detector_name}</div>
|
||||||
<div className="text-sm text-gray-400">ID: {detector.detector_id}</div>
|
<div className="text-gray-400">ID: {detector.detector_id}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4">
|
<td className="py-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -208,17 +211,17 @@ const ReportsList: React.FC<ReportsListProps> = ({ detectorsData, initialSearchT
|
|||||||
className="w-3 h-3 rounded-full"
|
className="w-3 h-3 rounded-full"
|
||||||
style={{ backgroundColor: getStatusColor(detector.type) }}
|
style={{ backgroundColor: getStatusColor(detector.type) }}
|
||||||
></div>
|
></div>
|
||||||
<span className="text-sm text-gray-300">
|
<span style={interRegularStyle} className="text-sm text-gray-300">
|
||||||
{detector.type === 'critical' ? 'Критический' :
|
{detector.type === 'critical' ? 'Критический' :
|
||||||
detector.type === 'warning' ? 'Предупреждение' : 'Информация'}
|
detector.type === 'warning' ? 'Предупреждение' : 'Информация'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4">
|
<td style={interRegularStyle} className="py-4 text-sm text-white">
|
||||||
<div className="text-sm text-white">{detector.message}</div>
|
{detector.message}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4">
|
<td style={interRegularStyle} className="py-4 text-sm text-white">
|
||||||
<div className="text-sm text-white">{detector.location}</div>
|
{detector.location}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4">
|
<td className="py-4">
|
||||||
<span
|
<span
|
||||||
@@ -230,7 +233,7 @@ const ReportsList: React.FC<ReportsListProps> = ({ detectorsData, initialSearchT
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4">
|
<td className="py-4">
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
<span style={interRegularStyle} className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs ${
|
||||||
detector.acknowledged
|
detector.acknowledged
|
||||||
? 'bg-green-600/20 text-green-300 ring-1 ring-green-600/40'
|
? 'bg-green-600/20 text-green-300 ring-1 ring-green-600/40'
|
||||||
: 'bg-red-600/20 text-red-300 ring-1 ring-red-600/40'
|
: 'bg-red-600/20 text-red-300 ring-1 ring-red-600/40'
|
||||||
@@ -238,10 +241,8 @@ const ReportsList: React.FC<ReportsListProps> = ({ detectorsData, initialSearchT
|
|||||||
{detector.acknowledged ? 'Да' : 'Нет'}
|
{detector.acknowledged ? 'Да' : 'Нет'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4">
|
<td style={interRegularStyle} className="py-4 text-sm text-gray-300">
|
||||||
<div className="text-sm text-gray-300">
|
|
||||||
{new Date(detector.timestamp).toLocaleString('ru-RU')}
|
{new Date(detector.timestamp).toLocaleString('ru-RU')}
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -12,21 +12,36 @@ const Button = ({
|
|||||||
size = 'lg',
|
size = 'lg',
|
||||||
}: ButtonProps) => {
|
}: ButtonProps) => {
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
sm: 'h-10 text-sm',
|
sm: 'h-10 text-sm px-4',
|
||||||
md: 'h-12 text-base',
|
md: 'h-12 text-base px-6',
|
||||||
lg: 'h-14 text-xl',
|
lg: 'h-14 text-lg px-8',
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`cursor-pointer rounded-xl transition-all duration-500 hover:shadow-2xl ${sizeClasses[size]} ${className}`}
|
className={`
|
||||||
|
cursor-pointer
|
||||||
|
rounded-2xl
|
||||||
|
transition-all
|
||||||
|
duration-300
|
||||||
|
font-medium
|
||||||
|
shadow-lg
|
||||||
|
hover:shadow-2xl
|
||||||
|
hover:scale-105
|
||||||
|
active:scale-95
|
||||||
|
backdrop-blur-sm
|
||||||
|
${sizeClasses[size]}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
type={type}
|
type={type}
|
||||||
>
|
>
|
||||||
{leftIcon && <span className="mr-2 flex items-center">{leftIcon}</span>}
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
{leftIcon && <span className="flex items-center">{leftIcon}</span>}
|
||||||
{midIcon && <span className="flex items-center">{midIcon}</span>}
|
{midIcon && <span className="flex items-center">{midIcon}</span>}
|
||||||
<span className="text-center font-normal">{text}</span>
|
<span className="text-center">{text}</span>
|
||||||
{rightIcon && <span className="ml-2 flex items-center">{rightIcon}</span>}
|
{rightIcon && <span className="flex items-center">{rightIcon}</span>}
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ interface LoadingSpinnerProps {
|
|||||||
|
|
||||||
const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||||
progress = 0,
|
progress = 0,
|
||||||
size = 120,
|
size = 140,
|
||||||
strokeWidth = 8,
|
strokeWidth = 6,
|
||||||
className = ''
|
className = ''
|
||||||
}) => {
|
}) => {
|
||||||
const radius = (size - strokeWidth) / 2
|
const radius = (size - strokeWidth) / 2
|
||||||
@@ -23,43 +23,59 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className={`flex flex-col items-center justify-center ${className}`}>
|
<div className={`flex flex-col items-center justify-center ${className}`}>
|
||||||
<div className="relative" style={{ width: size, height: size }}>
|
<div className="relative" style={{ width: size, height: size }}>
|
||||||
|
{/* Фоновый круг с градиентом */}
|
||||||
<svg
|
<svg
|
||||||
className="transform -rotate-90"
|
className="transform -rotate-90 drop-shadow-lg"
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
viewBox={`0 0 ${size} ${size}`}
|
viewBox={`0 0 ${size} ${size}`}
|
||||||
>
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="progressGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor="#2563eb" />
|
||||||
|
<stop offset="100%" stopColor="#0891b2" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Фоновый круг */}
|
||||||
<circle
|
<circle
|
||||||
cx={size / 2}
|
cx={size / 2}
|
||||||
cy={size / 2}
|
cy={size / 2}
|
||||||
r={radius}
|
r={radius}
|
||||||
stroke="rgba(255, 255, 255, 0.1)"
|
stroke="rgba(255, 255, 255, 0.08)"
|
||||||
strokeWidth={strokeWidth}
|
strokeWidth={strokeWidth}
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Прогресс круг с градиентом */}
|
||||||
<circle
|
<circle
|
||||||
cx={size / 2}
|
cx={size / 2}
|
||||||
cy={size / 2}
|
cy={size / 2}
|
||||||
r={radius}
|
r={radius}
|
||||||
stroke="#389ee8"
|
stroke="url(#progressGradient)"
|
||||||
strokeWidth={strokeWidth}
|
strokeWidth={strokeWidth}
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
strokeDasharray={strokeDasharray}
|
strokeDasharray={strokeDasharray}
|
||||||
strokeDashoffset={strokeDashoffset}
|
strokeDashoffset={strokeDashoffset}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
className="transition-all duration-300 ease-out"
|
className="transition-all duration-500 ease-out filter drop-shadow-md"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
|
{/* Процент в центре */}
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<span className="text-white text-xl font-semibold">
|
<div className="text-center">
|
||||||
|
<span className="text-white text-3xl font-bold bg-gradient-to-r from-blue-400 to-cyan-300 bg-clip-text text-transparent">
|
||||||
{Math.round(progress)}%
|
{Math.round(progress)}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 text-white text-base font-medium">
|
{/* Текст загрузки */}
|
||||||
Loading Model...
|
<div className="mt-6 text-center">
|
||||||
|
<p className="text-white text-lg font-semibold">Загрузка модели…</p>
|
||||||
|
<p className="text-gray-400 text-sm mt-2">Пожалуйста, подождите</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -185,19 +185,18 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
openMonitoring,
|
openMonitoring,
|
||||||
openFloorNavigation,
|
openFloorNavigation,
|
||||||
openNotifications,
|
openNotifications,
|
||||||
openListOfDetectors,
|
|
||||||
openSensors,
|
openSensors,
|
||||||
|
openListOfDetectors,
|
||||||
|
closeSensors,
|
||||||
|
closeListOfDetectors,
|
||||||
closeMonitoring,
|
closeMonitoring,
|
||||||
closeFloorNavigation,
|
closeFloorNavigation,
|
||||||
closeNotifications,
|
closeNotifications,
|
||||||
closeListOfDetectors,
|
|
||||||
closeSensors,
|
|
||||||
closeAllMenus,
|
|
||||||
showMonitoring,
|
showMonitoring,
|
||||||
showFloorNavigation,
|
showFloorNavigation,
|
||||||
showNotifications,
|
showNotifications,
|
||||||
showListOfDetectors,
|
showSensors,
|
||||||
showSensors
|
showListOfDetectors
|
||||||
} = useNavigationStore()
|
} = useNavigationStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -229,14 +228,10 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
case 3: // Monitoring
|
case 3: // Monitoring
|
||||||
if (pathname !== '/navigation') {
|
if (pathname !== '/navigation') {
|
||||||
router.push('/navigation')
|
router.push('/navigation')
|
||||||
setTimeout(() => {
|
setTimeout(() => openMonitoring(), 100)
|
||||||
closeAllMenus()
|
|
||||||
openMonitoring()
|
|
||||||
}, 100)
|
|
||||||
} else if (showMonitoring) {
|
} else if (showMonitoring) {
|
||||||
closeMonitoring()
|
closeMonitoring()
|
||||||
} else {
|
} else {
|
||||||
closeAllMenus()
|
|
||||||
openMonitoring()
|
openMonitoring()
|
||||||
}
|
}
|
||||||
handled = true
|
handled = true
|
||||||
@@ -244,14 +239,10 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
case 4: // Floor Navigation
|
case 4: // Floor Navigation
|
||||||
if (pathname !== '/navigation') {
|
if (pathname !== '/navigation') {
|
||||||
router.push('/navigation')
|
router.push('/navigation')
|
||||||
setTimeout(() => {
|
setTimeout(() => openFloorNavigation(), 100)
|
||||||
closeAllMenus()
|
|
||||||
openFloorNavigation()
|
|
||||||
}, 100)
|
|
||||||
} else if (showFloorNavigation) {
|
} else if (showFloorNavigation) {
|
||||||
closeFloorNavigation()
|
closeFloorNavigation()
|
||||||
} else {
|
} else {
|
||||||
closeAllMenus()
|
|
||||||
openFloorNavigation()
|
openFloorNavigation()
|
||||||
}
|
}
|
||||||
handled = true
|
handled = true
|
||||||
@@ -259,14 +250,10 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
case 5: // Notifications
|
case 5: // Notifications
|
||||||
if (pathname !== '/navigation') {
|
if (pathname !== '/navigation') {
|
||||||
router.push('/navigation')
|
router.push('/navigation')
|
||||||
setTimeout(() => {
|
setTimeout(() => openNotifications(), 100)
|
||||||
closeAllMenus()
|
|
||||||
openNotifications()
|
|
||||||
}, 100)
|
|
||||||
} else if (showNotifications) {
|
} else if (showNotifications) {
|
||||||
closeNotifications()
|
closeNotifications()
|
||||||
} else {
|
} else {
|
||||||
closeAllMenus()
|
|
||||||
openNotifications()
|
openNotifications()
|
||||||
}
|
}
|
||||||
handled = true
|
handled = true
|
||||||
@@ -274,29 +261,21 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
case 6: // Sensors
|
case 6: // Sensors
|
||||||
if (pathname !== '/navigation') {
|
if (pathname !== '/navigation') {
|
||||||
router.push('/navigation')
|
router.push('/navigation')
|
||||||
setTimeout(() => {
|
setTimeout(() => openSensors(), 100)
|
||||||
closeAllMenus()
|
|
||||||
openSensors()
|
|
||||||
}, 100)
|
|
||||||
} else if (showSensors) {
|
} else if (showSensors) {
|
||||||
closeSensors()
|
closeSensors()
|
||||||
} else {
|
} else {
|
||||||
closeAllMenus()
|
|
||||||
openSensors()
|
openSensors()
|
||||||
}
|
}
|
||||||
handled = true
|
handled = true
|
||||||
break
|
break
|
||||||
case 7: // List of Detectors
|
case 7: // Detector List
|
||||||
if (pathname !== '/navigation') {
|
if (pathname !== '/navigation') {
|
||||||
router.push('/navigation')
|
router.push('/navigation')
|
||||||
setTimeout(() => {
|
setTimeout(() => openListOfDetectors(), 100)
|
||||||
closeAllMenus()
|
|
||||||
openListOfDetectors()
|
|
||||||
}, 100)
|
|
||||||
} else if (showListOfDetectors) {
|
} else if (showListOfDetectors) {
|
||||||
closeListOfDetectors()
|
closeListOfDetectors()
|
||||||
} else {
|
} else {
|
||||||
closeAllMenus()
|
|
||||||
openListOfDetectors()
|
openListOfDetectors()
|
||||||
}
|
}
|
||||||
handled = true
|
handled = true
|
||||||
@@ -352,8 +331,8 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
return (
|
return (
|
||||||
<li key={item.id} className="flex-col flex items-center relative self-stretch w-full" role="listitem">
|
<li key={item.id} className="flex-col flex items-center relative self-stretch w-full" role="listitem">
|
||||||
<button
|
<button
|
||||||
className={`gap-2 pt-2 pr-2 pb-2 pl-2 rounded-md flex h-9 items-center relative self-stretch w-full transition-colors duration-200 hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset ${
|
className={`gap-2 pt-2 pr-2 pb-2 pl-2 rounded-md flex h-9 items-center relative self-stretch w-full transition-all duration-200 hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset ${
|
||||||
isActive ? 'bg-gray-700' : ''
|
isActive ? 'bg-gradient-to-r from-blue-600 to-cyan-500 shadow-lg shadow-blue-500/30' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleItemClick(item.id)}
|
onClick={() => handleItemClick(item.id)}
|
||||||
aria-current={isActive ? 'page' : undefined}
|
aria-current={isActive ? 'page' : undefined}
|
||||||
@@ -379,8 +358,6 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
closeMonitoring()
|
closeMonitoring()
|
||||||
closeFloorNavigation()
|
closeFloorNavigation()
|
||||||
closeNotifications()
|
closeNotifications()
|
||||||
closeListOfDetectors()
|
|
||||||
closeSensors()
|
|
||||||
}
|
}
|
||||||
toggleNavigationSubMenu()
|
toggleNavigationSubMenu()
|
||||||
}}
|
}}
|
||||||
@@ -395,8 +372,6 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
closeMonitoring()
|
closeMonitoring()
|
||||||
closeFloorNavigation()
|
closeFloorNavigation()
|
||||||
closeNotifications()
|
closeNotifications()
|
||||||
closeListOfDetectors()
|
|
||||||
closeSensors()
|
|
||||||
}
|
}
|
||||||
toggleNavigationSubMenu()
|
toggleNavigationSubMenu()
|
||||||
}
|
}
|
||||||
@@ -426,8 +401,8 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
return (
|
return (
|
||||||
<li key={subItem.id} className="flex-col flex h-8 items-center relative self-stretch w-full" role="listitem">
|
<li key={subItem.id} className="flex-col flex h-8 items-center relative self-stretch w-full" role="listitem">
|
||||||
<button
|
<button
|
||||||
className={`gap-2 pt-1.5 pr-2 pb-1.5 pl-2 rounded-md flex h-8 items-center relative self-stretch w-full transition-colors duration-200 hover:bg-gray-600 focus:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset ${
|
className={`gap-2 pt-1.5 pr-2 pb-1.5 pl-2 rounded-md flex h-8 items-center relative self-stretch w-full transition-all duration-200 hover:bg-gray-600 focus:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset ${
|
||||||
isSubActive ? 'bg-gray-600' : ''
|
isSubActive ? 'bg-gradient-to-r from-blue-600 to-cyan-500 shadow-lg shadow-blue-500/30' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleItemClick(subItem.id)}
|
onClick={() => handleItemClick(subItem.id)}
|
||||||
aria-current={isSubActive ? 'page' : undefined}
|
aria-current={isSubActive ? 'page' : undefined}
|
||||||
@@ -461,8 +436,6 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
closeMonitoring()
|
closeMonitoring()
|
||||||
closeFloorNavigation()
|
closeFloorNavigation()
|
||||||
closeNotifications()
|
closeNotifications()
|
||||||
closeListOfDetectors()
|
|
||||||
closeSensors()
|
|
||||||
}
|
}
|
||||||
// Убираем сайд-бар
|
// Убираем сайд-бар
|
||||||
toggleSidebar()
|
toggleSidebar()
|
||||||
@@ -511,7 +484,7 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="!relative !w-8 !h-8 p-1.5 rounded-lg bg-gray-800/60 border border-gray-600/40 shadow-lg hover:shadow-xl hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all duration-200"
|
className="relative w-4 h-4 aspect-[1] p-1 rounded hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-200"
|
||||||
aria-label="Logout"
|
aria-label="Logout"
|
||||||
title="Выйти"
|
title="Выйти"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
Reference in New Issue
Block a user