Merge branch 'AEB-57-loading-models-update' into 'main'
Aeb 57 loading models update See merge request wedeving/aerbim-www!7
BIN
frontend/.gitignore
vendored
103
frontend/app/(protected)/alerts/page.tsx
Normal file
@@ -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<number[]>([])
|
||||
|
||||
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 (
|
||||
<div className="flex h-screen bg-[#0e111a]">
|
||||
<Sidebar
|
||||
activeItem={8} // История тревог
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
<header className="bg-[#161824] border-b border-gray-700 px-6 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleBackClick}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
aria-label="Назад к дашборду"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<nav className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-400">Дашборд</span>
|
||||
<span className="text-gray-600">/</span>
|
||||
<span className="text-white">{objectTitle || 'Объект'}</span>
|
||||
<span className="text-gray-600">/</span>
|
||||
<span className="text-white">Уведомления</span>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 p-6 overflow-auto">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-white text-2xl font-semibold">Уведомления и тревоги</h1>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Кол-во выбранных объектов */}
|
||||
{selectedDetectors.length > 0 && (
|
||||
<span className="text-gray-300 text-sm">
|
||||
Выбрано: <span className="text-white font-medium">{selectedDetectors.length}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<ExportMenu onExport={handleExport} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DetectorList
|
||||
objectId={objectId || undefined}
|
||||
selectedDetectors={selectedDetectors}
|
||||
onDetectorSelect={handleDetectorSelect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AlertsPage
|
||||
@@ -1,9 +1,23 @@
|
||||
import React from 'react'
|
||||
'use client'
|
||||
|
||||
const page = () => {
|
||||
return (
|
||||
<div>page</div>
|
||||
)
|
||||
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 <Dashboard />
|
||||
}
|
||||
|
||||
export default page
|
||||
export default DashboardPage
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function ProtectedLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="protected-layout">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string | null>(null)
|
||||
|
||||
const handleModelLoaded = (data: {
|
||||
@@ -20,20 +13,18 @@ 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 (
|
||||
<div className="relative h-screen">
|
||||
<ModelViewer
|
||||
modelPath="/models/EXPO_АР_PostRecon_level.gltf"
|
||||
modelPath="/models/your_model_name.gltf" //пока что передаем модель через navigation page
|
||||
onModelLoaded={handleModelLoaded}
|
||||
onError={handleError}
|
||||
/>
|
||||
@@ -43,18 +34,6 @@ export default function Home() {
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modelInfo && (
|
||||
<div className="absolute top-4 right-4 z-50 max-w-xs rounded-lg bg-black/80 p-4 text-sm text-white">
|
||||
<h3 className="mb-3 text-base font-semibold">EXPO Building Model</h3>
|
||||
|
||||
<div className="space-y-1 text-xs text-gray-300">
|
||||
<div>🖱️ Left click + drag: Rotate</div>
|
||||
<div>🖱️ Right click + drag: Pan</div>
|
||||
<div>🖱️ Scroll: Zoom in/out</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
239
frontend/app/(protected)/navigation/page.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
'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 (
|
||||
<div className="flex h-screen bg-[#0e111a]">
|
||||
<Sidebar
|
||||
activeItem={2}
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col relative">
|
||||
|
||||
{showMonitoring && (
|
||||
<div className="absolute left-0 top-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]" style={{height: 'calc(100% - 73px)', top: '73px'}}>
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<Monitoring
|
||||
objectId={objectId || undefined}
|
||||
onClose={closeMonitoring}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFloorNavigation && (
|
||||
<div className="absolute left-0 top-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]" style={{height: 'calc(100% - 73px)', top: '73px'}}>
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<FloorNavigation
|
||||
objectId={objectId || undefined}
|
||||
detectorsData={detectorsData}
|
||||
onDetectorMenuClick={handleDetectorMenuClick}
|
||||
onClose={closeFloorNavigation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNotifications && (
|
||||
<div className="absolute left-0 top-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]" style={{height: 'calc(100% - 73px)', top: '73px'}}>
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<Notifications
|
||||
objectId={objectId || undefined}
|
||||
detectorsData={detectorsData}
|
||||
onNotificationClick={handleNotificationClick}
|
||||
onClose={closeNotifications}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNotifications && showNotificationDetectorInfo && selectedNotification && (() => {
|
||||
const detectorData = Object.values(detectorsData.detectors).find(
|
||||
detector => detector.detector_id === selectedNotification.detector_id
|
||||
);
|
||||
return detectorData ? (
|
||||
<div className="absolute left-[500px] top-0 bg-[#161824] border-r border-gray-700 z-30 w-[454px]" style={{height: 'calc(100% - 73px)', top: '73px'}}>
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<NotificationDetectorInfo
|
||||
detectorData={detectorData}
|
||||
onClose={closeNotificationDetectorInfo}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
{showFloorNavigation && showDetectorMenu && selectedDetector && (
|
||||
<DetectorMenu
|
||||
detector={selectedDetector}
|
||||
isOpen={showDetectorMenu}
|
||||
onClose={closeDetectorMenu}
|
||||
getStatusText={getStatusText}
|
||||
/>
|
||||
)}
|
||||
|
||||
<header className="bg-[#161824] border-b border-gray-700 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleBackClick}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
aria-label="Назад к дашборду"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<nav className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-400">Дашборд</span>
|
||||
<span className="text-gray-600">/</span>
|
||||
<span className="text-white">{objectTitle || 'Объект'}</span>
|
||||
<span className="text-gray-600">/</span>
|
||||
<span className="text-white">Навигация</span>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="h-full">
|
||||
<ModelViewer
|
||||
modelPath='/models/AerBIM-Monitor_ASM-HT-Viewer_Expo2017Astana_20250908_L_+1430.glb'
|
||||
onModelLoaded={handleModelLoaded}
|
||||
onError={handleModelError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NavigationPage
|
||||
@@ -1,9 +1,125 @@
|
||||
import React from 'react'
|
||||
'use client'
|
||||
|
||||
const page = () => {
|
||||
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<ObjectData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(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 (
|
||||
<div className="flex items-center justify-center min-h-screen bg-[#0e111a]">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||
<p className="text-white">Загрузка объектов...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-[#0e111a]">
|
||||
<div className="text-center">
|
||||
<div className="text-red-500 mb-4">
|
||||
<svg className="w-12 h-12 mx-auto" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white mb-2">Ошибка загрузки</h3>
|
||||
<p className="text-[#71717a] mb-4">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-[#3193f5] hover:bg-[#2563eb] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 transition-colors duration-200"
|
||||
>
|
||||
Попробовать снова
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div>page</div>
|
||||
<div className="flex h-screen bg-[#0e111a]">
|
||||
<Sidebar activeItem={null} />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ObjectGallery
|
||||
objects={objects}
|
||||
title="Объекты"
|
||||
onObjectSelect={handleObjectSelect}
|
||||
selectedObjectId={selectedObjectId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default page
|
||||
export default ObjectsPage
|
||||
83
frontend/app/(protected)/reports/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
'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 (
|
||||
<div className="flex h-screen bg-[#0e111a]">
|
||||
<Sidebar
|
||||
activeItem={9} // Reports
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
<header className="bg-[#161824] border-b border-gray-700 px-6 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleBackClick}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
aria-label="Назад к дашборду"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<nav className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-400">Дашборд</span>
|
||||
<span className="text-gray-600">/</span>
|
||||
<span className="text-white">{objectTitle || 'Объект'}</span>
|
||||
<span className="text-gray-600">/</span>
|
||||
<span className="text-white">Отчеты</span>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 p-6 overflow-auto">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-white text-2xl font-semibold">Отчеты по датчикам</h1>
|
||||
|
||||
<ExportMenu onExport={handleExport} />
|
||||
</div>
|
||||
|
||||
<ReportsList
|
||||
objectId={objectId || undefined}
|
||||
detectorsData={detectorsData}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReportsPage
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
28
frontend/app/api/get-detectors-data/route.ts
Normal file
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
207
frontend/app/store/navigationStore.ts
Normal file
@@ -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<NavigationStore>()(
|
||||
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
|
||||
|
||||
31
frontend/app/store/uiStore.ts
Normal file
@@ -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<UIState>()(
|
||||
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
|
||||
|
||||
241
frontend/components/alerts/DetectorList.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
'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 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<DetectorListProps> = ({ objectId, selectedDetectors, onDetectorSelect }) => {
|
||||
const [detectors, setDetectors] = useState<Detector[]>([])
|
||||
const [selectedFilter, setSelectedFilter] = useState<FilterType>('all')
|
||||
const [searchTerm, setSearchTerm] = useState<string>('')
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setSelectedFilter('all')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedFilter === 'all'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-[#161824] text-gray-300 hover:bg-[#1f2937]'
|
||||
}`}
|
||||
>
|
||||
Все ({detectors.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedFilter('critical')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedFilter === 'critical'
|
||||
? 'bg-red-600 text-white'
|
||||
: 'bg-[#161824] text-gray-300 hover:bg-[#1f2937]'
|
||||
}`}
|
||||
>
|
||||
Критические ({detectors.filter(d => d.status === '#b3261e').length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedFilter('warning')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedFilter === 'warning'
|
||||
? 'bg-orange-600 text-white'
|
||||
: 'bg-[#161824] text-gray-300 hover:bg-[#1f2937]'
|
||||
}`}
|
||||
>
|
||||
Предупреждения ({detectors.filter(d => d.status === '#fd7c22').length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedFilter('normal')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedFilter === 'normal'
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-[#161824] text-gray-300 hover:bg-[#1f2937]'
|
||||
}`}
|
||||
>
|
||||
Норма ({detectors.filter(d => d.status === '#00ff00').length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск детекторов..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="bg-[#161824] text-white placeholder-gray-400 px-4 py-2 rounded-lg border border-gray-600 focus:border-blue-500 focus:outline-none w-64"
|
||||
/>
|
||||
<svg className="absolute right-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Таблица детекторов */}
|
||||
<div className="bg-[#161824] rounded-[20px] p-6">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-700">
|
||||
<th className="text-left text-white font-medium py-3 w-12">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDetectors.length === filteredDetectors.length && filteredDetectors.length > 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"
|
||||
/>
|
||||
</th>
|
||||
<th className="text-left text-white font-medium py-3">Детектор</th>
|
||||
<th className="text-left text-white font-medium py-3">Статус</th>
|
||||
<th className="text-left text-white font-medium py-3">Местоположение</th>
|
||||
<th className="text-left text-white font-medium py-3">Проверен</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredDetectors.map((detector) => {
|
||||
const isSelected = selectedDetectors.includes(detector.detector_id)
|
||||
|
||||
return (
|
||||
<tr key={detector.detector_id} className="border-b border-gray-800">
|
||||
<td className="py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 text-white text-sm">{detector.name}</td>
|
||||
<td className="py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full`}
|
||||
style={{ backgroundColor: detector.status }}
|
||||
></div>
|
||||
<span className="text-sm text-gray-300">
|
||||
{detector.status === '#b3261e' ? 'Критическое' :
|
||||
detector.status === '#fd7c22' ? 'Предупреждение' : 'Норма'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 text-gray-400 text-sm">{detector.location}</td>
|
||||
<td className="py-3">
|
||||
{detector.checked ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-sm text-green-500">Да</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500">Нет</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Статы детекторров*/}
|
||||
<div className="mt-6 grid grid-cols-4 gap-4">
|
||||
<div className="bg-[#161824] p-4 rounded-lg">
|
||||
<div className="text-2xl font-bold text-white">{filteredDetectors.length}</div>
|
||||
<div className="text-sm text-gray-400">Всего</div>
|
||||
</div>
|
||||
<div className="bg-[#161824] p-4 rounded-lg">
|
||||
<div className="text-2xl font-bold text-green-500">{filteredDetectors.filter(d => d.status === '#00ff00').length}</div>
|
||||
<div className="text-sm text-gray-400">Норма</div>
|
||||
</div>
|
||||
<div className="bg-[#161824] p-4 rounded-lg">
|
||||
<div className="text-2xl font-bold text-orange-500">{filteredDetectors.filter(d => d.status === '#fd7c22').length}</div>
|
||||
<div className="text-sm text-gray-400">Предупреждения</div>
|
||||
</div>
|
||||
<div className="bg-[#161824] p-4 rounded-lg">
|
||||
<div className="text-2xl font-bold text-red-500">{filteredDetectors.filter(d => d.status === '#b3261e').length}</div>
|
||||
<div className="text-sm text-gray-400">Критические</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredDetectors.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-400">Детекторы не найдены</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DetectorList
|
||||
48
frontend/components/dashboard/AreaChart.tsx
Normal file
@@ -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<AreaChartProps> = ({ className = '' }) => {
|
||||
return (
|
||||
<div className={`w-full h-full ${className}`}>
|
||||
<svg className="w-full h-full" viewBox="0 0 635 200">
|
||||
<defs>
|
||||
<linearGradient id="areaGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="rgb(42, 157, 144)" stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor="rgb(42, 157, 144)" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M0,180 L100,120 L200,140 L300,80 L400,60 L500,100 L635,90 L635,200 L0,200 Z"
|
||||
fill="url(#areaGradient)"
|
||||
/>
|
||||
<path
|
||||
d="M0,180 L100,120 L200,140 L300,80 L400,60 L500,100 L635,90"
|
||||
stroke="rgb(42, 157, 144)"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
/>
|
||||
<circle cx="0" cy="180" r="3" fill="rgb(42, 157, 144)" />
|
||||
<circle cx="100" cy="120" r="3" fill="rgb(42, 157, 144)" />
|
||||
<circle cx="200" cy="140" r="3" fill="rgb(42, 157, 144)" />
|
||||
<circle cx="300" cy="80" r="3" fill="rgb(42, 157, 144)" />
|
||||
<circle cx="400" cy="60" r="3" fill="rgb(42, 157, 144)" />
|
||||
<circle cx="500" cy="100" r="3" fill="rgb(42, 157, 144)" />
|
||||
<circle cx="635" cy="90" r="3" fill="rgb(42, 157, 144)" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AreaChart
|
||||
62
frontend/components/dashboard/BarChart.tsx
Normal file
@@ -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<BarChartProps> = ({ 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 (
|
||||
<div className={`w-full h-full ${className}`}>
|
||||
<svg className="w-full h-full" viewBox="0 0 635 200">
|
||||
<g>
|
||||
{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 (
|
||||
<rect
|
||||
key={index}
|
||||
x={x}
|
||||
y={y}
|
||||
width={barWidth}
|
||||
height={barHeight}
|
||||
fill={bar.color}
|
||||
rx="4"
|
||||
ry="4"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BarChart
|
||||
41
frontend/components/dashboard/ChartCard.tsx
Normal file
@@ -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<ChartCardProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<div className={`bg-[#161824] rounded-[20px] p-6 ${className}`}>
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-white text-base font-semibold mb-1">{title}</h3>
|
||||
{subtitle && (
|
||||
<p className="text-[#71717a] text-sm">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-4 h-4">
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-[200px]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChartCard
|
||||
260
frontend/components/dashboard/Dashboard.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
'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 = () => {
|
||||
closeMonitoring()
|
||||
closeFloorNavigation()
|
||||
closeNotifications()
|
||||
setCurrentSubmenu(null)
|
||||
router.push('/navigation')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-[#0e111a]">
|
||||
<Sidebar
|
||||
activeItem={1} // Dashboard
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
<header className="bg-[#161824] border-b border-gray-700 px-6 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleBackClick}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
aria-label="Назад к объектам"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<nav className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-400">Объекты</span>
|
||||
<span className="text-gray-600">/</span>
|
||||
<span className="text-white">{objectTitle || 'Объект'}</span>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 p-6 overflow-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-white text-2xl font-semibold mb-6">Объект {objectId?.replace('object_', '')}</h1>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<button
|
||||
className="flex items-center gap-6 rounded-[10px] px-4 py-[18px] bg-[rgb(22,24,36)] text-white"
|
||||
>
|
||||
<span className="text-sm font-medium">Датчики : все</span>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3 ml-auto">
|
||||
<button
|
||||
onClick={handleNavigationClick}
|
||||
className="rounded-[10px] px-4 py-[18px] bg-gray-600 text-gray-300 hover:bg-[rgb(22,24,36)] hover:text-white transition-colors"
|
||||
>
|
||||
<span className="text-sm font-medium">Навигация</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 bg-[rgb(22,24,36)] rounded-lg px-3 py-2">
|
||||
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
<span className="text-white text-sm font-medium">Период</span>
|
||||
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Карты-графики */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[18px]">
|
||||
<ChartCard
|
||||
title="Показатель"
|
||||
subtitle="За последние 6 месяцев"
|
||||
>
|
||||
<AreaChart />
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Статистика"
|
||||
subtitle="Данные за период"
|
||||
>
|
||||
<BarChart />
|
||||
</ChartCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Список детекторов */}
|
||||
<div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-white text-2xl font-semibold">Тренды</h2>
|
||||
<div className="bg-[#161824] rounded-lg px-3 py-2 flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-white text-sm font-medium">Месяц</span>
|
||||
<svg className="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Таблица */}
|
||||
<div className="bg-[#161824] rounded-[20px] p-6">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-700">
|
||||
<th className="text-left text-white font-medium py-3">Детектор</th>
|
||||
<th className="text-left text-white font-medium py-3">Статус</th>
|
||||
<th className="text-left text-white font-medium py-3">Местоположение</th>
|
||||
<th className="text-left text-white font-medium py-3">Проверен</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detectorsArray.map((detector: DetectorData) => (
|
||||
<tr key={detector.detector_id} className="border-b border-gray-800">
|
||||
<td className="py-3 text-white text-sm">{detector.name}</td>
|
||||
<td className="py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full`}
|
||||
style={{ backgroundColor: detector.status }}
|
||||
></div>
|
||||
<span className="text-sm text-gray-300">
|
||||
{detector.status === '#b3261e' ? 'Критическое' :
|
||||
detector.status === '#fd7c22' ? 'Предупреждение' : 'Норма'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 text-gray-400 text-sm">{detector.location}</td>
|
||||
<td className="py-3">
|
||||
{detector.checked ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-sm text-green-500">Да</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500">Нет</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Статы */}
|
||||
<div className="mt-6 grid grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-white">{detectorsArray.length}</div>
|
||||
<div className="text-sm text-gray-400">Всего</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-500">{statusCounts.normal}</div>
|
||||
<div className="text-sm text-gray-400">Норма</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-orange-500">{statusCounts.warning}</div>
|
||||
<div className="text-sm text-gray-400">Предупреждения</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-red-500">{statusCounts.critical}</div>
|
||||
<div className="text-sm text-gray-400">Критические</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Графики с аналитикой */}
|
||||
<div className="mt-6 grid grid-cols-1 lg:grid-cols-4 gap-[18px]">
|
||||
<ChartCard
|
||||
title="Тренды детекторов"
|
||||
subtitle="За последний месяц"
|
||||
>
|
||||
<DetectorChart type="line" />
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Статистика по месяцам"
|
||||
subtitle="Активность детекторов"
|
||||
>
|
||||
<DetectorChart type="bar" />
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Анализ производительности"
|
||||
subtitle="Эффективность работы"
|
||||
>
|
||||
<DetectorChart type="line" />
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Сводка по статусам"
|
||||
subtitle="Распределение состояний"
|
||||
>
|
||||
<DetectorChart type="bar" />
|
||||
</ChartCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
103
frontend/components/dashboard/DetectorChart.tsx
Normal file
@@ -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<DetectorChartProps> = ({
|
||||
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 (
|
||||
<div className={`w-full h-full ${className}`}>
|
||||
<svg className="w-full h-full" viewBox="0 0 400 200">
|
||||
<g>
|
||||
{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 (
|
||||
<g key={index}>
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={barWidth}
|
||||
height={barHeight}
|
||||
fill="rgb(42, 157, 144)"
|
||||
rx="4"
|
||||
ry="4"
|
||||
/>
|
||||
<text
|
||||
x={x + barWidth / 2}
|
||||
y={180}
|
||||
textAnchor="middle"
|
||||
fill="#71717a"
|
||||
fontSize="12"
|
||||
>
|
||||
{bar.label}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`w-full h-full ${className}`}>
|
||||
<svg className="w-full h-full" viewBox="0 0 400 200">
|
||||
<defs>
|
||||
<linearGradient id="detectorGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="rgb(231, 110, 80)" stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor="rgb(231, 110, 80)" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M20,150 L80,120 L140,100 L200,80 L260,90 L320,70 L380,60 L380,180 L20,180 Z"
|
||||
fill="url(#detectorGradient)"
|
||||
/>
|
||||
<path
|
||||
d="M20,150 L80,120 L140,100 L200,80 L260,90 L320,70 L380,60"
|
||||
stroke="rgb(231, 110, 80)"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
/>
|
||||
<circle cx="20" cy="150" r="3" fill="rgb(231, 110, 80)" />
|
||||
<circle cx="80" cy="120" r="3" fill="rgb(231, 110, 80)" />
|
||||
<circle cx="140" cy="100" r="3" fill="rgb(231, 110, 80)" />
|
||||
<circle cx="200" cy="80" r="3" fill="rgb(231, 110, 80)" />
|
||||
<circle cx="260" cy="90" r="3" fill="rgb(231, 110, 80)" />
|
||||
<circle cx="320" cy="70" r="3" fill="rgb(231, 110, 80)" />
|
||||
<circle cx="380" cy="60" r="3" fill="rgb(231, 110, 80)" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DetectorChart
|
||||
@@ -1,23 +1,22 @@
|
||||
'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
|
||||
ImportMeshAsync
|
||||
} from '@babylonjs/core'
|
||||
import '@babylonjs/loaders'
|
||||
import { getCacheFileName, loadCachedData, parseAndCacheScene, ParsedMeshData } from '../utils/meshCache'
|
||||
|
||||
import LoadingSpinner from '../ui/LoadingSpinner'
|
||||
|
||||
|
||||
interface ModelViewerProps {
|
||||
modelPath: string
|
||||
@@ -40,11 +39,12 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
const engineRef = useRef<Nullable<Engine>>(null)
|
||||
const sceneRef = useRef<Nullable<Scene>>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadingProgress, setLoadingProgress] = useState(0)
|
||||
const [showModel, setShowModel] = useState(false)
|
||||
const isInitializedRef = useRef(false)
|
||||
const isDisposedRef = useRef(false)
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
isDisposedRef.current = false
|
||||
isInitializedRef.current = false
|
||||
@@ -60,6 +60,12 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
const engine = new Engine(canvas, true)
|
||||
engineRef.current = engine
|
||||
|
||||
engine.runRenderLoop(() => {
|
||||
if (!isDisposedRef.current && sceneRef.current) {
|
||||
sceneRef.current.render()
|
||||
}
|
||||
})
|
||||
|
||||
const scene = new Scene(engine)
|
||||
sceneRef.current = scene
|
||||
|
||||
@@ -89,12 +95,6 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
fillLight.intensity = 0.3
|
||||
fillLight.diffuse = new Color3(0.8, 0.8, 1)
|
||||
|
||||
engine.runRenderLoop(() => {
|
||||
if (!isDisposedRef.current) {
|
||||
scene.render()
|
||||
}
|
||||
})
|
||||
|
||||
const handleResize = () => {
|
||||
if (!isDisposedRef.current) {
|
||||
engine.resize()
|
||||
@@ -126,29 +126,37 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
return
|
||||
}
|
||||
|
||||
const oldMeshes = sceneRef.current.meshes.slice();
|
||||
oldMeshes.forEach(m => m.dispose());
|
||||
|
||||
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)
|
||||
const result = await ImportMeshAsync(modelPath, sceneRef.current)
|
||||
|
||||
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,
|
||||
'',
|
||||
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!')
|
||||
console.log('\n=== Complete Model Object ===')
|
||||
console.log(result)
|
||||
console.log('\n=== Structure Overview ===')
|
||||
console.log('Meshes:', result.meshes?.length || 0)
|
||||
console.log('Transform Nodes:', result.transformNodes?.length || 0)
|
||||
if (result.meshes.length > 0) {
|
||||
|
||||
const boundingBox = result.meshes[0].getHierarchyBoundingVectors()
|
||||
@@ -159,8 +167,6 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
camera.radius = maxDimension * 2
|
||||
camera.target = result.meshes[0].position
|
||||
|
||||
const parsedData = await parseAndCacheScene(result.meshes, cacheFileName, modelPath)
|
||||
|
||||
onModelLoaded?.({
|
||||
meshes: result.meshes,
|
||||
boundingBox: {
|
||||
@@ -169,34 +175,45 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
}
|
||||
})
|
||||
|
||||
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])
|
||||
// Загрузка модлеи начинается после появления спиннера
|
||||
requestIdleCallback(() => loadModel(), { timeout: 50 })
|
||||
}, [modelPath, onError, onModelLoaded])
|
||||
|
||||
return (
|
||||
<div className="w-full h-screen relative bg-gray-900 overflow-hidden">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full outline-none block"
|
||||
className={`w-full h-full outline-none block transition-opacity duration-500 ${
|
||||
showModel ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
/>
|
||||
{isLoading && (
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-black/80 text-white px-8 py-5 rounded-xl z-50 flex items-center gap-3 text-base font-medium">
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Loading Model...
|
||||
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-50">
|
||||
<LoadingSpinner
|
||||
progress={loadingProgress}
|
||||
size={120}
|
||||
strokeWidth={8}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
95
frontend/components/navigation/DetectorMenu.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'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<DetectorMenuProps> = ({ detector, isOpen, onClose, getStatusText }) => {
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="absolute left-[500px] top-0 bg-[#161824] border-r border-gray-700 z-30 w-[454px]" style={{height: 'calc(100% - 73px)', top: '73px'}}>
|
||||
<div className="h-full overflow-auto p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-white text-lg font-medium">
|
||||
Датч.{detector.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Отчет
|
||||
</button>
|
||||
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
История
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Таблица детекторов */}
|
||||
<div className="space-y-0 border border-[rgb(30,31,36)] rounded-lg overflow-hidden">
|
||||
<div className="flex">
|
||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Маркировка по проекту</div>
|
||||
<div className="text-white text-sm">{detector.name}</div>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Тип детектора</div>
|
||||
<div className="text-white text-sm">{detector.type}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex border-t border-[rgb(30,31,36)]">
|
||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Местоположение</div>
|
||||
<div className="text-white text-sm">{detector.location}</div>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Статус</div>
|
||||
<div className="text-white text-sm">{getStatusText(detector.status)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex border-t border-[rgb(30,31,36)]">
|
||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Временная метка</div>
|
||||
<div className="text-white text-sm">Сегодня, 14:30</div>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="text-white text-sm text-right">Вчера</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DetectorMenu
|
||||
206
frontend/components/navigation/FloorNavigation.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
interface DetectorsDataType {
|
||||
detectors: Record<string, DetectorType>
|
||||
}
|
||||
|
||||
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<FloorNavigationProps> = ({ objectId, detectorsData, onDetectorMenuClick, onClose }) => {
|
||||
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(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<number, DetectorType[]>)
|
||||
|
||||
// Сортировка этажей
|
||||
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 (
|
||||
<div className="w-full max-w-2xl">
|
||||
<div className="bg-[rgb(22,24,36)] rounded-[12px] p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-white text-2xl font-semibold">Навигация по этажам</h2>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск детекторов..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<svg className="absolute right-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-4 py-2 rounded-lg transition-colors flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4" />
|
||||
</svg>
|
||||
Фильтр
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{sortedFloors.map(floor => {
|
||||
const floorDetectors = detectorsByFloor[floor]
|
||||
const isExpanded = expandedFloors.has(floor)
|
||||
|
||||
return (
|
||||
<div key={floor} className="bg-[rgb(27,30,40)] rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleFloor(floor)}
|
||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-[rgb(53,58,70)] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-white font-medium">{floor} этаж</span>
|
||||
<span className="text-gray-400 text-sm">({floorDetectors.length} детекторов)</span>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-5 h-5 text-gray-400 transition-transform ${
|
||||
isExpanded ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* суб-меню с детекторами */}
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-3 space-y-2">
|
||||
{floorDetectors.map(detector => (
|
||||
<div
|
||||
key={detector.detector_id}
|
||||
className="bg-[rgb(53,58,70)] rounded-md p-3 flex items-center justify-between"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="text-white text-sm font-medium">{detector.name}</div>
|
||||
<div className="text-gray-400 text-xs">{detector.location}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-3 h-3 rounded-full ${getStatusColor(detector.status)}`}></div>
|
||||
<span className="text-xs text-gray-300">{getStatusText(detector.status)}</span>
|
||||
{detector.checked && (
|
||||
<svg className="w-4 h-4 text-green-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDetectorMenuClick(detector)}
|
||||
className="w-6 h-6 bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] rounded-full flex items-center justify-center transition-colors relative"
|
||||
>
|
||||
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FloorNavigation
|
||||
69
frontend/components/navigation/Monitoring.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface MonitoringProps {
|
||||
objectId?: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const Monitoring: React.FC<MonitoringProps> = ({ onClose }) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="bg-[rgb(22,24,36)] rounded-[12px] p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-white text-2xl font-semibold">Зоны мониторинга</h2>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-[rgb(158,168,183)] rounded-lg p-3 h-[200px] flex items-center justify-center">
|
||||
<div className="w-full h-full bg-gray-300 rounded flex items-center justify-center">
|
||||
<Image
|
||||
src="/images/test_image.png"
|
||||
alt="Object Model"
|
||||
width={200}
|
||||
height={200}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
style={{ height: 'auto' }}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[1, 2, 3, 4, 5, 6].map((zone) => (
|
||||
<div key={zone} className="flex-1 bg-gray-300 rounded-lg h-[120px] flex items-center justify-center">
|
||||
<div className="w-full h-full bg-gray-200 rounded flex items-center justify-center">
|
||||
<Image
|
||||
src="/images/test_image.png"
|
||||
alt={`Зона ${zone}`}
|
||||
width={120}
|
||||
height={120}
|
||||
className="max-w-full max-h-full object-contain opacity-50"
|
||||
style={{ height: 'auto' }}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Monitoring;
|
||||
174
frontend/components/notifications/NotificationDetectorInfo.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
'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<NotificationDetectorInfoProps> = ({ detectorData, onClose }) => {
|
||||
const detectorInfo = detectorData
|
||||
|
||||
if (!detectorInfo) {
|
||||
return (
|
||||
<div className="w-full max-w-4xl">
|
||||
<div className="bg-[rgb(22,24,36)] rounded-[12px] p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-white text-2xl font-semibold">Информация о детекторе</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-gray-400">Детектор не найден</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="h-full">
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-white text-lg font-medium">
|
||||
Датч.{detectorInfo.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Отчет
|
||||
</button>
|
||||
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
История
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Табличка с детекторами */}
|
||||
<div className="space-y-0 border border-[rgb(30,31,36)] rounded-lg overflow-hidden">
|
||||
<div className="flex">
|
||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Маркировка по проекту</div>
|
||||
<div className="text-white text-sm">{detectorInfo.name}</div>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Тип детектора</div>
|
||||
<div className="text-white text-sm">{detectorInfo.type}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex border-t border-[rgb(30,31,36)]">
|
||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Местоположение</div>
|
||||
<div className="text-white text-sm">{detectorInfo.location}</div>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Статус</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: detectorInfo.status }}
|
||||
></div>
|
||||
<span className={`text-sm font-medium ${getStatusColor(detectorInfo.status)}`}>
|
||||
{detectorInfo.status === '#b3261e' ? 'Критический' :
|
||||
detectorInfo.status === '#fd7c22' ? 'Предупреждение' :
|
||||
detectorInfo.status === '#4caf50' ? 'Нормальный' : 'Неизвестно'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{latestNotification && (
|
||||
<div className="flex border-t border-[rgb(30,31,36)]">
|
||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Последнее уведомление</div>
|
||||
<div className="text-white text-sm">{formatDate(latestNotification.timestamp)}</div>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Приоритет</div>
|
||||
<div className={`text-sm font-medium ${getPriorityColor(latestNotification.priority)}`}>
|
||||
{latestNotification.priority}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{latestNotification && (
|
||||
<div className="border-t border-[rgb(30,31,36)] p-4">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Сообщение</div>
|
||||
<div className="text-white text-sm">{latestNotification.message}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotificationDetectorInfo
|
||||
134
frontend/components/notifications/Notifications.tsx
Normal file
@@ -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<string, DetectorType>
|
||||
}
|
||||
|
||||
interface NotificationsProps {
|
||||
objectId?: string
|
||||
detectorsData: DetectorsDataType
|
||||
onNotificationClick: (notification: NotificationType) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const Notifications: React.FC<NotificationsProps> = ({ 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 (
|
||||
<div className="h-full bg-[#161824] flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-700">
|
||||
<h2 className="text-white text-lg font-medium">Уведомления</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<IoClose className="w-5 h-5 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Список уведомлений*/}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{sortedNotifications.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<p className="text-gray-400">Уведомления не найдены</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sortedNotifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className="bg-[rgb(53,58,70)] rounded-md p-3 flex items-center justify-between hover:bg-[rgb(63,68,80)] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-3 h-3 rounded-full ${getStatusColor(notification.type)}`}></div>
|
||||
<div>
|
||||
<div className="text-white text-sm font-medium">{notification.detector_name}</div>
|
||||
<div className="text-gray-400 text-xs">{notification.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onNotificationClick(notification)}
|
||||
className="w-6 h-6 bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] rounded-full flex items-center justify-center transition-colors relative"
|
||||
>
|
||||
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Notifications
|
||||
103
frontend/components/objects/ObjectCard.tsx
Normal file
@@ -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 }) => (
|
||||
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ObjectCard: React.FC<ObjectCardProps> = ({ 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 (
|
||||
<article
|
||||
className={`flex flex-col w-full min-h-[414px] h-[414px] sm:h-auto sm:min-h-[350px] items-start gap-4 p-4 sm:p-6 relative bg-[#161824] rounded-[20px] overflow-hidden cursor-pointer transition-all duration-200 hover:bg-[#1a1d2e] ${
|
||||
isSelected ? 'ring-2 ring-blue-500' : ''
|
||||
}`}
|
||||
onClick={handleCardClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
handleCardClick()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<header className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-2 relative self-stretch w-full flex-[0_0_auto]">
|
||||
<div className="flex-col items-start flex-1 grow flex relative min-w-0">
|
||||
<h2 className="self-stretch mt-[-1.00px] font-medium text-white text-lg leading-7 relative tracking-[0] break-words">
|
||||
{object.title}
|
||||
</h2>
|
||||
<p className="self-stretch font-normal text-[#71717a] text-sm leading-5 relative tracking-[0] break-words">
|
||||
{object.description}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="inline-flex flex-shrink-0 bg-[#3193f5] h-10 items-center justify-center gap-2 px-3 sm:px-4 py-2 relative rounded-md transition-colors duration-200 hover:bg-[#2563eb] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 w-full sm:w-auto"
|
||||
aria-label={`Изменить ${object.title}`}
|
||||
onClick={handleEditClick}
|
||||
>
|
||||
<EditIcon className="!relative !w-4 !h-4 text-white flex-shrink-0" />
|
||||
<span className="font-medium text-white text-sm leading-5 relative tracking-[0] sm:whitespace-nowrap">
|
||||
Изменить
|
||||
</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Изображение объекта */}
|
||||
<div className="relative flex-1 self-stretch w-full grow bg-[#f1f1f1] rounded-lg overflow-hidden min-h-[200px] sm:min-h-[250px]">
|
||||
<Image
|
||||
className="absolute w-full h-full top-0 left-0 object-cover"
|
||||
alt={object.title}
|
||||
src={object.image}
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
onError={(e) => {
|
||||
// Заглушка при ошибке загрузки изображения
|
||||
const target = e.target as HTMLImageElement
|
||||
target.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDUyIiBoZWlnaHQ9IjMwMiIgdmlld0JveD0iMCAwIDQ1MiAzMDIiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSI0NTIiIGhlaWdodD0iMzAyIiBmaWxsPSIjRjFGMUYxIi8+CjxwYXRoIGQ9Ik0yMjYgMTUxTDI0NiAxMzFMMjY2IDE1MUwyNDYgMTcxTDIyNiAxNTFaIiBmaWxsPSIjOTk5OTk5Ii8+Cjx0ZXh0IHg9IjIyNiIgeT0iMTkwIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjOTk5OTk5IiBmb250LXNpemU9IjE0Ij7QntCx0YrQtdC60YI8L3RleHQ+Cjwvc3ZnPgo='
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export default ObjectCard
|
||||
export type { ObjectData, ObjectCardProps }
|
||||
102
frontend/components/objects/ObjectGallery.tsx
Normal file
@@ -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 }) => (
|
||||
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ObjectGallery: React.FC<ObjectGalleryProps> = ({
|
||||
objects,
|
||||
title = 'Объекты',
|
||||
onObjectSelect,
|
||||
selectedObjectId,
|
||||
className = ''
|
||||
}) => {
|
||||
|
||||
const handleObjectSelect = (objectId: string) => {
|
||||
if (onObjectSelect) {
|
||||
onObjectSelect(objectId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBackClick = () => {
|
||||
console.log('Back clicked')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col items-start relative bg-[#0e111a] min-h-screen ${className}`}>
|
||||
<main className="relative self-stretch w-full">
|
||||
<div className="flex flex-col w-full items-start gap-6 p-4 sm:p-8 lg:p-16">
|
||||
<header className="flex flex-col items-start gap-9 relative self-stretch w-full flex-[0_0_auto]">
|
||||
<nav className="items-center gap-4 self-stretch w-full flex-[0_0_auto] flex relative">
|
||||
<button
|
||||
className="flex w-10 bg-[#161824] h-10 items-center justify-center gap-2 px-2 py-2 relative rounded-md transition-colors duration-200 hover:bg-[#1a1d2e] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
|
||||
aria-label="Назад"
|
||||
onClick={handleBackClick}
|
||||
>
|
||||
<BackIcon className="relative w-5 h-5 text-white" />
|
||||
</button>
|
||||
|
||||
<div className="inline-flex flex-wrap items-center gap-2.5 relative flex-[0_0_auto]">
|
||||
<div className="inline-flex items-center justify-center gap-2.5 relative flex-[0_0_auto]">
|
||||
<span className="relative w-fit mt-[-1.00px] font-normal text-white text-sm tracking-[0] leading-5 whitespace-nowrap">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-start gap-4 relative self-stretch w-full flex-[0_0_auto]">
|
||||
<h1 className="relative w-fit mt-[-1.00px] font-semibold text-white text-2xl tracking-[0] leading-8 whitespace-nowrap">
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
|
||||
</header>
|
||||
|
||||
{/* Галерея объектов */}
|
||||
{objects.length > 0 ? (
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-[18px] w-full">
|
||||
{objects.map((object) => (
|
||||
<ObjectCard
|
||||
key={object.object_id}
|
||||
object={object}
|
||||
onSelect={handleObjectSelect}
|
||||
isSelected={selectedObjectId === object.object_id}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-64 w-full">
|
||||
<div className="text-center">
|
||||
<div className="text-[#71717a] mb-2">
|
||||
<svg className="w-12 h-12 mx-auto" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white mb-2">Объекты не найдены</h3>
|
||||
<p className="text-[#71717a]">Нет доступных объектов</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ObjectGallery
|
||||
export type { ObjectGalleryProps }
|
||||
288
frontend/components/reports/ReportsList.tsx
Normal file
@@ -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<string, DetectorType>
|
||||
}
|
||||
|
||||
interface ReportsListProps {
|
||||
objectId?: string;
|
||||
detectorsData: DetectorsDataType;
|
||||
}
|
||||
|
||||
const ReportsList: React.FC<ReportsListProps> = ({ 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 (
|
||||
<div className="space-y-6">
|
||||
{/* Поиск и сортировка*/}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setStatusFilter('all')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
statusFilter === 'all'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-[#161824] text-gray-300 hover:bg-[#1f2937]'
|
||||
}`}
|
||||
>
|
||||
Все ({allNotifications.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('critical')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
statusFilter === 'critical'
|
||||
? 'bg-red-600 text-white'
|
||||
: 'bg-[#161824] text-gray-300 hover:bg-[#1f2937]'
|
||||
}`}
|
||||
>
|
||||
Критические ({allNotifications.filter(d => d.type === 'critical').length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('warning')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
statusFilter === 'warning'
|
||||
? 'bg-orange-600 text-white'
|
||||
: 'bg-[#161824] text-gray-300 hover:bg-[#1f2937]'
|
||||
}`}
|
||||
>
|
||||
Предупреждения ({allNotifications.filter(d => d.type === 'warning').length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('info')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
statusFilter === 'info'
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-[#161824] text-gray-300 hover:bg-[#1f2937]'
|
||||
}`}
|
||||
>
|
||||
Информация ({allNotifications.filter(d => d.type === 'info').length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск детекторов..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="bg-[#161824] text-white placeholder-gray-400 px-4 py-2 rounded-lg border border-gray-600 focus:border-blue-500 focus:outline-none w-64"
|
||||
/>
|
||||
<svg className="absolute right-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Табличка с детекторами*/}
|
||||
<div className="bg-[#161824] rounded-[20px] p-6">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-700">
|
||||
<th className="text-left text-white font-medium py-3">Детектор</th>
|
||||
<th className="text-left text-white font-medium py-3">Статус</th>
|
||||
<th className="text-left text-white font-medium py-3">Сообщение</th>
|
||||
<th className="text-left text-white font-medium py-3">Местоположение</th>
|
||||
<th className="text-left text-white font-medium py-3">Приоритет</th>
|
||||
<th className="text-left text-white font-medium py-3">Подтверждено</th>
|
||||
<th className="text-left text-white font-medium py-3">Время</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredDetectors.map((detector) => (
|
||||
<tr key={detector.id} className="border-b border-gray-700 hover:bg-gray-800/50 transition-colors">
|
||||
<td className="py-4">
|
||||
<div className="text-sm font-medium text-white">{detector.detector_name}</div>
|
||||
<div className="text-sm text-gray-400">ID: {detector.detector_id}</div>
|
||||
</td>
|
||||
<td className="py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: getStatusColor(detector.type) }}
|
||||
></div>
|
||||
<span className="text-sm text-gray-300">
|
||||
{detector.type === 'critical' ? 'Критический' :
|
||||
detector.type === 'warning' ? 'Предупреждение' : 'Информация'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4">
|
||||
<div className="text-sm text-white">{detector.message}</div>
|
||||
</td>
|
||||
<td className="py-4">
|
||||
<div className="text-sm text-white">{detector.location}</div>
|
||||
</td>
|
||||
<td className="py-4">
|
||||
<span
|
||||
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
|
||||
style={{ backgroundColor: getPriorityColor(detector.priority) }}
|
||||
>
|
||||
{detector.priority === 'high' ? 'Высокий' :
|
||||
detector.priority === 'medium' ? 'Средний' : 'Низкий'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
detector.acknowledged
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{detector.acknowledged ? 'Да' : 'Нет'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4">
|
||||
<div className="text-sm text-gray-300">
|
||||
{new Date(detector.timestamp).toLocaleString('ru-RU')}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredDetectors.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-8 text-center text-gray-400">
|
||||
Детекторы не найдены
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<div className="bg-[#161824] p-4 rounded-lg">
|
||||
<div className="text-2xl font-bold text-white">{counts.total}</div>
|
||||
<div className="text-sm text-gray-400">Всего</div>
|
||||
</div>
|
||||
<div className="bg-[#161824] p-4 rounded-lg">
|
||||
<div className="text-2xl font-bold text-red-400">{counts.critical}</div>
|
||||
<div className="text-sm text-gray-400">Критические</div>
|
||||
</div>
|
||||
<div className="bg-[#161824] p-4 rounded-lg">
|
||||
<div className="text-2xl font-bold text-orange-400">{counts.warning}</div>
|
||||
<div className="text-sm text-gray-400">Предупреждения</div>
|
||||
</div>
|
||||
<div className="bg-[#161824] p-4 rounded-lg">
|
||||
<div className="text-2xl font-bold text-green-400">{counts.info}</div>
|
||||
<div className="text-sm text-gray-400">Информация</div>
|
||||
</div>
|
||||
<div className="bg-[#161824] p-4 rounded-lg">
|
||||
<div className="text-2xl font-bold text-blue-400">{counts.acknowledged}</div>
|
||||
<div className="text-sm text-gray-400">Подтверждено</div>
|
||||
</div>
|
||||
<div className="bg-[#161824] p-4 rounded-lg">
|
||||
<div className="text-2xl font-bold text-yellow-400">{counts.unacknowledged}</div>
|
||||
<div className="text-sm text-gray-400">Не подтверждено</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportsList;
|
||||
54
frontend/components/ui/ExportMenu.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
interface ExportMenuProps {
|
||||
onExport: (format: 'csv' | 'pdf') => void
|
||||
}
|
||||
|
||||
const ExportMenu: React.FC<ExportMenuProps> = ({ onExport }) => {
|
||||
const [selectedFormat, setSelectedFormat] = useState<'csv' | 'pdf'>('csv')
|
||||
|
||||
const handleExport = () => {
|
||||
onExport(selectedFormat)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 bg-[#161824] rounded-lg p-3 border border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="format"
|
||||
value="csv"
|
||||
checked={selectedFormat === 'csv'}
|
||||
onChange={(e) => setSelectedFormat(e.target.value as 'csv' | 'pdf')}
|
||||
className="w-3 h-3 text-blue-600"
|
||||
/>
|
||||
<span className="text-gray-300 text-sm">CSV</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="format"
|
||||
value="pdf"
|
||||
checked={selectedFormat === 'pdf'}
|
||||
onChange={(e) => setSelectedFormat(e.target.value as 'csv' | 'pdf')}
|
||||
className="w-3 h-3 text-blue-600"
|
||||
/>
|
||||
<span className="text-gray-300 text-sm">PDF</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded transition-colors"
|
||||
>
|
||||
Экспорт
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExportMenu
|
||||
68
frontend/components/ui/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
progress?: number
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
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 (
|
||||
<div className={`flex flex-col items-center justify-center ${className}`}>
|
||||
<div className="relative" style={{ width: size, height: size }}>
|
||||
<svg
|
||||
className="transform -rotate-90"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke="rgba(255, 255, 255, 0.1)"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="transparent"
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke="#389ee8"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="transparent"
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-300 ease-out"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-white text-xl font-semibold">
|
||||
{Math.round(progress)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-white text-base font-medium">
|
||||
Loading Model...
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoadingSpinner
|
||||
443
frontend/components/ui/Sidebar.tsx
Normal file
@@ -0,0 +1,443 @@
|
||||
'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 IconWrapper = ({ src, alt, className }: { src: string; alt: string; className?: string }) => (
|
||||
<div className={`relative ${className}`}>
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={20}
|
||||
height={20}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
const BookOpen = ({ className }: { className?: string }) => (
|
||||
<IconWrapper src="/icons/BookOpen.png" alt="Dashboard" className={className} />
|
||||
)
|
||||
|
||||
const Bot = ({ className }: { className?: string }) => (
|
||||
<IconWrapper src="/icons/Bot.png" alt="Navigation" className={className} />
|
||||
)
|
||||
|
||||
const SquareTerminal = ({ className }: { className?: string }) => (
|
||||
<IconWrapper src="/icons/SquareTerminal.png" alt="Terminal" className={className} />
|
||||
)
|
||||
|
||||
const CircleDot = ({ className }: { className?: string }) => (
|
||||
<IconWrapper src="/icons/CircleDot.png" alt="Sensors" className={className} />
|
||||
)
|
||||
|
||||
const BellDot = ({ className }: { className?: string }) => (
|
||||
<IconWrapper src="/icons/BellDot.png" alt="Notifications" className={className} />
|
||||
)
|
||||
|
||||
const History = ({ className }: { className?: string }) => (
|
||||
<IconWrapper src="/icons/History.png" alt="History" className={className} />
|
||||
)
|
||||
|
||||
const Settings2 = ({ className }: { className?: string }) => (
|
||||
<IconWrapper src="/icons/Settings2.png" alt="Settings" className={className} />
|
||||
)
|
||||
|
||||
const Monitor = ({ className }: { className?: string }) => (
|
||||
<IconWrapper src="/icons/Bot.png" alt="Monitor" className={className} />
|
||||
)
|
||||
|
||||
const Building = ({ className }: { className?: string }) => (
|
||||
<IconWrapper src="/icons/BookOpen.png" alt="Building" className={className} />
|
||||
)
|
||||
|
||||
// основные 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<SidebarProps> = ({
|
||||
logoSrc,
|
||||
userInfo = {
|
||||
name: 'Александр',
|
||||
role: 'Администратор'
|
||||
},
|
||||
activeItem: propActiveItem,
|
||||
onCustomItemClick
|
||||
}) => {
|
||||
const navigationService = useNavigationService()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const [internalActiveItem, setInternalActiveItem] = useState<number | null>(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
|
||||
|
||||
// Управление суб-меню через navigationStore (суб-меню - работают как отдельные элементы, но не страницы)
|
||||
switch (itemId) {
|
||||
case 2:
|
||||
if (pathname !== '/navigation') {
|
||||
router.push('/navigation')
|
||||
}
|
||||
handled = true
|
||||
break
|
||||
case 3: // Monitoring
|
||||
if (pathname !== '/navigation') {
|
||||
router.push('/navigation')
|
||||
setTimeout(() => openMonitoring(), 100)
|
||||
} else if (showMonitoring) {
|
||||
closeMonitoring()
|
||||
} else {
|
||||
openMonitoring()
|
||||
}
|
||||
handled = true
|
||||
break
|
||||
case 4: // Floor Navigation
|
||||
if (pathname !== '/navigation') {
|
||||
router.push('/navigation')
|
||||
setTimeout(() => openFloorNavigation(), 100)
|
||||
} else if (showFloorNavigation) {
|
||||
closeFloorNavigation()
|
||||
} else {
|
||||
openFloorNavigation()
|
||||
}
|
||||
handled = true
|
||||
break
|
||||
case 5: // Notifications
|
||||
if (pathname !== '/navigation') {
|
||||
router.push('/navigation')
|
||||
setTimeout(() => openNotifications(), 100)
|
||||
} else if (showNotifications) {
|
||||
closeNotifications()
|
||||
} else {
|
||||
openNotifications()
|
||||
}
|
||||
handled = true
|
||||
break
|
||||
default:
|
||||
// Для остального используем routes
|
||||
if (navigationService) {
|
||||
handled = navigationService.handleSidebarItemClick(itemId, pathname)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
if (propActiveItem === undefined) {
|
||||
setInternalActiveItem(itemId)
|
||||
}
|
||||
|
||||
if (onCustomItemClick) {
|
||||
onCustomItemClick(itemId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`flex flex-col items-start gap-6 relative bg-[#161824] transition-all duration-300 h-screen ${
|
||||
isCollapsed ? 'w-16' : 'w-64'
|
||||
}`}
|
||||
role="navigation"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<header className="flex items-center gap-2 pt-2 pb-2 px-4 relative self-stretch w-full flex-[0_0_auto] bg-[#161824]">
|
||||
{!isCollapsed && (
|
||||
<div className="relative">
|
||||
<Image
|
||||
className="w-auto h-[33px]"
|
||||
alt="AerBIM Monitor Logo"
|
||||
src={logoSrc || "/icons/logo.png"}
|
||||
width={169}
|
||||
height={33}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<nav className="flex flex-col items-end gap-2 relative flex-1 self-stretch w-full grow">
|
||||
<div className="flex flex-col items-end gap-2 px-4 py-2 relative self-stretch w-full flex-[0_0_auto]">
|
||||
<ul className="flex flex-col items-start gap-3 relative self-stretch w-full flex-[0_0_auto]" role="list">
|
||||
{mainNavigationItems.map((item) => {
|
||||
const IconComponent = item.icon
|
||||
const isActive = item.id === 2 ? shouldShowNavigationAsActive : activeItem === item.id
|
||||
|
||||
return (
|
||||
<li key={item.id} className="flex-col flex items-center relative self-stretch w-full" role="listitem">
|
||||
<button
|
||||
className={`gap-2 pt-2 pr-2 pb-2 pl-2 rounded-md flex h-9 items-center relative self-stretch w-full transition-colors duration-200 hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset ${
|
||||
isActive ? 'bg-gray-700' : ''
|
||||
}`}
|
||||
onClick={() => handleItemClick(item.id)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
type="button"
|
||||
>
|
||||
<IconComponent
|
||||
className="!relative !w-5 !h-5 text-white"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
<span className="flex-1 [font-family:'Inter-Regular',Helvetica] font-normal text-white text-sm leading-[14px] relative tracking-[0] overflow-hidden text-ellipsis [display:-webkit-box] [-webkit-line-clamp:1] [-webkit-box-orient:vertical] text-left">
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
{item.id === 2 && !isCollapsed && (
|
||||
// Закрыть все суб-меню при закрытии главного окна
|
||||
<div
|
||||
className="p-1.5 hover:bg-gray-600 rounded-md transition-colors duration-200 cursor-pointer bg-gray-700/50 border border-gray-600/50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setManuallyToggled(true)
|
||||
if (showNavigationSubItems || isNavigationSubItemActive) {
|
||||
closeMonitoring()
|
||||
closeFloorNavigation()
|
||||
closeNotifications()
|
||||
}
|
||||
toggleNavigationSubMenu()
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setManuallyToggled(true)
|
||||
if (showNavigationSubItems || isNavigationSubItemActive) {
|
||||
closeMonitoring()
|
||||
closeFloorNavigation()
|
||||
closeNotifications()
|
||||
}
|
||||
toggleNavigationSubMenu()
|
||||
}
|
||||
}}
|
||||
aria-label={isHydrated ? (showNavigationSubItems || isNavigationSubItemActive ? 'Collapse navigation menu' : 'Expand navigation menu') : 'Toggle navigation menu'}
|
||||
>
|
||||
<svg
|
||||
className={`!relative !w-4 !h-4 text-white transition-transform duration-200 drop-shadow-sm ${
|
||||
isHydrated && (showNavigationSubItems || isNavigationSubItemActive) ? 'rotate-90' : ''
|
||||
}`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Суб-меню */}
|
||||
{item.id === 2 && !isCollapsed && (showNavigationSubItems || isNavigationSubItemActive) && (
|
||||
<ul className="flex flex-col items-start gap-1 mt-1 ml-6 relative w-full" role="list">
|
||||
{navigationSubItems.map((subItem) => {
|
||||
const SubIconComponent = subItem.icon
|
||||
const isSubActive = activeItem === subItem.id
|
||||
|
||||
return (
|
||||
<li key={subItem.id} className="flex-col flex h-8 items-center relative self-stretch w-full" role="listitem">
|
||||
<button
|
||||
className={`gap-2 pt-1.5 pr-2 pb-1.5 pl-2 rounded-md flex h-8 items-center relative self-stretch w-full transition-colors duration-200 hover:bg-gray-600 focus:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset ${
|
||||
isSubActive ? 'bg-gray-600' : ''
|
||||
}`}
|
||||
onClick={() => handleItemClick(subItem.id)}
|
||||
aria-current={isSubActive ? 'page' : undefined}
|
||||
type="button"
|
||||
>
|
||||
<SubIconComponent
|
||||
className="!relative !w-4 !h-4 text-gray-300"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="flex-1 [font-family:'Inter-Regular',Helvetica] font-normal text-gray-300 text-sm leading-[14px] relative tracking-[0] overflow-hidden text-ellipsis [display:-webkit-box] [-webkit-line-clamp:1] [-webkit-box-orient:vertical] text-left">
|
||||
{subItem.label}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<button
|
||||
className="!relative !w-8 !h-8 p-1.5 rounded-lg hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all duration-200 bg-gray-800/60 border border-gray-600/40 shadow-lg hover:shadow-xl"
|
||||
onClick={() => {
|
||||
// Убираем суб-меню перед сворачиванием сайдбара
|
||||
if (showNavigationSubItems) {
|
||||
setShowNavigationSubItems(false)
|
||||
setManuallyToggled(true)
|
||||
|
||||
closeMonitoring()
|
||||
closeFloorNavigation()
|
||||
closeNotifications()
|
||||
}
|
||||
// Убираем сайд-бар
|
||||
toggleSidebar()
|
||||
}}
|
||||
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
type="button"
|
||||
>
|
||||
<svg className={`!relative !w-5 !h-5 text-white transition-transform duration-200 drop-shadow-sm ${isCollapsed ? 'rotate-180' : ''}`} fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{!isCollapsed && (
|
||||
<footer className="flex w-64 items-center gap-2 pt-2 pr-2 pb-2 pl-2 mt-auto bg-[#161824]">
|
||||
<div className="inline-flex flex-[0_0_auto] items-center gap-2 relative rounded-md">
|
||||
<div className="inline-flex items-center relative flex-[0_0_auto]">
|
||||
<div
|
||||
className="relative w-8 h-8 rounded-lg bg-white"
|
||||
role="img"
|
||||
aria-label="User avatar"
|
||||
>
|
||||
{userInfo.avatar && (
|
||||
<Image
|
||||
src={userInfo.avatar}
|
||||
alt="User avatar"
|
||||
className="w-full h-full rounded-lg object-cover"
|
||||
fill
|
||||
sizes="32px"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex p-2 flex-1 grow items-center gap-2 relative rounded-md">
|
||||
<div className="flex flex-col items-start justify-center gap-0.5 relative flex-1 grow">
|
||||
<div className="self-stretch mt-[-1.00px] [font-family:'Inter-SemiBold',Helvetica] font-semibold text-white text-sm leading-[14px] relative tracking-[0] overflow-hidden text-ellipsis [display:-webkit-box] [-webkit-line-clamp:1] [-webkit-box-orient:vertical]">
|
||||
{userInfo.name}
|
||||
</div>
|
||||
<div className="self-stretch [font-family:'Inter-Regular',Helvetica] font-normal text-[#71717a] text-[10px] leading-[10px] relative tracking-[0] overflow-hidden text-ellipsis [display:-webkit-box] [-webkit-line-clamp:1] [-webkit-box-orient:vertical]">
|
||||
{userInfo.role}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="relative w-4 h-4 aspect-[1] p-1 rounded hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-200"
|
||||
aria-label="User menu"
|
||||
type="button"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</footer>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export default Sidebar
|
||||
243
frontend/data/detectors.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/icons/BellDot.png
Normal file
|
After Width: | Height: | Size: 346 B |
BIN
frontend/public/icons/BookOpen.png
Normal file
|
After Width: | Height: | Size: 286 B |
BIN
frontend/public/icons/Bot.png
Normal file
|
After Width: | Height: | Size: 328 B |
BIN
frontend/public/icons/CircleDot.png
Normal file
|
After Width: | Height: | Size: 319 B |
BIN
frontend/public/icons/History.png
Normal file
|
After Width: | Height: | Size: 363 B |
BIN
frontend/public/icons/Settings2.png
Normal file
|
After Width: | Height: | Size: 324 B |
BIN
frontend/public/icons/SquareTerminal.png
Normal file
|
After Width: | Height: | Size: 302 B |
BIN
frontend/public/icons/logo.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 55 KiB |
BIN
frontend/public/images/test_image.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
91
frontend/services/navigationService.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
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<typeof useRouter>
|
||||
private navigationStore!: NavigationStore
|
||||
private initialized = false
|
||||
|
||||
init(router: ReturnType<typeof useRouter>, navigationStore: NavigationStore) {
|
||||
if (this.initialized) {
|
||||
return // Предотвращаем повторную инициализацию
|
||||
}
|
||||
this.router = router
|
||||
this.navigationStore = navigationStore
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
isInitialized(): boolean {
|
||||
return this.initialized
|
||||
}
|
||||
|
||||
navigateToRoute(route: MainRoutes) {
|
||||
// Убираем суб-меню перед переходом на другую страницу
|
||||
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()
|
||||
|
||||
React.useMemo(() => {
|
||||
if (!navigationService.isInitialized()) {
|
||||
navigationService.init(router, navigationStore)
|
||||
}
|
||||
}, [router, navigationStore])
|
||||
|
||||
return navigationService
|
||||
}
|
||||
17
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./app/**/*.{js,ts,jsx,tsx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx}',
|
||||
'./lib/**/*.{js,ts,jsx,tsx}',
|
||||
'./services/**/*.{js,ts,jsx,tsx}',
|
||||
],
|
||||
safelist: [],
|
||||
blocklist: [
|
||||
'./public/**/*',
|
||||
'./data/**/*',
|
||||
'!./assets/big-models/**/*'
|
||||
],
|
||||
theme: { extend: {} },
|
||||
plugins: [],
|
||||
};
|
||||
@@ -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<ParsedMeshData | null> => {
|
||||
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<string, unknown> = {}
|
||||
|
||||
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<string, unknown>
|
||||
|
||||
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<string, unknown>)[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<ParsedMeshData> => {
|
||||
const parsedMeshes = meshes.map(mesh => extractAllMeshData(mesh))
|
||||
|
||||
const boundingBox = meshes[0].getHierarchyBoundingVectors()
|
||||
const totalVertices = parsedMeshes.reduce((sum, mesh) => {
|
||||
const meshData = mesh as Record<string, unknown>
|
||||
const geometry = meshData.geometry as Record<string, unknown>
|
||||
const vertices = geometry.vertices as number[]
|
||||
return sum + vertices.length / 3
|
||||
}, 0)
|
||||
const totalIndices = parsedMeshes.reduce((sum, mesh) => {
|
||||
const meshData = mesh as Record<string, unknown>
|
||||
const geometry = meshData.geometry as Record<string, unknown>
|
||||
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
|
||||
}
|
||||