Разработка интерфейса фронт
This commit is contained in:
4
frontend/.gitignore
vendored
4
frontend/.gitignore
vendored
@@ -39,3 +39,7 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# Include data and models folders
|
||||||
|
!/data/
|
||||||
|
!/public/models/
|
||||||
|
|||||||
103
frontend/app/(protected)/alerts/page.tsx
Normal file
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 = () => {
|
import React, { useEffect } from 'react'
|
||||||
return (
|
import { useSearchParams } from 'next/navigation'
|
||||||
<div>page</div>
|
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'
|
'use client'
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import ModelViewer from '@/components/ModelViewer'
|
import ModelViewer from '@/components/model/ModelViewer'
|
||||||
|
|
||||||
export default function Home() {
|
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 [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const handleModelLoaded = (data: {
|
const handleModelLoaded = (data: {
|
||||||
@@ -20,14 +13,12 @@ export default function Home() {
|
|||||||
max: { x: number; y: number; z: number }
|
max: { x: number; y: number; z: number }
|
||||||
}
|
}
|
||||||
}) => {
|
}) => {
|
||||||
setModelInfo(data)
|
|
||||||
setError(null)
|
setError(null)
|
||||||
console.log('Model loaded successfully:', data)
|
console.log('Model loaded successfully:', data)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleError = (errorMessage: string) => {
|
const handleError = (errorMessage: string) => {
|
||||||
setError(errorMessage)
|
setError(errorMessage)
|
||||||
setModelInfo(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -42,19 +33,7 @@ export default function Home() {
|
|||||||
<div className="absolute top-4 right-4 left-4 z-50 rounded-lg bg-red-600/90 p-4 text-sm text-white md:right-auto md:left-4 md:w-80">
|
<div className="absolute top-4 right-4 left-4 z-50 rounded-lg bg-red-600/90 p-4 text-sm text-white md:right-auto md:left-4 md:w-80">
|
||||||
<strong>Error:</strong> {error}
|
<strong>Error:</strong> {error}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
241
frontend/app/(protected)/navigation/page.tsx
Normal file
241
frontend/app/(protected)/navigation/page.tsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useEffect, useCallback } from 'react'
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
|
import Sidebar from '../../../components/ui/Sidebar'
|
||||||
|
import useNavigationStore from '../../store/navigationStore'
|
||||||
|
import Monitoring from '../../../components/navigation/Monitoring'
|
||||||
|
import FloorNavigation from '../../../components/navigation/FloorNavigation'
|
||||||
|
import DetectorMenu from '../../../components/navigation/DetectorMenu'
|
||||||
|
import Notifications from '../../../components/notifications/Notifications'
|
||||||
|
import NotificationDetectorInfo from '../../../components/notifications/NotificationDetectorInfo'
|
||||||
|
import ModelViewer from '../../../components/model/ModelViewer'
|
||||||
|
import detectorsData from '../../../data/detectors.json'
|
||||||
|
|
||||||
|
interface DetectorType {
|
||||||
|
detector_id: number
|
||||||
|
name: string
|
||||||
|
object: string
|
||||||
|
status: string
|
||||||
|
checked: boolean
|
||||||
|
type: string
|
||||||
|
location: string
|
||||||
|
floor: number
|
||||||
|
notifications: Array<{
|
||||||
|
id: number
|
||||||
|
type: string
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
acknowledged: boolean
|
||||||
|
priority: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationType {
|
||||||
|
id: number
|
||||||
|
detector_id: number
|
||||||
|
detector_name: string
|
||||||
|
type: string
|
||||||
|
status: string
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
location: string
|
||||||
|
object: string
|
||||||
|
acknowledged: boolean
|
||||||
|
priority: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const NavigationPage: React.FC = () => {
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const {
|
||||||
|
currentObject,
|
||||||
|
setCurrentObject,
|
||||||
|
showMonitoring,
|
||||||
|
showFloorNavigation,
|
||||||
|
showNotifications,
|
||||||
|
selectedDetector,
|
||||||
|
showDetectorMenu,
|
||||||
|
selectedNotification,
|
||||||
|
showNotificationDetectorInfo,
|
||||||
|
closeMonitoring,
|
||||||
|
closeFloorNavigation,
|
||||||
|
closeNotifications,
|
||||||
|
setSelectedDetector,
|
||||||
|
setShowDetectorMenu,
|
||||||
|
setSelectedNotification,
|
||||||
|
setShowNotificationDetectorInfo
|
||||||
|
} = useNavigationStore()
|
||||||
|
|
||||||
|
const urlObjectId = searchParams.get('objectId')
|
||||||
|
const urlObjectTitle = searchParams.get('objectTitle')
|
||||||
|
const objectId = currentObject.id || urlObjectId
|
||||||
|
const objectTitle = currentObject.title || urlObjectTitle
|
||||||
|
|
||||||
|
const handleModelLoaded = useCallback(() => {
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleModelError = useCallback((error: string) => {
|
||||||
|
console.error('Model loading error:', error)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (urlObjectId && urlObjectTitle && (!currentObject.id || currentObject.id !== urlObjectId)) {
|
||||||
|
setCurrentObject(urlObjectId, urlObjectTitle)
|
||||||
|
}
|
||||||
|
}, [urlObjectId, urlObjectTitle, currentObject.id, setCurrentObject])
|
||||||
|
|
||||||
|
const handleBackClick = () => {
|
||||||
|
router.push('/dashboard')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDetectorMenuClick = (detector: DetectorType) => {
|
||||||
|
if (selectedDetector?.detector_id === detector.detector_id && showDetectorMenu) {
|
||||||
|
setShowDetectorMenu(false)
|
||||||
|
setSelectedDetector(null)
|
||||||
|
} else {
|
||||||
|
setSelectedDetector(detector)
|
||||||
|
setShowDetectorMenu(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDetectorMenu = () => {
|
||||||
|
setShowDetectorMenu(false)
|
||||||
|
setSelectedDetector(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNotificationClick = (notification: NotificationType) => {
|
||||||
|
if (selectedNotification?.id === notification.id && showNotificationDetectorInfo) {
|
||||||
|
setShowNotificationDetectorInfo(false)
|
||||||
|
setSelectedNotification(null)
|
||||||
|
} else {
|
||||||
|
setSelectedNotification(notification)
|
||||||
|
setShowNotificationDetectorInfo(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeNotificationDetectorInfo = () => {
|
||||||
|
setShowNotificationDetectorInfo(false)
|
||||||
|
setSelectedNotification(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'active': return 'Активен'
|
||||||
|
case 'inactive': return 'Неактивен'
|
||||||
|
case 'error': return 'Ошибка'
|
||||||
|
case 'maintenance': return 'Обслуживание'
|
||||||
|
default: return 'Неизвестно'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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/EXPO_АР_PostRecon_level.gltf'
|
||||||
|
onModelLoaded={handleModelLoaded}
|
||||||
|
onError={handleModelError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NavigationPage
|
||||||
@@ -1,9 +1,126 @@
|
|||||||
import React from 'react'
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import ObjectGallery from '../../../components/objects/ObjectGallery'
|
||||||
|
import { ObjectData } from '../../../components/objects/ObjectCard'
|
||||||
|
import Sidebar from '../../../components/ui/Sidebar'
|
||||||
|
import detectorsData from '../../../data/detectors.json'
|
||||||
|
|
||||||
|
// Интерфейс для данных объекта из JSON
|
||||||
|
interface RawObjectData {
|
||||||
|
name: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
image?: string
|
||||||
|
location: string
|
||||||
|
address: string
|
||||||
|
floors: number
|
||||||
|
area: number
|
||||||
|
type?: string
|
||||||
|
status?: string
|
||||||
|
zones: Array<{
|
||||||
|
zone_id: string
|
||||||
|
name: string
|
||||||
|
detectors: number[]
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для преобразования данных объекта из JSON
|
||||||
|
const transformObjectToObjectData = (objectId: string, objectData: RawObjectData): ObjectData => {
|
||||||
|
return {
|
||||||
|
object_id: objectId,
|
||||||
|
title: objectData.title || `Объект ${objectId}`,
|
||||||
|
description: objectData.description || `Описание объекта ${objectData.title || objectId}`,
|
||||||
|
image: objectData.image || '/images/default-object.jpg',
|
||||||
|
location: objectData.location || 'Не указано',
|
||||||
|
floors: objectData.floors,
|
||||||
|
area: objectData.area.toString(),
|
||||||
|
type: objectData.type || 'object',
|
||||||
|
status: objectData.status || 'active'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ObjectsPage: React.FC = () => {
|
||||||
|
const [objects, setObjects] = useState<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const page = () => {
|
|
||||||
return (
|
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
|
||||||
85
frontend/app/(protected)/reports/page.tsx
Normal file
85
frontend/app/(protected)/reports/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useEffect } from 'react'
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
|
import Sidebar from '../../../components/ui/Sidebar'
|
||||||
|
import useNavigationStore from '../../store/navigationStore'
|
||||||
|
import ReportsList from '../../../components/reports/ReportsList'
|
||||||
|
import ExportMenu from '../../../components/ui/ExportMenu'
|
||||||
|
import detectorsData from '../../../data/detectors.json'
|
||||||
|
|
||||||
|
const ReportsPage: React.FC = () => {
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const { currentObject, setCurrentObject } = useNavigationStore()
|
||||||
|
|
||||||
|
const urlObjectId = searchParams.get('objectId')
|
||||||
|
const urlObjectTitle = searchParams.get('objectTitle')
|
||||||
|
const objectId = currentObject.id || urlObjectId
|
||||||
|
const objectTitle = currentObject.title || urlObjectTitle
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (urlObjectId && urlObjectTitle && (!currentObject.id || currentObject.id !== urlObjectId)) {
|
||||||
|
setCurrentObject(urlObjectId, urlObjectTitle)
|
||||||
|
}
|
||||||
|
}, [urlObjectId, urlObjectTitle, currentObject.id, setCurrentObject])
|
||||||
|
|
||||||
|
const handleBackClick = () => {
|
||||||
|
router.push('/dashboard')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = (format: 'csv' | 'pdf') => {
|
||||||
|
// TODO: добавить функционал по экспорту отчетов
|
||||||
|
console.log(`Exporting reports as ${format}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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
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);
|
color: var(--foreground);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
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
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
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
|
||||||
|
|
||||||
246
frontend/components/alerts/DetectorList.tsx
Normal file
246
frontend/components/alerts/DetectorList.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import detectorsData from '../../data/detectors.json'
|
||||||
|
|
||||||
|
interface Detector {
|
||||||
|
detector_id: number
|
||||||
|
name: string
|
||||||
|
location: string
|
||||||
|
status: string
|
||||||
|
object: string
|
||||||
|
floor: number
|
||||||
|
checked: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interface for raw detector data from JSON
|
||||||
|
interface RawDetector {
|
||||||
|
detector_id: number
|
||||||
|
name: string
|
||||||
|
object: string
|
||||||
|
status: string
|
||||||
|
type: string
|
||||||
|
location: string
|
||||||
|
floor: number
|
||||||
|
notifications: Array<{
|
||||||
|
id: number
|
||||||
|
type: string
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
acknowledged: boolean
|
||||||
|
priority: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilterType = 'all' | 'critical' | 'warning' | 'normal'
|
||||||
|
|
||||||
|
interface DetectorListProps {
|
||||||
|
objectId?: string
|
||||||
|
selectedDetectors: number[]
|
||||||
|
onDetectorSelect: (detectorId: number, selected: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const DetectorList: React.FC<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
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
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
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
|
||||||
263
frontend/components/dashboard/Dashboard.tsx
Normal file
263
frontend/components/dashboard/Dashboard.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import Sidebar from '../ui/Sidebar'
|
||||||
|
import useNavigationStore from '../../app/store/navigationStore'
|
||||||
|
import ChartCard from './ChartCard'
|
||||||
|
import AreaChart from './AreaChart'
|
||||||
|
import BarChart from './BarChart'
|
||||||
|
import DetectorChart from './DetectorChart'
|
||||||
|
import detectorsData from '../../data/detectors.json'
|
||||||
|
|
||||||
|
const Dashboard: React.FC = () => {
|
||||||
|
const router = useRouter()
|
||||||
|
const { currentObject, setCurrentSubmenu, closeMonitoring, closeFloorNavigation, closeNotifications } = useNavigationStore()
|
||||||
|
const objectId = currentObject?.id
|
||||||
|
const objectTitle = currentObject?.title
|
||||||
|
|
||||||
|
const handleBackClick = () => {
|
||||||
|
router.push('/objects')
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetectorData {
|
||||||
|
detector_id: number
|
||||||
|
name: string
|
||||||
|
object: string
|
||||||
|
status: string
|
||||||
|
type: string
|
||||||
|
location: string
|
||||||
|
floor: number
|
||||||
|
checked?: boolean
|
||||||
|
notifications: Array<{
|
||||||
|
id: number
|
||||||
|
type: string
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
acknowledged: boolean
|
||||||
|
priority: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
const detectorsArray = Object.values(detectorsData.detectors).filter(
|
||||||
|
(detector: DetectorData) => objectId ? detector.object === objectId : true
|
||||||
|
)
|
||||||
|
|
||||||
|
// Статусы
|
||||||
|
const statusCounts = detectorsArray.reduce((acc: { critical: number; warning: number; normal: number }, detector: DetectorData) => {
|
||||||
|
if (detector.status === '#b3261e') acc.critical++
|
||||||
|
else if (detector.status === '#fd7c22') acc.warning++
|
||||||
|
else if (detector.status === '#00ff00') acc.normal++
|
||||||
|
return acc
|
||||||
|
}, { critical: 0, warning: 0, normal: 0 })
|
||||||
|
|
||||||
|
const handleNavigationClick = () => {
|
||||||
|
// Close all submenus before navigating
|
||||||
|
closeMonitoring()
|
||||||
|
closeFloorNavigation()
|
||||||
|
closeNotifications()
|
||||||
|
setCurrentSubmenu(null)
|
||||||
|
router.push('/navigation')
|
||||||
|
}
|
||||||
|
|
||||||
|
// No custom sidebar handling needed - using unified navigation service
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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
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,21 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState, useCallback } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Engine,
|
Engine,
|
||||||
Scene,
|
Scene,
|
||||||
Vector3,
|
Vector3,
|
||||||
HemisphericLight,
|
HemisphericLight,
|
||||||
ArcRotateCamera,
|
ArcRotateCamera,
|
||||||
MeshBuilder,
|
|
||||||
StandardMaterial,
|
|
||||||
Color3,
|
Color3,
|
||||||
Color4,
|
Color4,
|
||||||
AbstractMesh,
|
AbstractMesh,
|
||||||
Mesh,
|
|
||||||
Nullable,
|
Nullable,
|
||||||
SceneLoader
|
SceneLoader
|
||||||
} from '@babylonjs/core'
|
} from '@babylonjs/core'
|
||||||
import '@babylonjs/loaders'
|
import '@babylonjs/loaders'
|
||||||
import { getCacheFileName, loadCachedData, parseAndCacheScene, ParsedMeshData } from '../utils/meshCache'
|
import LoadingSpinner from '../ui/LoadingSpinner'
|
||||||
|
|
||||||
|
|
||||||
interface ModelViewerProps {
|
interface ModelViewerProps {
|
||||||
modelPath: string
|
modelPath: string
|
||||||
@@ -40,6 +38,8 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
const engineRef = useRef<Nullable<Engine>>(null)
|
const engineRef = useRef<Nullable<Engine>>(null)
|
||||||
const sceneRef = useRef<Nullable<Scene>>(null)
|
const sceneRef = useRef<Nullable<Scene>>(null)
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [loadingProgress, setLoadingProgress] = useState(0)
|
||||||
|
const [showModel, setShowModel] = useState(false)
|
||||||
const isInitializedRef = useRef(false)
|
const isInitializedRef = useRef(false)
|
||||||
const isDisposedRef = useRef(false)
|
const isDisposedRef = useRef(false)
|
||||||
|
|
||||||
@@ -127,18 +127,22 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true)
|
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 {
|
try {
|
||||||
const cacheFileName = getCacheFileName(modelPath)
|
|
||||||
const cachedData = await loadCachedData(cacheFileName)
|
|
||||||
|
|
||||||
if (cachedData) {
|
|
||||||
console.log('📦 Using cached mesh data for analysis')
|
|
||||||
} else {
|
|
||||||
console.log('🔄 No cached data found, parsing scene...')
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await SceneLoader.ImportMeshAsync(
|
const result = await SceneLoader.ImportMeshAsync(
|
||||||
'',
|
'',
|
||||||
modelPath,
|
modelPath,
|
||||||
@@ -146,9 +150,11 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
sceneRef.current
|
sceneRef.current
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log('✅ GLTF Model loaded successfully!')
|
clearInterval(progressInterval)
|
||||||
console.log('📊 Loaded Meshes:', result.meshes.length)
|
setLoadingProgress(100)
|
||||||
|
|
||||||
|
console.log('GLTF Model loaded successfully!')
|
||||||
|
|
||||||
if (result.meshes.length > 0) {
|
if (result.meshes.length > 0) {
|
||||||
|
|
||||||
const boundingBox = result.meshes[0].getHierarchyBoundingVectors()
|
const boundingBox = result.meshes[0].getHierarchyBoundingVectors()
|
||||||
@@ -159,8 +165,7 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
camera.radius = maxDimension * 2
|
camera.radius = maxDimension * 2
|
||||||
camera.target = result.meshes[0].position
|
camera.target = result.meshes[0].position
|
||||||
|
|
||||||
const parsedData = await parseAndCacheScene(result.meshes, cacheFileName, modelPath)
|
|
||||||
|
|
||||||
onModelLoaded?.({
|
onModelLoaded?.({
|
||||||
meshes: result.meshes,
|
meshes: result.meshes,
|
||||||
boundingBox: {
|
boundingBox: {
|
||||||
@@ -169,38 +174,48 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('🎉 Model ready for viewing!')
|
// Плавное появление модели
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!isDisposedRef.current) {
|
||||||
|
setShowModel(true)
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
} else {
|
} else {
|
||||||
console.warn('⚠️ No meshes found in model')
|
console.warn('No meshes found in model')
|
||||||
onError?.('No geometry 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)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
clearInterval(progressInterval)
|
||||||
|
console.error('Error loading GLTF model:', error)
|
||||||
|
onError?.(`Failed to load model: ${error}`)
|
||||||
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadModel()
|
loadModel()
|
||||||
}, [modelPath])
|
}, [modelPath, onError, onModelLoaded])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-screen relative bg-gray-900 overflow-hidden">
|
<div className="w-full h-screen relative bg-gray-900 overflow-hidden">
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
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 && (
|
{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="absolute inset-0 bg-gray-900 flex items-center justify-center z-50">
|
||||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
<LoadingSpinner
|
||||||
Loading Model...
|
progress={loadingProgress}
|
||||||
|
size={120}
|
||||||
|
strokeWidth={8}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ModelViewer
|
export default ModelViewer
|
||||||
97
frontend/components/navigation/DetectorMenu.tsx
Normal file
97
frontend/components/navigation/DetectorMenu.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface DetectorType {
|
||||||
|
detector_id: number
|
||||||
|
name: string
|
||||||
|
object: string
|
||||||
|
status: string
|
||||||
|
checked: boolean
|
||||||
|
type: string
|
||||||
|
location: string
|
||||||
|
floor: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetectorMenuProps {
|
||||||
|
detector: DetectorType
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
getStatusText: (status: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DetectorMenu: React.FC<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">
|
||||||
|
{/* Header */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Detector Information Table */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Close Button */}
|
||||||
|
<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
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
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;
|
||||||
175
frontend/components/notifications/NotificationDetectorInfo.tsx
Normal file
175
frontend/components/notifications/NotificationDetectorInfo.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface DetectorInfoType {
|
||||||
|
detector_id: number
|
||||||
|
name: string
|
||||||
|
object: string
|
||||||
|
status: string
|
||||||
|
type: string
|
||||||
|
location: string
|
||||||
|
floor: number
|
||||||
|
checked: boolean
|
||||||
|
notifications: Array<{
|
||||||
|
id: number
|
||||||
|
type: string
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
acknowledged: boolean
|
||||||
|
priority: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationDetectorInfoProps {
|
||||||
|
detectorData: DetectorInfoType
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const NotificationDetectorInfo: React.FC<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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the latest notification for this detector
|
||||||
|
const latestNotification = detectorInfo.notifications && detectorInfo.notifications.length > 0
|
||||||
|
? detectorInfo.notifications.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())[0]
|
||||||
|
: null
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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
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
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
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
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
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
|
||||||
72
frontend/components/ui/LoadingSpinner.tsx
Normal file
72
frontend/components/ui/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface LoadingSpinnerProps {
|
||||||
|
progress?: number // 0-100
|
||||||
|
size?: number // diameter in pixels
|
||||||
|
strokeWidth?: number
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoadingSpinner: React.FC<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 }}>
|
||||||
|
{/* Background circle */}
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
{/* Progress circle */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Percentage text */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<span className="text-white text-xl font-semibold">
|
||||||
|
{Math.round(progress)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading text */}
|
||||||
|
<div className="mt-4 text-white text-base font-medium">
|
||||||
|
Loading Model...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoadingSpinner
|
||||||
474
frontend/components/ui/Sidebar.tsx
Normal file
474
frontend/components/ui/Sidebar.tsx
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useRouter, usePathname } from 'next/navigation'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import useUIStore from '../../app/store/uiStore'
|
||||||
|
import useNavigationStore from '../../app/store/navigationStore'
|
||||||
|
import { useNavigationService } from '@/services/navigationService'
|
||||||
|
|
||||||
|
interface NavigationItem {
|
||||||
|
id: number
|
||||||
|
label: string
|
||||||
|
icon: React.ComponentType<{ className?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
navigationItems?: NavigationItem[]
|
||||||
|
logoSrc?: string
|
||||||
|
userInfo?: {
|
||||||
|
name: string
|
||||||
|
role: string
|
||||||
|
avatar?: string
|
||||||
|
}
|
||||||
|
activeItem?: number | null
|
||||||
|
onCustomItemClick?: (itemId: number) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Иконки-заглушки
|
||||||
|
const BookOpen = ({ className }: { className?: string }) => (
|
||||||
|
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2L3 7v10c0 5.55 3.84 10 9 10s9-4.45 9-10V7l-9-5z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const Bot = ({ className }: { className?: string }) => (
|
||||||
|
<svg className={className} 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 2z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const SquareTerminal = ({ className }: { className?: string }) => (
|
||||||
|
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M4 6h16v2H4zm0 5h16v2H4zm0 5h16v2H4z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const CircleDot = ({ className }: { className?: string }) => (
|
||||||
|
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const BellDot = ({ className }: { className?: string }) => (
|
||||||
|
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2C8.13 2 5 5.13 5 9v7l-2 2v1h16v-1l-2-2V9c0-3.87-3.13-7-7-7z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const History = ({ className }: { className?: string }) => (
|
||||||
|
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const Settings2 = ({ className }: { className?: string }) => (
|
||||||
|
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const Monitor = ({ className }: { className?: string }) => (
|
||||||
|
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M20 3H4c-1.1 0-2 .9-2 2v11c0 1.1.9 2 2 2h3l-1 1v1h12v-1l-1-1h3c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 13H4V5h16v11z" />
|
||||||
|
<circle cx="8" cy="10" r="2" />
|
||||||
|
<circle cx="16" cy="10" r="2" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const Building = ({ className }: { className?: string }) => (
|
||||||
|
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2L2 7v10c0 5.55 3.84 10 9 10s9-4.45 9-10V7l-9-5zM12 4.14L18 7.69V17c0 3.31-2.69 6-6 6s-6-2.69-6-6V7.69L12 4.14z" />
|
||||||
|
<rect x="8" y="10" width="2" height="2" />
|
||||||
|
<rect x="14" y="10" width="2" height="2" />
|
||||||
|
<rect x="8" y="14" width="2" height="2" />
|
||||||
|
<rect x="14" y="14" width="2" height="2" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
// основные 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
|
||||||
|
|
||||||
|
// Handle submenu items directly with navigation store
|
||||||
|
switch (itemId) {
|
||||||
|
case 2: // Navigation - only navigate to page, don't open submenus
|
||||||
|
if (pathname !== '/navigation') {
|
||||||
|
router.push('/navigation')
|
||||||
|
}
|
||||||
|
handled = true
|
||||||
|
break
|
||||||
|
case 3: // Monitoring
|
||||||
|
if (pathname !== '/navigation') {
|
||||||
|
// Navigate to navigation page first, then open monitoring
|
||||||
|
router.push('/navigation')
|
||||||
|
setTimeout(() => openMonitoring(), 100)
|
||||||
|
} else if (showMonitoring) {
|
||||||
|
// Close if already open
|
||||||
|
closeMonitoring()
|
||||||
|
} else {
|
||||||
|
openMonitoring()
|
||||||
|
}
|
||||||
|
handled = true
|
||||||
|
break
|
||||||
|
case 4: // Floor Navigation
|
||||||
|
if (pathname !== '/navigation') {
|
||||||
|
// Navigate to navigation page first, then open floor navigation
|
||||||
|
router.push('/navigation')
|
||||||
|
setTimeout(() => openFloorNavigation(), 100)
|
||||||
|
} else if (showFloorNavigation) {
|
||||||
|
// Close if already open
|
||||||
|
closeFloorNavigation()
|
||||||
|
} else {
|
||||||
|
openFloorNavigation()
|
||||||
|
}
|
||||||
|
handled = true
|
||||||
|
break
|
||||||
|
case 5: // Notifications
|
||||||
|
if (pathname !== '/navigation') {
|
||||||
|
// Navigate to navigation page first, then open notifications
|
||||||
|
router.push('/navigation')
|
||||||
|
setTimeout(() => openNotifications(), 100)
|
||||||
|
} else if (showNotifications) {
|
||||||
|
// Close if already open
|
||||||
|
closeNotifications()
|
||||||
|
} else {
|
||||||
|
openNotifications()
|
||||||
|
}
|
||||||
|
handled = true
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
// For other items, use navigation service
|
||||||
|
if (navigationService) {
|
||||||
|
handled = navigationService.handleSidebarItemClick(itemId, pathname)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handled) {
|
||||||
|
// Update internal active item state
|
||||||
|
if (propActiveItem === undefined) {
|
||||||
|
setInternalActiveItem(itemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call custom handler if provided (for additional logic)
|
||||||
|
if (onCustomItemClick) {
|
||||||
|
onCustomItemClick(itemId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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 w-[169.83px] h-[34px]">
|
||||||
|
{logoSrc && (
|
||||||
|
<Image
|
||||||
|
className="absolute w-12 h-[33px] top-0 left-0"
|
||||||
|
alt="AerBIM Monitor Logo"
|
||||||
|
src={logoSrc}
|
||||||
|
width={48}
|
||||||
|
height={33}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="absolute w-[99px] top-[21px] left-[50px] [font-family:'Open_Sans-Regular',Helvetica] font-normal text-[#389ee8] text-[13px] tracking-[0] leading-[13px] whitespace-nowrap">
|
||||||
|
AMS HT Viewer
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-px left-[50px] [font-family:'Open_Sans-SemiBold',Helvetica] font-semibold text-[#f1f6fa] text-[15px] tracking-[0] leading-[15px] whitespace-nowrap">
|
||||||
|
AerBIM Monitor
|
||||||
|
</div>
|
||||||
|
</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 hover:bg-gray-600 rounded transition-colors duration-200 cursor-pointer"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setManuallyToggled(true)
|
||||||
|
// Close all active submenus when collapsing navigation menu
|
||||||
|
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)
|
||||||
|
// Close all active submenus when collapsing navigation menu
|
||||||
|
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 ${
|
||||||
|
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-6 !h-6 p-1 rounded hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all duration-200"
|
||||||
|
onClick={() => {
|
||||||
|
// If navigation submenu is open, first collapse it (which closes all submenus)
|
||||||
|
if (showNavigationSubItems) {
|
||||||
|
setShowNavigationSubItems(false)
|
||||||
|
setManuallyToggled(true)
|
||||||
|
// Close all active submenus when collapsing navigation menu
|
||||||
|
closeMonitoring()
|
||||||
|
closeFloorNavigation()
|
||||||
|
closeNotifications()
|
||||||
|
}
|
||||||
|
// Always collapse the sidebar
|
||||||
|
toggleSidebar()
|
||||||
|
}}
|
||||||
|
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg className={`!relative !w-4 !h-4 text-white transition-transform duration-200 ${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
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/images/test_image.png
Normal file
BIN
frontend/public/images/test_image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
92
frontend/services/navigationService.ts
Normal file
92
frontend/services/navigationService.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import useNavigationStore from '@/app/store/navigationStore'
|
||||||
|
import type { NavigationStore } from '@/app/store/navigationStore'
|
||||||
|
|
||||||
|
export enum MainRoutes {
|
||||||
|
DASHBOARD = '/dashboard',
|
||||||
|
NAVIGATION = '/navigation',
|
||||||
|
ALERTS = '/alerts',
|
||||||
|
REPORTS = '/reports',
|
||||||
|
OBJECTS = '/objects'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SIDEBAR_ITEM_MAP = {
|
||||||
|
1: { type: 'route', value: MainRoutes.DASHBOARD },
|
||||||
|
2: { type: 'route', value: MainRoutes.NAVIGATION },
|
||||||
|
8: { type: 'route', value: MainRoutes.ALERTS },
|
||||||
|
9: { type: 'route', value: MainRoutes.REPORTS }
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export class NavigationService {
|
||||||
|
private router!: ReturnType<typeof useRouter>
|
||||||
|
private navigationStore!: NavigationStore
|
||||||
|
private initialized = false
|
||||||
|
|
||||||
|
init(router: ReturnType<typeof useRouter>, navigationStore: NavigationStore) {
|
||||||
|
if (this.initialized) {
|
||||||
|
return // Prevent multiple initializations
|
||||||
|
}
|
||||||
|
this.router = router
|
||||||
|
this.navigationStore = navigationStore
|
||||||
|
this.initialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
isInitialized(): boolean {
|
||||||
|
return this.initialized
|
||||||
|
}
|
||||||
|
|
||||||
|
navigateToRoute(route: MainRoutes) {
|
||||||
|
// Clear any active submenus when navigating to a different route
|
||||||
|
if (route !== MainRoutes.NAVIGATION) {
|
||||||
|
this.navigationStore.setCurrentSubmenu(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.router.push(route)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSidebarItemClick(itemId: number, currentPath: string): boolean {
|
||||||
|
if (!this.initialized) {
|
||||||
|
console.error('NavigationService not initialized!')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapping = SIDEBAR_ITEM_MAP[itemId as keyof typeof SIDEBAR_ITEM_MAP]
|
||||||
|
|
||||||
|
if (!mapping) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mapping.type === 'route') {
|
||||||
|
this.navigateToRoute(mapping.value as MainRoutes)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
goBack() {
|
||||||
|
this.navigationStore.goBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
selectObjectAndGoToDashboard(objectId: string, objectTitle: string) {
|
||||||
|
this.navigationStore.setCurrentObject(objectId, objectTitle)
|
||||||
|
this.navigateToRoute(MainRoutes.DASHBOARD)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const navigationService = new NavigationService()
|
||||||
|
|
||||||
|
export function useNavigationService() {
|
||||||
|
const router = useRouter()
|
||||||
|
const navigationStore = useNavigationStore()
|
||||||
|
|
||||||
|
// Initialize only once per component lifecycle
|
||||||
|
React.useMemo(() => {
|
||||||
|
if (!navigationService.isInitialized()) {
|
||||||
|
navigationService.init(router, navigationStore)
|
||||||
|
}
|
||||||
|
}, [router, navigationStore])
|
||||||
|
|
||||||
|
return navigationService
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user