From f275db88c9eb9fd24ed67d6205091da3dea0b24a Mon Sep 17 00:00:00 2001 From: sysadminix Date: Wed, 4 Feb 2026 00:02:37 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B2=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B4?= =?UTF-8?q?=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BF=D0=BE=20=D0=B4=D0=B0?= =?UTF-8?q?=D1=82=D1=87=D0=B8=D0=BA=D0=B0=D0=BC=20=D0=B8=D0=B7=20=D0=B1?= =?UTF-8?q?=D1=8D=D0=BA=D0=B5=D0=BD=D0=B4=D0=B0,=20=D0=B8=D0=B7=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=82=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B8=D0=BF=D0=B0=20=D1=81=D0=B5=D0=BD=D1=81=D0=BE=D1=80=D0=B0?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../account/serializers/sensor_serializers.py | 5 +- .../sensor_serializers — копия 2.py | 92 +++ frontend/app/(protected)/navigation/page.tsx | 6 + .../navigation/page.tsx-копия 4 | 636 ++++++++++++++++++ .../navigation/page.tsx — копия 4 | 24 +- .../navigation/page.tsx — копия 6 | 618 +++++++++++++++++ frontend/app/api/get-detectors/route.ts | 17 +- .../get-detectors/route — копия.ts | 104 +++ frontend/components/model/ModelViewer.tsx | 2 +- .../model/ModelViewer.tsx — копия 3 | 61 +- .../model/ModelViewer.tsx — копия 4 | 45 +- .../components/navigation/DetectorMenu.tsx | 78 ++- .../DetectorMenu.tsx — копия 3 | 329 +++++++++ .../DetectorMenu.tsx — копия 4 | 329 +++++++++ .../DetectorMenu.tsx — копия 5 | 381 +++++++++++ 15 files changed, 2644 insertions(+), 83 deletions(-) create mode 100644 backend/api/account/serializers/sensor_serializers — копия 2.py create mode 100644 frontend/app/(protected)/navigation/page.tsx-копия 4 create mode 100644 frontend/app/(protected)/navigation/page.tsx — копия 6 create mode 100644 frontend/app/api/get-detectors/route — копия.ts create mode 100644 frontend/components/navigation/DetectorMenu.tsx — копия 3 create mode 100644 frontend/components/navigation/DetectorMenu.tsx — копия 4 create mode 100644 frontend/components/navigation/DetectorMenu.tsx — копия 5 diff --git a/backend/api/account/serializers/sensor_serializers.py b/backend/api/account/serializers/sensor_serializers.py index 960ed6a..20aa065 100644 --- a/backend/api/account/serializers/sensor_serializers.py +++ b/backend/api/account/serializers/sensor_serializers.py @@ -66,8 +66,9 @@ class DetectorSerializer(serializers.ModelSerializer): return None 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: return latest_alert.severity # вернет 'warning' или 'critical' return 'normal' diff --git a/backend/api/account/serializers/sensor_serializers — копия 2.py b/backend/api/account/serializers/sensor_serializers — копия 2.py new file mode 100644 index 0000000..960ed6a --- /dev/null +++ b/backend/api/account/serializers/sensor_serializers — копия 2.py @@ -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 + } \ No newline at end of file diff --git a/frontend/app/(protected)/navigation/page.tsx b/frontend/app/(protected)/navigation/page.tsx index 5264c75..9af0103 100644 --- a/frontend/app/(protected)/navigation/page.tsx +++ b/frontend/app/(protected)/navigation/page.tsx @@ -120,6 +120,12 @@ const NavigationPage: React.FC = () => { Object.values(detectorsData.detectors).forEach(d => { if (d.serial_number && 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') diff --git a/frontend/app/(protected)/navigation/page.tsx-копия 4 b/frontend/app/(protected)/navigation/page.tsx-копия 4 new file mode 100644 index 0000000..a5177a8 --- /dev/null +++ b/frontend/app/(protected)/navigation/page.tsx-копия 4 @@ -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(() => import('../../../components/model/ModelViewer'), { + ssr: false, + loading: () => ( +
+
Загрузка 3D-модуля…
+
+ ), + }) + +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 }>({ detectors: {} }) + const [detectorsError, setDetectorsError] = useState(null) + const [modelError, setModelError] = useState(null) + const [isModelReady, setIsModelReady] = useState(false) + const [focusedSensorId, setFocusedSensorId] = useState(null) + const [highlightAllSensors, setHighlightAllSensors] = useState(false) + const sensorStatusMap = React.useMemo(() => { + const map: Record = {} + 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(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 + 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 ( +
+ +
+ +
+ +
+ + {showMonitoring && ( +
+
+ { + console.log('[NavigationPage] Model selected:', path); + setSelectedModelPath(path) + setModelError(null) + setIsModelReady(false) + }} + /> +
+
+ )} + + {showFloorNavigation && ( +
+
+ +
+
+ )} + + {showNotifications && ( +
+
+ + {detectorsError && ( +
{detectorsError}
+ )} +
+
+ )} + + {showListOfDetectors && ( +
+
+ + {detectorsError && ( +
{detectorsError}
+ )} +
+
+ )} + + {showSensors && ( +
+
+ + {detectorsError && ( +
{detectorsError}
+ )} +
+
+ )} + + {showNotifications && showNotificationDetectorInfo && selectedNotification && (() => { + const detectorData = Object.values(detectorsData.detectors).find( + detector => detector.detector_id === selectedNotification.detector_id + ); + return detectorData ? ( +
+
+ +
+
+ ) : null; + })()} + + {showFloorNavigation && showDetectorMenu && selectedDetector && ( + null + )} + +
+
+
+ + +
+
+
+ +
+
+ {modelError ? ( + <> + {console.log('[NavigationPage] Rendering error message, modelError:', modelError)} +
+
+
+ Ошибка загрузки 3D модели +
+
+ {modelError} +
+
+ Используйте навигацию по этажам для просмотра детекторов +
+
+
+ + ) : !selectedModelPath ? ( +
+
+ AerBIM HT Monitor +
+ Выберите модель для отображения +
+
+
+ ) : ( + { + 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 ? ( + + ) : selectedDetector && showDetectorMenu && anchor ? ( + + ) : null} + + )} + /> + )} +
+
+
+
+ ) +} + +export default NavigationPage diff --git a/frontend/app/(protected)/navigation/page.tsx — копия 4 b/frontend/app/(protected)/navigation/page.tsx — копия 4 index a5177a8..5264c75 100644 --- a/frontend/app/(protected)/navigation/page.tsx — копия 4 +++ b/frontend/app/(protected)/navigation/page.tsx — копия 4 @@ -115,6 +115,7 @@ const NavigationPage: React.FC = () => { const [focusedSensorId, setFocusedSensorId] = useState(null) const [highlightAllSensors, setHighlightAllSensors] = useState(false) const sensorStatusMap = React.useMemo(() => { + // Создаём карту статусов всегда для отображения цветов датчиков const map: Record = {} Object.values(detectorsData.detectors).forEach(d => { if (d.serial_number && d.status) { @@ -347,27 +348,8 @@ const NavigationPage: React.FC = () => { ); 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); - } + // Всегда показываем меню детектора для всех датчиков + handleDetectorMenuClick(detector); } else { setFocusedSensorId(null); closeDetectorMenu(); diff --git a/frontend/app/(protected)/navigation/page.tsx — копия 6 b/frontend/app/(protected)/navigation/page.tsx — копия 6 new file mode 100644 index 0000000..5264c75 --- /dev/null +++ b/frontend/app/(protected)/navigation/page.tsx — копия 6 @@ -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(() => import('../../../components/model/ModelViewer'), { + ssr: false, + loading: () => ( +
+
Загрузка 3D-модуля…
+
+ ), + }) + +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 }>({ detectors: {} }) + const [detectorsError, setDetectorsError] = useState(null) + const [modelError, setModelError] = useState(null) + const [isModelReady, setIsModelReady] = useState(false) + const [focusedSensorId, setFocusedSensorId] = useState(null) + const [highlightAllSensors, setHighlightAllSensors] = useState(false) + const sensorStatusMap = React.useMemo(() => { + // Создаём карту статусов всегда для отображения цветов датчиков + const map: Record = {} + 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(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 + 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 ( +
+ +
+ +
+ +
+ + {showMonitoring && ( +
+
+ { + console.log('[NavigationPage] Model selected:', path); + setSelectedModelPath(path) + setModelError(null) + setIsModelReady(false) + }} + /> +
+
+ )} + + {showFloorNavigation && ( +
+
+ +
+
+ )} + + {showNotifications && ( +
+
+ + {detectorsError && ( +
{detectorsError}
+ )} +
+
+ )} + + {showListOfDetectors && ( +
+
+ + {detectorsError && ( +
{detectorsError}
+ )} +
+
+ )} + + {showSensors && ( +
+
+ + {detectorsError && ( +
{detectorsError}
+ )} +
+
+ )} + + {showNotifications && showNotificationDetectorInfo && selectedNotification && (() => { + const detectorData = Object.values(detectorsData.detectors).find( + detector => detector.detector_id === selectedNotification.detector_id + ); + return detectorData ? ( +
+
+ +
+
+ ) : null; + })()} + + {showFloorNavigation && showDetectorMenu && selectedDetector && ( + null + )} + +
+
+
+ + +
+
+
+ +
+
+ {modelError ? ( + <> + {console.log('[NavigationPage] Rendering error message, modelError:', modelError)} +
+
+
+ Ошибка загрузки 3D модели +
+
+ {modelError} +
+
+ Используйте навигацию по этажам для просмотра детекторов +
+
+
+ + ) : !selectedModelPath ? ( +
+
+ AerBIM HT Monitor +
+ Выберите модель для отображения +
+
+
+ ) : ( + { + 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 ? ( + + ) : selectedDetector && showDetectorMenu && anchor ? ( + + ) : null} + + )} + /> + )} +
+
+
+
+ ) +} + +export default NavigationPage diff --git a/frontend/app/api/get-detectors/route.ts b/frontend/app/api/get-detectors/route.ts index 8e3effd..f9f2483 100644 --- a/frontend/app/api/get-detectors/route.ts +++ b/frontend/app/api/get-detectors/route.ts @@ -71,8 +71,21 @@ export async function GET() { 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' + + // Логируем оригинальные данные для отладки + 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 { id: n.id, type, diff --git a/frontend/app/api/get-detectors/route — копия.ts b/frontend/app/api/get-detectors/route — копия.ts new file mode 100644 index 0000000..8e3effd --- /dev/null +++ b/frontend/app/api/get-detectors/route — копия.ts @@ -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 = {} + 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 = { + critical: statusColors.STATUS_COLOR_CRITICAL, + warning: statusColors.STATUS_COLOR_WARNING, + normal: statusColors.STATUS_COLOR_NORMAL, + } + + const transformedDetectors: Record = {} + const detectorsObj = detectorsPayload?.detectors ?? {} + for (const [key, sensor] of Object.entries(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 } + ) + } +} diff --git a/frontend/components/model/ModelViewer.tsx b/frontend/components/model/ModelViewer.tsx index 943f55b..a9e0064 100644 --- a/frontend/components/model/ModelViewer.tsx +++ b/frontend/components/model/ModelViewer.tsx @@ -968,4 +968,4 @@ const ModelViewer: React.FC = ({ ) } -export default ModelViewer +export default React.memo(ModelViewer) diff --git a/frontend/components/model/ModelViewer.tsx — копия 3 b/frontend/components/model/ModelViewer.tsx — копия 3 index 2ba0366..943f55b 100644 --- a/frontend/components/model/ModelViewer.tsx — копия 3 +++ b/frontend/components/model/ModelViewer.tsx — копия 3 @@ -20,6 +20,7 @@ import { PointerEventTypes, PointerInfo, Matrix, + Ray, } from '@babylonjs/core' import '@babylonjs/loaders' @@ -68,6 +69,8 @@ const ModelViewer: React.FC = ({ onSensorPick, highlightAllSensors, sensorStatusMap, + showStats = false, + onToggleStats, }) => { const canvasRef = useRef(null) const engineRef = useRef>(null) @@ -340,7 +343,8 @@ const ModelViewer: React.FC = ({ let engine: Engine try { - engine = new Engine(canvas, true, { stencil: true }) + // Оптимизация: используем FXAA вместо MSAA для снижения нагрузки на GPU + engine = new Engine(canvas, false, { stencil: true }) // false = отключаем MSAA } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) const message = `WebGL недоступен: ${errorMessage}` @@ -362,6 +366,9 @@ const ModelViewer: React.FC = ({ sceneRef.current = scene 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) camera.attachControl(canvas, true) @@ -689,16 +696,65 @@ const ModelViewer: React.FC = ({ const maxDimension = Math.max(size.x, size.y, size.z) 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) + // Логирование перед анимацией + 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() ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT) const frameRate = 60 - const durationMs = 600 + const durationMs = 800 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('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( highlightLayerRef.current, @@ -866,6 +922,7 @@ const ModelViewer: React.FC = ({ onToggleSensorHighlights={useNavigationStore.getState().toggleSensorHighlights} sensorHighlightsActive={useNavigationStore.getState().showSensorHighlights} /> + )} {/* UPDATED: Interactive overlay circles with hover effects */} diff --git a/frontend/components/model/ModelViewer.tsx — копия 4 b/frontend/components/model/ModelViewer.tsx — копия 4 index 3487235..943f55b 100644 --- a/frontend/components/model/ModelViewer.tsx — копия 4 +++ b/frontend/components/model/ModelViewer.tsx — копия 4 @@ -56,8 +56,6 @@ export interface ModelViewerProps { onSensorPick?: (sensorId: string | null) => void highlightAllSensors?: boolean sensorStatusMap?: Record - showStats?: boolean - onToggleStats?: () => void } const ModelViewer: React.FC = ({ @@ -923,49 +921,8 @@ const ModelViewer: React.FC = ({ panActive={panActive} onToggleSensorHighlights={useNavigationStore.getState().toggleSensorHighlights} sensorHighlightsActive={useNavigationStore.getState().showSensorHighlights} - onToggleStats={onToggleStats} - statsActive={showStats} /> - {/* Блок статистики датчиков */} - {showStats && sensorStatusMap && ( -
-
-
- {/* Всего датчиков */} -
- Всего: - {Object.keys(sensorStatusMap).length} -
- -
- - {/* Нормальный */} -
- Норма: - - {Object.values(sensorStatusMap).filter(s => s === '#4ade80' || s.toLowerCase() === 'normal').length} - -
- - {/* Предупреждение */} -
- Предупр.: - - {Object.values(sensorStatusMap).filter(s => s === '#fb923c' || s.toLowerCase() === 'warning').length} - -
- - {/* Критический */} -
- Критич.: - - {Object.values(sensorStatusMap).filter(s => s === '#ef4444' || s.toLowerCase() === 'critical').length} - -
-
-
-
- )} + )} {/* UPDATED: Interactive overlay circles with hover effects */} diff --git a/frontend/components/navigation/DetectorMenu.tsx b/frontend/components/navigation/DetectorMenu.tsx index 30b39f8..25d6c6b 100644 --- a/frontend/components/navigation/DetectorMenu.tsx +++ b/frontend/components/navigation/DetectorMenu.tsx @@ -55,13 +55,69 @@ const DetectorMenu: React.FC = ({ detector, isOpen, onClose, ? 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 }, - ] + // Данные для графика за последний месяц из реальных 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 = {} + + // Инициализируем все дни нулями + 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() @@ -262,9 +318,9 @@ const DetectorMenu: React.FC = ({ detector, isOpen, onClose, - {/* График за последние 3 дня */} + {/* График за последний месяц */}
-
График за 3 дня
+
График за месяц
@@ -304,9 +360,9 @@ const DetectorMenu: React.FC = ({ detector, isOpen, onClose, {/* Секция с детальной информацией о детекторе */} - {/* График за последние 3 дня */} + {/* График за последний месяц */}
-

График за последние 3 дня

+

График за последний месяц

diff --git a/frontend/components/navigation/DetectorMenu.tsx — копия 3 b/frontend/components/navigation/DetectorMenu.tsx — копия 3 new file mode 100644 index 0000000..30b39f8 --- /dev/null +++ b/frontend/components/navigation/DetectorMenu.tsx — копия 3 @@ -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 = ({ 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 = { + 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 }) => ( +
+ {compact ? ( + // Компактный режим: 4 строки по 2 колонки с основной информацией + <> + {/* Строка 1: Маркировка и тип детектора */} +
+
+
Маркировка по проекту
+
{detector.name}
+
+
+
Тип детектора
+
{displayDetectorTypeLabel}
+
+
+ {/* Строка 2: Местоположение и статус */} +
+
+
Местоположение
+
{detector.location}
+
+
+
Статус
+
{getStatusText(detector.status)}
+
+
+ {/* Строка 3: Временная метка и этаж */} +
+
+
Временная метка
+
{formattedTimestamp}
+
+
+
Этаж
+
{detector.floor}
+
+
+ {/* Строка 4: Серийный номер */} +
+
+
Серийный номер
+
{detector.serial_number}
+
+
+ + ) : ( + // Полный режим: 3 строки по 2 колонки с рамками между элементами + <> + {/* Строка 1: Маркировка по проекту и тип детектора */} +
+
+
Маркировка по проекту
+
{detector.name}
+
+
+
Тип детектора
+
{displayDetectorTypeLabel}
+
+
+ {/* Строка 2: Местоположение и статус */} +
+
+
Местоположение
+
{detector.location}
+
+
+
Статус
+
{getStatusText(detector.status)}
+
+
+ {/* Строка 3: Временная метка и серийный номер */} +
+
+
Временная метка
+
{formattedTimestamp}
+
+
+
Серийный номер
+
{detector.serial_number}
+
+
+ + )} +
+ ) + + // Компактный режим с якорной позицией (всплывающее окно) + // Используется для отображения информации при наведении на детектор в списке + 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 ( +
+
+
+
+
{detector.name}
+
+ +
+
+ + +
+ + + {/* График за последние 3 дня */} +
+
График за 3 дня
+
+ +
+
+
+
+ ) + } + + // Полный режим боковой панели (основной режим) + // Отображается как правая панель с полной информацией о детекторе + return ( +
+
+
+ {/* Заголовок с названием детектора */} +

+ {detector.name} +

+ {/* Кнопки действий: Отчет и История */} +
+ + +
+
+ + {/* Секция с детальной информацией о детекторе */} + + + {/* График за последние 3 дня */} +
+

График за последние 3 дня

+
+ +
+
+ + {/* Кнопка закрытия панели */} + +
+
+ ) +} + +export default DetectorMenu \ No newline at end of file diff --git a/frontend/components/navigation/DetectorMenu.tsx — копия 4 b/frontend/components/navigation/DetectorMenu.tsx — копия 4 new file mode 100644 index 0000000..356f1b6 --- /dev/null +++ b/frontend/components/navigation/DetectorMenu.tsx — копия 4 @@ -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 = ({ 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 = { + 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 }) => ( +
+ {compact ? ( + // Компактный режим: 4 строки по 2 колонки с основной информацией + <> + {/* Строка 1: Маркировка и тип детектора */} +
+
+
Маркировка по проекту
+
{detector.name}
+
+
+
Тип детектора
+
{displayDetectorTypeLabel}
+
+
+ {/* Строка 2: Местоположение и статус */} +
+
+
Местоположение
+
{detector.location}
+
+
+
Статус
+
{getStatusText(detector.status)}
+
+
+ {/* Строка 3: Временная метка и этаж */} +
+
+
Временная метка
+
{formattedTimestamp}
+
+
+
Этаж
+
{detector.floor}
+
+
+ {/* Строка 4: Серийный номер */} +
+
+
Серийный номер
+
{detector.serial_number}
+
+
+ + ) : ( + // Полный режим: 3 строки по 2 колонки с рамками между элементами + <> + {/* Строка 1: Маркировка по проекту и тип детектора */} +
+
+
Маркировка по проекту
+
{detector.name}
+
+
+
Тип детектора
+
{displayDetectorTypeLabel}
+
+
+ {/* Строка 2: Местоположение и статус */} +
+
+
Местоположение
+
{detector.location}
+
+
+
Статус
+
{getStatusText(detector.status)}
+
+
+ {/* Строка 3: Временная метка и серийный номер */} +
+
+
Временная метка
+
{formattedTimestamp}
+
+
+
Серийный номер
+
{detector.serial_number}
+
+
+ + )} +
+ ) + + // Компактный режим с якорной позицией (всплывающее окно) + // Используется для отображения информации при наведении на детектор в списке + 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 ( +
+
+
+
+
{detector.name}
+
+ +
+
+ + +
+ + + {/* График за последние 3 дня */} +
+
График за 3 дня
+
+ +
+
+
+
+ ) + } + + // Полный режим боковой панели (основной режим) + // Отображается как правая панель с полной информацией о детекторе + return ( +
+
+
+ {/* Заголовок с названием детектора */} +

+ {detector.name} +

+ {/* Кнопки действий: Отчет и История */} +
+ + +
+
+ + {/* Секция с детальной информацией о детекторе */} + + + {/* График за последние 3 дня */} +
+

График за последние 3 дня

+
+ +
+
+ + {/* Кнопка закрытия панели */} + +
+
+ ) +} + +export default DetectorMenu \ No newline at end of file diff --git a/frontend/components/navigation/DetectorMenu.tsx — копия 5 b/frontend/components/navigation/DetectorMenu.tsx — копия 5 new file mode 100644 index 0000000..633caef --- /dev/null +++ b/frontend/components/navigation/DetectorMenu.tsx — копия 5 @@ -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 = ({ 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 = {} + + // Инициализируем все дни нулями + 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 = { + 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 }) => ( +
+ {compact ? ( + // Компактный режим: 4 строки по 2 колонки с основной информацией + <> + {/* Строка 1: Маркировка и тип детектора */} +
+
+
Маркировка по проекту
+
{detector.name}
+
+
+
Тип детектора
+
{displayDetectorTypeLabel}
+
+
+ {/* Строка 2: Местоположение и статус */} +
+
+
Местоположение
+
{detector.location}
+
+
+
Статус
+
{getStatusText(detector.status)}
+
+
+ {/* Строка 3: Временная метка и этаж */} +
+
+
Временная метка
+
{formattedTimestamp}
+
+
+
Этаж
+
{detector.floor}
+
+
+ {/* Строка 4: Серийный номер */} +
+
+
Серийный номер
+
{detector.serial_number}
+
+
+ + ) : ( + // Полный режим: 3 строки по 2 колонки с рамками между элементами + <> + {/* Строка 1: Маркировка по проекту и тип детектора */} +
+
+
Маркировка по проекту
+
{detector.name}
+
+
+
Тип детектора
+
{displayDetectorTypeLabel}
+
+
+ {/* Строка 2: Местоположение и статус */} +
+
+
Местоположение
+
{detector.location}
+
+
+
Статус
+
{getStatusText(detector.status)}
+
+
+ {/* Строка 3: Временная метка и серийный номер */} +
+
+
Временная метка
+
{formattedTimestamp}
+
+
+
Серийный номер
+
{detector.serial_number}
+
+
+ + )} +
+ ) + + // Компактный режим с якорной позицией (всплывающее окно) + // Используется для отображения информации при наведении на детектор в списке + 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 ( +
+
+
+
+
{detector.name}
+
+ +
+
+ + +
+ + + {/* График за последний месяц */} +
+
График за месяц
+
+ +
+
+
+
+ ) + } + + // Полный режим боковой панели (основной режим) + // Отображается как правая панель с полной информацией о детекторе + return ( +
+
+
+ {/* Заголовок с названием детектора */} +

+ {detector.name} +

+ {/* Кнопки действий: Отчет и История */} +
+ + +
+
+ + {/* Секция с детальной информацией о детекторе */} + + + {/* График за последний месяц */} +
+

График за последний месяц

+
+ +
+
+ + {/* Кнопка закрытия панели */} + +
+
+ ) +} + +export default DetectorMenu \ No newline at end of file