diff --git a/frontend/.gitignore b/frontend/.gitignore index 5ef6a52..d83c234 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -39,3 +39,7 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# Include data and models folders +!/data/ +!/public/models/ diff --git a/frontend/app/(protected)/alerts/page.tsx b/frontend/app/(protected)/alerts/page.tsx new file mode 100644 index 0000000..96d3a2b --- /dev/null +++ b/frontend/app/(protected)/alerts/page.tsx @@ -0,0 +1,103 @@ +'use client' + +import React, { useState, useEffect } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import Sidebar from '../../../components/ui/Sidebar' +import useNavigationStore from '../../store/navigationStore' +import DetectorList from '../../../components/alerts/DetectorList' +import ExportMenu from '../../../components/ui/ExportMenu' + +const AlertsPage: React.FC = () => { + const router = useRouter() + const searchParams = useSearchParams() + const { currentObject, setCurrentObject } = useNavigationStore() + const [selectedDetectors, setSelectedDetectors] = useState([]) + + const urlObjectId = searchParams.get('objectId') + const urlObjectTitle = searchParams.get('objectTitle') + const objectId = currentObject.id || urlObjectId + const objectTitle = currentObject.title || urlObjectTitle + + useEffect(() => { + if (urlObjectId && urlObjectTitle && (!currentObject.id || currentObject.id !== urlObjectId)) { + setCurrentObject(urlObjectId, urlObjectTitle) + } + }, [urlObjectId, urlObjectTitle, currentObject.id, setCurrentObject]) + + const handleBackClick = () => { + router.push('/dashboard') + } + + const handleDetectorSelect = (detectorId: number, selected: boolean) => { + if (selected) { + setSelectedDetectors(prev => [...prev, detectorId]) + } else { + setSelectedDetectors(prev => prev.filter(id => id !== detectorId)) + } + } + + const handleExport = (format: 'csv' | 'pdf') => { + // TODO: добавить функционал по экспорту + console.log(`Exporting ${selectedDetectors.length} items as ${format}`) + } + + + + return ( +
+ + +
+
+
+ + +
+
+ +
+
+
+

Уведомления и тревоги

+ +
+ {/* Кол-во выбранных объектов */} + {selectedDetectors.length > 0 && ( + + Выбрано: {selectedDetectors.length} + + )} + + +
+
+ + +
+
+
+
+ ) +} + +export default AlertsPage \ No newline at end of file diff --git a/frontend/app/(protected)/dashboard/page.tsx b/frontend/app/(protected)/dashboard/page.tsx index 983ebb8..ca7cb2d 100644 --- a/frontend/app/(protected)/dashboard/page.tsx +++ b/frontend/app/(protected)/dashboard/page.tsx @@ -1,9 +1,23 @@ -import React from 'react' +'use client' -const page = () => { - return ( -
page
- ) +import React, { useEffect } from 'react' +import { useSearchParams } from 'next/navigation' +import Dashboard from '../../../components/dashboard/Dashboard' +import useNavigationStore from '../../store/navigationStore' + +const DashboardPage = () => { + const searchParams = useSearchParams() + const { currentObject, setCurrentObject } = useNavigationStore() + + const urlObjectId = searchParams.get('objectId') + const urlObjectTitle = searchParams.get('objectTitle') + useEffect(() => { + if (urlObjectId && urlObjectTitle && (!currentObject.id || currentObject.id !== urlObjectId)) { + setCurrentObject(urlObjectId, urlObjectTitle) + } + }, [urlObjectId, urlObjectTitle, currentObject.id, setCurrentObject]) + + return } -export default page \ No newline at end of file +export default DashboardPage \ No newline at end of file diff --git a/frontend/app/(protected)/layout.tsx b/frontend/app/(protected)/layout.tsx index e69de29..6ff9672 100644 --- a/frontend/app/(protected)/layout.tsx +++ b/frontend/app/(protected)/layout.tsx @@ -0,0 +1,13 @@ +import React from 'react' + +export default function ProtectedLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( +
+ {children} +
+ ) +} \ No newline at end of file diff --git a/frontend/app/(protected)/model/page.tsx b/frontend/app/(protected)/model/page.tsx index 17a6231..027d2da 100644 --- a/frontend/app/(protected)/model/page.tsx +++ b/frontend/app/(protected)/model/page.tsx @@ -1,16 +1,9 @@ 'use client' import React, { useState } from 'react' -import ModelViewer from '@/components/ModelViewer' +import ModelViewer from '@/components/model/ModelViewer' export default function Home() { - const [modelInfo, setModelInfo] = useState<{ - meshes: unknown[] - boundingBox: { - min: { x: number; y: number; z: number } - max: { x: number; y: number; z: number } - } - } | null>(null) const [error, setError] = useState(null) const handleModelLoaded = (data: { @@ -20,14 +13,12 @@ export default function Home() { max: { x: number; y: number; z: number } } }) => { - setModelInfo(data) setError(null) console.log('Model loaded successfully:', data) } const handleError = (errorMessage: string) => { setError(errorMessage) - setModelInfo(null) } return ( @@ -42,19 +33,7 @@ export default function Home() {
Error: {error}
- )} - - {modelInfo && ( -
-

EXPO Building Model

- -
-
🖱️ Left click + drag: Rotate
-
🖱️ Right click + drag: Pan
-
🖱️ Scroll: Zoom in/out
-
-
- )} + )} ) } diff --git a/frontend/app/(protected)/navigation/page.tsx b/frontend/app/(protected)/navigation/page.tsx new file mode 100644 index 0000000..f59ff96 --- /dev/null +++ b/frontend/app/(protected)/navigation/page.tsx @@ -0,0 +1,241 @@ +'use client' + +import React, { useEffect, useCallback } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import Sidebar from '../../../components/ui/Sidebar' +import useNavigationStore from '../../store/navigationStore' +import Monitoring from '../../../components/navigation/Monitoring' +import FloorNavigation from '../../../components/navigation/FloorNavigation' +import DetectorMenu from '../../../components/navigation/DetectorMenu' +import Notifications from '../../../components/notifications/Notifications' +import NotificationDetectorInfo from '../../../components/notifications/NotificationDetectorInfo' +import ModelViewer from '../../../components/model/ModelViewer' +import detectorsData from '../../../data/detectors.json' + +interface DetectorType { + detector_id: number + name: string + object: string + status: string + checked: boolean + 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 +} + +const NavigationPage: React.FC = () => { + const router = useRouter() + const searchParams = useSearchParams() + const { + currentObject, + setCurrentObject, + showMonitoring, + showFloorNavigation, + showNotifications, + selectedDetector, + showDetectorMenu, + selectedNotification, + showNotificationDetectorInfo, + closeMonitoring, + closeFloorNavigation, + closeNotifications, + setSelectedDetector, + setShowDetectorMenu, + setSelectedNotification, + setShowNotificationDetectorInfo + } = useNavigationStore() + + const urlObjectId = searchParams.get('objectId') + const urlObjectTitle = searchParams.get('objectTitle') + const objectId = currentObject.id || urlObjectId + const objectTitle = currentObject.title || urlObjectTitle + + const handleModelLoaded = useCallback(() => { + }, []) + + const handleModelError = useCallback((error: string) => { + console.error('Model loading error:', error) + }, []) + + useEffect(() => { + if (urlObjectId && urlObjectTitle && (!currentObject.id || currentObject.id !== urlObjectId)) { + setCurrentObject(urlObjectId, urlObjectTitle) + } + }, [urlObjectId, urlObjectTitle, currentObject.id, setCurrentObject]) + + const handleBackClick = () => { + router.push('/dashboard') + } + + const handleDetectorMenuClick = (detector: DetectorType) => { + if (selectedDetector?.detector_id === detector.detector_id && showDetectorMenu) { + setShowDetectorMenu(false) + setSelectedDetector(null) + } else { + setSelectedDetector(detector) + setShowDetectorMenu(true) + } + } + + const closeDetectorMenu = () => { + setShowDetectorMenu(false) + setSelectedDetector(null) + } + + 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 getStatusText = (status: string) => { + switch (status) { + case 'active': return 'Активен' + case 'inactive': return 'Неактивен' + case 'error': return 'Ошибка' + case 'maintenance': return 'Обслуживание' + default: return 'Неизвестно' + } + } + + return ( +
+ + +
+ + {showMonitoring && ( +
+
+ +
+
+ )} + + {showFloorNavigation && ( +
+
+ +
+
+ )} + + {showNotifications && ( +
+
+ +
+
+ )} + + {showNotifications && showNotificationDetectorInfo && selectedNotification && (() => { + const detectorData = Object.values(detectorsData.detectors).find( + detector => detector.detector_id === selectedNotification.detector_id + ); + return detectorData ? ( +
+
+ +
+
+ ) : null; + })()} + + {showFloorNavigation && showDetectorMenu && selectedDetector && ( + + )} + +
+
+
+ + +
+ + +
+
+ +
+
+ +
+
+
+
+ ) +} + +export default NavigationPage \ No newline at end of file diff --git a/frontend/app/(protected)/objects/page.tsx b/frontend/app/(protected)/objects/page.tsx index 983ebb8..ef574ca 100644 --- a/frontend/app/(protected)/objects/page.tsx +++ b/frontend/app/(protected)/objects/page.tsx @@ -1,9 +1,126 @@ -import React from 'react' +'use client' + +import React, { useState, useEffect } from 'react' +import ObjectGallery from '../../../components/objects/ObjectGallery' +import { ObjectData } from '../../../components/objects/ObjectCard' +import Sidebar from '../../../components/ui/Sidebar' +import detectorsData from '../../../data/detectors.json' + +// Интерфейс для данных объекта из JSON +interface RawObjectData { + name: string + title: string + description: string + image?: string + location: string + address: string + floors: number + area: number + type?: string + status?: string + zones: Array<{ + zone_id: string + name: string + detectors: number[] + }> +} + +// Функция для преобразования данных объекта из JSON +const transformObjectToObjectData = (objectId: string, objectData: RawObjectData): ObjectData => { + return { + object_id: objectId, + title: objectData.title || `Объект ${objectId}`, + description: objectData.description || `Описание объекта ${objectData.title || objectId}`, + image: objectData.image || '/images/default-object.jpg', + location: objectData.location || 'Не указано', + floors: objectData.floors, + area: objectData.area.toString(), + type: objectData.type || 'object', + status: objectData.status || 'active' + } +} + +const ObjectsPage: React.FC = () => { + const [objects, setObjects] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [selectedObjectId, setSelectedObjectId] = useState(null) + + useEffect(() => { + const loadData = () => { + try { + setLoading(true) + + if (detectorsData.objects) { + const transformedObjects = Object.entries(detectorsData.objects).map( + ([objectId, objectData]) => transformObjectToObjectData(objectId, objectData) + ) + setObjects(transformedObjects) + } else { + throw new Error('Не удалось получить данные объектов') + } + } catch (err) { + console.error('Ошибка при загрузке данных:', err) + setError(err instanceof Error ? err.message : 'Произошла неизвестная ошибка') + } finally { + setLoading(false) + } + } + + loadData() + }, []) + + const handleObjectSelect = (objectId: string) => { + console.log('Object selected:', objectId) + setSelectedObjectId(objectId) + } + + if (loading) { + return ( +
+
+
+

