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

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

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,9 +150,11 @@ 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) {
const boundingBox = result.meshes[0].getHierarchyBoundingVectors()
@@ -159,8 +165,7 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
camera.radius = maxDimension * 2
camera.target = result.meshes[0].position
const parsedData = await parseAndCacheScene(result.meshes, cacheFileName, modelPath)
onModelLoaded?.({
meshes: result.meshes,
boundingBox: {
@@ -169,38 +174,48 @@ 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>
)
}
export default ModelViewer
export default ModelViewer

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