Изменения в логике получения данных по датчикам из бэкенда, изменение тултипа сенсора для исправления получения данных на реальные
This commit is contained in:
@@ -66,8 +66,9 @@ class DetectorSerializer(serializers.ModelSerializer):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def get_status(self, obj):
|
def get_status(self, obj):
|
||||||
# проверяем наличие активных алертов
|
# Проверяем наличие нерешённых алертов
|
||||||
latest_alert = obj.alerts.filter(resolved=False).first()
|
# Берём самый свежий (последний по времени) нерешённый алерт
|
||||||
|
latest_alert = obj.alerts.filter(resolved=False).order_by('-created_at').first()
|
||||||
if latest_alert:
|
if latest_alert:
|
||||||
return latest_alert.severity # вернет 'warning' или 'critical'
|
return latest_alert.severity # вернет 'warning' или 'critical'
|
||||||
return 'normal'
|
return 'normal'
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
from drf_spectacular.utils import extend_schema_field
|
||||||
|
from drf_spectacular.types import OpenApiTypes
|
||||||
|
from typing import Dict, Any
|
||||||
|
from sitemanagement.models import Sensor, Alert
|
||||||
|
|
||||||
|
class NotificationSerializer(serializers.ModelSerializer):
|
||||||
|
type = serializers.SerializerMethodField()
|
||||||
|
priority = serializers.SerializerMethodField()
|
||||||
|
timestamp = serializers.DateTimeField(source='created_at')
|
||||||
|
acknowledged = serializers.BooleanField(source='resolved', default=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Alert
|
||||||
|
fields = ('id', 'type', 'timestamp', 'acknowledged', 'priority')
|
||||||
|
|
||||||
|
def get_type(self, obj):
|
||||||
|
return 'critical' if obj.severity == 'critical' else 'warning'
|
||||||
|
|
||||||
|
def get_priority(self, obj):
|
||||||
|
return 'high' if obj.severity == 'critical' else 'medium'
|
||||||
|
|
||||||
|
class DetectorSerializer(serializers.ModelSerializer):
|
||||||
|
detector_id = serializers.SerializerMethodField()
|
||||||
|
type = serializers.SerializerMethodField()
|
||||||
|
detector_type = serializers.SerializerMethodField()
|
||||||
|
name = serializers.SerializerMethodField()
|
||||||
|
object = serializers.SerializerMethodField()
|
||||||
|
status = serializers.SerializerMethodField()
|
||||||
|
zone = serializers.SerializerMethodField()
|
||||||
|
floor = serializers.SerializerMethodField()
|
||||||
|
notifications = NotificationSerializer(source='alerts', many=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Sensor
|
||||||
|
fields = ('detector_id', 'type', 'detector_type', 'serial_number', 'name', 'object', 'status', 'zone', 'floor', 'notifications')
|
||||||
|
|
||||||
|
def get_detector_id(self, obj):
|
||||||
|
# Используем serial_number для совместимости с 3D моделью
|
||||||
|
# Если serial_number нет, используем ID с префиксом
|
||||||
|
return obj.serial_number or f"sensor_{obj.id}"
|
||||||
|
|
||||||
|
def get_type(self, obj):
|
||||||
|
sensor_type_mapping = {
|
||||||
|
'GA': 'Инклинометр',
|
||||||
|
'PE': 'Тензометр',
|
||||||
|
'GLE': 'Гидроуровень',
|
||||||
|
}
|
||||||
|
code = (getattr(obj.sensor_type, 'code', '') or '').upper()
|
||||||
|
return sensor_type_mapping.get(code, (getattr(obj.sensor_type, 'name', '') or ''))
|
||||||
|
|
||||||
|
def get_detector_type(self, obj):
|
||||||
|
return (getattr(obj.sensor_type, 'code', '') or '').upper()
|
||||||
|
|
||||||
|
def get_name(self, obj):
|
||||||
|
sensor_type = getattr(obj, 'sensor_type', None) or getattr(obj, 'sensor_type', None)
|
||||||
|
serial = getattr(obj, 'serial_number', '') or ''
|
||||||
|
base_name = getattr(obj, 'name', '') or ''
|
||||||
|
return base_name or f"{getattr(obj.sensor_type, 'code', '')}-{serial}".strip('-')
|
||||||
|
|
||||||
|
def get_object(self, obj):
|
||||||
|
# получаем первую зону датчика и её объект
|
||||||
|
zone = obj.zones.first()
|
||||||
|
if zone:
|
||||||
|
return zone.object.title
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_status(self, obj):
|
||||||
|
# проверяем наличие активных алертов
|
||||||
|
latest_alert = obj.alerts.filter(resolved=False).first()
|
||||||
|
if latest_alert:
|
||||||
|
return latest_alert.severity # вернет 'warning' или 'critical'
|
||||||
|
return 'normal'
|
||||||
|
|
||||||
|
def get_zone(self, obj):
|
||||||
|
first_zone = obj.zones.first()
|
||||||
|
return first_zone.name if first_zone else None
|
||||||
|
|
||||||
|
def get_floor(self, obj):
|
||||||
|
first_zone = obj.zones.first()
|
||||||
|
return getattr(first_zone, 'floor', None)
|
||||||
|
|
||||||
|
class DetectorsResponseSerializer(serializers.Serializer):
|
||||||
|
detectors = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
@extend_schema_field(OpenApiTypes.OBJECT)
|
||||||
|
def get_detectors(self, sensors) -> Dict[str, Any]:
|
||||||
|
detector_serializer = DetectorSerializer(sensors, many=True)
|
||||||
|
return {
|
||||||
|
sensor['detector_id']: sensor
|
||||||
|
for sensor in detector_serializer.data
|
||||||
|
}
|
||||||
@@ -120,6 +120,12 @@ const NavigationPage: React.FC = () => {
|
|||||||
Object.values(detectorsData.detectors).forEach(d => {
|
Object.values(detectorsData.detectors).forEach(d => {
|
||||||
if (d.serial_number && d.status) {
|
if (d.serial_number && d.status) {
|
||||||
map[String(d.serial_number).trim()] = d.status
|
map[String(d.serial_number).trim()] = d.status
|
||||||
|
|
||||||
|
// Логируем GLE-1
|
||||||
|
if (d.serial_number === 'GLE-1') {
|
||||||
|
console.log('[NavigationPage] GLE-1 status from API:', d.status)
|
||||||
|
console.log('[NavigationPage] GLE-1 notifications:', d.notifications)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
console.log('[NavigationPage] sensorStatusMap created with', Object.keys(map).length, 'sensors')
|
console.log('[NavigationPage] sensorStatusMap created with', Object.keys(map).length, 'sensors')
|
||||||
|
|||||||
636
frontend/app/(protected)/navigation/page.tsx-копия 4
Normal file
636
frontend/app/(protected)/navigation/page.tsx-копия 4
Normal file
@@ -0,0 +1,636 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useEffect, useCallback, useState } from 'react'
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import Sidebar from '../../../components/ui/Sidebar'
|
||||||
|
import AnimatedBackground from '../../../components/ui/AnimatedBackground'
|
||||||
|
import useNavigationStore from '../../store/navigationStore'
|
||||||
|
import Monitoring from '../../../components/navigation/Monitoring'
|
||||||
|
import FloorNavigation from '../../../components/navigation/FloorNavigation'
|
||||||
|
import DetectorMenu from '../../../components/navigation/DetectorMenu'
|
||||||
|
import ListOfDetectors from '../../../components/navigation/ListOfDetectors'
|
||||||
|
import Sensors from '../../../components/navigation/Sensors'
|
||||||
|
import AlertMenu from '../../../components/navigation/AlertMenu'
|
||||||
|
import Notifications from '../../../components/notifications/Notifications'
|
||||||
|
import NotificationDetectorInfo from '../../../components/notifications/NotificationDetectorInfo'
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
import type { ModelViewerProps } from '../../../components/model/ModelViewer'
|
||||||
|
import * as statusColors from '../../../lib/statusColors'
|
||||||
|
|
||||||
|
const ModelViewer = dynamic<ModelViewerProps>(() => import('../../../components/model/ModelViewer'), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-[#0e111a]">
|
||||||
|
<div className="text-gray-300 animate-pulse">Загрузка 3D-модуля…</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
interface DetectorType {
|
||||||
|
detector_id: number
|
||||||
|
name: string
|
||||||
|
serial_number: string
|
||||||
|
object: string
|
||||||
|
status: string
|
||||||
|
checked: boolean
|
||||||
|
type: string
|
||||||
|
detector_type: string
|
||||||
|
location: string
|
||||||
|
floor: number
|
||||||
|
notifications: Array<{
|
||||||
|
id: number
|
||||||
|
type: string
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
acknowledged: boolean
|
||||||
|
priority: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationType {
|
||||||
|
id: number
|
||||||
|
detector_id: number
|
||||||
|
detector_name: string
|
||||||
|
type: string
|
||||||
|
status: string
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
location: string
|
||||||
|
object: string
|
||||||
|
acknowledged: boolean
|
||||||
|
priority: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlertType {
|
||||||
|
id: number
|
||||||
|
detector_id: number
|
||||||
|
detector_name: string
|
||||||
|
type: string
|
||||||
|
status: string
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
location: string
|
||||||
|
object: string
|
||||||
|
acknowledged: boolean
|
||||||
|
priority: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const NavigationPage: React.FC = () => {
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const {
|
||||||
|
currentObject,
|
||||||
|
setCurrentObject,
|
||||||
|
showMonitoring,
|
||||||
|
showFloorNavigation,
|
||||||
|
showNotifications,
|
||||||
|
showListOfDetectors,
|
||||||
|
showSensors,
|
||||||
|
selectedDetector,
|
||||||
|
showDetectorMenu,
|
||||||
|
selectedNotification,
|
||||||
|
showNotificationDetectorInfo,
|
||||||
|
selectedAlert,
|
||||||
|
showAlertMenu,
|
||||||
|
closeMonitoring,
|
||||||
|
closeFloorNavigation,
|
||||||
|
closeNotifications,
|
||||||
|
closeListOfDetectors,
|
||||||
|
closeSensors,
|
||||||
|
setSelectedDetector,
|
||||||
|
setShowDetectorMenu,
|
||||||
|
setSelectedNotification,
|
||||||
|
setShowNotificationDetectorInfo,
|
||||||
|
setSelectedAlert,
|
||||||
|
setShowAlertMenu,
|
||||||
|
showSensorHighlights,
|
||||||
|
toggleSensorHighlights
|
||||||
|
} = useNavigationStore()
|
||||||
|
|
||||||
|
const [detectorsData, setDetectorsData] = useState<{ detectors: Record<string, DetectorType> }>({ detectors: {} })
|
||||||
|
const [detectorsError, setDetectorsError] = useState<string | null>(null)
|
||||||
|
const [modelError, setModelError] = useState<string | null>(null)
|
||||||
|
const [isModelReady, setIsModelReady] = useState(false)
|
||||||
|
const [focusedSensorId, setFocusedSensorId] = useState<string | null>(null)
|
||||||
|
const [highlightAllSensors, setHighlightAllSensors] = useState(false)
|
||||||
|
const sensorStatusMap = React.useMemo(() => {
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
Object.values(detectorsData.detectors).forEach(d => {
|
||||||
|
if (d.serial_number && 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
|
||||||
|
}, [detectorsData])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedDetector === null && selectedAlert === null) {
|
||||||
|
setFocusedSensorId(null);
|
||||||
|
}
|
||||||
|
}, [selectedDetector, selectedAlert]);
|
||||||
|
|
||||||
|
// Управление выделением всех сенсоров при открытии/закрытии меню Sensors
|
||||||
|
// ИСПРАВЛЕНО: Подсветка датчиков остается включенной всегда, независимо от состояния панели Sensors
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('[NavigationPage] showSensors changed:', showSensors, 'modelReady:', isModelReady)
|
||||||
|
if (isModelReady) {
|
||||||
|
// Всегда включаем подсветку всех сенсоров когда модель готова
|
||||||
|
console.log('[NavigationPage] Setting highlightAllSensors to TRUE (always enabled)')
|
||||||
|
setHighlightAllSensors(true)
|
||||||
|
// Сбрасываем фокус только если панель Sensors закрыта
|
||||||
|
if (!showSensors) {
|
||||||
|
setFocusedSensorId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [showSensors, isModelReady])
|
||||||
|
|
||||||
|
// Дополнительный эффект для задержки выделения сенсоров при открытии меню
|
||||||
|
// ИСПРАВЛЕНО: Задержка применяется только при открытии панели Sensors
|
||||||
|
useEffect(() => {
|
||||||
|
if (showSensors && isModelReady) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
console.log('[NavigationPage] Delayed highlightAllSensors to TRUE')
|
||||||
|
setHighlightAllSensors(true)
|
||||||
|
}, 500) // Задержка 500мс для полной инициализации модели
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [showSensors, isModelReady])
|
||||||
|
|
||||||
|
const urlObjectId = searchParams.get('objectId')
|
||||||
|
const urlObjectTitle = searchParams.get('objectTitle')
|
||||||
|
const urlModelPath = searchParams.get('modelPath')
|
||||||
|
const urlFocusSensorId = searchParams.get('focusSensorId')
|
||||||
|
const objectId = currentObject.id || urlObjectId
|
||||||
|
const objectTitle = currentObject.title || urlObjectTitle
|
||||||
|
const [selectedModelPath, setSelectedModelPath] = useState<string>(urlModelPath || '')
|
||||||
|
|
||||||
|
|
||||||
|
const handleModelLoaded = useCallback(() => {
|
||||||
|
setIsModelReady(true)
|
||||||
|
setModelError(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleModelError = useCallback((error: string) => {
|
||||||
|
console.error('[NavigationPage] Model loading error:', error)
|
||||||
|
setModelError(error)
|
||||||
|
setIsModelReady(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedModelPath) {
|
||||||
|
setIsModelReady(false);
|
||||||
|
setModelError(null);
|
||||||
|
// Сохраняем выбранную модель в URL для восстановления при возврате
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.set('modelPath', selectedModelPath);
|
||||||
|
window.history.replaceState(null, '', `?${params.toString()}`);
|
||||||
|
}
|
||||||
|
}, [selectedModelPath, searchParams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (urlObjectId && (!currentObject.id || currentObject.id !== urlObjectId)) {
|
||||||
|
setCurrentObject(urlObjectId, urlObjectTitle ?? currentObject.title ?? undefined)
|
||||||
|
}
|
||||||
|
}, [urlObjectId, urlObjectTitle, currentObject.id, currentObject.title, setCurrentObject])
|
||||||
|
|
||||||
|
// Восстановление выбранной модели из URL при загрузке страницы
|
||||||
|
useEffect(() => {
|
||||||
|
if (urlModelPath && !selectedModelPath) {
|
||||||
|
setSelectedModelPath(urlModelPath);
|
||||||
|
}
|
||||||
|
}, [urlModelPath, selectedModelPath])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadDetectors = async () => {
|
||||||
|
try {
|
||||||
|
setDetectorsError(null)
|
||||||
|
const res = await fetch('/api/get-detectors', { cache: 'no-store' })
|
||||||
|
const text = await res.text()
|
||||||
|
let payload: any
|
||||||
|
try { payload = JSON.parse(text) } catch { payload = text }
|
||||||
|
console.log('[NavigationPage] GET /api/get-detectors', { status: res.status, payload })
|
||||||
|
if (!res.ok) throw new Error(typeof payload === 'string' ? payload : (payload?.error || 'Не удалось получить детекторов'))
|
||||||
|
const data = payload?.data ?? payload
|
||||||
|
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 })
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Ошибка загрузки детекторов:', e)
|
||||||
|
setDetectorsError(e?.message || 'Ошибка при загрузке детекторов')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadDetectors()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleBackClick = () => {
|
||||||
|
router.push('/dashboard')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDetectorMenuClick = (detector: DetectorType) => {
|
||||||
|
// Для тестов. Выбор детектора.
|
||||||
|
console.log('[NavigationPage] Selected detector click:', {
|
||||||
|
detector_id: detector.detector_id,
|
||||||
|
name: detector.name,
|
||||||
|
serial_number: detector.serial_number,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Проверяем, что детектор имеет необходимые данные
|
||||||
|
if (!detector || !detector.detector_id || !detector.serial_number) {
|
||||||
|
console.warn('[NavigationPage] Invalid detector data, skipping menu display:', detector)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedDetector?.serial_number === detector.serial_number && showDetectorMenu) {
|
||||||
|
closeDetectorMenu()
|
||||||
|
} else {
|
||||||
|
setSelectedDetector(detector)
|
||||||
|
setShowDetectorMenu(true)
|
||||||
|
setFocusedSensorId(detector.serial_number)
|
||||||
|
setShowAlertMenu(false)
|
||||||
|
setSelectedAlert(null)
|
||||||
|
// При открытии меню детектора - сбрасываем множественное выделение
|
||||||
|
setHighlightAllSensors(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDetectorMenu = () => {
|
||||||
|
setShowDetectorMenu(false)
|
||||||
|
setSelectedDetector(null)
|
||||||
|
setFocusedSensorId(null)
|
||||||
|
setSelectedAlert(null)
|
||||||
|
// При закрытии меню детектора - выделяем все сенсоры снова
|
||||||
|
setHighlightAllSensors(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNotificationClick = (notification: NotificationType) => {
|
||||||
|
if (selectedNotification?.id === notification.id && showNotificationDetectorInfo) {
|
||||||
|
setShowNotificationDetectorInfo(false)
|
||||||
|
setSelectedNotification(null)
|
||||||
|
} else {
|
||||||
|
setSelectedNotification(notification)
|
||||||
|
setShowNotificationDetectorInfo(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeNotificationDetectorInfo = () => {
|
||||||
|
setShowNotificationDetectorInfo(false)
|
||||||
|
setSelectedNotification(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeAlertMenu = () => {
|
||||||
|
setShowAlertMenu(false)
|
||||||
|
setSelectedAlert(null)
|
||||||
|
setFocusedSensorId(null)
|
||||||
|
setSelectedDetector(null)
|
||||||
|
// При закрытии меню алерта - выделяем все сенсоры снова
|
||||||
|
setHighlightAllSensors(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAlertClick = (alert: AlertType) => {
|
||||||
|
console.log('[NavigationPage] Alert clicked, focusing on detector in 3D scene:', alert)
|
||||||
|
|
||||||
|
const detector = Object.values(detectorsData.detectors).find(
|
||||||
|
d => d.detector_id === alert.detector_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (detector) {
|
||||||
|
if (selectedAlert?.id === alert.id && showAlertMenu) {
|
||||||
|
closeAlertMenu()
|
||||||
|
} else {
|
||||||
|
setSelectedAlert(alert)
|
||||||
|
setShowAlertMenu(true)
|
||||||
|
setFocusedSensorId(detector.serial_number)
|
||||||
|
setShowDetectorMenu(false)
|
||||||
|
setSelectedDetector(null)
|
||||||
|
// При открытии меню алерта - сбрасываем множественное выделение
|
||||||
|
setHighlightAllSensors(false)
|
||||||
|
console.log('[NavigationPage] Showing AlertMenu for alert:', alert.detector_name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('[NavigationPage] Could not find detector for alert:', alert.detector_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSensorSelection = (serialNumber: string | null) => {
|
||||||
|
if (serialNumber === null) {
|
||||||
|
setFocusedSensorId(null);
|
||||||
|
closeDetectorMenu();
|
||||||
|
closeAlertMenu();
|
||||||
|
// If we're in Sensors menu and no sensor is selected, highlight all sensors
|
||||||
|
if (showSensors) {
|
||||||
|
setHighlightAllSensors(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focusedSensorId === serialNumber) {
|
||||||
|
setFocusedSensorId(null);
|
||||||
|
closeDetectorMenu();
|
||||||
|
closeAlertMenu();
|
||||||
|
// If we're in Sensors menu and deselected the current sensor, highlight all sensors
|
||||||
|
if (showSensors) {
|
||||||
|
setHighlightAllSensors(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// При выборе конкретного сенсора - сбрасываем множественное выделение
|
||||||
|
setHighlightAllSensors(false)
|
||||||
|
|
||||||
|
const detector = Object.values(detectorsData?.detectors || {}).find(
|
||||||
|
(d) => d.serial_number === serialNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
if (detector) {
|
||||||
|
if (showFloorNavigation || showListOfDetectors) {
|
||||||
|
handleDetectorMenuClick(detector);
|
||||||
|
} else if (detector.notifications && detector.notifications.length > 0) {
|
||||||
|
const sortedNotifications = [...detector.notifications].sort((a, b) => {
|
||||||
|
const priorityOrder: { [key: string]: number } = { critical: 0, warning: 1, info: 2 };
|
||||||
|
return priorityOrder[a.priority.toLowerCase()] - priorityOrder[b.priority.toLowerCase()];
|
||||||
|
});
|
||||||
|
const notification = sortedNotifications[0];
|
||||||
|
const alert: AlertType = {
|
||||||
|
...notification,
|
||||||
|
detector_id: detector.detector_id,
|
||||||
|
detector_name: detector.name,
|
||||||
|
location: detector.location,
|
||||||
|
object: detector.object,
|
||||||
|
status: detector.status,
|
||||||
|
type: notification.type || 'info',
|
||||||
|
};
|
||||||
|
handleAlertClick(alert);
|
||||||
|
} else {
|
||||||
|
handleDetectorMenuClick(detector);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setFocusedSensorId(null);
|
||||||
|
closeDetectorMenu();
|
||||||
|
closeAlertMenu();
|
||||||
|
// If we're in Sensors menu and no valid detector found, highlight all sensors
|
||||||
|
if (showSensors) {
|
||||||
|
setHighlightAllSensors(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Обработка focusSensorId из URL (при переходе из таблиц событий)
|
||||||
|
useEffect(() => {
|
||||||
|
if (urlFocusSensorId && isModelReady && detectorsData) {
|
||||||
|
console.log('[NavigationPage] Setting focusSensorId from URL:', urlFocusSensorId)
|
||||||
|
setFocusedSensorId(urlFocusSensorId)
|
||||||
|
setHighlightAllSensors(false)
|
||||||
|
|
||||||
|
// Автоматически открываем тултип датчика
|
||||||
|
setTimeout(() => {
|
||||||
|
handleSensorSelection(urlFocusSensorId)
|
||||||
|
}, 500) // Задержка для полной инициализации
|
||||||
|
|
||||||
|
// Очищаем URL от параметра после применения
|
||||||
|
const newUrl = new URL(window.location.href)
|
||||||
|
newUrl.searchParams.delete('focusSensorId')
|
||||||
|
window.history.replaceState({}, '', newUrl.toString())
|
||||||
|
}
|
||||||
|
}, [urlFocusSensorId, isModelReady, detectorsData])
|
||||||
|
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
const s = (status || '').toLowerCase()
|
||||||
|
switch (s) {
|
||||||
|
case statusColors.STATUS_COLOR_CRITICAL:
|
||||||
|
case 'critical':
|
||||||
|
return 'Критический'
|
||||||
|
case statusColors.STATUS_COLOR_WARNING:
|
||||||
|
case 'warning':
|
||||||
|
return 'Предупреждение'
|
||||||
|
case statusColors.STATUS_COLOR_NORMAL:
|
||||||
|
case 'normal':
|
||||||
|
return 'Норма'
|
||||||
|
default:
|
||||||
|
return 'Неизвестно'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-screen bg-[#0e111a] overflow-hidden">
|
||||||
|
<AnimatedBackground />
|
||||||
|
<div className="relative z-20">
|
||||||
|
<Sidebar
|
||||||
|
activeItem={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex-1 flex flex-col">
|
||||||
|
|
||||||
|
{showMonitoring && (
|
||||||
|
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||||
|
<div className="h-full overflow-auto p-4">
|
||||||
|
<Monitoring
|
||||||
|
onClose={closeMonitoring}
|
||||||
|
onSelectModel={(path) => {
|
||||||
|
console.log('[NavigationPage] Model selected:', path);
|
||||||
|
setSelectedModelPath(path)
|
||||||
|
setModelError(null)
|
||||||
|
setIsModelReady(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showFloorNavigation && (
|
||||||
|
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||||
|
<div className="h-full overflow-auto p-4">
|
||||||
|
<FloorNavigation
|
||||||
|
objectId={objectId || undefined}
|
||||||
|
detectorsData={detectorsData}
|
||||||
|
onDetectorMenuClick={handleDetectorMenuClick}
|
||||||
|
onClose={closeFloorNavigation}
|
||||||
|
is3DReady={isModelReady && !modelError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showNotifications && (
|
||||||
|
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||||
|
<div className="h-full overflow-auto p-4">
|
||||||
|
<Notifications
|
||||||
|
objectId={objectId || undefined}
|
||||||
|
detectorsData={detectorsData}
|
||||||
|
onNotificationClick={handleNotificationClick}
|
||||||
|
onClose={closeNotifications}
|
||||||
|
/>
|
||||||
|
{detectorsError && (
|
||||||
|
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showListOfDetectors && (
|
||||||
|
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||||
|
<div className="h-full overflow-auto p-4">
|
||||||
|
<ListOfDetectors
|
||||||
|
objectId={objectId || undefined}
|
||||||
|
detectorsData={detectorsData}
|
||||||
|
onDetectorMenuClick={handleDetectorMenuClick}
|
||||||
|
onClose={closeListOfDetectors}
|
||||||
|
is3DReady={selectedModelPath ? !modelError : false}
|
||||||
|
/>
|
||||||
|
{detectorsError && (
|
||||||
|
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showSensors && (
|
||||||
|
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||||
|
<div className="h-full overflow-auto p-4">
|
||||||
|
<Sensors
|
||||||
|
objectId={objectId || undefined}
|
||||||
|
detectorsData={detectorsData}
|
||||||
|
onAlertClick={handleAlertClick}
|
||||||
|
onClose={closeSensors}
|
||||||
|
is3DReady={selectedModelPath ? !modelError : false}
|
||||||
|
/>
|
||||||
|
{detectorsError && (
|
||||||
|
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showNotifications && showNotificationDetectorInfo && selectedNotification && (() => {
|
||||||
|
const detectorData = Object.values(detectorsData.detectors).find(
|
||||||
|
detector => detector.detector_id === selectedNotification.detector_id
|
||||||
|
);
|
||||||
|
return detectorData ? (
|
||||||
|
<div className="absolute left-[500px] top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-30 w-[454px]">
|
||||||
|
<div className="h-full overflow-auto p-4">
|
||||||
|
<NotificationDetectorInfo
|
||||||
|
detectorData={detectorData}
|
||||||
|
onClose={closeNotificationDetectorInfo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{showFloorNavigation && showDetectorMenu && selectedDetector && (
|
||||||
|
null
|
||||||
|
)}
|
||||||
|
|
||||||
|
<header className="bg-[#161824] border-b border-gray-700 px-6 h-[73px] flex items-center">
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={handleBackClick}
|
||||||
|
className="text-gray-400 hover:text-white transition-colors"
|
||||||
|
aria-label="Назад к дашборду"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<nav className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-gray-400">Дашборд</span>
|
||||||
|
<span className="text-gray-600">{'/'}</span>
|
||||||
|
<span className="text-white">{objectTitle || 'Объект'}</span>
|
||||||
|
<span className="text-gray-600">{'/'}</span>
|
||||||
|
<span className="text-white">Навигация</span>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<div className="h-full">
|
||||||
|
{modelError ? (
|
||||||
|
<>
|
||||||
|
{console.log('[NavigationPage] Rendering error message, modelError:', modelError)}
|
||||||
|
<div className="h-full flex items-center justify-center bg-[#0e111a]">
|
||||||
|
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md">
|
||||||
|
<div className="text-red-400 text-lg font-semibold mb-4">
|
||||||
|
Ошибка загрузки 3D модели
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-300 mb-4">
|
||||||
|
{modelError}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
Используйте навигацию по этажам для просмотра детекторов
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : !selectedModelPath ? (
|
||||||
|
<div className="h-full flex items-center justify-center bg-[#0e111a]">
|
||||||
|
<div className="text-center p-8 flex flex-col items-center">
|
||||||
|
<Image
|
||||||
|
src="/icons/logo.png"
|
||||||
|
alt="AerBIM HT Monitor"
|
||||||
|
width={300}
|
||||||
|
height={41}
|
||||||
|
className="mb-6"
|
||||||
|
/>
|
||||||
|
<div className="text-gray-300 text-lg">
|
||||||
|
Выберите модель для отображения
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ModelViewer
|
||||||
|
key={selectedModelPath || 'no-model'}
|
||||||
|
modelPath={selectedModelPath}
|
||||||
|
onSelectModel={(path) => {
|
||||||
|
console.log('[NavigationPage] Model selected:', path);
|
||||||
|
setSelectedModelPath(path)
|
||||||
|
setModelError(null)
|
||||||
|
setIsModelReady(false)
|
||||||
|
}}
|
||||||
|
onModelLoaded={handleModelLoaded}
|
||||||
|
onError={handleModelError}
|
||||||
|
activeMenu={showSensors ? 'sensors' : showFloorNavigation ? 'floor' : showListOfDetectors ? 'detectors' : null}
|
||||||
|
focusSensorId={focusedSensorId}
|
||||||
|
highlightAllSensors={showSensorHighlights && highlightAllSensors}
|
||||||
|
sensorStatusMap={sensorStatusMap}
|
||||||
|
isSensorSelectionEnabled={showSensors || showFloorNavigation || showListOfDetectors}
|
||||||
|
onSensorPick={handleSensorSelection}
|
||||||
|
renderOverlay={({ anchor }) => (
|
||||||
|
<>
|
||||||
|
{selectedAlert && showAlertMenu && anchor ? (
|
||||||
|
<AlertMenu
|
||||||
|
alert={selectedAlert}
|
||||||
|
isOpen={true}
|
||||||
|
onClose={closeAlertMenu}
|
||||||
|
getStatusText={getStatusText}
|
||||||
|
compact={true}
|
||||||
|
anchor={anchor}
|
||||||
|
/>
|
||||||
|
) : selectedDetector && showDetectorMenu && anchor ? (
|
||||||
|
<DetectorMenu
|
||||||
|
detector={selectedDetector}
|
||||||
|
isOpen={true}
|
||||||
|
onClose={closeDetectorMenu}
|
||||||
|
getStatusText={getStatusText}
|
||||||
|
compact={true}
|
||||||
|
anchor={anchor}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NavigationPage
|
||||||
@@ -115,6 +115,7 @@ const NavigationPage: React.FC = () => {
|
|||||||
const [focusedSensorId, setFocusedSensorId] = useState<string | null>(null)
|
const [focusedSensorId, setFocusedSensorId] = useState<string | null>(null)
|
||||||
const [highlightAllSensors, setHighlightAllSensors] = useState(false)
|
const [highlightAllSensors, setHighlightAllSensors] = useState(false)
|
||||||
const sensorStatusMap = React.useMemo(() => {
|
const sensorStatusMap = React.useMemo(() => {
|
||||||
|
// Создаём карту статусов всегда для отображения цветов датчиков
|
||||||
const map: Record<string, string> = {}
|
const map: Record<string, string> = {}
|
||||||
Object.values(detectorsData.detectors).forEach(d => {
|
Object.values(detectorsData.detectors).forEach(d => {
|
||||||
if (d.serial_number && d.status) {
|
if (d.serial_number && d.status) {
|
||||||
@@ -347,27 +348,8 @@ const NavigationPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (detector) {
|
if (detector) {
|
||||||
if (showFloorNavigation || showListOfDetectors) {
|
// Всегда показываем меню детектора для всех датчиков
|
||||||
handleDetectorMenuClick(detector);
|
handleDetectorMenuClick(detector);
|
||||||
} else if (detector.notifications && detector.notifications.length > 0) {
|
|
||||||
const sortedNotifications = [...detector.notifications].sort((a, b) => {
|
|
||||||
const priorityOrder: { [key: string]: number } = { critical: 0, warning: 1, info: 2 };
|
|
||||||
return priorityOrder[a.priority.toLowerCase()] - priorityOrder[b.priority.toLowerCase()];
|
|
||||||
});
|
|
||||||
const notification = sortedNotifications[0];
|
|
||||||
const alert: AlertType = {
|
|
||||||
...notification,
|
|
||||||
detector_id: detector.detector_id,
|
|
||||||
detector_name: detector.name,
|
|
||||||
location: detector.location,
|
|
||||||
object: detector.object,
|
|
||||||
status: detector.status,
|
|
||||||
type: notification.type || 'info',
|
|
||||||
};
|
|
||||||
handleAlertClick(alert);
|
|
||||||
} else {
|
|
||||||
handleDetectorMenuClick(detector);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setFocusedSensorId(null);
|
setFocusedSensorId(null);
|
||||||
closeDetectorMenu();
|
closeDetectorMenu();
|
||||||
|
|||||||
618
frontend/app/(protected)/navigation/page.tsx — копия 6
Normal file
618
frontend/app/(protected)/navigation/page.tsx — копия 6
Normal file
@@ -0,0 +1,618 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useEffect, useCallback, useState } from 'react'
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import Sidebar from '../../../components/ui/Sidebar'
|
||||||
|
import AnimatedBackground from '../../../components/ui/AnimatedBackground'
|
||||||
|
import useNavigationStore from '../../store/navigationStore'
|
||||||
|
import Monitoring from '../../../components/navigation/Monitoring'
|
||||||
|
import FloorNavigation from '../../../components/navigation/FloorNavigation'
|
||||||
|
import DetectorMenu from '../../../components/navigation/DetectorMenu'
|
||||||
|
import ListOfDetectors from '../../../components/navigation/ListOfDetectors'
|
||||||
|
import Sensors from '../../../components/navigation/Sensors'
|
||||||
|
import AlertMenu from '../../../components/navigation/AlertMenu'
|
||||||
|
import Notifications from '../../../components/notifications/Notifications'
|
||||||
|
import NotificationDetectorInfo from '../../../components/notifications/NotificationDetectorInfo'
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
import type { ModelViewerProps } from '../../../components/model/ModelViewer'
|
||||||
|
import * as statusColors from '../../../lib/statusColors'
|
||||||
|
|
||||||
|
const ModelViewer = dynamic<ModelViewerProps>(() => import('../../../components/model/ModelViewer'), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-[#0e111a]">
|
||||||
|
<div className="text-gray-300 animate-pulse">Загрузка 3D-модуля…</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
interface DetectorType {
|
||||||
|
detector_id: number
|
||||||
|
name: string
|
||||||
|
serial_number: string
|
||||||
|
object: string
|
||||||
|
status: string
|
||||||
|
checked: boolean
|
||||||
|
type: string
|
||||||
|
detector_type: string
|
||||||
|
location: string
|
||||||
|
floor: number
|
||||||
|
notifications: Array<{
|
||||||
|
id: number
|
||||||
|
type: string
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
acknowledged: boolean
|
||||||
|
priority: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationType {
|
||||||
|
id: number
|
||||||
|
detector_id: number
|
||||||
|
detector_name: string
|
||||||
|
type: string
|
||||||
|
status: string
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
location: string
|
||||||
|
object: string
|
||||||
|
acknowledged: boolean
|
||||||
|
priority: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlertType {
|
||||||
|
id: number
|
||||||
|
detector_id: number
|
||||||
|
detector_name: string
|
||||||
|
type: string
|
||||||
|
status: string
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
location: string
|
||||||
|
object: string
|
||||||
|
acknowledged: boolean
|
||||||
|
priority: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const NavigationPage: React.FC = () => {
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const {
|
||||||
|
currentObject,
|
||||||
|
setCurrentObject,
|
||||||
|
showMonitoring,
|
||||||
|
showFloorNavigation,
|
||||||
|
showNotifications,
|
||||||
|
showListOfDetectors,
|
||||||
|
showSensors,
|
||||||
|
selectedDetector,
|
||||||
|
showDetectorMenu,
|
||||||
|
selectedNotification,
|
||||||
|
showNotificationDetectorInfo,
|
||||||
|
selectedAlert,
|
||||||
|
showAlertMenu,
|
||||||
|
closeMonitoring,
|
||||||
|
closeFloorNavigation,
|
||||||
|
closeNotifications,
|
||||||
|
closeListOfDetectors,
|
||||||
|
closeSensors,
|
||||||
|
setSelectedDetector,
|
||||||
|
setShowDetectorMenu,
|
||||||
|
setSelectedNotification,
|
||||||
|
setShowNotificationDetectorInfo,
|
||||||
|
setSelectedAlert,
|
||||||
|
setShowAlertMenu,
|
||||||
|
showSensorHighlights,
|
||||||
|
toggleSensorHighlights
|
||||||
|
} = useNavigationStore()
|
||||||
|
|
||||||
|
const [detectorsData, setDetectorsData] = useState<{ detectors: Record<string, DetectorType> }>({ detectors: {} })
|
||||||
|
const [detectorsError, setDetectorsError] = useState<string | null>(null)
|
||||||
|
const [modelError, setModelError] = useState<string | null>(null)
|
||||||
|
const [isModelReady, setIsModelReady] = useState(false)
|
||||||
|
const [focusedSensorId, setFocusedSensorId] = useState<string | null>(null)
|
||||||
|
const [highlightAllSensors, setHighlightAllSensors] = useState(false)
|
||||||
|
const sensorStatusMap = React.useMemo(() => {
|
||||||
|
// Создаём карту статусов всегда для отображения цветов датчиков
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
Object.values(detectorsData.detectors).forEach(d => {
|
||||||
|
if (d.serial_number && 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
|
||||||
|
}, [detectorsData])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedDetector === null && selectedAlert === null) {
|
||||||
|
setFocusedSensorId(null);
|
||||||
|
}
|
||||||
|
}, [selectedDetector, selectedAlert]);
|
||||||
|
|
||||||
|
// Управление выделением всех сенсоров при открытии/закрытии меню Sensors
|
||||||
|
// ИСПРАВЛЕНО: Подсветка датчиков остается включенной всегда, независимо от состояния панели Sensors
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('[NavigationPage] showSensors changed:', showSensors, 'modelReady:', isModelReady)
|
||||||
|
if (isModelReady) {
|
||||||
|
// Всегда включаем подсветку всех сенсоров когда модель готова
|
||||||
|
console.log('[NavigationPage] Setting highlightAllSensors to TRUE (always enabled)')
|
||||||
|
setHighlightAllSensors(true)
|
||||||
|
// Сбрасываем фокус только если панель Sensors закрыта
|
||||||
|
if (!showSensors) {
|
||||||
|
setFocusedSensorId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [showSensors, isModelReady])
|
||||||
|
|
||||||
|
// Дополнительный эффект для задержки выделения сенсоров при открытии меню
|
||||||
|
// ИСПРАВЛЕНО: Задержка применяется только при открытии панели Sensors
|
||||||
|
useEffect(() => {
|
||||||
|
if (showSensors && isModelReady) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
console.log('[NavigationPage] Delayed highlightAllSensors to TRUE')
|
||||||
|
setHighlightAllSensors(true)
|
||||||
|
}, 500) // Задержка 500мс для полной инициализации модели
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [showSensors, isModelReady])
|
||||||
|
|
||||||
|
const urlObjectId = searchParams.get('objectId')
|
||||||
|
const urlObjectTitle = searchParams.get('objectTitle')
|
||||||
|
const urlModelPath = searchParams.get('modelPath')
|
||||||
|
const urlFocusSensorId = searchParams.get('focusSensorId')
|
||||||
|
const objectId = currentObject.id || urlObjectId
|
||||||
|
const objectTitle = currentObject.title || urlObjectTitle
|
||||||
|
const [selectedModelPath, setSelectedModelPath] = useState<string>(urlModelPath || '')
|
||||||
|
|
||||||
|
|
||||||
|
const handleModelLoaded = useCallback(() => {
|
||||||
|
setIsModelReady(true)
|
||||||
|
setModelError(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleModelError = useCallback((error: string) => {
|
||||||
|
console.error('[NavigationPage] Model loading error:', error)
|
||||||
|
setModelError(error)
|
||||||
|
setIsModelReady(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedModelPath) {
|
||||||
|
setIsModelReady(false);
|
||||||
|
setModelError(null);
|
||||||
|
// Сохраняем выбранную модель в URL для восстановления при возврате
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.set('modelPath', selectedModelPath);
|
||||||
|
window.history.replaceState(null, '', `?${params.toString()}`);
|
||||||
|
}
|
||||||
|
}, [selectedModelPath, searchParams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (urlObjectId && (!currentObject.id || currentObject.id !== urlObjectId)) {
|
||||||
|
setCurrentObject(urlObjectId, urlObjectTitle ?? currentObject.title ?? undefined)
|
||||||
|
}
|
||||||
|
}, [urlObjectId, urlObjectTitle, currentObject.id, currentObject.title, setCurrentObject])
|
||||||
|
|
||||||
|
// Восстановление выбранной модели из URL при загрузке страницы
|
||||||
|
useEffect(() => {
|
||||||
|
if (urlModelPath && !selectedModelPath) {
|
||||||
|
setSelectedModelPath(urlModelPath);
|
||||||
|
}
|
||||||
|
}, [urlModelPath, selectedModelPath])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadDetectors = async () => {
|
||||||
|
try {
|
||||||
|
setDetectorsError(null)
|
||||||
|
const res = await fetch('/api/get-detectors', { cache: 'no-store' })
|
||||||
|
const text = await res.text()
|
||||||
|
let payload: any
|
||||||
|
try { payload = JSON.parse(text) } catch { payload = text }
|
||||||
|
console.log('[NavigationPage] GET /api/get-detectors', { status: res.status, payload })
|
||||||
|
if (!res.ok) throw new Error(typeof payload === 'string' ? payload : (payload?.error || 'Не удалось получить детекторов'))
|
||||||
|
const data = payload?.data ?? payload
|
||||||
|
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 })
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Ошибка загрузки детекторов:', e)
|
||||||
|
setDetectorsError(e?.message || 'Ошибка при загрузке детекторов')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadDetectors()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleBackClick = () => {
|
||||||
|
router.push('/dashboard')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDetectorMenuClick = (detector: DetectorType) => {
|
||||||
|
// Для тестов. Выбор детектора.
|
||||||
|
console.log('[NavigationPage] Selected detector click:', {
|
||||||
|
detector_id: detector.detector_id,
|
||||||
|
name: detector.name,
|
||||||
|
serial_number: detector.serial_number,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Проверяем, что детектор имеет необходимые данные
|
||||||
|
if (!detector || !detector.detector_id || !detector.serial_number) {
|
||||||
|
console.warn('[NavigationPage] Invalid detector data, skipping menu display:', detector)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedDetector?.serial_number === detector.serial_number && showDetectorMenu) {
|
||||||
|
closeDetectorMenu()
|
||||||
|
} else {
|
||||||
|
setSelectedDetector(detector)
|
||||||
|
setShowDetectorMenu(true)
|
||||||
|
setFocusedSensorId(detector.serial_number)
|
||||||
|
setShowAlertMenu(false)
|
||||||
|
setSelectedAlert(null)
|
||||||
|
// При открытии меню детектора - сбрасываем множественное выделение
|
||||||
|
setHighlightAllSensors(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDetectorMenu = () => {
|
||||||
|
setShowDetectorMenu(false)
|
||||||
|
setSelectedDetector(null)
|
||||||
|
setFocusedSensorId(null)
|
||||||
|
setSelectedAlert(null)
|
||||||
|
// При закрытии меню детектора - выделяем все сенсоры снова
|
||||||
|
setHighlightAllSensors(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNotificationClick = (notification: NotificationType) => {
|
||||||
|
if (selectedNotification?.id === notification.id && showNotificationDetectorInfo) {
|
||||||
|
setShowNotificationDetectorInfo(false)
|
||||||
|
setSelectedNotification(null)
|
||||||
|
} else {
|
||||||
|
setSelectedNotification(notification)
|
||||||
|
setShowNotificationDetectorInfo(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeNotificationDetectorInfo = () => {
|
||||||
|
setShowNotificationDetectorInfo(false)
|
||||||
|
setSelectedNotification(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeAlertMenu = () => {
|
||||||
|
setShowAlertMenu(false)
|
||||||
|
setSelectedAlert(null)
|
||||||
|
setFocusedSensorId(null)
|
||||||
|
setSelectedDetector(null)
|
||||||
|
// При закрытии меню алерта - выделяем все сенсоры снова
|
||||||
|
setHighlightAllSensors(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAlertClick = (alert: AlertType) => {
|
||||||
|
console.log('[NavigationPage] Alert clicked, focusing on detector in 3D scene:', alert)
|
||||||
|
|
||||||
|
const detector = Object.values(detectorsData.detectors).find(
|
||||||
|
d => d.detector_id === alert.detector_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (detector) {
|
||||||
|
if (selectedAlert?.id === alert.id && showAlertMenu) {
|
||||||
|
closeAlertMenu()
|
||||||
|
} else {
|
||||||
|
setSelectedAlert(alert)
|
||||||
|
setShowAlertMenu(true)
|
||||||
|
setFocusedSensorId(detector.serial_number)
|
||||||
|
setShowDetectorMenu(false)
|
||||||
|
setSelectedDetector(null)
|
||||||
|
// При открытии меню алерта - сбрасываем множественное выделение
|
||||||
|
setHighlightAllSensors(false)
|
||||||
|
console.log('[NavigationPage] Showing AlertMenu for alert:', alert.detector_name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('[NavigationPage] Could not find detector for alert:', alert.detector_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSensorSelection = (serialNumber: string | null) => {
|
||||||
|
if (serialNumber === null) {
|
||||||
|
setFocusedSensorId(null);
|
||||||
|
closeDetectorMenu();
|
||||||
|
closeAlertMenu();
|
||||||
|
// If we're in Sensors menu and no sensor is selected, highlight all sensors
|
||||||
|
if (showSensors) {
|
||||||
|
setHighlightAllSensors(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focusedSensorId === serialNumber) {
|
||||||
|
setFocusedSensorId(null);
|
||||||
|
closeDetectorMenu();
|
||||||
|
closeAlertMenu();
|
||||||
|
// If we're in Sensors menu and deselected the current sensor, highlight all sensors
|
||||||
|
if (showSensors) {
|
||||||
|
setHighlightAllSensors(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// При выборе конкретного сенсора - сбрасываем множественное выделение
|
||||||
|
setHighlightAllSensors(false)
|
||||||
|
|
||||||
|
const detector = Object.values(detectorsData?.detectors || {}).find(
|
||||||
|
(d) => d.serial_number === serialNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
if (detector) {
|
||||||
|
// Всегда показываем меню детектора для всех датчиков
|
||||||
|
handleDetectorMenuClick(detector);
|
||||||
|
} else {
|
||||||
|
setFocusedSensorId(null);
|
||||||
|
closeDetectorMenu();
|
||||||
|
closeAlertMenu();
|
||||||
|
// If we're in Sensors menu and no valid detector found, highlight all sensors
|
||||||
|
if (showSensors) {
|
||||||
|
setHighlightAllSensors(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Обработка focusSensorId из URL (при переходе из таблиц событий)
|
||||||
|
useEffect(() => {
|
||||||
|
if (urlFocusSensorId && isModelReady && detectorsData) {
|
||||||
|
console.log('[NavigationPage] Setting focusSensorId from URL:', urlFocusSensorId)
|
||||||
|
setFocusedSensorId(urlFocusSensorId)
|
||||||
|
setHighlightAllSensors(false)
|
||||||
|
|
||||||
|
// Автоматически открываем тултип датчика
|
||||||
|
setTimeout(() => {
|
||||||
|
handleSensorSelection(urlFocusSensorId)
|
||||||
|
}, 500) // Задержка для полной инициализации
|
||||||
|
|
||||||
|
// Очищаем URL от параметра после применения
|
||||||
|
const newUrl = new URL(window.location.href)
|
||||||
|
newUrl.searchParams.delete('focusSensorId')
|
||||||
|
window.history.replaceState({}, '', newUrl.toString())
|
||||||
|
}
|
||||||
|
}, [urlFocusSensorId, isModelReady, detectorsData])
|
||||||
|
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
const s = (status || '').toLowerCase()
|
||||||
|
switch (s) {
|
||||||
|
case statusColors.STATUS_COLOR_CRITICAL:
|
||||||
|
case 'critical':
|
||||||
|
return 'Критический'
|
||||||
|
case statusColors.STATUS_COLOR_WARNING:
|
||||||
|
case 'warning':
|
||||||
|
return 'Предупреждение'
|
||||||
|
case statusColors.STATUS_COLOR_NORMAL:
|
||||||
|
case 'normal':
|
||||||
|
return 'Норма'
|
||||||
|
default:
|
||||||
|
return 'Неизвестно'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-screen bg-[#0e111a] overflow-hidden">
|
||||||
|
<AnimatedBackground />
|
||||||
|
<div className="relative z-20">
|
||||||
|
<Sidebar
|
||||||
|
activeItem={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex-1 flex flex-col">
|
||||||
|
|
||||||
|
{showMonitoring && (
|
||||||
|
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||||
|
<div className="h-full overflow-auto p-4">
|
||||||
|
<Monitoring
|
||||||
|
onClose={closeMonitoring}
|
||||||
|
onSelectModel={(path) => {
|
||||||
|
console.log('[NavigationPage] Model selected:', path);
|
||||||
|
setSelectedModelPath(path)
|
||||||
|
setModelError(null)
|
||||||
|
setIsModelReady(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showFloorNavigation && (
|
||||||
|
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||||
|
<div className="h-full overflow-auto p-4">
|
||||||
|
<FloorNavigation
|
||||||
|
objectId={objectId || undefined}
|
||||||
|
detectorsData={detectorsData}
|
||||||
|
onDetectorMenuClick={handleDetectorMenuClick}
|
||||||
|
onClose={closeFloorNavigation}
|
||||||
|
is3DReady={isModelReady && !modelError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showNotifications && (
|
||||||
|
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||||
|
<div className="h-full overflow-auto p-4">
|
||||||
|
<Notifications
|
||||||
|
objectId={objectId || undefined}
|
||||||
|
detectorsData={detectorsData}
|
||||||
|
onNotificationClick={handleNotificationClick}
|
||||||
|
onClose={closeNotifications}
|
||||||
|
/>
|
||||||
|
{detectorsError && (
|
||||||
|
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showListOfDetectors && (
|
||||||
|
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||||
|
<div className="h-full overflow-auto p-4">
|
||||||
|
<ListOfDetectors
|
||||||
|
objectId={objectId || undefined}
|
||||||
|
detectorsData={detectorsData}
|
||||||
|
onDetectorMenuClick={handleDetectorMenuClick}
|
||||||
|
onClose={closeListOfDetectors}
|
||||||
|
is3DReady={selectedModelPath ? !modelError : false}
|
||||||
|
/>
|
||||||
|
{detectorsError && (
|
||||||
|
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showSensors && (
|
||||||
|
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||||
|
<div className="h-full overflow-auto p-4">
|
||||||
|
<Sensors
|
||||||
|
objectId={objectId || undefined}
|
||||||
|
detectorsData={detectorsData}
|
||||||
|
onAlertClick={handleAlertClick}
|
||||||
|
onClose={closeSensors}
|
||||||
|
is3DReady={selectedModelPath ? !modelError : false}
|
||||||
|
/>
|
||||||
|
{detectorsError && (
|
||||||
|
<div className="mt-2 text-sm text-red-400">{detectorsError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showNotifications && showNotificationDetectorInfo && selectedNotification && (() => {
|
||||||
|
const detectorData = Object.values(detectorsData.detectors).find(
|
||||||
|
detector => detector.detector_id === selectedNotification.detector_id
|
||||||
|
);
|
||||||
|
return detectorData ? (
|
||||||
|
<div className="absolute left-[500px] top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-30 w-[454px]">
|
||||||
|
<div className="h-full overflow-auto p-4">
|
||||||
|
<NotificationDetectorInfo
|
||||||
|
detectorData={detectorData}
|
||||||
|
onClose={closeNotificationDetectorInfo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{showFloorNavigation && showDetectorMenu && selectedDetector && (
|
||||||
|
null
|
||||||
|
)}
|
||||||
|
|
||||||
|
<header className="bg-[#161824] border-b border-gray-700 px-6 h-[73px] flex items-center">
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={handleBackClick}
|
||||||
|
className="text-gray-400 hover:text-white transition-colors"
|
||||||
|
aria-label="Назад к дашборду"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<nav className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-gray-400">Дашборд</span>
|
||||||
|
<span className="text-gray-600">{'/'}</span>
|
||||||
|
<span className="text-white">{objectTitle || 'Объект'}</span>
|
||||||
|
<span className="text-gray-600">{'/'}</span>
|
||||||
|
<span className="text-white">Навигация</span>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<div className="h-full">
|
||||||
|
{modelError ? (
|
||||||
|
<>
|
||||||
|
{console.log('[NavigationPage] Rendering error message, modelError:', modelError)}
|
||||||
|
<div className="h-full flex items-center justify-center bg-[#0e111a]">
|
||||||
|
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md">
|
||||||
|
<div className="text-red-400 text-lg font-semibold mb-4">
|
||||||
|
Ошибка загрузки 3D модели
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-300 mb-4">
|
||||||
|
{modelError}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
Используйте навигацию по этажам для просмотра детекторов
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : !selectedModelPath ? (
|
||||||
|
<div className="h-full flex items-center justify-center bg-[#0e111a]">
|
||||||
|
<div className="text-center p-8 flex flex-col items-center">
|
||||||
|
<Image
|
||||||
|
src="/icons/logo.png"
|
||||||
|
alt="AerBIM HT Monitor"
|
||||||
|
width={300}
|
||||||
|
height={41}
|
||||||
|
className="mb-6"
|
||||||
|
/>
|
||||||
|
<div className="text-gray-300 text-lg">
|
||||||
|
Выберите модель для отображения
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ModelViewer
|
||||||
|
key={selectedModelPath || 'no-model'}
|
||||||
|
modelPath={selectedModelPath}
|
||||||
|
onSelectModel={(path) => {
|
||||||
|
console.log('[NavigationPage] Model selected:', path);
|
||||||
|
setSelectedModelPath(path)
|
||||||
|
setModelError(null)
|
||||||
|
setIsModelReady(false)
|
||||||
|
}}
|
||||||
|
onModelLoaded={handleModelLoaded}
|
||||||
|
onError={handleModelError}
|
||||||
|
activeMenu={showSensors ? 'sensors' : showFloorNavigation ? 'floor' : showListOfDetectors ? 'detectors' : null}
|
||||||
|
focusSensorId={focusedSensorId}
|
||||||
|
highlightAllSensors={showSensorHighlights && highlightAllSensors}
|
||||||
|
sensorStatusMap={sensorStatusMap}
|
||||||
|
isSensorSelectionEnabled={showSensors || showFloorNavigation || showListOfDetectors}
|
||||||
|
onSensorPick={handleSensorSelection}
|
||||||
|
renderOverlay={({ anchor }) => (
|
||||||
|
<>
|
||||||
|
{selectedAlert && showAlertMenu && anchor ? (
|
||||||
|
<AlertMenu
|
||||||
|
alert={selectedAlert}
|
||||||
|
isOpen={true}
|
||||||
|
onClose={closeAlertMenu}
|
||||||
|
getStatusText={getStatusText}
|
||||||
|
compact={true}
|
||||||
|
anchor={anchor}
|
||||||
|
/>
|
||||||
|
) : selectedDetector && showDetectorMenu && anchor ? (
|
||||||
|
<DetectorMenu
|
||||||
|
detector={selectedDetector}
|
||||||
|
isOpen={true}
|
||||||
|
onClose={closeDetectorMenu}
|
||||||
|
getStatusText={getStatusText}
|
||||||
|
compact={true}
|
||||||
|
anchor={anchor}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NavigationPage
|
||||||
@@ -71,8 +71,21 @@ export async function GET() {
|
|||||||
detector_type: sensor.detector_type ?? '',
|
detector_type: sensor.detector_type ?? '',
|
||||||
notifications: Array.isArray(sensor.notifications) ? sensor.notifications.map((n: any) => {
|
notifications: Array.isArray(sensor.notifications) ? sensor.notifications.map((n: any) => {
|
||||||
const severity = String(n?.severity || n?.type || '').toLowerCase()
|
const severity = String(n?.severity || n?.type || '').toLowerCase()
|
||||||
const type = severity === 'critical' ? 'critical' : severity === 'warning' ? 'warning' : 'info'
|
|
||||||
const priority = severity === 'critical' ? 'high' : severity === 'warning' ? 'medium' : 'low'
|
// Логируем оригинальные данные для отладки
|
||||||
|
if (sensor.serial_number === 'GLE-1') {
|
||||||
|
console.log('[get-detectors] Original notification for GLE-1:', { severity: n?.severity, type: n?.type, message: n?.message })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем поддержку русских названий
|
||||||
|
let type = 'info'
|
||||||
|
if (severity === 'critical' || severity === 'критический' || severity === 'критичный') {
|
||||||
|
type = 'critical'
|
||||||
|
} else if (severity === 'warning' || severity === 'предупреждение') {
|
||||||
|
type = 'warning'
|
||||||
|
}
|
||||||
|
|
||||||
|
const priority = type === 'critical' ? 'high' : type === 'warning' ? 'medium' : 'low'
|
||||||
return {
|
return {
|
||||||
id: n.id,
|
id: n.id,
|
||||||
type,
|
type,
|
||||||
|
|||||||
104
frontend/app/api/get-detectors/route — копия.ts
Normal file
104
frontend/app/api/get-detectors/route — копия.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import * as statusColors from '@/lib/statusColors'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session?.accessToken) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const backendUrl = process.env.BACKEND_URL
|
||||||
|
|
||||||
|
const [detectorsRes, objectsRes] = await Promise.all([
|
||||||
|
fetch(`${backendUrl}/account/get-detectors/`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${session.accessToken}`,
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
}),
|
||||||
|
fetch(`${backendUrl}/account/get-objects/`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${session.accessToken}`,
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!detectorsRes.ok) {
|
||||||
|
const err = await detectorsRes.text()
|
||||||
|
return NextResponse.json({ success: false, error: `Backend detectors error: ${err}` }, { status: detectorsRes.status })
|
||||||
|
}
|
||||||
|
if (!objectsRes.ok) {
|
||||||
|
const err = await objectsRes.text()
|
||||||
|
return NextResponse.json({ success: false, error: `Backend objects error: ${err}` }, { status: objectsRes.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
const detectorsPayload = await detectorsRes.json()
|
||||||
|
const objectsPayload = await objectsRes.json()
|
||||||
|
|
||||||
|
const titleToIdMap: Record<string, string> = {}
|
||||||
|
if (Array.isArray(objectsPayload)) {
|
||||||
|
for (const obj of objectsPayload) {
|
||||||
|
if (obj && typeof obj.title === 'string' && typeof obj.id === 'number') {
|
||||||
|
titleToIdMap[obj.title] = `object_${obj.id}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusToColor: Record<string, string> = {
|
||||||
|
critical: statusColors.STATUS_COLOR_CRITICAL,
|
||||||
|
warning: statusColors.STATUS_COLOR_WARNING,
|
||||||
|
normal: statusColors.STATUS_COLOR_NORMAL,
|
||||||
|
}
|
||||||
|
|
||||||
|
const transformedDetectors: Record<string, any> = {}
|
||||||
|
const detectorsObj = detectorsPayload?.detectors ?? {}
|
||||||
|
for (const [key, sensor] of Object.entries<any>(detectorsObj)) {
|
||||||
|
const color = statusToColor[sensor.status] ?? statusColors.STATUS_COLOR_NORMAL
|
||||||
|
const objectId = titleToIdMap[sensor.object] || sensor.object
|
||||||
|
transformedDetectors[key] = {
|
||||||
|
...sensor,
|
||||||
|
status: color,
|
||||||
|
object: objectId,
|
||||||
|
checked: sensor.checked ?? false,
|
||||||
|
location: sensor.zone ?? '',
|
||||||
|
serial_number: sensor.serial_number ?? sensor.name ?? '',
|
||||||
|
detector_type: sensor.detector_type ?? '',
|
||||||
|
notifications: Array.isArray(sensor.notifications) ? sensor.notifications.map((n: any) => {
|
||||||
|
const severity = String(n?.severity || n?.type || '').toLowerCase()
|
||||||
|
const type = severity === 'critical' ? 'critical' : severity === 'warning' ? 'warning' : 'info'
|
||||||
|
const priority = severity === 'critical' ? 'high' : severity === 'warning' ? 'medium' : 'low'
|
||||||
|
return {
|
||||||
|
id: n.id,
|
||||||
|
type,
|
||||||
|
message: n.message,
|
||||||
|
timestamp: n.timestamp || n.created_at,
|
||||||
|
acknowledged: typeof n.acknowledged === 'boolean' ? n.acknowledged : !!n.resolved,
|
||||||
|
priority,
|
||||||
|
}
|
||||||
|
}) : []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: { detectors: transformedDetectors },
|
||||||
|
objectsCount: Array.isArray(objectsPayload) ? objectsPayload.length : 0,
|
||||||
|
detectorsCount: Object.keys(transformedDetectors).length,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching detectors data:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch detectors data',
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -968,4 +968,4 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ModelViewer
|
export default React.memo(ModelViewer)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
PointerEventTypes,
|
PointerEventTypes,
|
||||||
PointerInfo,
|
PointerInfo,
|
||||||
Matrix,
|
Matrix,
|
||||||
|
Ray,
|
||||||
} from '@babylonjs/core'
|
} from '@babylonjs/core'
|
||||||
import '@babylonjs/loaders'
|
import '@babylonjs/loaders'
|
||||||
|
|
||||||
@@ -68,6 +69,8 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
onSensorPick,
|
onSensorPick,
|
||||||
highlightAllSensors,
|
highlightAllSensors,
|
||||||
sensorStatusMap,
|
sensorStatusMap,
|
||||||
|
showStats = false,
|
||||||
|
onToggleStats,
|
||||||
}) => {
|
}) => {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
const engineRef = useRef<Nullable<Engine>>(null)
|
const engineRef = useRef<Nullable<Engine>>(null)
|
||||||
@@ -340,7 +343,8 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
|
|
||||||
let engine: Engine
|
let engine: Engine
|
||||||
try {
|
try {
|
||||||
engine = new Engine(canvas, true, { stencil: true })
|
// Оптимизация: используем FXAA вместо MSAA для снижения нагрузки на GPU
|
||||||
|
engine = new Engine(canvas, false, { stencil: true }) // false = отключаем MSAA
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
const message = `WebGL недоступен: ${errorMessage}`
|
const message = `WebGL недоступен: ${errorMessage}`
|
||||||
@@ -363,6 +367,9 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
|
|
||||||
scene.clearColor = new Color4(0.1, 0.1, 0.15, 1)
|
scene.clearColor = new Color4(0.1, 0.1, 0.15, 1)
|
||||||
|
|
||||||
|
// Оптимизация: включаем FXAA (более легковесное сглаживание)
|
||||||
|
scene.imageProcessingConfiguration.fxaaEnabled = true
|
||||||
|
|
||||||
const camera = new ArcRotateCamera('camera', 0, Math.PI / 3, 20, Vector3.Zero(), scene)
|
const camera = new ArcRotateCamera('camera', 0, Math.PI / 3, 20, Vector3.Zero(), scene)
|
||||||
camera.attachControl(canvas, true)
|
camera.attachControl(canvas, true)
|
||||||
camera.lowerRadiusLimit = 2
|
camera.lowerRadiusLimit = 2
|
||||||
@@ -689,16 +696,65 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
const maxDimension = Math.max(size.x, size.y, size.z)
|
const maxDimension = Math.max(size.x, size.y, size.z)
|
||||||
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5)
|
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5)
|
||||||
|
|
||||||
|
// Простое позиционирование камеры - всегда поворачиваемся к датчику
|
||||||
|
console.log('[ModelViewer] Calculating camera direction to sensor')
|
||||||
|
|
||||||
|
// Вычисляем направление от текущей позиции камеры к датчику
|
||||||
|
const directionToSensor = center.subtract(camera.position).normalize()
|
||||||
|
|
||||||
|
// Преобразуем в сферические координаты
|
||||||
|
// alpha - горизонтальный угол (вокруг оси Y)
|
||||||
|
let targetAlpha = Math.atan2(directionToSensor.x, directionToSensor.z)
|
||||||
|
|
||||||
|
// beta - вертикальный угол (от вертикали)
|
||||||
|
// Используем оптимальный угол 60° для обзора
|
||||||
|
let targetBeta = Math.PI / 3 // 60°
|
||||||
|
|
||||||
|
console.log('[ModelViewer] Calculated camera direction:', {
|
||||||
|
alpha: (targetAlpha * 180 / Math.PI).toFixed(1) + '°',
|
||||||
|
beta: (targetBeta * 180 / Math.PI).toFixed(1) + '°',
|
||||||
|
sensorPosition: { x: center.x.toFixed(2), y: center.y.toFixed(2), z: center.z.toFixed(2) },
|
||||||
|
cameraPosition: { x: camera.position.x.toFixed(2), y: camera.position.y.toFixed(2), z: camera.position.z.toFixed(2) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Нормализуем alpha в диапазон [-PI, PI]
|
||||||
|
while (targetAlpha > Math.PI) targetAlpha -= 2 * Math.PI
|
||||||
|
while (targetAlpha < -Math.PI) targetAlpha += 2 * Math.PI
|
||||||
|
|
||||||
|
// Ограничиваем beta в разумных пределах
|
||||||
|
targetBeta = Math.max(0.1, Math.min(Math.PI - 0.1, targetBeta))
|
||||||
|
|
||||||
scene.stopAnimation(camera)
|
scene.stopAnimation(camera)
|
||||||
|
|
||||||
|
// Логирование перед анимацией
|
||||||
|
console.log('[ModelViewer] Starting camera animation:', {
|
||||||
|
sensorId,
|
||||||
|
from: {
|
||||||
|
target: { x: camera.target.x.toFixed(2), y: camera.target.y.toFixed(2), z: camera.target.z.toFixed(2) },
|
||||||
|
radius: camera.radius.toFixed(2),
|
||||||
|
alpha: (camera.alpha * 180 / Math.PI).toFixed(1) + '°',
|
||||||
|
beta: (camera.beta * 180 / Math.PI).toFixed(1) + '°'
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
target: { x: center.x.toFixed(2), y: center.y.toFixed(2), z: center.z.toFixed(2) },
|
||||||
|
radius: targetRadius.toFixed(2),
|
||||||
|
alpha: (targetAlpha * 180 / Math.PI).toFixed(1) + '°',
|
||||||
|
beta: (targetBeta * 180 / Math.PI).toFixed(1) + '°'
|
||||||
|
},
|
||||||
|
alphaChange: ((targetAlpha - camera.alpha) * 180 / Math.PI).toFixed(1) + '°',
|
||||||
|
betaChange: ((targetBeta - camera.beta) * 180 / Math.PI).toFixed(1) + '°'
|
||||||
|
})
|
||||||
|
|
||||||
const ease = new CubicEase()
|
const ease = new CubicEase()
|
||||||
ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT)
|
ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT)
|
||||||
const frameRate = 60
|
const frameRate = 60
|
||||||
const durationMs = 600
|
const durationMs = 800
|
||||||
const totalFrames = Math.round((durationMs / 1000) * frameRate)
|
const totalFrames = Math.round((durationMs / 1000) * frameRate)
|
||||||
|
|
||||||
Animation.CreateAndStartAnimation('camTarget', camera, 'target', frameRate, totalFrames, camera.target.clone(), center.clone(), Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
|
Animation.CreateAndStartAnimation('camTarget', camera, 'target', frameRate, totalFrames, camera.target.clone(), center.clone(), Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
|
||||||
Animation.CreateAndStartAnimation('camRadius', camera, 'radius', frameRate, totalFrames, camera.radius, targetRadius, Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
|
Animation.CreateAndStartAnimation('camRadius', camera, 'radius', frameRate, totalFrames, camera.radius, targetRadius, Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
|
||||||
|
Animation.CreateAndStartAnimation('camAlpha', camera, 'alpha', frameRate, totalFrames, camera.alpha, targetAlpha, Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
|
||||||
|
Animation.CreateAndStartAnimation('camBeta', camera, 'beta', frameRate, totalFrames, camera.beta, targetBeta, Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
|
||||||
|
|
||||||
applyHighlightToMeshes(
|
applyHighlightToMeshes(
|
||||||
highlightLayerRef.current,
|
highlightLayerRef.current,
|
||||||
@@ -866,6 +922,7 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
onToggleSensorHighlights={useNavigationStore.getState().toggleSensorHighlights}
|
onToggleSensorHighlights={useNavigationStore.getState().toggleSensorHighlights}
|
||||||
sensorHighlightsActive={useNavigationStore.getState().showSensorHighlights}
|
sensorHighlightsActive={useNavigationStore.getState().showSensorHighlights}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* UPDATED: Interactive overlay circles with hover effects */}
|
{/* UPDATED: Interactive overlay circles with hover effects */}
|
||||||
|
|||||||
@@ -56,8 +56,6 @@ export interface ModelViewerProps {
|
|||||||
onSensorPick?: (sensorId: string | null) => void
|
onSensorPick?: (sensorId: string | null) => void
|
||||||
highlightAllSensors?: boolean
|
highlightAllSensors?: boolean
|
||||||
sensorStatusMap?: Record<string, string>
|
sensorStatusMap?: Record<string, string>
|
||||||
showStats?: boolean
|
|
||||||
onToggleStats?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ModelViewer: React.FC<ModelViewerProps> = ({
|
const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||||
@@ -923,49 +921,8 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
panActive={panActive}
|
panActive={panActive}
|
||||||
onToggleSensorHighlights={useNavigationStore.getState().toggleSensorHighlights}
|
onToggleSensorHighlights={useNavigationStore.getState().toggleSensorHighlights}
|
||||||
sensorHighlightsActive={useNavigationStore.getState().showSensorHighlights}
|
sensorHighlightsActive={useNavigationStore.getState().showSensorHighlights}
|
||||||
onToggleStats={onToggleStats}
|
|
||||||
statsActive={showStats}
|
|
||||||
/>
|
/>
|
||||||
{/* Блок статистики датчиков */}
|
|
||||||
{showStats && sensorStatusMap && (
|
|
||||||
<div className="absolute top-24 left-8 z-[60] pointer-events-none">
|
|
||||||
<div className="bg-[#161824] rounded-[15px] border border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.3)] py-3 px-4">
|
|
||||||
<div className="flex flex-col gap-2 text-gray-300">
|
|
||||||
{/* Всего датчиков */}
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<span className="text-xs">Всего:</span>
|
|
||||||
<span className="text-sm font-semibold">{Object.keys(sensorStatusMap).length}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-px bg-white/10"></div>
|
|
||||||
|
|
||||||
{/* Нормальный */}
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<span className="text-xs">Норма:</span>
|
|
||||||
<span className="text-sm font-semibold">
|
|
||||||
{Object.values(sensorStatusMap).filter(s => s === '#4ade80' || s.toLowerCase() === 'normal').length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Предупреждение */}
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<span className="text-xs">Предупр.:</span>
|
|
||||||
<span className="text-sm font-semibold">
|
|
||||||
{Object.values(sensorStatusMap).filter(s => s === '#fb923c' || s.toLowerCase() === 'warning').length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Критический */}
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<span className="text-xs">Критич.:</span>
|
|
||||||
<span className="text-sm font-semibold">
|
|
||||||
{Object.values(sensorStatusMap).filter(s => s === '#ef4444' || s.toLowerCase() === 'critical').length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* UPDATED: Interactive overlay circles with hover effects */}
|
{/* UPDATED: Interactive overlay circles with hover effects */}
|
||||||
|
|||||||
@@ -55,13 +55,69 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
? latestTimestamp.toLocaleString('ru-RU', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
? latestTimestamp.toLocaleString('ru-RU', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||||
: 'Нет данных'
|
: 'Нет данных'
|
||||||
|
|
||||||
// Данные для графика за последние 3 дня (мок данные)
|
// Данные для графика за последний месяц из реальных notifications
|
||||||
const chartData: { timestamp: string; value: number }[] = [
|
const chartData = React.useMemo(() => {
|
||||||
{ timestamp: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), value: 75 },
|
const notifications = detector.notifications ?? []
|
||||||
{ timestamp: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), value: 82 },
|
const DAYS_COUNT = 30 // Последний месяц
|
||||||
{ timestamp: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), value: 78 },
|
|
||||||
{ timestamp: new Date().toISOString(), value: 85 },
|
if (notifications.length === 0) {
|
||||||
]
|
// Если нет уведомлений, возвращаем пустой график
|
||||||
|
return Array.from({ length: DAYS_COUNT }, (_, i) => ({
|
||||||
|
timestamp: new Date(Date.now() - (DAYS_COUNT - 1 - i) * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
critical: 0,
|
||||||
|
warning: 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Группируем уведомления по дням за последний месяц
|
||||||
|
const now = new Date()
|
||||||
|
const monthAgo = new Date(now.getTime() - DAYS_COUNT * 24 * 60 * 60 * 1000)
|
||||||
|
|
||||||
|
// Создаём карту: дата -> { critical: count, warning: count }
|
||||||
|
const dayMap: Record<string, { critical: number; warning: number }> = {}
|
||||||
|
|
||||||
|
// Инициализируем все дни нулями
|
||||||
|
for (let i = 0; i < DAYS_COUNT; i++) {
|
||||||
|
const date = new Date(monthAgo.getTime() + i * 24 * 60 * 60 * 1000)
|
||||||
|
const dateKey = date.toISOString().split('T')[0]
|
||||||
|
dayMap[dateKey] = { critical: 0, warning: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подсчитываем уведомления по дням
|
||||||
|
notifications.forEach(notification => {
|
||||||
|
// Детальное логирование для GLE-1
|
||||||
|
if (detector.serial_number === 'GLE-1') {
|
||||||
|
console.log('[DetectorMenu] Full notification object for GLE-1:', JSON.stringify(notification, null, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifDate = new Date(notification.timestamp)
|
||||||
|
if (notifDate >= monthAgo && notifDate <= now) {
|
||||||
|
const dateKey = notifDate.toISOString().split('T')[0]
|
||||||
|
if (dayMap[dateKey]) {
|
||||||
|
const notifType = String(notification.type || '').toLowerCase()
|
||||||
|
|
||||||
|
if (notifType === 'critical') {
|
||||||
|
dayMap[dateKey].critical++
|
||||||
|
} else if (notifType === 'warning') {
|
||||||
|
dayMap[dateKey].warning++
|
||||||
|
} else {
|
||||||
|
// Если тип не распознан, логируем
|
||||||
|
console.warn('[DetectorMenu] Unknown notification type:', notification.type, 'Full notification:', JSON.stringify(notification, null, 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Преобразуем в массив для графика
|
||||||
|
return Object.keys(dayMap)
|
||||||
|
.sort()
|
||||||
|
.map(dateKey => ({
|
||||||
|
timestamp: dateKey,
|
||||||
|
label: new Date(dateKey).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }),
|
||||||
|
critical: dayMap[dateKey].critical,
|
||||||
|
warning: dayMap[dateKey].warning,
|
||||||
|
}))
|
||||||
|
}, [detector.notifications])
|
||||||
|
|
||||||
// Определение типа детектора и его отображаемого названия
|
// Определение типа детектора и его отображаемого названия
|
||||||
const rawDetectorTypeCode = (detector.detector_type || '').toUpperCase()
|
const rawDetectorTypeCode = (detector.detector_type || '').toUpperCase()
|
||||||
@@ -262,9 +318,9 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
</div>
|
</div>
|
||||||
<DetailsSection compact={true} />
|
<DetailsSection compact={true} />
|
||||||
|
|
||||||
{/* График за последние 3 дня */}
|
{/* График за последний месяц */}
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<div className="text-[rgb(113,113,122)] text-[11px] mb-2">График за 3 дня</div>
|
<div className="text-[rgb(113,113,122)] text-[11px] mb-2">График за месяц</div>
|
||||||
<div className="min-h-[100px]">
|
<div className="min-h-[100px]">
|
||||||
<AreaChart data={chartData} />
|
<AreaChart data={chartData} />
|
||||||
</div>
|
</div>
|
||||||
@@ -304,9 +360,9 @@ const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose,
|
|||||||
{/* Секция с детальной информацией о детекторе */}
|
{/* Секция с детальной информацией о детекторе */}
|
||||||
<DetailsSection />
|
<DetailsSection />
|
||||||
|
|
||||||
{/* График за последние 3 дня */}
|
{/* График за последний месяц */}
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<h4 className="text-white text-base font-medium mb-4">График за последние 3 дня</h4>
|
<h4 className="text-white text-base font-medium mb-4">График за последний месяц</h4>
|
||||||
<div className="min-h-[200px] bg-[rgb(22,24,36)] rounded-lg p-4">
|
<div className="min-h-[200px] bg-[rgb(22,24,36)] rounded-lg p-4">
|
||||||
<AreaChart data={chartData} />
|
<AreaChart data={chartData} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
329
frontend/components/navigation/DetectorMenu.tsx — копия 3
Normal file
329
frontend/components/navigation/DetectorMenu.tsx — копия 3
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import useNavigationStore from '@/app/store/navigationStore'
|
||||||
|
import AreaChart from '../dashboard/AreaChart'
|
||||||
|
|
||||||
|
interface DetectorType {
|
||||||
|
detector_id: number
|
||||||
|
name: string
|
||||||
|
serial_number: string
|
||||||
|
object: string
|
||||||
|
status: string
|
||||||
|
checked: boolean
|
||||||
|
type: string
|
||||||
|
detector_type: string
|
||||||
|
location: string
|
||||||
|
floor: number
|
||||||
|
notifications: Array<{
|
||||||
|
id: number
|
||||||
|
type: string
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
acknowledged: boolean
|
||||||
|
priority: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetectorMenuProps {
|
||||||
|
detector: DetectorType
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
getStatusText: (status: string) => string
|
||||||
|
compact?: boolean
|
||||||
|
anchor?: { left: number; top: number } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Главный компонент меню детектора
|
||||||
|
// Показывает детальную информацию о датчике с возможностью навигации к отчетам и истории
|
||||||
|
const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose, getStatusText, compact = false, anchor = null }) => {
|
||||||
|
const router = useRouter()
|
||||||
|
const { setSelectedDetector, currentObject } = useNavigationStore()
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
// Определение последней временной метки из уведомлений детектора
|
||||||
|
const latestTimestamp = (() => {
|
||||||
|
const list = detector.notifications ?? []
|
||||||
|
if (!Array.isArray(list) || list.length === 0) return null
|
||||||
|
const dates = list.map(n => new Date(n.timestamp)).filter(d => !isNaN(d.getTime()))
|
||||||
|
if (dates.length === 0) return null
|
||||||
|
dates.sort((a, b) => b.getTime() - a.getTime())
|
||||||
|
return dates[0]
|
||||||
|
})()
|
||||||
|
const formattedTimestamp = latestTimestamp
|
||||||
|
? latestTimestamp.toLocaleString('ru-RU', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||||
|
: 'Нет данных'
|
||||||
|
|
||||||
|
// Данные для графика за последние 3 дня (мок данные)
|
||||||
|
const chartData: { timestamp: string; value: number }[] = [
|
||||||
|
{ timestamp: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), value: 75 },
|
||||||
|
{ timestamp: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), value: 82 },
|
||||||
|
{ timestamp: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), value: 78 },
|
||||||
|
{ timestamp: new Date().toISOString(), value: 85 },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Определение типа детектора и его отображаемого названия
|
||||||
|
const rawDetectorTypeCode = (detector.detector_type || '').toUpperCase()
|
||||||
|
const deriveCodeFromType = (): string => {
|
||||||
|
const t = (detector.type || '').toLowerCase()
|
||||||
|
if (!t) return ''
|
||||||
|
if (t.includes('инклинометр')) return 'GA'
|
||||||
|
if (t.includes('тензометр')) return 'PE'
|
||||||
|
if (t.includes('гидроуров')) return 'GLE'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const effectiveDetectorTypeCode = rawDetectorTypeCode || deriveCodeFromType()
|
||||||
|
|
||||||
|
// Карта соответствия кодов типов детекторов их русским названиям
|
||||||
|
const detectorTypeLabelMap: Record<string, string> = {
|
||||||
|
GA: 'Инклинометр',
|
||||||
|
PE: 'Тензометр',
|
||||||
|
GLE: 'Гидроуровень',
|
||||||
|
}
|
||||||
|
const displayDetectorTypeLabel = detectorTypeLabelMap[effectiveDetectorTypeCode] || '—'
|
||||||
|
|
||||||
|
// Обработчик клика по кнопке "Отчет" - навигация на страницу отчетов с выбранным детектором
|
||||||
|
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 detectorData = {
|
||||||
|
...detector,
|
||||||
|
notifications: detector.notifications || []
|
||||||
|
}
|
||||||
|
setSelectedDetector(detectorData)
|
||||||
|
|
||||||
|
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 detectorData = {
|
||||||
|
...detector,
|
||||||
|
notifications: detector.notifications || []
|
||||||
|
}
|
||||||
|
setSelectedDetector(detectorData)
|
||||||
|
|
||||||
|
let alertsUrl = '/alerts'
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
|
||||||
|
if (objectId) params.set('objectId', objectId)
|
||||||
|
if (objectTitle) params.set('objectTitle', objectTitle)
|
||||||
|
|
||||||
|
if (params.toString()) {
|
||||||
|
alertsUrl += `?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(alertsUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Компонент секции деталей детектора
|
||||||
|
// Отображает информацию о датчике в компактном или полном формате
|
||||||
|
const DetailsSection: React.FC<{ compact?: boolean }> = ({ compact = false }) => (
|
||||||
|
<div className={compact ? 'mt-2 space-y-1' : 'space-y-0 border border-[rgb(30,31,36)] rounded-lg overflow-hidden'}>
|
||||||
|
{compact ? (
|
||||||
|
// Компактный режим: 4 строки по 2 колонки с основной информацией
|
||||||
|
<>
|
||||||
|
{/* Строка 1: Маркировка и тип детектора */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-[rgb(113,113,122)] text-[11px]">Маркировка по проекту</div>
|
||||||
|
<div className="text-white text-xs truncate">{detector.name}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[rgb(113,113,122)] text-[11px]">Тип детектора</div>
|
||||||
|
<div className="text-white text-xs truncate">{displayDetectorTypeLabel}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Строка 2: Местоположение и статус */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-[rgb(113,113,122)] text-[11px]">Местоположение</div>
|
||||||
|
<div className="text-white text-xs truncate">{detector.location}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[rgb(113,113,122)] text-[11px]">Статус</div>
|
||||||
|
<div className="text-white text-xs truncate">{getStatusText(detector.status)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Строка 3: Временная метка и этаж */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-[rgb(113,113,122)] text-[11px]">Временная метка</div>
|
||||||
|
<div className="text-white text-xs truncate">{formattedTimestamp}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[rgb(113,113,122)] text-[11px]">Этаж</div>
|
||||||
|
<div className="text-white text-xs truncate">{detector.floor}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Строка 4: Серийный номер */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-[rgb(113,113,122)] text-[11px]">Серийный номер</div>
|
||||||
|
<div className="text-white text-xs truncate">{detector.serial_number}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Полный режим: 3 строки по 2 колонки с рамками между элементами
|
||||||
|
<>
|
||||||
|
{/* Строка 1: Маркировка по проекту и тип детектора */}
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Маркировка по проекту</div>
|
||||||
|
<div className="text-white text-sm">{detector.name}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-4">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Тип детектора</div>
|
||||||
|
<div className="text-white text-sm">{displayDetectorTypeLabel}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Строка 2: Местоположение и статус */}
|
||||||
|
<div className="flex border-t border-[rgb(30,31,36)]">
|
||||||
|
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Местоположение</div>
|
||||||
|
<div className="text-white text-sm">{detector.location}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-4">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Статус</div>
|
||||||
|
<div className="text-white text-sm">{getStatusText(detector.status)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Строка 3: Временная метка и серийный номер */}
|
||||||
|
<div className="flex border-t border-[rgb(30,31,36)]">
|
||||||
|
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Временная метка</div>
|
||||||
|
<div className="text-white text-sm">{formattedTimestamp}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-4">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Серийный номер</div>
|
||||||
|
<div className="text-white text-sm">{detector.serial_number}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Компактный режим с якорной позицией (всплывающее окно)
|
||||||
|
// Используется для отображения информации при наведении на детектор в списке
|
||||||
|
if (compact && anchor) {
|
||||||
|
// Проверяем границы экрана и корректируем позицию
|
||||||
|
const tooltipHeight = 450 // Примерная высота толтипа с графиком
|
||||||
|
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 800
|
||||||
|
const bottomOverflow = anchor.top + tooltipHeight - viewportHeight
|
||||||
|
|
||||||
|
// Если толтип выходит за нижнюю границу, сдвигаем вверх
|
||||||
|
const adjustedTop = bottomOverflow > 0 ? anchor.top - bottomOverflow - 20 : anchor.top
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute z-40" style={{ left: anchor.left, top: adjustedTop }}>
|
||||||
|
<div className="rounded-[10px] bg-black/80 text-white text-xs px-3 py-2 shadow-xl min-w-[300px] max-w-[400px]">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-semibold truncate">{detector.name}</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-gray-300 hover:text-white transition-colors">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
Отчет
|
||||||
|
</button>
|
||||||
|
<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">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
История
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<DetailsSection compact={true} />
|
||||||
|
|
||||||
|
{/* График за последние 3 дня */}
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-[11px] mb-2">График за 3 дня</div>
|
||||||
|
<div className="min-h-[100px]">
|
||||||
|
<AreaChart data={chartData} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Полный режим боковой панели (основной режим)
|
||||||
|
// Отображается как правая панель с полной информацией о детекторе
|
||||||
|
return (
|
||||||
|
<div className="absolute left-[500px] top-0 bg-[#161824] border-r border-gray-700 з-30 w-[454px]" style={{height: 'calc(100% - 73px)', top: '73px'}}>
|
||||||
|
<div className="h-full overflow-auto p-5">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
{/* Заголовок с названием детектора */}
|
||||||
|
<h3 className="text-white text-lg font-medium">
|
||||||
|
{detector.name}
|
||||||
|
</h3>
|
||||||
|
{/* Кнопки действий: Отчет и История */}
|
||||||
|
<div className="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">
|
||||||
|
<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>
|
||||||
|
Отчет
|
||||||
|
</button>
|
||||||
|
<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">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
История
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Секция с детальной информацией о детекторе */}
|
||||||
|
<DetailsSection />
|
||||||
|
|
||||||
|
{/* График за последние 3 дня */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<h4 className="text-white text-base font-medium mb-4">График за последние 3 дня</h4>
|
||||||
|
<div className="min-h-[200px] bg-[rgb(22,24,36)] rounded-lg p-4">
|
||||||
|
<AreaChart data={chartData} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Кнопка закрытия панели */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6л12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DetectorMenu
|
||||||
329
frontend/components/navigation/DetectorMenu.tsx — копия 4
Normal file
329
frontend/components/navigation/DetectorMenu.tsx — копия 4
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import useNavigationStore from '@/app/store/navigationStore'
|
||||||
|
import AreaChart from '../dashboard/AreaChart'
|
||||||
|
|
||||||
|
interface DetectorType {
|
||||||
|
detector_id: number
|
||||||
|
name: string
|
||||||
|
serial_number: string
|
||||||
|
object: string
|
||||||
|
status: string
|
||||||
|
checked: boolean
|
||||||
|
type: string
|
||||||
|
detector_type: string
|
||||||
|
location: string
|
||||||
|
floor: number
|
||||||
|
notifications: Array<{
|
||||||
|
id: number
|
||||||
|
type: string
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
acknowledged: boolean
|
||||||
|
priority: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetectorMenuProps {
|
||||||
|
detector: DetectorType
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
getStatusText: (status: string) => string
|
||||||
|
compact?: boolean
|
||||||
|
anchor?: { left: number; top: number } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Главный компонент меню детектора
|
||||||
|
// Показывает детальную информацию о датчике с возможностью навигации к отчетам и истории
|
||||||
|
const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose, getStatusText, compact = false, anchor = null }) => {
|
||||||
|
const router = useRouter()
|
||||||
|
const { setSelectedDetector, currentObject } = useNavigationStore()
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
// Определение последней временной метки из уведомлений детектора
|
||||||
|
const latestTimestamp = (() => {
|
||||||
|
const list = detector.notifications ?? []
|
||||||
|
if (!Array.isArray(list) || list.length === 0) return null
|
||||||
|
const dates = list.map(n => new Date(n.timestamp)).filter(d => !isNaN(d.getTime()))
|
||||||
|
if (dates.length === 0) return null
|
||||||
|
dates.sort((a, b) => b.getTime() - a.getTime())
|
||||||
|
return dates[0]
|
||||||
|
})()
|
||||||
|
const formattedTimestamp = latestTimestamp
|
||||||
|
? latestTimestamp.toLocaleString('ru-RU', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||||
|
: 'Нет данных'
|
||||||
|
|
||||||
|
// Данные для графика за последние 3 дня (мок данные)
|
||||||
|
const chartData = [
|
||||||
|
{ timestamp: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), critical: 75, warning: 0 },
|
||||||
|
{ timestamp: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), critical: 82, warning: 0 },
|
||||||
|
{ timestamp: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), critical: 78, warning: 0 },
|
||||||
|
{ timestamp: new Date().toISOString(), critical: 85, warning: 0 },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Определение типа детектора и его отображаемого названия
|
||||||
|
const rawDetectorTypeCode = (detector.detector_type || '').toUpperCase()
|
||||||
|
const deriveCodeFromType = (): string => {
|
||||||
|
const t = (detector.type || '').toLowerCase()
|
||||||
|
if (!t) return ''
|
||||||
|
if (t.includes('инклинометр')) return 'GA'
|
||||||
|
if (t.includes('тензометр')) return 'PE'
|
||||||
|
if (t.includes('гидроуров')) return 'GLE'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const effectiveDetectorTypeCode = rawDetectorTypeCode || deriveCodeFromType()
|
||||||
|
|
||||||
|
// Карта соответствия кодов типов детекторов их русским названиям
|
||||||
|
const detectorTypeLabelMap: Record<string, string> = {
|
||||||
|
GA: 'Инклинометр',
|
||||||
|
PE: 'Тензометр',
|
||||||
|
GLE: 'Гидроуровень',
|
||||||
|
}
|
||||||
|
const displayDetectorTypeLabel = detectorTypeLabelMap[effectiveDetectorTypeCode] || '—'
|
||||||
|
|
||||||
|
// Обработчик клика по кнопке "Отчет" - навигация на страницу отчетов с выбранным детектором
|
||||||
|
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 detectorData = {
|
||||||
|
...detector,
|
||||||
|
notifications: detector.notifications || []
|
||||||
|
}
|
||||||
|
setSelectedDetector(detectorData)
|
||||||
|
|
||||||
|
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 detectorData = {
|
||||||
|
...detector,
|
||||||
|
notifications: detector.notifications || []
|
||||||
|
}
|
||||||
|
setSelectedDetector(detectorData)
|
||||||
|
|
||||||
|
let alertsUrl = '/alerts'
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
|
||||||
|
if (objectId) params.set('objectId', objectId)
|
||||||
|
if (objectTitle) params.set('objectTitle', objectTitle)
|
||||||
|
|
||||||
|
if (params.toString()) {
|
||||||
|
alertsUrl += `?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(alertsUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Компонент секции деталей детектора
|
||||||
|
// Отображает информацию о датчике в компактном или полном формате
|
||||||
|
const DetailsSection: React.FC<{ compact?: boolean }> = ({ compact = false }) => (
|
||||||
|
<div className={compact ? 'mt-2 space-y-1' : 'space-y-0 border border-[rgb(30,31,36)] rounded-lg overflow-hidden'}>
|
||||||
|
{compact ? (
|
||||||
|
// Компактный режим: 4 строки по 2 колонки с основной информацией
|
||||||
|
<>
|
||||||
|
{/* Строка 1: Маркировка и тип детектора */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-[rgb(113,113,122)] text-[11px]">Маркировка по проекту</div>
|
||||||
|
<div className="text-white text-xs truncate">{detector.name}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[rgb(113,113,122)] text-[11px]">Тип детектора</div>
|
||||||
|
<div className="text-white text-xs truncate">{displayDetectorTypeLabel}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Строка 2: Местоположение и статус */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-[rgb(113,113,122)] text-[11px]">Местоположение</div>
|
||||||
|
<div className="text-white text-xs truncate">{detector.location}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[rgb(113,113,122)] text-[11px]">Статус</div>
|
||||||
|
<div className="text-white text-xs truncate">{getStatusText(detector.status)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Строка 3: Временная метка и этаж */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-[rgb(113,113,122)] text-[11px]">Временная метка</div>
|
||||||
|
<div className="text-white text-xs truncate">{formattedTimestamp}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[rgb(113,113,122)] text-[11px]">Этаж</div>
|
||||||
|
<div className="text-white text-xs truncate">{detector.floor}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Строка 4: Серийный номер */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-[rgb(113,113,122)] text-[11px]">Серийный номер</div>
|
||||||
|
<div className="text-white text-xs truncate">{detector.serial_number}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Полный режим: 3 строки по 2 колонки с рамками между элементами
|
||||||
|
<>
|
||||||
|
{/* Строка 1: Маркировка по проекту и тип детектора */}
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Маркировка по проекту</div>
|
||||||
|
<div className="text-white text-sm">{detector.name}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-4">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Тип детектора</div>
|
||||||
|
<div className="text-white text-sm">{displayDetectorTypeLabel}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Строка 2: Местоположение и статус */}
|
||||||
|
<div className="flex border-t border-[rgb(30,31,36)]">
|
||||||
|
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Местоположение</div>
|
||||||
|
<div className="text-white text-sm">{detector.location}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-4">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Статус</div>
|
||||||
|
<div className="text-white text-sm">{getStatusText(detector.status)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Строка 3: Временная метка и серийный номер */}
|
||||||
|
<div className="flex border-t border-[rgb(30,31,36)]">
|
||||||
|
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Временная метка</div>
|
||||||
|
<div className="text-white text-sm">{formattedTimestamp}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-4">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Серийный номер</div>
|
||||||
|
<div className="text-white text-sm">{detector.serial_number}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Компактный режим с якорной позицией (всплывающее окно)
|
||||||
|
// Используется для отображения информации при наведении на детектор в списке
|
||||||
|
if (compact && anchor) {
|
||||||
|
// Проверяем границы экрана и корректируем позицию
|
||||||
|
const tooltipHeight = 450 // Примерная высота толтипа с графиком
|
||||||
|
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 800
|
||||||
|
const bottomOverflow = anchor.top + tooltipHeight - viewportHeight
|
||||||
|
|
||||||
|
// Если толтип выходит за нижнюю границу, сдвигаем вверх
|
||||||
|
const adjustedTop = bottomOverflow > 0 ? anchor.top - bottomOverflow - 20 : anchor.top
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute z-40" style={{ left: anchor.left, top: adjustedTop }}>
|
||||||
|
<div className="rounded-[10px] bg-black/80 text-white text-xs px-3 py-2 shadow-xl min-w-[300px] max-w-[400px]">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-semibold truncate">{detector.name}</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-gray-300 hover:text-white transition-colors">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
Отчет
|
||||||
|
</button>
|
||||||
|
<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">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
История
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<DetailsSection compact={true} />
|
||||||
|
|
||||||
|
{/* График за последние 3 дня */}
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-[11px] mb-2">График за 3 дня</div>
|
||||||
|
<div className="min-h-[100px]">
|
||||||
|
<AreaChart data={chartData} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Полный режим боковой панели (основной режим)
|
||||||
|
// Отображается как правая панель с полной информацией о детекторе
|
||||||
|
return (
|
||||||
|
<div className="absolute left-[500px] top-0 bg-[#161824] border-r border-gray-700 з-30 w-[454px]" style={{height: 'calc(100% - 73px)', top: '73px'}}>
|
||||||
|
<div className="h-full overflow-auto p-5">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
{/* Заголовок с названием детектора */}
|
||||||
|
<h3 className="text-white text-lg font-medium">
|
||||||
|
{detector.name}
|
||||||
|
</h3>
|
||||||
|
{/* Кнопки действий: Отчет и История */}
|
||||||
|
<div className="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">
|
||||||
|
<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>
|
||||||
|
Отчет
|
||||||
|
</button>
|
||||||
|
<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">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
История
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Секция с детальной информацией о детекторе */}
|
||||||
|
<DetailsSection />
|
||||||
|
|
||||||
|
{/* График за последние 3 дня */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<h4 className="text-white text-base font-medium mb-4">График за последние 3 дня</h4>
|
||||||
|
<div className="min-h-[200px] bg-[rgb(22,24,36)] rounded-lg p-4">
|
||||||
|
<AreaChart data={chartData} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Кнопка закрытия панели */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6л12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DetectorMenu
|
||||||
381
frontend/components/navigation/DetectorMenu.tsx — копия 5
Normal file
381
frontend/components/navigation/DetectorMenu.tsx — копия 5
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import useNavigationStore from '@/app/store/navigationStore'
|
||||||
|
import AreaChart from '../dashboard/AreaChart'
|
||||||
|
|
||||||
|
interface DetectorType {
|
||||||
|
detector_id: number
|
||||||
|
name: string
|
||||||
|
serial_number: string
|
||||||
|
object: string
|
||||||
|
status: string
|
||||||
|
checked: boolean
|
||||||
|
type: string
|
||||||
|
detector_type: string
|
||||||
|
location: string
|
||||||
|
floor: number
|
||||||
|
notifications: Array<{
|
||||||
|
id: number
|
||||||
|
type: string
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
acknowledged: boolean
|
||||||
|
priority: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetectorMenuProps {
|
||||||
|
detector: DetectorType
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
getStatusText: (status: string) => string
|
||||||
|
compact?: boolean
|
||||||
|
anchor?: { left: number; top: number } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Главный компонент меню детектора
|
||||||
|
// Показывает детальную информацию о датчике с возможностью навигации к отчетам и истории
|
||||||
|
const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose, getStatusText, compact = false, anchor = null }) => {
|
||||||
|
const router = useRouter()
|
||||||
|
const { setSelectedDetector, currentObject } = useNavigationStore()
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
// Определение последней временной метки из уведомлений детектора
|
||||||
|
const latestTimestamp = (() => {
|
||||||
|
const list = detector.notifications ?? []
|
||||||
|
if (!Array.isArray(list) || list.length === 0) return null
|
||||||
|
const dates = list.map(n => new Date(n.timestamp)).filter(d => !isNaN(d.getTime()))
|
||||||
|
if (dates.length === 0) return null
|
||||||
|
dates.sort((a, b) => b.getTime() - a.getTime())
|
||||||
|
return dates[0]
|
||||||
|
})()
|
||||||
|
const formattedTimestamp = latestTimestamp
|
||||||
|
? latestTimestamp.toLocaleString('ru-RU', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||||
|
: 'Нет данных'
|
||||||
|
|
||||||
|
// Данные для графика за последний месяц из реальных notifications
|
||||||
|
const chartData = React.useMemo(() => {
|
||||||
|
const notifications = detector.notifications ?? []
|
||||||
|
const DAYS_COUNT = 30 // Последний месяц
|
||||||
|
|
||||||
|
if (notifications.length === 0) {
|
||||||
|
// Если нет уведомлений, возвращаем пустой график
|
||||||
|
return Array.from({ length: DAYS_COUNT }, (_, i) => ({
|
||||||
|
timestamp: new Date(Date.now() - (DAYS_COUNT - 1 - i) * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
critical: 0,
|
||||||
|
warning: 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Группируем уведомления по дням за последний месяц
|
||||||
|
const now = new Date()
|
||||||
|
const monthAgo = new Date(now.getTime() - DAYS_COUNT * 24 * 60 * 60 * 1000)
|
||||||
|
|
||||||
|
// Создаём карту: дата -> { critical: count, warning: count }
|
||||||
|
const dayMap: Record<string, { critical: number; warning: number }> = {}
|
||||||
|
|
||||||
|
// Инициализируем все дни нулями
|
||||||
|
for (let i = 0; i < DAYS_COUNT; i++) {
|
||||||
|
const date = new Date(monthAgo.getTime() + i * 24 * 60 * 60 * 1000)
|
||||||
|
const dateKey = date.toISOString().split('T')[0]
|
||||||
|
dayMap[dateKey] = { critical: 0, warning: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подсчитываем уведомления по дням
|
||||||
|
notifications.forEach(notification => {
|
||||||
|
const notifDate = new Date(notification.timestamp)
|
||||||
|
if (notifDate >= monthAgo && notifDate <= now) {
|
||||||
|
const dateKey = notifDate.toISOString().split('T')[0]
|
||||||
|
if (dayMap[dateKey]) {
|
||||||
|
const notifType = String(notification.type || '').toLowerCase()
|
||||||
|
console.log('[DetectorMenu] Notification type:', notifType, 'for sensor:', detector.serial_number)
|
||||||
|
|
||||||
|
if (notifType === 'critical') {
|
||||||
|
dayMap[dateKey].critical++
|
||||||
|
} else if (notifType === 'warning') {
|
||||||
|
dayMap[dateKey].warning++
|
||||||
|
} else {
|
||||||
|
// Если тип не распознан, логируем
|
||||||
|
console.warn('[DetectorMenu] Unknown notification type:', notification.type, 'notification:', notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Преобразуем в массив для графика
|
||||||
|
return Object.keys(dayMap)
|
||||||
|
.sort()
|
||||||
|
.map(dateKey => ({
|
||||||
|
timestamp: dateKey,
|
||||||
|
label: new Date(dateKey).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }),
|
||||||
|
critical: dayMap[dateKey].critical,
|
||||||
|
warning: dayMap[dateKey].warning,
|
||||||
|
}))
|
||||||
|
}, [detector.notifications])
|
||||||
|
|
||||||
|
// Определение типа детектора и его отображаемого названия
|
||||||
|
const rawDetectorTypeCode = (detector.detector_type || '').toUpperCase()
|
||||||
|
const deriveCodeFromType = (): string => {
|
||||||
|
const t = (detector.type || '').toLowerCase()
|
||||||
|
if (!t) return ''
|
||||||
|
if (t.includes('инклинометр')) return 'GA'
|
||||||
|
if (t.includes('тензометр')) return 'PE'
|
||||||
|
if (t.includes('гидроуров')) return 'GLE'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const effectiveDetectorTypeCode = rawDetectorTypeCode || deriveCodeFromType()
|
||||||
|
|
||||||
|
// Карта соответствия кодов типов детекторов их русским названиям
|
||||||
|
const detectorTypeLabelMap: Record<string, string> = {
|
||||||
|
GA: 'Инклинометр',
|
||||||
|
PE: 'Тензометр',
|
||||||
|
GLE: 'Гидроуровень',
|
||||||
|
}
|
||||||
|
const displayDetectorTypeLabel = detectorTypeLabelMap[effectiveDetectorTypeCode] || '—'
|
||||||
|
|
||||||
|
// Обработчик клика по кнопке "Отчет" - навигация на страницу отчетов с выбранным детектором
|
||||||
|
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 detectorData = {
|
||||||
|
...detector,
|
||||||
|
notifications: detector.notifications || []
|
||||||
|
}
|
||||||
|
setSelectedDetector(detectorData)
|
||||||
|
|
||||||
|
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 detectorData = {
|
||||||
|
...detector,
|
||||||
|
notifications: detector.notifications || []
|
||||||
|
}
|
||||||
|
setSelectedDetector(detectorData)
|
||||||
|
|
||||||
|
let alertsUrl = '/alerts'
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
|
||||||
|
if (objectId) params.set('objectId', objectId)
|
||||||
|
if (objectTitle) params.set('objectTitle', objectTitle)
|
||||||
|
|
||||||
|
if (params.toString()) {
|
||||||
|
alertsUrl += `?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(alertsUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Компонент секции деталей детектора
|
||||||
|
// Отображает информацию о датчике в компактном или полном формате
|
||||||
|
const DetailsSection: React.FC<{ compact?: boolean }> = ({ compact = false }) => (
|
||||||
|
<div className={compact ? 'mt-2 space-y-1' : 'space-y-0 border border-[rgb(30,31,36)] rounded-lg overflow-hidden'}>
|
||||||
|
{compact ? (
|
||||||
|
// Компактный режим: 4 строки по 2 колонки с основной информацией
|
||||||
|
<>
|
||||||
|
{/* Строка 1: Маркировка и тип детектора */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-[rgb(113,113,122)] text-[11px]">Маркировка по проекту</div>
|
||||||
|
<div className="text-white text-xs truncate">{detector.name}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[rgb(113,113,122)] text-[11px]">Тип детектора</div>
|
||||||
|
<div className="text-white text-xs truncate">{displayDetectorTypeLabel}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Строка 2: Местоположение и статус */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-[rgb(113,113,122)] text-[11px]">Местоположение</div>
|
||||||
|
<div className="text-white text-xs truncate">{detector.location}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[rgb(113,113,122)] text-[11px]">Статус</div>
|
||||||
|
<div className="text-white text-xs truncate">{getStatusText(detector.status)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Строка 3: Временная метка и этаж */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-[rgb(113,113,122)] text-[11px]">Временная метка</div>
|
||||||
|
<div className="text-white text-xs truncate">{formattedTimestamp}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[rgb(113,113,122)] text-[11px]">Этаж</div>
|
||||||
|
<div className="text-white text-xs truncate">{detector.floor}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Строка 4: Серийный номер */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-[rgb(113,113,122)] text-[11px]">Серийный номер</div>
|
||||||
|
<div className="text-white text-xs truncate">{detector.serial_number}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Полный режим: 3 строки по 2 колонки с рамками между элементами
|
||||||
|
<>
|
||||||
|
{/* Строка 1: Маркировка по проекту и тип детектора */}
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Маркировка по проекту</div>
|
||||||
|
<div className="text-white text-sm">{detector.name}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-4">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Тип детектора</div>
|
||||||
|
<div className="text-white text-sm">{displayDetectorTypeLabel}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Строка 2: Местоположение и статус */}
|
||||||
|
<div className="flex border-t border-[rgb(30,31,36)]">
|
||||||
|
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Местоположение</div>
|
||||||
|
<div className="text-white text-sm">{detector.location}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-4">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Статус</div>
|
||||||
|
<div className="text-white text-sm">{getStatusText(detector.status)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Строка 3: Временная метка и серийный номер */}
|
||||||
|
<div className="flex border-t border-[rgb(30,31,36)]">
|
||||||
|
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Временная метка</div>
|
||||||
|
<div className="text-white text-sm">{formattedTimestamp}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-4">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Серийный номер</div>
|
||||||
|
<div className="text-white text-sm">{detector.serial_number}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Компактный режим с якорной позицией (всплывающее окно)
|
||||||
|
// Используется для отображения информации при наведении на детектор в списке
|
||||||
|
if (compact && anchor) {
|
||||||
|
// Проверяем границы экрана и корректируем позицию
|
||||||
|
const tooltipHeight = 450 // Примерная высота толтипа с графиком
|
||||||
|
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 800
|
||||||
|
const bottomOverflow = anchor.top + tooltipHeight - viewportHeight
|
||||||
|
|
||||||
|
// Если толтип выходит за нижнюю границу, сдвигаем вверх
|
||||||
|
const adjustedTop = bottomOverflow > 0 ? anchor.top - bottomOverflow - 20 : anchor.top
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute z-40" style={{ left: anchor.left, top: adjustedTop }}>
|
||||||
|
<div className="rounded-[10px] bg-black/80 text-white text-xs px-3 py-2 shadow-xl min-w-[300px] max-w-[400px]">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-semibold truncate">{detector.name}</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-gray-300 hover:text-white transition-colors">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
Отчет
|
||||||
|
</button>
|
||||||
|
<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">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
История
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<DetailsSection compact={true} />
|
||||||
|
|
||||||
|
{/* График за последний месяц */}
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="text-[rgb(113,113,122)] text-[11px] mb-2">График за месяц</div>
|
||||||
|
<div className="min-h-[100px]">
|
||||||
|
<AreaChart data={chartData} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Полный режим боковой панели (основной режим)
|
||||||
|
// Отображается как правая панель с полной информацией о детекторе
|
||||||
|
return (
|
||||||
|
<div className="absolute left-[500px] top-0 bg-[#161824] border-r border-gray-700 з-30 w-[454px]" style={{height: 'calc(100% - 73px)', top: '73px'}}>
|
||||||
|
<div className="h-full overflow-auto p-5">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
{/* Заголовок с названием детектора */}
|
||||||
|
<h3 className="text-white text-lg font-medium">
|
||||||
|
{detector.name}
|
||||||
|
</h3>
|
||||||
|
{/* Кнопки действий: Отчет и История */}
|
||||||
|
<div className="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">
|
||||||
|
<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>
|
||||||
|
Отчет
|
||||||
|
</button>
|
||||||
|
<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">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
История
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Секция с детальной информацией о детекторе */}
|
||||||
|
<DetailsSection />
|
||||||
|
|
||||||
|
{/* График за последний месяц */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<h4 className="text-white text-base font-medium mb-4">График за последний месяц</h4>
|
||||||
|
<div className="min-h-[200px] bg-[rgb(22,24,36)] rounded-lg p-4">
|
||||||
|
<AreaChart data={chartData} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Кнопка закрытия панели */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6л12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DetectorMenu
|
||||||
Reference in New Issue
Block a user