Разработка интерфейса фронт

This commit is contained in:
iv_vuytsik
2025-09-05 03:16:17 +03:00
parent 6c2ea027a4
commit 4d6b7b48d7
35 changed files with 3806 additions and 276 deletions

4
frontend/.gitignore vendored
View File

@@ -39,3 +39,7 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# Include data and models folders
!/data/
!/public/models/

View 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

View File

@@ -1,9 +1,23 @@
import React from 'react'
'use client'
const page = () => {
return (
<div>page</div>
)
import React, { useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import Dashboard from '../../../components/dashboard/Dashboard'
import useNavigationStore from '../../store/navigationStore'
const DashboardPage = () => {
const searchParams = useSearchParams()
const { currentObject, setCurrentObject } = useNavigationStore()
const urlObjectId = searchParams.get('objectId')
const urlObjectTitle = searchParams.get('objectTitle')
useEffect(() => {
if (urlObjectId && urlObjectTitle && (!currentObject.id || currentObject.id !== urlObjectId)) {
setCurrentObject(urlObjectId, urlObjectTitle)
}
}, [urlObjectId, urlObjectTitle, currentObject.id, setCurrentObject])
return <Dashboard />
}
export default page
export default DashboardPage

View File

@@ -0,0 +1,13 @@
import React from 'react'
export default function ProtectedLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="protected-layout">
{children}
</div>
)
}

View File

@@ -1,16 +1,9 @@
'use client'
import React, { useState } from 'react'
import ModelViewer from '@/components/ModelViewer'
import ModelViewer from '@/components/model/ModelViewer'
export default function Home() {
const [modelInfo, setModelInfo] = useState<{
meshes: unknown[]
boundingBox: {
min: { x: number; y: number; z: number }
max: { x: number; y: number; z: number }
}
} | null>(null)
const [error, setError] = useState<string | null>(null)
const handleModelLoaded = (data: {
@@ -20,14 +13,12 @@ export default function Home() {
max: { x: number; y: number; z: number }
}
}) => {
setModelInfo(data)
setError(null)
console.log('Model loaded successfully:', data)
}
const handleError = (errorMessage: string) => {
setError(errorMessage)
setModelInfo(null)
}
return (
@@ -43,18 +34,6 @@ export default function Home() {
<strong>Error:</strong> {error}
</div>
)}
{modelInfo && (
<div className="absolute top-4 right-4 z-50 max-w-xs rounded-lg bg-black/80 p-4 text-sm text-white">
<h3 className="mb-3 text-base font-semibold">EXPO Building Model</h3>
<div className="space-y-1 text-xs text-gray-300">
<div>🖱 Left click + drag: Rotate</div>
<div>🖱 Right click + drag: Pan</div>
<div>🖱 Scroll: Zoom in/out</div>
</div>
</div>
)}
</div>
)
}

View 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

View File

@@ -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 (
<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

View 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

View File

@@ -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 }
)
}
}

View 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 }
)
}
}

View File

@@ -43,3 +43,34 @@ body {
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
/* Стилизация скроллбара */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #0e111a;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #374151;
border-radius: 4px;
border: 1px solid #1f2937;
}
::-webkit-scrollbar-thumb:hover {
background: #4b5563;
}
::-webkit-scrollbar-corner {
background: #0e111a;
}
/* Стилизация скроллбара - Firefox*/
* {
scrollbar-width: thin;
scrollbar-color: #374151 #0e111a;
}

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -1,23 +1,21 @@
'use client'
import React, { useEffect, useRef, useState, useCallback } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import {
Engine,
Scene,
Vector3,
HemisphericLight,
ArcRotateCamera,
MeshBuilder,
StandardMaterial,
Color3,
Color4,
AbstractMesh,
Mesh,
Nullable,
SceneLoader
} from '@babylonjs/core'
import '@babylonjs/loaders'
import { getCacheFileName, loadCachedData, parseAndCacheScene, ParsedMeshData } from '../utils/meshCache'
import LoadingSpinner from '../ui/LoadingSpinner'
interface ModelViewerProps {
modelPath: string
@@ -40,6 +38,8 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
const engineRef = useRef<Nullable<Engine>>(null)
const sceneRef = useRef<Nullable<Scene>>(null)
const [isLoading, setIsLoading] = useState(false)
const [loadingProgress, setLoadingProgress] = useState(0)
const [showModel, setShowModel] = useState(false)
const isInitializedRef = useRef(false)
const isDisposedRef = useRef(false)
@@ -127,18 +127,22 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
}
setIsLoading(true)
console.log('🚀 Loading GLTF model:', modelPath)
setLoadingProgress(0)
setShowModel(false)
console.log('Loading GLTF model:', modelPath)
// UI элемент загрузчика (есть эффект замедленности)
const progressInterval = setInterval(() => {
setLoadingProgress(prev => {
if (prev >= 90) {
clearInterval(progressInterval)
return 90
}
return prev + Math.random() * 15
})
}, 100)
try {
const cacheFileName = getCacheFileName(modelPath)
const cachedData = await loadCachedData(cacheFileName)
if (cachedData) {
console.log('📦 Using cached mesh data for analysis')
} else {
console.log('🔄 No cached data found, parsing scene...')
}
const result = await SceneLoader.ImportMeshAsync(
'',
modelPath,
@@ -146,8 +150,10 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
sceneRef.current
)
console.log('✅ GLTF Model loaded successfully!')
console.log('📊 Loaded Meshes:', result.meshes.length)
clearInterval(progressInterval)
setLoadingProgress(100)
console.log('GLTF Model loaded successfully!')
if (result.meshes.length > 0) {
@@ -159,7 +165,6 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
camera.radius = maxDimension * 2
camera.target = result.meshes[0].position
const parsedData = await parseAndCacheScene(result.meshes, cacheFileName, modelPath)
onModelLoaded?.({
meshes: result.meshes,
@@ -169,34 +174,44 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
}
})
console.log('🎉 Model ready for viewing!')
// Плавное появление модели
setTimeout(() => {
if (!isDisposedRef.current) {
setShowModel(true)
setIsLoading(false)
}
}, 500)
} else {
console.warn('⚠️ No meshes found in model')
console.warn('No meshes found in model')
onError?.('No geometry found in model')
}
} catch (error) {
console.error('❌ Error loading GLTF model:', error)
onError?.(`Failed to load model: ${error}`)
} finally {
if (!isDisposedRef.current) {
setIsLoading(false)
}
} catch (error) {
clearInterval(progressInterval)
console.error('Error loading GLTF model:', error)
onError?.(`Failed to load model: ${error}`)
setIsLoading(false)
}
}
loadModel()
}, [modelPath])
}, [modelPath, onError, onModelLoaded])
return (
<div className="w-full h-screen relative bg-gray-900 overflow-hidden">
<canvas
ref={canvasRef}
className="w-full h-full outline-none block"
className={`w-full h-full outline-none block transition-opacity duration-500 ${
showModel ? 'opacity-100' : 'opacity-0'
}`}
/>
{isLoading && (
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-black/80 text-white px-8 py-5 rounded-xl z-50 flex items-center gap-3 text-base font-medium">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
Loading Model...
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-50">
<LoadingSpinner
progress={loadingProgress}
size={120}
strokeWidth={8}
/>
</div>
)}
</div>

View 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

View 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

View 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;

View 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

View 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

View 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 }

View 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 }

View 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;

View 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

View 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

View 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

View 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"
}
]
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View 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
}

View File

@@ -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
}