Загрузка объектов...

+
+
+ ) + } + + if (error) { + return ( +
+
+
+ + + +
+

Ошибка загрузки

+

{error}

+ +
+
+ ) + } -const page = () => { return ( -
page
+
+ +
+ +
+
) } -export default page \ No newline at end of file +export default ObjectsPage \ No newline at end of file diff --git a/frontend/app/(protected)/reports/page.tsx b/frontend/app/(protected)/reports/page.tsx new file mode 100644 index 0000000..2b81eda --- /dev/null +++ b/frontend/app/(protected)/reports/page.tsx @@ -0,0 +1,85 @@ +'use client' + +import React, { useEffect } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import Sidebar from '../../../components/ui/Sidebar' +import useNavigationStore from '../../store/navigationStore' +import ReportsList from '../../../components/reports/ReportsList' +import ExportMenu from '../../../components/ui/ExportMenu' +import detectorsData from '../../../data/detectors.json' + +const ReportsPage: React.FC = () => { + const router = useRouter() + const searchParams = useSearchParams() + const { currentObject, setCurrentObject } = useNavigationStore() + + const urlObjectId = searchParams.get('objectId') + const urlObjectTitle = searchParams.get('objectTitle') + const objectId = currentObject.id || urlObjectId + const objectTitle = currentObject.title || urlObjectTitle + + useEffect(() => { + if (urlObjectId && urlObjectTitle && (!currentObject.id || currentObject.id !== urlObjectId)) { + setCurrentObject(urlObjectId, urlObjectTitle) + } + }, [urlObjectId, urlObjectTitle, currentObject.id, setCurrentObject]) + + const handleBackClick = () => { + router.push('/dashboard') + } + + const handleExport = (format: 'csv' | 'pdf') => { + // TODO: добавить функционал по экспорту отчетов + console.log(`Exporting reports as ${format}`) + } + + + + return ( +
+ + +
+
+
+ + +
+
+ +
+
+
+

Отчеты по датчикам

+ + +
+ + +
+
+
+
+ ) +} + +export default ReportsPage \ No newline at end of file diff --git a/frontend/app/api/cache-mesh-data/route.ts b/frontend/app/api/cache-mesh-data/route.ts deleted file mode 100644 index 25343f2..0000000 --- a/frontend/app/api/cache-mesh-data/route.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { writeFile, mkdir } from 'fs/promises' -import { join } from 'path' - -export async function POST(request: NextRequest) { - try { - const body = await request.json() - console.log('API: Received request with fileName:', body.fileName) - - const { fileName, data } = body - - const dataDir = join(process.cwd(), 'data') - const filePath = join(dataDir, fileName) - - console.log('API: Writing to:', filePath) - - try { - await mkdir(dataDir, { recursive: true }) - console.log('API: Data directory created/verified') - } catch (error) { - console.log('API: Data directory already exists') - } - - await writeFile(filePath, JSON.stringify(data, null, 2), 'utf8') - console.log('API: File written successfully') - - return NextResponse.json({ - success: true, - message: 'Mesh data cached successfully', - fileName - }) - } catch (error) { - console.error('API: Error caching mesh data:', error) - return NextResponse.json( - { - success: false, - error: `Failed to cache mesh data: ${error}` - }, - { status: 500 } - ) - } -} \ No newline at end of file diff --git a/frontend/app/api/get-detectors-data/route.ts b/frontend/app/api/get-detectors-data/route.ts new file mode 100644 index 0000000..3a4a45d --- /dev/null +++ b/frontend/app/api/get-detectors-data/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from 'next/server' +import { readFile } from 'fs/promises' +import { join } from 'path' + +export async function GET() { + try { + // Имитация полученных данных в формате json (данные в data/detectors.json) + const filePath = join(process.cwd(), 'frontend', 'data', 'detectors.json') + const fileContent = await readFile(filePath, 'utf8') + const allData = JSON.parse(fileContent) + + return NextResponse.json({ + success: true, + data: allData, + objectsCount: Object.keys(allData.objects || {}).length, + detectorsCount: Object.keys(allData.detectors || {}).length + }) + } catch (error) { + console.error('Error fetching detectors data:', error) + return NextResponse.json( + { + success: false, + error: 'Failed to fetch detectors data' + }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 76aba25..626ec7f 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -43,3 +43,34 @@ body { color: var(--foreground); font-family: Arial, Helvetica, sans-serif; } + +/* Стилизация скроллбара */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #0e111a; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb { + background: #374151; + border-radius: 4px; + border: 1px solid #1f2937; +} + +::-webkit-scrollbar-thumb:hover { + background: #4b5563; +} + +::-webkit-scrollbar-corner { + background: #0e111a; +} + +/* Стилизация скроллбара - Firefox*/ +* { + scrollbar-width: thin; + scrollbar-color: #374151 #0e111a; +} diff --git a/frontend/app/store/navigationStore.ts b/frontend/app/store/navigationStore.ts new file mode 100644 index 0000000..b48c7be --- /dev/null +++ b/frontend/app/store/navigationStore.ts @@ -0,0 +1,207 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' + +export interface DetectorType { + detector_id: number + name: string + object: string + status: string + type: string + location: string + floor: number + checked: boolean + 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 +} + +export interface NavigationStore { + currentObject: { id: string | undefined; title: string | undefined } + navigationHistory: string[] + currentSubmenu: string | null + + showMonitoring: boolean + showFloorNavigation: boolean + showNotifications: boolean + + selectedDetector: DetectorType | null + showDetectorMenu: boolean + selectedNotification: NotificationType | null + showNotificationDetectorInfo: boolean + + setCurrentObject: (id: string | undefined, title: string | undefined) => void + clearCurrentObject: () => void + addToHistory: (path: string) => void + goBack: () => string | null + + setCurrentSubmenu: (submenu: string | null) => void + clearSubmenu: () => void + + openMonitoring: () => void + closeMonitoring: () => void + openFloorNavigation: () => void + closeFloorNavigation: () => void + openNotifications: () => void + closeNotifications: () => void + + setSelectedDetector: (detector: DetectorType | null) => void + setShowDetectorMenu: (show: boolean) => void + setSelectedNotification: (notification: NotificationType | null) => void + setShowNotificationDetectorInfo: (show: boolean) => void + + isOnNavigationPage: () => boolean + getCurrentRoute: () => string | null + getActiveSidebarItem: () => number +} + +const useNavigationStore = create()( + persist( + (set, get) => ({ + currentObject: { + id: undefined, + title: undefined, + }, + navigationHistory: [], + currentSubmenu: null, + + showMonitoring: false, + showFloorNavigation: false, + showNotifications: false, + + selectedDetector: null, + showDetectorMenu: false, + selectedNotification: null, + showNotificationDetectorInfo: false, + + setCurrentObject: (id: string | undefined, title: string | undefined) => + set({ currentObject: { id, title } }), + + clearCurrentObject: () => + set({ currentObject: { id: undefined, title: undefined } }), + + addToHistory: (path: string) => { + const { navigationHistory } = get() + const newHistory = [...navigationHistory, path] + if (newHistory.length > 10) { + newHistory.shift() + } + set({ navigationHistory: newHistory }) + }, + + goBack: () => { + const { navigationHistory } = get() + if (navigationHistory.length > 1) { + const newHistory = [...navigationHistory] + newHistory.pop() + const previousPage = newHistory.pop() + set({ navigationHistory: newHistory }) + return previousPage || null + } + return null + }, + + setCurrentSubmenu: (submenu: string | null) => + set({ currentSubmenu: submenu }), + + clearSubmenu: () => + set({ currentSubmenu: null }), + + openMonitoring: () => set({ + showMonitoring: true, + showFloorNavigation: false, + showNotifications: false, + currentSubmenu: 'monitoring', + showDetectorMenu: false, + selectedDetector: null, + showNotificationDetectorInfo: false, + selectedNotification: null + }), + + closeMonitoring: () => set({ + showMonitoring: false, + currentSubmenu: null + }), + + openFloorNavigation: () => set({ + showFloorNavigation: true, + showMonitoring: false, + showNotifications: false, + currentSubmenu: 'floors', + showNotificationDetectorInfo: false, + selectedNotification: null + }), + + closeFloorNavigation: () => set({ + showFloorNavigation: false, + showDetectorMenu: false, + selectedDetector: null, + currentSubmenu: null + }), + + openNotifications: () => set({ + showNotifications: true, + showMonitoring: false, + showFloorNavigation: false, + currentSubmenu: 'notifications', + showDetectorMenu: false, + selectedDetector: null + }), + + closeNotifications: () => set({ + showNotifications: false, + showNotificationDetectorInfo: false, + selectedNotification: null, + currentSubmenu: null + }), + + setSelectedDetector: (detector: DetectorType | null) => set({ selectedDetector: detector }), + setShowDetectorMenu: (show: boolean) => set({ showDetectorMenu: show }), + setSelectedNotification: (notification: NotificationType | null) => set({ selectedNotification: notification }), + setShowNotificationDetectorInfo: (show: boolean) => set({ showNotificationDetectorInfo: show }), + + isOnNavigationPage: () => { + const { navigationHistory } = get() + const currentRoute = navigationHistory[navigationHistory.length - 1] + return currentRoute === '/navigation' + }, + + getCurrentRoute: () => { + const { navigationHistory } = get() + return navigationHistory[navigationHistory.length - 1] || null + }, + + getActiveSidebarItem: () => { + const { showMonitoring, showFloorNavigation, showNotifications } = get() + if (showMonitoring) return 3 // Зоны Мониторинга + if (showFloorNavigation) return 4 // Навигация по этажам + if (showNotifications) return 5 // Уведомления + return 2 // Навигация (базовая) + } + }), + { + name: 'navigation-store', + } + ) +) + +export default useNavigationStore + \ No newline at end of file diff --git a/frontend/app/store/uiStore.ts b/frontend/app/store/uiStore.ts new file mode 100644 index 0000000..f70d552 --- /dev/null +++ b/frontend/app/store/uiStore.ts @@ -0,0 +1,31 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' + +interface UIState { + isSidebarCollapsed: boolean + isNavigationSubMenuExpanded: boolean + setSidebarCollapsed: (collapsed: boolean) => void + toggleSidebar: () => void + setNavigationSubMenuExpanded: (expanded: boolean) => void + toggleNavigationSubMenu: () => void +} + +const useUIStore = create()( + persist( + (set, get) => ({ + isSidebarCollapsed: false, + isNavigationSubMenuExpanded: false, + + setSidebarCollapsed: (collapsed: boolean) => set({ isSidebarCollapsed: collapsed }), + toggleSidebar: () => set({ isSidebarCollapsed: !get().isSidebarCollapsed }), + setNavigationSubMenuExpanded: (expanded: boolean) => set({ isNavigationSubMenuExpanded: expanded }), + toggleNavigationSubMenu: () => set({ isNavigationSubMenuExpanded: !get().isNavigationSubMenuExpanded }), + }), + { + name: 'ui-store', + } + ) +) + +export default useUIStore + \ No newline at end of file diff --git a/frontend/components/alerts/DetectorList.tsx b/frontend/components/alerts/DetectorList.tsx new file mode 100644 index 0000000..0d0339f --- /dev/null +++ b/frontend/components/alerts/DetectorList.tsx @@ -0,0 +1,246 @@ +'use client' + +import React, { useState, useEffect } from 'react' +import detectorsData from '../../data/detectors.json' + +interface Detector { + detector_id: number + name: string + location: string + status: string + object: string + floor: number + checked: boolean +} + +// Interface for raw detector data from JSON +interface RawDetector { + detector_id: number + name: string + object: string + status: string + type: string + location: string + floor: number + notifications: Array<{ + id: number + type: string + message: string + timestamp: string + acknowledged: boolean + priority: string + }> +} + +type FilterType = 'all' | 'critical' | 'warning' | 'normal' + +interface DetectorListProps { + objectId?: string + selectedDetectors: number[] + onDetectorSelect: (detectorId: number, selected: boolean) => void +} + +const DetectorList: React.FC = ({ objectId, selectedDetectors, onDetectorSelect }) => { + const [detectors, setDetectors] = useState([]) + const [selectedFilter, setSelectedFilter] = useState('all') + const [searchTerm, setSearchTerm] = useState('') + + useEffect(() => { + const detectorsArray = Object.values(detectorsData.detectors).filter( + (detector: RawDetector) => objectId ? detector.object === objectId : true + ) + setDetectors(detectorsArray as Detector[]) + + + }, [objectId]) + + + + const filteredDetectors = detectors.filter(detector => { + const matchesSearch = detector.name.toLowerCase().includes(searchTerm.toLowerCase()) || + detector.location.toLowerCase().includes(searchTerm.toLowerCase()) + + if (selectedFilter === 'all') return matchesSearch + if (selectedFilter === 'critical') return matchesSearch && detector.status === '#b3261e' + if (selectedFilter === 'warning') return matchesSearch && detector.status === '#fd7c22' + if (selectedFilter === 'normal') return matchesSearch && detector.status === '#00ff00' + + return matchesSearch + }) + + + + return ( +
+
+
+ + + + +
+ +
+
+ setSearchTerm(e.target.value)} + className="bg-[#161824] text-white placeholder-gray-400 px-4 py-2 rounded-lg border border-gray-600 focus:border-blue-500 focus:outline-none w-64" + /> + + + +
+
+
+ + {/* Таблица детекторов */} +
+
+ + + + + + + + + + + + {filteredDetectors.map((detector) => { + const isSelected = selectedDetectors.includes(detector.detector_id) + + return ( + + + + + + + + ) + })} + +
+ 0} + onChange={(e) => { + if (e.target.checked) { + filteredDetectors.forEach(detector => { + if (!selectedDetectors.includes(detector.detector_id)) { + onDetectorSelect(detector.detector_id, true) + } + }) + } else { + filteredDetectors.forEach(detector => { + if (selectedDetectors.includes(detector.detector_id)) { + onDetectorSelect(detector.detector_id, false) + } + }) + } + }} + className="w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500 focus:ring-2" + /> + ДетекторСтатусМестоположениеПроверен
+ onDetectorSelect(detector.detector_id, e.target.checked)} + className="w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500 focus:ring-2" + /> + {detector.name} +
+
+ + {detector.status === '#b3261e' ? 'Критическое' : + detector.status === '#fd7c22' ? 'Предупреждение' : 'Норма'} + +
+
{detector.location} + {detector.checked ? ( +
+ + + + Да +
+ ) : ( + Нет + )} +
+
+
+ + {/* Статы детекторров*/} +
+
+
{filteredDetectors.length}
+
Всего
+
+
+
{filteredDetectors.filter(d => d.status === '#00ff00').length}
+
Норма
+
+
+
{filteredDetectors.filter(d => d.status === '#fd7c22').length}
+
Предупреждения
+
+
+
{filteredDetectors.filter(d => d.status === '#b3261e').length}
+
Критические
+
+
+ + {filteredDetectors.length === 0 && ( +
+

Детекторы не найдены

+
+ )} +
+ ) +} + +export default DetectorList \ No newline at end of file diff --git a/frontend/components/dashboard/AreaChart.tsx b/frontend/components/dashboard/AreaChart.tsx new file mode 100644 index 0000000..61c20bd --- /dev/null +++ b/frontend/components/dashboard/AreaChart.tsx @@ -0,0 +1,48 @@ +'use client' + +import React from 'react' + +interface ChartDataPoint { + value: number + label?: string + timestamp?: string +} + +interface AreaChartProps { + className?: string + data?: ChartDataPoint[] +} + +const AreaChart: React.FC = ({ className = '' }) => { + return ( +
+ + + + + + + + + + + + + + + + + +
+ ) +} + +export default AreaChart \ No newline at end of file diff --git a/frontend/components/dashboard/BarChart.tsx b/frontend/components/dashboard/BarChart.tsx new file mode 100644 index 0000000..7485ad1 --- /dev/null +++ b/frontend/components/dashboard/BarChart.tsx @@ -0,0 +1,62 @@ +'use client' + +import React from 'react' + +interface ChartDataPoint { + value: number + label?: string + color?: string +} + +interface BarChartProps { + className?: string + data?: ChartDataPoint[] +} + +const BarChart: React.FC = ({ className = '' }) => { + const barData = [ + { value: 80, color: 'rgb(42, 157, 144)' }, + { value: 65, color: 'rgb(42, 157, 144)' }, + { value: 90, color: 'rgb(42, 157, 144)' }, + { value: 45, color: 'rgb(42, 157, 144)' }, + { value: 75, color: 'rgb(42, 157, 144)' }, + { value: 55, color: 'rgb(42, 157, 144)' }, + { value: 85, color: 'rgb(42, 157, 144)' }, + { value: 70, color: 'rgb(42, 157, 144)' }, + { value: 60, color: 'rgb(42, 157, 144)' }, + { value: 95, color: 'rgb(42, 157, 144)' }, + { value: 40, color: 'rgb(42, 157, 144)' }, + { value: 80, color: 'rgb(42, 157, 144)' } + ] + + return ( +
+ + + {barData.map((bar, index) => { + const barWidth = 40 + const barSpacing = 12 + const x = index * (barWidth + barSpacing) + 20 + const barHeight = (bar.value / 100) * 160 + const y = 180 - barHeight + + return ( + + ) + })} + + +
+ ) +} + +export default BarChart \ No newline at end of file diff --git a/frontend/components/dashboard/ChartCard.tsx b/frontend/components/dashboard/ChartCard.tsx new file mode 100644 index 0000000..7e556af --- /dev/null +++ b/frontend/components/dashboard/ChartCard.tsx @@ -0,0 +1,41 @@ +'use client' + +import React from 'react' + +interface ChartCardProps { + title: string + subtitle?: string + children: React.ReactNode + className?: string +} + +const ChartCard: React.FC = ({ + title, + subtitle, + children, + className = '' +}) => { + return ( +
+
+
+

{title}

+ {subtitle && ( +

{subtitle}

+ )} +
+
+ + + +
+
+ +
+ {children} +
+
+ ) +} + +export default ChartCard \ No newline at end of file diff --git a/frontend/components/dashboard/Dashboard.tsx b/frontend/components/dashboard/Dashboard.tsx new file mode 100644 index 0000000..475a54d --- /dev/null +++ b/frontend/components/dashboard/Dashboard.tsx @@ -0,0 +1,263 @@ +'use client' + +import React from 'react' +import { useRouter } from 'next/navigation' +import Sidebar from '../ui/Sidebar' +import useNavigationStore from '../../app/store/navigationStore' +import ChartCard from './ChartCard' +import AreaChart from './AreaChart' +import BarChart from './BarChart' +import DetectorChart from './DetectorChart' +import detectorsData from '../../data/detectors.json' + +const Dashboard: React.FC = () => { + const router = useRouter() + const { currentObject, setCurrentSubmenu, closeMonitoring, closeFloorNavigation, closeNotifications } = useNavigationStore() + const objectId = currentObject?.id + const objectTitle = currentObject?.title + + const handleBackClick = () => { + router.push('/objects') + } + + interface DetectorData { + detector_id: number + name: string + object: string + status: string + type: string + location: string + floor: number + checked?: boolean + notifications: Array<{ + id: number + type: string + message: string + timestamp: string + acknowledged: boolean + priority: string + }> + } + + const detectorsArray = Object.values(detectorsData.detectors).filter( + (detector: DetectorData) => objectId ? detector.object === objectId : true + ) + + // Статусы + const statusCounts = detectorsArray.reduce((acc: { critical: number; warning: number; normal: number }, detector: DetectorData) => { + if (detector.status === '#b3261e') acc.critical++ + else if (detector.status === '#fd7c22') acc.warning++ + else if (detector.status === '#00ff00') acc.normal++ + return acc + }, { critical: 0, warning: 0, normal: 0 }) + + const handleNavigationClick = () => { + // Close all submenus before navigating + closeMonitoring() + closeFloorNavigation() + closeNotifications() + setCurrentSubmenu(null) + router.push('/navigation') + } + + // No custom sidebar handling needed - using unified navigation service + + return ( +
+ + +
+
+
+ + +
+
+ +
+
+

Объект {objectId?.replace('object_', '')}

+ +
+ + +
+ + +
+ + + + Период +
+
+
+
+ + {/* Карты-графики */} +
+ + + + + + + +
+
+ + {/* Список детекторов */} +
+
+
+

Тренды

+
+ + + + Месяц + + + +
+
+ + {/* Таблица */} +
+
+ + + + + + + + + + + {detectorsArray.map((detector: DetectorData) => ( + + + + + + + ))} + +
ДетекторСтатусМестоположениеПроверен
{detector.name} +
+
+ + {detector.status === '#b3261e' ? 'Критическое' : + detector.status === '#fd7c22' ? 'Предупреждение' : 'Норма'} + +
+
{detector.location} + {detector.checked ? ( +
+ + + + Да +
+ ) : ( + Нет + )} +
+
+ + {/* Статы */} +
+
+
{detectorsArray.length}
+
Всего
+
+
+
{statusCounts.normal}
+
Норма
+
+
+
{statusCounts.warning}
+
Предупреждения
+
+
+
{statusCounts.critical}
+
Критические
+
+
+
+ + {/* Графики с аналитикой */} +
+ + + + + + + + + + + + + + + +
+
+
+
+
+
+ ) +} + +export default Dashboard \ No newline at end of file diff --git a/frontend/components/dashboard/DetectorChart.tsx b/frontend/components/dashboard/DetectorChart.tsx new file mode 100644 index 0000000..19b5953 --- /dev/null +++ b/frontend/components/dashboard/DetectorChart.tsx @@ -0,0 +1,103 @@ +'use client' + +import React from 'react' + +interface DetectorDataPoint { + value: number + label?: string + timestamp?: string + status?: string +} + +interface DetectorChartProps { + className?: string + data?: DetectorDataPoint[] + type?: 'line' | 'bar' +} + +const DetectorChart: React.FC = ({ + className = '', + type = 'line' +}) => { + if (type === 'bar') { + const barData = [ + { value: 85, label: 'Янв' }, + { value: 70, label: 'Фев' }, + { value: 90, label: 'Мар' }, + { value: 65, label: 'Апр' }, + { value: 80, label: 'Май' }, + { value: 95, label: 'Июн' } + ] + + return ( +
+ + + {barData.map((bar, index) => { + const barWidth = 50 + const barSpacing = 15 + const x = index * (barWidth + barSpacing) + 20 + const barHeight = (bar.value / 100) * 150 + const y = 160 - barHeight + + return ( + + + + {bar.label} + + + ) + })} + + +
+ ) + } + + return ( +
+ + + + + + + + + + + + + + + + + +
+ ) +} + +export default DetectorChart \ No newline at end of file diff --git a/frontend/components/ModelViewer.tsx b/frontend/components/model/ModelViewer.tsx similarity index 72% rename from frontend/components/ModelViewer.tsx rename to frontend/components/model/ModelViewer.tsx index 4a4f04c..b563a6d 100644 --- a/frontend/components/ModelViewer.tsx +++ b/frontend/components/model/ModelViewer.tsx @@ -1,23 +1,21 @@ 'use client' -import React, { useEffect, useRef, useState, useCallback } from 'react' +import React, { useEffect, useRef, useState } from 'react' import { Engine, Scene, Vector3, HemisphericLight, ArcRotateCamera, - MeshBuilder, - StandardMaterial, Color3, Color4, AbstractMesh, - Mesh, Nullable, SceneLoader } from '@babylonjs/core' import '@babylonjs/loaders' -import { getCacheFileName, loadCachedData, parseAndCacheScene, ParsedMeshData } from '../utils/meshCache' +import LoadingSpinner from '../ui/LoadingSpinner' + interface ModelViewerProps { modelPath: string @@ -40,6 +38,8 @@ const ModelViewer: React.FC = ({ const engineRef = useRef>(null) const sceneRef = useRef>(null) const [isLoading, setIsLoading] = useState(false) + const [loadingProgress, setLoadingProgress] = useState(0) + const [showModel, setShowModel] = useState(false) const isInitializedRef = useRef(false) const isDisposedRef = useRef(false) @@ -127,18 +127,22 @@ const ModelViewer: React.FC = ({ } setIsLoading(true) - console.log('🚀 Loading GLTF model:', modelPath) + setLoadingProgress(0) + setShowModel(false) + console.log('Loading GLTF model:', modelPath) + + // UI элемент загрузчика (есть эффект замедленности) + const progressInterval = setInterval(() => { + setLoadingProgress(prev => { + if (prev >= 90) { + clearInterval(progressInterval) + return 90 + } + return prev + Math.random() * 15 + }) + }, 100) try { - const cacheFileName = getCacheFileName(modelPath) - const cachedData = await loadCachedData(cacheFileName) - - if (cachedData) { - console.log('📦 Using cached mesh data for analysis') - } else { - console.log('🔄 No cached data found, parsing scene...') - } - const result = await SceneLoader.ImportMeshAsync( '', modelPath, @@ -146,9 +150,11 @@ const ModelViewer: React.FC = ({ sceneRef.current ) - console.log('✅ GLTF Model loaded successfully!') - console.log('📊 Loaded Meshes:', result.meshes.length) + clearInterval(progressInterval) + setLoadingProgress(100) + console.log('GLTF Model loaded successfully!') + if (result.meshes.length > 0) { const boundingBox = result.meshes[0].getHierarchyBoundingVectors() @@ -159,8 +165,7 @@ const ModelViewer: React.FC = ({ camera.radius = maxDimension * 2 camera.target = result.meshes[0].position - const parsedData = await parseAndCacheScene(result.meshes, cacheFileName, modelPath) - + onModelLoaded?.({ meshes: result.meshes, boundingBox: { @@ -169,38 +174,48 @@ const ModelViewer: React.FC = ({ } }) - console.log('🎉 Model ready for viewing!') + // Плавное появление модели + setTimeout(() => { + if (!isDisposedRef.current) { + setShowModel(true) + setIsLoading(false) + } + }, 500) } else { - console.warn('⚠️ No meshes found in model') + console.warn('No meshes found in model') onError?.('No geometry found in model') - } - } catch (error) { - console.error('❌ Error loading GLTF model:', error) - onError?.(`Failed to load model: ${error}`) - } finally { - if (!isDisposedRef.current) { setIsLoading(false) } + } catch (error) { + clearInterval(progressInterval) + console.error('Error loading GLTF model:', error) + onError?.(`Failed to load model: ${error}`) + setIsLoading(false) } } loadModel() - }, [modelPath]) + }, [modelPath, onError, onModelLoaded]) return (
{isLoading && ( -
-
- Loading Model... +
+
)}
) } -export default ModelViewer \ No newline at end of file +export default ModelViewer \ No newline at end of file diff --git a/frontend/components/navigation/DetectorMenu.tsx b/frontend/components/navigation/DetectorMenu.tsx new file mode 100644 index 0000000..6f892ac --- /dev/null +++ b/frontend/components/navigation/DetectorMenu.tsx @@ -0,0 +1,97 @@ +'use client' + +import React from 'react' + +interface DetectorType { + detector_id: number + name: string + object: string + status: string + checked: boolean + type: string + location: string + floor: number +} + +interface DetectorMenuProps { + detector: DetectorType + isOpen: boolean + onClose: () => void + getStatusText: (status: string) => string +} + +const DetectorMenu: React.FC = ({ detector, isOpen, onClose, getStatusText }) => { + if (!isOpen) return null + + return ( +
+
+ {/* Header */} +
+

+ Датч.{detector.name} +

+
+ + +
+
+ + {/* Detector Information Table */} +
+
+
+
Маркировка по проекту
+
{detector.name}
+
+
+
Тип детектора
+
{detector.type}
+
+
+
+
+
Местоположение
+
{detector.location}
+
+
+
Статус
+
{getStatusText(detector.status)}
+
+
+
+
+
Временная метка
+
Сегодня, 14:30
+
+
+
Вчера
+
+
+
+ + {/* Close Button */} + +
+
+ ) +} + +export default DetectorMenu \ No newline at end of file diff --git a/frontend/components/navigation/FloorNavigation.tsx b/frontend/components/navigation/FloorNavigation.tsx new file mode 100644 index 0000000..cc5b2c4 --- /dev/null +++ b/frontend/components/navigation/FloorNavigation.tsx @@ -0,0 +1,206 @@ +'use client' + +import React, { useState } from 'react' + +interface DetectorsDataType { + detectors: Record +} + +interface FloorNavigationProps { + objectId?: string + detectorsData: DetectorsDataType + onDetectorMenuClick: (detector: DetectorType) => void + onClose?: () => void +} + +interface DetectorType { + detector_id: number + name: string + object: string + status: string + checked: boolean + type: string + location: string + floor: number + notifications: Array<{ + id: number + type: string + message: string + timestamp: string + acknowledged: boolean + priority: string + }> +} + +const FloorNavigation: React.FC = ({ objectId, detectorsData, onDetectorMenuClick, onClose }) => { + const [expandedFloors, setExpandedFloors] = useState>(new Set()) + const [searchTerm, setSearchTerm] = useState('') + + // конвертация детекторов в array и фильтруем по objectId и тексту запроса + const detectorsArray = Object.values(detectorsData.detectors) as DetectorType[] + let filteredDetectors = objectId + ? detectorsArray.filter(detector => detector.object === objectId) + : detectorsArray + + // Фильтр-поиск + if (searchTerm) { + filteredDetectors = filteredDetectors.filter(detector => + detector.name.toLowerCase().includes(searchTerm.toLowerCase()) || + detector.location.toLowerCase().includes(searchTerm.toLowerCase()) + ) + } + + // Группиовка детекторов по этажам + const detectorsByFloor = filteredDetectors.reduce((acc, detector) => { + const floor = detector.floor + if (!acc[floor]) { + acc[floor] = [] + } + acc[floor].push(detector) + return acc + }, {} as Record) + + // Сортировка этажей + const sortedFloors = Object.keys(detectorsByFloor) + .map(Number) + .sort((a, b) => a - b) + + const toggleFloor = (floor: number) => { + const newExpanded = new Set(expandedFloors) + if (newExpanded.has(floor)) { + newExpanded.delete(floor) + } else { + newExpanded.add(floor) + } + setExpandedFloors(newExpanded) + } + + const getStatusColor = (status: string) => { + switch (status) { + case '#b3261e': return 'bg-red-500' + case '#fd7c22': return 'bg-orange-500' + case '#00ff00': return 'bg-green-500' + default: return 'bg-gray-500' + } + } + + const getStatusText = (status: string) => { + switch (status) { + case '#b3261e': return 'Критический' + case '#fd7c22': return 'Предупреждение' + case '#00ff00': return 'Норма' + default: return 'Неизвестно' + } + } + + const handleDetectorMenuClick = (detector: DetectorType) => { + onDetectorMenuClick(detector) + } + + return ( +
+
+
+

Навигация по этажам

+ {onClose && ( + + )} +
+ +
+
+ setSearchTerm(e.target.value)} + className="w-full bg-[rgb(27,30,40)] text-white placeholder-gray-400 px-4 py-2 rounded-lg border border-gray-600 focus:border-blue-500 focus:outline-none" + /> + + + +
+ +
+ +
+ {sortedFloors.map(floor => { + const floorDetectors = detectorsByFloor[floor] + const isExpanded = expandedFloors.has(floor) + + return ( +
+ + + {/* суб-меню с детекторами */} + {isExpanded && ( +
+ {floorDetectors.map(detector => ( +
+
+
{detector.name}
+
{detector.location}
+
+
+
+ {getStatusText(detector.status)} + {detector.checked && ( + + + + )} + +
+
+ ))} +
+ )} +
+ ) + })} +
+
+ +
+ ) +} + +export default FloorNavigation \ No newline at end of file diff --git a/frontend/components/navigation/Monitoring.tsx b/frontend/components/navigation/Monitoring.tsx new file mode 100644 index 0000000..c669d54 --- /dev/null +++ b/frontend/components/navigation/Monitoring.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import Image from 'next/image'; + +interface MonitoringProps { + objectId?: string; + onClose?: () => void; +} + +const Monitoring: React.FC = ({ onClose }) => { + return ( +
+
+
+

Зоны мониторинга

+ {onClose && ( + + )} +
+ +
+
+ Object Model { + const target = e.target as HTMLImageElement; + target.style.display = 'none'; + }} + /> +
+
+ +
+ {[1, 2, 3, 4, 5, 6].map((zone) => ( +
+
+ {`Зона { + const target = e.target as HTMLImageElement; + target.style.display = 'none'; + }} + /> +
+
+ ))} +
+
+
+ ); +}; + +export default Monitoring; \ No newline at end of file diff --git a/frontend/components/notifications/NotificationDetectorInfo.tsx b/frontend/components/notifications/NotificationDetectorInfo.tsx new file mode 100644 index 0000000..c87a60f --- /dev/null +++ b/frontend/components/notifications/NotificationDetectorInfo.tsx @@ -0,0 +1,175 @@ +'use client' + +import React from 'react' + +interface DetectorInfoType { + detector_id: number + name: string + object: string + status: string + type: string + location: string + floor: number + checked: boolean + notifications: Array<{ + id: number + type: string + message: string + timestamp: string + acknowledged: boolean + priority: string + }> +} + +interface NotificationDetectorInfoProps { + detectorData: DetectorInfoType + onClose: () => void +} + +const NotificationDetectorInfo: React.FC = ({ detectorData, onClose }) => { + const detectorInfo = detectorData + + if (!detectorInfo) { + return ( +
+
+
+

Информация о детекторе

+ +
+

Детектор не найден

+
+
+ ) + } + + const getStatusColor = (status: string) => { + if (status === '#b3261e') return 'text-red-400' + if (status === '#fd7c22') return 'text-orange-400' + if (status === '#4caf50') return 'text-green-400' + return 'text-gray-400' + } + + const getPriorityColor = (priority: string) => { + switch (priority.toLowerCase()) { + case 'high': return 'text-red-400' + case 'medium': return 'text-orange-400' + case 'low': return 'text-green-400' + default: return 'text-gray-400' + } + } + + // Get the latest notification for this detector + const latestNotification = detectorInfo.notifications && detectorInfo.notifications.length > 0 + ? detectorInfo.notifications.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())[0] + : null + + const formatDate = (dateString: string) => { + const date = new Date(dateString) + return date.toLocaleString('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) + } + + return ( +
+
+
+

+ Датч.{detectorInfo.name} +

+
+ + +
+
+ + {/* Табличка с детекторами */} +
+
+
+
Маркировка по проекту
+
{detectorInfo.name}
+
+
+
Тип детектора
+
{detectorInfo.type}
+
+
+
+
+
Местоположение
+
{detectorInfo.location}
+
+
+
Статус
+
+
+ + {detectorInfo.status === '#b3261e' ? 'Критический' : + detectorInfo.status === '#fd7c22' ? 'Предупреждение' : + detectorInfo.status === '#4caf50' ? 'Нормальный' : 'Неизвестно'} + +
+
+
+ {latestNotification && ( +
+
+
Последнее уведомление
+
{formatDate(latestNotification.timestamp)}
+
+
+
Приоритет
+
+ {latestNotification.priority} +
+
+
+ )} + {latestNotification && ( +
+
Сообщение
+
{latestNotification.message}
+
+ )} +
+ + +
+
+ ) +} + +export default NotificationDetectorInfo \ No newline at end of file diff --git a/frontend/components/notifications/Notifications.tsx b/frontend/components/notifications/Notifications.tsx new file mode 100644 index 0000000..b4adbc7 --- /dev/null +++ b/frontend/components/notifications/Notifications.tsx @@ -0,0 +1,134 @@ +'use client' + +import React from 'react' +import { IoClose } from 'react-icons/io5' + +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 DetectorType { + detector_id: number + name: string + object: string + status: string + type: string + location: string + floor: number + checked: boolean + notifications: Array<{ + id: number + type: string + message: string + timestamp: string + acknowledged: boolean + priority: string + }> +} + +interface DetectorsDataType { + detectors: Record +} + +interface NotificationsProps { + objectId?: string + detectorsData: DetectorsDataType + onNotificationClick: (notification: NotificationType) => void + onClose: () => void +} + +const Notifications: React.FC = ({ objectId, detectorsData, onNotificationClick, onClose }) => { + const allNotifications = React.useMemo(() => { + const notifications: NotificationType[] = []; + Object.values(detectorsData.detectors).forEach(detector => { + if (detector.notifications && detector.notifications.length > 0) { + detector.notifications.forEach(notification => { + notifications.push({ + ...notification, + detector_id: detector.detector_id, + detector_name: detector.name, + location: detector.location, + object: detector.object, + status: detector.status + }); + }); + } + }); + return notifications; + }, [detectorsData]); + + // сортировка по objectId + const filteredNotifications = objectId + ? allNotifications.filter(notification => notification.object.toString() === objectId.toString()) + : allNotifications + + // сортировка по timestamp + const sortedNotifications = [...filteredNotifications].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + + const getStatusColor = (type: string) => { + switch (type.toLowerCase()) { + case 'critical': return 'bg-red-500' + case 'warning': return 'bg-orange-500' + case 'info': return 'bg-green-500' + default: return 'bg-gray-500' + } + } + + return ( +
+
+

Уведомления

+ +
+ + {/* Список уведомлений*/} +
+ {sortedNotifications.length === 0 ? ( +
+

Уведомления не найдены

+
+ ) : ( +
+ {sortedNotifications.map((notification) => ( +
+
+
+
+
{notification.detector_name}
+
{notification.message}
+
+
+ +
+ ))} +
+ )} +
+
+ ) +} + +export default Notifications \ No newline at end of file diff --git a/frontend/components/objects/ObjectCard.tsx b/frontend/components/objects/ObjectCard.tsx new file mode 100644 index 0000000..875a5fc --- /dev/null +++ b/frontend/components/objects/ObjectCard.tsx @@ -0,0 +1,103 @@ +'use client' + +import React from 'react' +import Image from 'next/image' +import { useNavigationService } from '@/services/navigationService' +interface ObjectData { + object_id: string + title: string + description: string + image: string + location: string + floors?: number + area?: string + type?: string + status?: string +} + +interface ObjectCardProps { + object: ObjectData + onSelect?: (objectId: string) => void + isSelected?: boolean +} + +// Иконка редактирования +const EditIcon = ({ className }: { className?: string }) => ( + + + +) + +const ObjectCard: React.FC = ({ object, onSelect, isSelected = false }) => { + const navigationService = useNavigationService() + + const handleCardClick = () => { + if (onSelect) { + onSelect(object.object_id) + } + // Навигация к дашборду с выбранным объектом + navigationService.selectObjectAndGoToDashboard(object.object_id, object.title) + } + + const handleEditClick = (e: React.MouseEvent) => { + e.stopPropagation() + console.log('Edit object:', object.object_id) + // Логика редактирования объекта + } + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + handleCardClick() + } + }} + > +
+
+

+ {object.title} +

+

+ {object.description} +

+
+ +
+ + {/* Изображение объекта */} +
+ {object.title} { + // Заглушка при ошибке загрузки изображения + const target = e.target as HTMLImageElement + target.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDUyIiBoZWlnaHQ9IjMwMiIgdmlld0JveD0iMCAwIDQ1MiAzMDIiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSI0NTIiIGhlaWdodD0iMzAyIiBmaWxsPSIjRjFGMUYxIi8+CjxwYXRoIGQ9Ik0yMjYgMTUxTDI0NiAxMzFMMjY2IDE1MUwyNDYgMTcxTDIyNiAxNTFaIiBmaWxsPSIjOTk5OTk5Ii8+Cjx0ZXh0IHg9IjIyNiIgeT0iMTkwIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjOTk5OTk5IiBmb250LXNpemU9IjE0Ij7QntCx0YrQtdC60YI8L3RleHQ+Cjwvc3ZnPgo=' + }} + /> +
+
+ ) +} + +export default ObjectCard +export type { ObjectData, ObjectCardProps } \ No newline at end of file diff --git a/frontend/components/objects/ObjectGallery.tsx b/frontend/components/objects/ObjectGallery.tsx new file mode 100644 index 0000000..3cfbc2d --- /dev/null +++ b/frontend/components/objects/ObjectGallery.tsx @@ -0,0 +1,102 @@ +'use client' + +import React from 'react' +import ObjectCard, { ObjectData } from './ObjectCard' + +interface ObjectGalleryProps { + objects: ObjectData[] + title?: string + onObjectSelect?: (objectId: string) => void + selectedObjectId?: string | null + className?: string +} + +const BackIcon = ({ className }: { className?: string }) => ( + + + +) + +const ObjectGallery: React.FC = ({ + objects, + title = 'Объекты', + onObjectSelect, + selectedObjectId, + className = '' +}) => { + + const handleObjectSelect = (objectId: string) => { + if (onObjectSelect) { + onObjectSelect(objectId) + } + } + + const handleBackClick = () => { + console.log('Back clicked') + } + + return ( +
+
+
+
+ + +
+

+ {title} +

+
+ + +
+ + {/* Галерея объектов */} + {objects.length > 0 ? ( +
+ {objects.map((object) => ( + + ))} +
+ ) : ( +
+
+
+ + + +
+

Объекты не найдены

+

Нет доступных объектов

+
+
+ )} +
+
+
+ ) +} + +export default ObjectGallery +export type { ObjectGalleryProps } \ No newline at end of file diff --git a/frontend/components/reports/ReportsList.tsx b/frontend/components/reports/ReportsList.tsx new file mode 100644 index 0000000..a99f63c --- /dev/null +++ b/frontend/components/reports/ReportsList.tsx @@ -0,0 +1,288 @@ +'use client'; + +import React, { useState, useMemo } from 'react'; + +interface NotificationType { + id: number; + type: string; + message: string; + timestamp: string; + acknowledged: boolean; + priority: string; + detector_id: number; + detector_name: string; + location: string; + object: string; +} + +interface DetectorType { + detector_id: number + name: string + object: string + status: string + type: string + location: string + floor: number + checked: boolean + notifications: Array<{ + id: number + type: string + message: string + timestamp: string + acknowledged: boolean + priority: string + }> +} + +interface DetectorsDataType { + detectors: Record +} + +interface ReportsListProps { + objectId?: string; + detectorsData: DetectorsDataType; +} + +const ReportsList: React.FC = ({ detectorsData }) => { + const [searchTerm, setSearchTerm] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [priorityFilter] = useState('all'); + const [acknowledgedFilter] = useState('all'); + + const allNotifications = useMemo(() => { + const notifications: NotificationType[] = []; + Object.values(detectorsData.detectors).forEach(detector => { + if (detector.notifications && detector.notifications.length > 0) { + detector.notifications.forEach(notification => { + notifications.push({ + ...notification, + detector_id: detector.detector_id, + detector_name: detector.name, + location: detector.location, + object: detector.object + }); + }); + } + }); + return notifications; + }, [detectorsData]); + + const filteredDetectors = useMemo(() => { + return allNotifications.filter(notification => { + const matchesSearch = notification.detector_name.toLowerCase().includes(searchTerm.toLowerCase()) || + notification.location.toLowerCase().includes(searchTerm.toLowerCase()) || + notification.message.toLowerCase().includes(searchTerm.toLowerCase()); + + const matchesStatus = statusFilter === 'all' || notification.type === statusFilter; + const matchesPriority = priorityFilter === 'all' || notification.priority === priorityFilter; + const matchesAcknowledged = acknowledgedFilter === 'all' || + (acknowledgedFilter === 'acknowledged' && notification.acknowledged) || + (acknowledgedFilter === 'unacknowledged' && !notification.acknowledged); + + return matchesSearch && matchesStatus && matchesPriority && matchesAcknowledged; + }); + }, [allNotifications, searchTerm, statusFilter, priorityFilter, acknowledgedFilter]); + + const getStatusColor = (type: string) => { + switch (type) { + case 'critical': return '#b3261e'; + case 'warning': return '#fd7c22'; + case 'info': return '#00ff00'; + default: return '#666'; + } + }; + + const getPriorityColor = (priority: string) => { + switch (priority) { + case 'high': return '#b3261e'; + case 'medium': return '#fd7c22'; + case 'low': return '#00ff00'; + default: return '#666'; + } + }; + + const getStatusCounts = () => { + const counts = { + total: allNotifications.length, + critical: allNotifications.filter(d => d.type === 'critical').length, + warning: allNotifications.filter(d => d.type === 'warning').length, + info: allNotifications.filter(d => d.type === 'info').length, + acknowledged: allNotifications.filter(d => d.acknowledged).length, + unacknowledged: allNotifications.filter(d => !d.acknowledged).length + }; + return counts; + }; + + const counts = getStatusCounts(); + + return ( +
+ {/* Поиск и сортировка*/} +
+
+ + + + +
+ +
+
+ setSearchTerm(e.target.value)} + className="bg-[#161824] text-white placeholder-gray-400 px-4 py-2 rounded-lg border border-gray-600 focus:border-blue-500 focus:outline-none w-64" + /> + + + +
+
+
+ + {/* Табличка с детекторами*/} +
+
+ + + + + + + + + + + + + + {filteredDetectors.map((detector) => ( + + + + + + + + + + ))} + {filteredDetectors.length === 0 && ( + + + + )} + +
ДетекторСтатусСообщениеМестоположениеПриоритетПодтвержденоВремя
+
{detector.detector_name}
+
ID: {detector.detector_id}
+
+
+
+ + {detector.type === 'critical' ? 'Критический' : + detector.type === 'warning' ? 'Предупреждение' : 'Информация'} + +
+
+
{detector.message}
+
+
{detector.location}
+
+ + {detector.priority === 'high' ? 'Высокий' : + detector.priority === 'medium' ? 'Средний' : 'Низкий'} + + + + {detector.acknowledged ? 'Да' : 'Нет'} + + +
+ {new Date(detector.timestamp).toLocaleString('ru-RU')} +
+
+ Детекторы не найдены +
+
+
+ +
+
+
{counts.total}
+
Всего
+
+
+
{counts.critical}
+
Критические
+
+
+
{counts.warning}
+
Предупреждения
+
+
+
{counts.info}
+
Информация
+
+
+
{counts.acknowledged}
+
Подтверждено
+
+
+
{counts.unacknowledged}
+
Не подтверждено
+
+
+
+ ); +}; + +export default ReportsList; \ No newline at end of file diff --git a/frontend/components/ui/ExportMenu.tsx b/frontend/components/ui/ExportMenu.tsx new file mode 100644 index 0000000..ee7e3ae --- /dev/null +++ b/frontend/components/ui/ExportMenu.tsx @@ -0,0 +1,54 @@ +'use client' + +import React, { useState } from 'react' + +interface ExportMenuProps { + onExport: (format: 'csv' | 'pdf') => void +} + +const ExportMenu: React.FC = ({ onExport }) => { + const [selectedFormat, setSelectedFormat] = useState<'csv' | 'pdf'>('csv') + + const handleExport = () => { + onExport(selectedFormat) + } + + return ( +
+
+ + + +
+ + +
+ ) +} + +export default ExportMenu \ No newline at end of file diff --git a/frontend/components/ui/LoadingSpinner.tsx b/frontend/components/ui/LoadingSpinner.tsx new file mode 100644 index 0000000..856cca6 --- /dev/null +++ b/frontend/components/ui/LoadingSpinner.tsx @@ -0,0 +1,72 @@ +'use client' + +import React from 'react' + +interface LoadingSpinnerProps { + progress?: number // 0-100 + size?: number // diameter in pixels + strokeWidth?: number + className?: string +} + +const LoadingSpinner: React.FC = ({ + progress = 0, + size = 120, + strokeWidth = 8, + className = '' +}) => { + const radius = (size - strokeWidth) / 2 + const circumference = radius * 2 * Math.PI + const strokeDasharray = circumference + const strokeDashoffset = circumference - (progress / 100) * circumference + + return ( +
+
+ {/* Background circle */} + + + {/* Progress circle */} + + + + {/* Percentage text */} +
+ + {Math.round(progress)}% + +
+
+ + {/* Loading text */} +
+ Loading Model... +
+
+ ) +} + +export default LoadingSpinner \ No newline at end of file diff --git a/frontend/components/ui/Sidebar.tsx b/frontend/components/ui/Sidebar.tsx new file mode 100644 index 0000000..7f6e782 --- /dev/null +++ b/frontend/components/ui/Sidebar.tsx @@ -0,0 +1,474 @@ +'use client' + +import React, { useState, useEffect } from 'react' +import { useRouter, usePathname } from 'next/navigation' +import Image from 'next/image' +import useUIStore from '../../app/store/uiStore' +import useNavigationStore from '../../app/store/navigationStore' +import { useNavigationService } from '@/services/navigationService' + +interface NavigationItem { + id: number + label: string + icon: React.ComponentType<{ className?: string }> +} + +interface SidebarProps { + navigationItems?: NavigationItem[] + logoSrc?: string + userInfo?: { + name: string + role: string + avatar?: string + } + activeItem?: number | null + onCustomItemClick?: (itemId: number) => boolean +} + +// Иконки-заглушки +const BookOpen = ({ className }: { className?: string }) => ( + + + +) + +const Bot = ({ className }: { className?: string }) => ( + + + +) + +const SquareTerminal = ({ className }: { className?: string }) => ( + + + +) + +const CircleDot = ({ className }: { className?: string }) => ( + + + + +) + +const BellDot = ({ className }: { className?: string }) => ( + + + +) + +const History = ({ className }: { className?: string }) => ( + + + +) + +const Settings2 = ({ className }: { className?: string }) => ( + + + +) + +const Monitor = ({ className }: { className?: string }) => ( + + + + + +) + +const Building = ({ className }: { className?: string }) => ( + + + + + + + +) + +// основные routes +const mainNavigationItems: NavigationItem[] = [ + { + id: 1, + icon: BookOpen, + label: 'Дашборд' + }, + { + id: 2, + icon: Bot, + label: 'Навигация по зданию' + }, + { + id: 8, + icon: History, + label: 'История тревог' + }, + { + id: 9, + icon: Settings2, + label: 'Отчеты' + } +] + +// суб-меню под "Навигация по зданию" +const navigationSubItems: NavigationItem[] = [ + { + id: 3, + icon: Monitor, + label: 'Зоны Мониторинга' + }, + { + id: 4, + icon: Building, + label: 'Навигация по этажам' + }, + { + id: 5, + icon: BellDot, + label: 'Уведомления' + }, + { + id: 6, + icon: CircleDot, + label: 'Сенсоры' + }, + { + id: 7, + icon: SquareTerminal, + label: 'Список датчиков' + } +] + + +const Sidebar: React.FC = ({ + logoSrc, + userInfo = { + name: 'Александр', + role: 'Администратор' + }, + activeItem: propActiveItem, + onCustomItemClick +}) => { + const navigationService = useNavigationService() + const router = useRouter() + const pathname = usePathname() + const [internalActiveItem, setInternalActiveItem] = useState(null) + const [isHydrated, setIsHydrated] = useState(false) + const [manuallyToggled, setManuallyToggled] = useState(false) + const activeItem = propActiveItem !== undefined ? propActiveItem : internalActiveItem + const { + isSidebarCollapsed: isCollapsed, + toggleSidebar, + isNavigationSubMenuExpanded: showNavigationSubItems, + setNavigationSubMenuExpanded: setShowNavigationSubItems, + toggleNavigationSubMenu + } = useUIStore() + + const { + openMonitoring, + openFloorNavigation, + openNotifications, + closeMonitoring, + closeFloorNavigation, + closeNotifications, + showMonitoring, + showFloorNavigation, + showNotifications + } = useNavigationStore() + + useEffect(() => { + setIsHydrated(true) + }, []) + + // Чек если суб-меню активны + const isNavigationSubItemActive = activeItem && [3, 4, 5, 6, 7].includes(activeItem) + const shouldShowNavigationAsActive = activeItem === 2 || isNavigationSubItemActive + + // Авто-расткрытие меню, если суб-меню стало активным (только если не было ручного переключения) + useEffect(() => { + if (isNavigationSubItemActive && !showNavigationSubItems && !manuallyToggled) { + setShowNavigationSubItems(true) + } + }, [isNavigationSubItemActive, showNavigationSubItems, manuallyToggled, setShowNavigationSubItems]) + + const handleItemClick = (itemId: number) => { + let handled = false + + // Handle submenu items directly with navigation store + switch (itemId) { + case 2: // Navigation - only navigate to page, don't open submenus + if (pathname !== '/navigation') { + router.push('/navigation') + } + handled = true + break + case 3: // Monitoring + if (pathname !== '/navigation') { + // Navigate to navigation page first, then open monitoring + router.push('/navigation') + setTimeout(() => openMonitoring(), 100) + } else if (showMonitoring) { + // Close if already open + closeMonitoring() + } else { + openMonitoring() + } + handled = true + break + case 4: // Floor Navigation + if (pathname !== '/navigation') { + // Navigate to navigation page first, then open floor navigation + router.push('/navigation') + setTimeout(() => openFloorNavigation(), 100) + } else if (showFloorNavigation) { + // Close if already open + closeFloorNavigation() + } else { + openFloorNavigation() + } + handled = true + break + case 5: // Notifications + if (pathname !== '/navigation') { + // Navigate to navigation page first, then open notifications + router.push('/navigation') + setTimeout(() => openNotifications(), 100) + } else if (showNotifications) { + // Close if already open + closeNotifications() + } else { + openNotifications() + } + handled = true + break + default: + // For other items, use navigation service + if (navigationService) { + handled = navigationService.handleSidebarItemClick(itemId, pathname) + } + break + } + + if (handled) { + // Update internal active item state + if (propActiveItem === undefined) { + setInternalActiveItem(itemId) + } + + // Call custom handler if provided (for additional logic) + if (onCustomItemClick) { + onCustomItemClick(itemId) + } + } + } + + return ( + + ) +} + +export default Sidebar \ No newline at end of file diff --git a/frontend/data/detectors.json b/frontend/data/detectors.json new file mode 100644 index 0000000..fc05a74 --- /dev/null +++ b/frontend/data/detectors.json @@ -0,0 +1,243 @@ +{ + "objects": { + "object_1": { + "name": "Торговый центр 'Галерея'", + "title": "Торговый центр 'Галерея'", + "description": "Современный торговый центр в центре города", + "image": "/images/test_image.png", + "location": "ул. Лиговский пр., 30А", + "address": "ул. Лиговский пр., 30А", + "floors": 3, + "area": 15000, + "zones": [ + { + "zone_id": "zone_1", + "name": "Зона 1", + "detectors": [4000, 4001, 4004] + }, + { + "zone_id": "zone_2", + "name": "Зона 2", + "detectors": [4002, 4003] + } + ] + }, + "object_2": { + "name": "Офисное здание 'Невский'", + "title": "Офисное здание 'Невский'", + "description": "Многоэтажное офисное здание на Невском проспекте", + "image": "/images/test_image.png", + "location": "Невский пр., 85", + "address": "Невский пр., 85", + "floors": 12, + "area": 8500, + "zones": [ + { + "zone_id": "zone_3", + "name": "Зона 3", + "detectors": [4005, 4006] + } + ] + }, + "object_3": { + "name": "Жилой комплекс 'Северная звезда'", + "title": "Жилой комплекс 'Северная звезда'", + "description": "Высотный жилой комплекс с современными квартирами", + "image": "/images/test_image.png", + "location": "ул. Комендантский пр., 17", + "address": "ул. Комендантский пр., 17", + "floors": 25, + "area": 45000, + "zones": [ + { + "zone_id": "zone_4", + "name": "Зона 4", + "detectors": [4007, 4008] + } + ] + }, + "object_4": { + "name": "Производственный комплекс 'Балтика'", + "title": "Производственный комплекс 'Балтика'", + "description": "Крупный производственный комплекс с современным оборудованием", + "image": "/images/test_image.png", + "location": "Индустриальный пр., 44", + "address": "Индустриальный пр., 44", + "floors": 2, + "area": 12000, + "zones": [ + { + "zone_id": "zone_5", + "name": "Зона 5", + "detectors": [4009] + } + ] + } + }, + "detectors": { + "4000": { + "detector_id": 4000, + "name": "SJ-4000 N81 Мультиплексор", + "object": "object_1", + "status": "#b3261e", + "checked": false, + "type": "fire_detector", + "location": "1 этаж, секция А", + "floor": 1, + "notifications": [ + { + "id": 1, + "type": "critical", + "message": "Критическое превышение температуры", + "timestamp": "2024-01-15T14:30:00Z", + "acknowledged": false, + "priority": "high" + } + ] + }, + "4001": { + "detector_id": 4001, + "name": "SJ-4001 N82 Датчик дыма", + "object": "object_1", + "status": "#fd7c22", + "checked": true, + "type": "fire_detector", + "location": "1 этаж, секция Б", + "floor": 1, + "notifications": [ + { + "id": 2, + "type": "warning", + "message": "Обнаружен дым в помещении", + "timestamp": "2024-01-15T14:25:00Z", + "acknowledged": true, + "priority": "medium" + } + ] + }, + "4002": { + "detector_id": 4002, + "name": "SJ-4002 N83 Тепловой датчик", + "object": "object_1", + "status": "#00ff00", + "checked": true, + "type": "fire_detector", + "location": "2 этаж, секция А", + "floor": 2, + "notifications": [ + { + "id": 3, + "type": "info", + "message": "Плановое техническое обслуживание", + "timestamp": "2024-01-15T14:20:00Z", + "acknowledged": true, + "priority": "low" + } + ] + }, + "4003": { + "detector_id": 4003, + "name": "SJ-4003 N81 Мультиплексор", + "object": "object_1", + "status": "#fd7c22", + "checked": false, + "type": "fire_detector", + "location": "2 этаж, секция Б", + "floor": 2, + "notifications": [] + }, + "4004": { + "detector_id": 4004, + "name": "SJ-4004 N81 Мультиплексор", + "object": "object_1", + "status": "#00ff00", + "checked": true, + "type": "fire_detector", + "location": "3 этаж, секция А", + "floor": 3, + "notifications": [] + }, + "4005": { + "detector_id": 4005, + "name": "SJ-4005 N86 Датчик CO", + "object": "object_2", + "status": "#b3261e", + "checked": false, + "type": "fire_detector", + "location": "3 этаж, секция В", + "floor": 3, + "notifications": [ + { + "id": 4, + "type": "critical", + "message": "Превышение концентрации угарного газа", + "timestamp": "2024-01-15T14:15:00Z", + "acknowledged": false, + "priority": "high" + } + ] + }, + "4006": { + "detector_id": 4006, + "name": "SJ-4006 N81 Мультиплексор", + "object": "object_2", + "status": "#00ff00", + "checked": true, + "type": "fire_detector", + "location": "6 этаж, офис 601", + "floor": 6, + "notifications": [] + }, + "4007": { + "detector_id": 4007, + "name": "SJ-4007 N88 Датчик движения", + "object": "object_3", + "status": "#fd7c22", + "checked": false, + "type": "fire_detector", + "location": "4 этаж, секция А", + "floor": 4, + "notifications": [ + { + "id": 5, + "type": "warning", + "message": "Несанкционированное движение", + "timestamp": "2024-01-15T14:10:00Z", + "acknowledged": false, + "priority": "medium" + } + ] + }, + "4008": { + "detector_id": 4008, + "name": "SJ-4008 N81 Мультиплексор", + "object": "object_3", + "status": "#00ff00", + "checked": false, + "type": "fire_detector", + "location": "15 этаж, квартира 1501", + "floor": 15, + "notifications": [] + }, + "4009": { + "detector_id": 4009, + "name": "SJ-4009 N90 Датчик протечки", + "object": "object_4", + "status": "#00ff00", + "checked": true, + "type": "fire_detector", + "location": "1 этаж, техническое помещение", + "floor": 1, + "notifications": [ + { + "id": 6, + "type": "info", + "message": "Система работает в штатном режиме", + "timestamp": "2024-01-15T14:05:00Z", + "acknowledged": true, + "priority": "low" + } + ] + } + } +} \ No newline at end of file diff --git a/frontend/public/images/test_image.png b/frontend/public/images/test_image.png new file mode 100644 index 0000000..9dd3161 Binary files /dev/null and b/frontend/public/images/test_image.png differ diff --git a/frontend/services/navigationService.ts b/frontend/services/navigationService.ts new file mode 100644 index 0000000..aa5fd27 --- /dev/null +++ b/frontend/services/navigationService.ts @@ -0,0 +1,92 @@ +import React from 'react' +import { useRouter } from 'next/navigation' +import useNavigationStore from '@/app/store/navigationStore' +import type { NavigationStore } from '@/app/store/navigationStore' + +export enum MainRoutes { + DASHBOARD = '/dashboard', + NAVIGATION = '/navigation', + ALERTS = '/alerts', + REPORTS = '/reports', + OBJECTS = '/objects' +} + +export const SIDEBAR_ITEM_MAP = { + 1: { type: 'route', value: MainRoutes.DASHBOARD }, + 2: { type: 'route', value: MainRoutes.NAVIGATION }, + 8: { type: 'route', value: MainRoutes.ALERTS }, + 9: { type: 'route', value: MainRoutes.REPORTS } +} as const + +export class NavigationService { + private router!: ReturnType + private navigationStore!: NavigationStore + private initialized = false + + init(router: ReturnType, navigationStore: NavigationStore) { + if (this.initialized) { + return // Prevent multiple initializations + } + this.router = router + this.navigationStore = navigationStore + this.initialized = true + } + + isInitialized(): boolean { + return this.initialized + } + + navigateToRoute(route: MainRoutes) { + // Clear any active submenus when navigating to a different route + if (route !== MainRoutes.NAVIGATION) { + this.navigationStore.setCurrentSubmenu(null) + } + + this.router.push(route) + } + + handleSidebarItemClick(itemId: number, currentPath: string): boolean { + if (!this.initialized) { + console.error('NavigationService not initialized!') + return false + } + + const mapping = SIDEBAR_ITEM_MAP[itemId as keyof typeof SIDEBAR_ITEM_MAP] + + if (!mapping) { + return false + } + + if (mapping.type === 'route') { + this.navigateToRoute(mapping.value as MainRoutes) + return true + } + + return false + } + + goBack() { + this.navigationStore.goBack() + } + + selectObjectAndGoToDashboard(objectId: string, objectTitle: string) { + this.navigationStore.setCurrentObject(objectId, objectTitle) + this.navigateToRoute(MainRoutes.DASHBOARD) + } +} + +export const navigationService = new NavigationService() + +export function useNavigationService() { + const router = useRouter() + const navigationStore = useNavigationStore() + + // Initialize only once per component lifecycle + React.useMemo(() => { + if (!navigationService.isInitialized()) { + navigationService.init(router, navigationStore) + } + }, [router, navigationStore]) + + return navigationService +} \ No newline at end of file diff --git a/frontend/utils/meshCache.ts b/frontend/utils/meshCache.ts deleted file mode 100644 index 292094b..0000000 --- a/frontend/utils/meshCache.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { AbstractMesh } from '@babylonjs/core' - -export interface ParsedMeshData { - meshes: unknown[] - boundingBox: { - min: { x: number; y: number; z: number } - max: { x: number; y: number; z: number } - } - metadata: { - modelPath: string - parsedAt: string - totalVertices: number - totalIndices: number - } -} - -export const getCacheFileName = (modelPath: string) => { - const fileName = modelPath.split('/').pop()?.replace('.gltf', '') || 'model' - return `${fileName}_parsed.json` -} - -export const loadCachedData = async (cacheFileName: string): Promise => { - try { - const response = await fetch(`/data/${cacheFileName}`) - if (response.ok) { - const cachedData = await response.json() - console.log('📦 Loaded cached mesh data:', cachedData.metadata) - return cachedData - } - } catch (error) { - console.log('❌ No cached data found, will parse scene') - } - return null -} - -export const extractAllMeshData = (mesh: AbstractMesh) => { - const meshData: Record = {} - - const safeStringify = (obj: unknown): unknown => { - try { - JSON.stringify(obj) - return obj - } catch { - return '[Circular Reference]' - } - } - - const extractValue = (value: unknown): unknown => { - if (value === null || value === undefined) { - return value - } - - if (typeof value === 'function') { - return '[Function]' - } - - if (typeof value === 'object' && value !== null) { - const obj = value as Record - - if (obj.constructor.name === 'Vector3') { - return { x: obj.x, y: obj.y, z: obj.z } - } else if (obj.constructor.name === 'Quaternion') { - return { x: obj.x, y: obj.y, z: obj.z, w: obj.w } - } else if (obj.constructor.name === 'Color3') { - return { r: obj.r, g: obj.g, b: obj.b } - } else if (obj.constructor.name === 'Color4') { - return { r: obj.r, g: obj.g, b: obj.b, a: obj.a } - } else if (Array.isArray(value)) { - return value.map(item => extractValue(item)) - } else { - return safeStringify(value) - } - } - - return value - } - - for (const key in mesh) { - try { - const value = (mesh as unknown as Record)[key] - meshData[key] = extractValue(value) - } catch (error) { - meshData[key] = '[Error accessing property]' - } - } - - meshData.geometry = { - vertices: mesh.getVerticesData('position') ? Array.from(mesh.getVerticesData('position')!) : [], - indices: mesh.getIndices() ? Array.from(mesh.getIndices()!) : [], - normals: mesh.getVerticesData('normal') ? Array.from(mesh.getVerticesData('normal')!) : [], - uvs: mesh.getVerticesData('uv') ? Array.from(mesh.getVerticesData('uv')!) : [], - colors: mesh.getVerticesData('color') ? Array.from(mesh.getVerticesData('color')!) : [], - tangents: mesh.getVerticesData('tangent') ? Array.from(mesh.getVerticesData('tangent')!) : [], - matricesWeights: mesh.getVerticesData('matricesWeights') ? Array.from(mesh.getVerticesData('matricesWeights')!) : [], - matricesIndices: mesh.getVerticesData('matricesIndices') ? Array.from(mesh.getVerticesData('matricesIndices')!) : [], - totalVertices: mesh.getTotalVertices(), - totalIndices: mesh.getIndices() ? mesh.getIndices()!.length : 0 - } - - return meshData -} - -export const parseAndCacheScene = async (meshes: AbstractMesh[], cacheFileName: string, modelPath: string): Promise => { - const parsedMeshes = meshes.map(mesh => extractAllMeshData(mesh)) - - const boundingBox = meshes[0].getHierarchyBoundingVectors() - const totalVertices = parsedMeshes.reduce((sum, mesh) => { - const meshData = mesh as Record - const geometry = meshData.geometry as Record - const vertices = geometry.vertices as number[] - return sum + vertices.length / 3 - }, 0) - const totalIndices = parsedMeshes.reduce((sum, mesh) => { - const meshData = mesh as Record - const geometry = meshData.geometry as Record - const indices = geometry.indices as number[] - return sum + indices.length - }, 0) - - const parsedData: ParsedMeshData = { - meshes: parsedMeshes, - boundingBox: { - min: boundingBox.min, - max: boundingBox.max - }, - metadata: { - modelPath, - parsedAt: new Date().toISOString(), - totalVertices: Math.floor(totalVertices), - totalIndices - } - } - - console.log('💾 Caching parsed mesh data:', parsedData.metadata) - - try { - console.log('💾 Attempting to cache mesh data...') - console.log('💾 Cache file name:', cacheFileName) - console.log('💾 Data size:', JSON.stringify(parsedData).length, 'bytes') - - const response = await fetch('/api/cache-mesh-data', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - fileName: cacheFileName, - data: parsedData - }) - }) - - console.log('💾 Response status:', response.status) - console.log('💾 Response ok:', response.ok) - - if (response.ok) { - const result = await response.json() - console.log('✅ Mesh data cached successfully:', result) - } else { - const errorText = await response.text() - console.warn('⚠️ Failed to cache mesh data:', response.status, errorText) - } - } catch (error) { - console.warn('⚠️ Could not cache mesh data:', error) - console.error('💾 Full error details:', error) - } - - return parsedData -} \ No newline at end of file