Разработка интерфейса фронт
This commit is contained in:
246
frontend/components/alerts/DetectorList.tsx
Normal file
246
frontend/components/alerts/DetectorList.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import detectorsData from '../../data/detectors.json'
|
||||
|
||||
interface Detector {
|
||||
detector_id: number
|
||||
name: string
|
||||
location: string
|
||||
status: string
|
||||
object: string
|
||||
floor: number
|
||||
checked: boolean
|
||||
}
|
||||
|
||||
// Interface for raw detector data from JSON
|
||||
interface RawDetector {
|
||||
detector_id: number
|
||||
name: string
|
||||
object: string
|
||||
status: string
|
||||
type: string
|
||||
location: string
|
||||
floor: number
|
||||
notifications: Array<{
|
||||
id: number
|
||||
type: string
|
||||
message: string
|
||||
timestamp: string
|
||||
acknowledged: boolean
|
||||
priority: string
|
||||
}>
|
||||
}
|
||||
|
||||
type FilterType = 'all' | 'critical' | 'warning' | 'normal'
|
||||
|
||||
interface DetectorListProps {
|
||||
objectId?: string
|
||||
selectedDetectors: number[]
|
||||
onDetectorSelect: (detectorId: number, selected: boolean) => void
|
||||
}
|
||||
|
||||
const DetectorList: React.FC<DetectorListProps> = ({ objectId, selectedDetectors, onDetectorSelect }) => {
|
||||
const [detectors, setDetectors] = useState<Detector[]>([])
|
||||
const [selectedFilter, setSelectedFilter] = useState<FilterType>('all')
|
||||
const [searchTerm, setSearchTerm] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
const detectorsArray = Object.values(detectorsData.detectors).filter(
|
||||
(detector: RawDetector) => objectId ? detector.object === objectId : true
|
||||
)
|
||||
setDetectors(detectorsArray as Detector[])
|
||||
|
||||
|
||||
}, [objectId])
|
||||
|
||||
|
||||
|
||||
const filteredDetectors = detectors.filter(detector => {
|
||||
const matchesSearch = detector.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
detector.location.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
|
||||
if (selectedFilter === 'all') return matchesSearch
|
||||
if (selectedFilter === 'critical') return matchesSearch && detector.status === '#b3261e'
|
||||
if (selectedFilter === 'warning') return matchesSearch && detector.status === '#fd7c22'
|
||||
if (selectedFilter === 'normal') return matchesSearch && detector.status === '#00ff00'
|
||||
|
||||
return matchesSearch
|
||||
})
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setSelectedFilter('all')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedFilter === 'all'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-[#161824] text-gray-300 hover:bg-[#1f2937]'
|
||||
}`}
|
||||
>
|
||||
Все ({detectors.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedFilter('critical')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedFilter === 'critical'
|
||||
? 'bg-red-600 text-white'
|
||||
: 'bg-[#161824] text-gray-300 hover:bg-[#1f2937]'
|
||||
}`}
|
||||
>
|
||||
Критические ({detectors.filter(d => d.status === '#b3261e').length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedFilter('warning')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedFilter === 'warning'
|
||||
? 'bg-orange-600 text-white'
|
||||
: 'bg-[#161824] text-gray-300 hover:bg-[#1f2937]'
|
||||
}`}
|
||||
>
|
||||
Предупреждения ({detectors.filter(d => d.status === '#fd7c22').length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedFilter('normal')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedFilter === 'normal'
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-[#161824] text-gray-300 hover:bg-[#1f2937]'
|
||||
}`}
|
||||
>
|
||||
Норма ({detectors.filter(d => d.status === '#00ff00').length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск детекторов..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="bg-[#161824] text-white placeholder-gray-400 px-4 py-2 rounded-lg border border-gray-600 focus:border-blue-500 focus:outline-none w-64"
|
||||
/>
|
||||
<svg className="absolute right-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Таблица детекторов */}
|
||||
<div className="bg-[#161824] rounded-[20px] p-6">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-700">
|
||||
<th className="text-left text-white font-medium py-3 w-12">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDetectors.length === filteredDetectors.length && filteredDetectors.length > 0}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
filteredDetectors.forEach(detector => {
|
||||
if (!selectedDetectors.includes(detector.detector_id)) {
|
||||
onDetectorSelect(detector.detector_id, true)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
filteredDetectors.forEach(detector => {
|
||||
if (selectedDetectors.includes(detector.detector_id)) {
|
||||
onDetectorSelect(detector.detector_id, false)
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500 focus:ring-2"
|
||||
/>
|
||||
</th>
|
||||
<th className="text-left text-white font-medium py-3">Детектор</th>
|
||||
<th className="text-left text-white font-medium py-3">Статус</th>
|
||||
<th className="text-left text-white font-medium py-3">Местоположение</th>
|
||||
<th className="text-left text-white font-medium py-3">Проверен</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredDetectors.map((detector) => {
|
||||
const isSelected = selectedDetectors.includes(detector.detector_id)
|
||||
|
||||
return (
|
||||
<tr key={detector.detector_id} className="border-b border-gray-800">
|
||||
<td className="py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => onDetectorSelect(detector.detector_id, e.target.checked)}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500 focus:ring-2"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 text-white text-sm">{detector.name}</td>
|
||||
<td className="py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full`}
|
||||
style={{ backgroundColor: detector.status }}
|
||||
></div>
|
||||
<span className="text-sm text-gray-300">
|
||||
{detector.status === '#b3261e' ? 'Критическое' :
|
||||
detector.status === '#fd7c22' ? 'Предупреждение' : 'Норма'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 text-gray-400 text-sm">{detector.location}</td>
|
||||
<td className="py-3">
|
||||
{detector.checked ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-sm text-green-500">Да</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500">Нет</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Статы детекторров*/}
|
||||
<div className="mt-6 grid grid-cols-4 gap-4">
|
||||
<div className="bg-[#161824] p-4 rounded-lg">
|
||||
<div className="text-2xl font-bold text-white">{filteredDetectors.length}</div>
|
||||
<div className="text-sm text-gray-400">Всего</div>
|
||||
</div>
|
||||
<div className="bg-[#161824] p-4 rounded-lg">
|
||||
<div className="text-2xl font-bold text-green-500">{filteredDetectors.filter(d => d.status === '#00ff00').length}</div>
|
||||
<div className="text-sm text-gray-400">Норма</div>
|
||||
</div>
|
||||
<div className="bg-[#161824] p-4 rounded-lg">
|
||||
<div className="text-2xl font-bold text-orange-500">{filteredDetectors.filter(d => d.status === '#fd7c22').length}</div>
|
||||
<div className="text-sm text-gray-400">Предупреждения</div>
|
||||
</div>
|
||||
<div className="bg-[#161824] p-4 rounded-lg">
|
||||
<div className="text-2xl font-bold text-red-500">{filteredDetectors.filter(d => d.status === '#b3261e').length}</div>
|
||||
<div className="text-sm text-gray-400">Критические</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredDetectors.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-400">Детекторы не найдены</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DetectorList
|
||||
48
frontend/components/dashboard/AreaChart.tsx
Normal file
48
frontend/components/dashboard/AreaChart.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
interface ChartDataPoint {
|
||||
value: number
|
||||
label?: string
|
||||
timestamp?: string
|
||||
}
|
||||
|
||||
interface AreaChartProps {
|
||||
className?: string
|
||||
data?: ChartDataPoint[]
|
||||
}
|
||||
|
||||
const AreaChart: React.FC<AreaChartProps> = ({ className = '' }) => {
|
||||
return (
|
||||
<div className={`w-full h-full ${className}`}>
|
||||
<svg className="w-full h-full" viewBox="0 0 635 200">
|
||||
<defs>
|
||||
<linearGradient id="areaGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="rgb(42, 157, 144)" stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor="rgb(42, 157, 144)" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M0,180 L100,120 L200,140 L300,80 L400,60 L500,100 L635,90 L635,200 L0,200 Z"
|
||||
fill="url(#areaGradient)"
|
||||
/>
|
||||
<path
|
||||
d="M0,180 L100,120 L200,140 L300,80 L400,60 L500,100 L635,90"
|
||||
stroke="rgb(42, 157, 144)"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
/>
|
||||
<circle cx="0" cy="180" r="3" fill="rgb(42, 157, 144)" />
|
||||
<circle cx="100" cy="120" r="3" fill="rgb(42, 157, 144)" />
|
||||
<circle cx="200" cy="140" r="3" fill="rgb(42, 157, 144)" />
|
||||
<circle cx="300" cy="80" r="3" fill="rgb(42, 157, 144)" />
|
||||
<circle cx="400" cy="60" r="3" fill="rgb(42, 157, 144)" />
|
||||
<circle cx="500" cy="100" r="3" fill="rgb(42, 157, 144)" />
|
||||
<circle cx="635" cy="90" r="3" fill="rgb(42, 157, 144)" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AreaChart
|
||||
62
frontend/components/dashboard/BarChart.tsx
Normal file
62
frontend/components/dashboard/BarChart.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
interface ChartDataPoint {
|
||||
value: number
|
||||
label?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
interface BarChartProps {
|
||||
className?: string
|
||||
data?: ChartDataPoint[]
|
||||
}
|
||||
|
||||
const BarChart: React.FC<BarChartProps> = ({ className = '' }) => {
|
||||
const barData = [
|
||||
{ value: 80, color: 'rgb(42, 157, 144)' },
|
||||
{ value: 65, color: 'rgb(42, 157, 144)' },
|
||||
{ value: 90, color: 'rgb(42, 157, 144)' },
|
||||
{ value: 45, color: 'rgb(42, 157, 144)' },
|
||||
{ value: 75, color: 'rgb(42, 157, 144)' },
|
||||
{ value: 55, color: 'rgb(42, 157, 144)' },
|
||||
{ value: 85, color: 'rgb(42, 157, 144)' },
|
||||
{ value: 70, color: 'rgb(42, 157, 144)' },
|
||||
{ value: 60, color: 'rgb(42, 157, 144)' },
|
||||
{ value: 95, color: 'rgb(42, 157, 144)' },
|
||||
{ value: 40, color: 'rgb(42, 157, 144)' },
|
||||
{ value: 80, color: 'rgb(42, 157, 144)' }
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={`w-full h-full ${className}`}>
|
||||
<svg className="w-full h-full" viewBox="0 0 635 200">
|
||||
<g>
|
||||
{barData.map((bar, index) => {
|
||||
const barWidth = 40
|
||||
const barSpacing = 12
|
||||
const x = index * (barWidth + barSpacing) + 20
|
||||
const barHeight = (bar.value / 100) * 160
|
||||
const y = 180 - barHeight
|
||||
|
||||
return (
|
||||
<rect
|
||||
key={index}
|
||||
x={x}
|
||||
y={y}
|
||||
width={barWidth}
|
||||
height={barHeight}
|
||||
fill={bar.color}
|
||||
rx="4"
|
||||
ry="4"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BarChart
|
||||
41
frontend/components/dashboard/ChartCard.tsx
Normal file
41
frontend/components/dashboard/ChartCard.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
interface ChartCardProps {
|
||||
title: string
|
||||
subtitle?: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const ChartCard: React.FC<ChartCardProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<div className={`bg-[#161824] rounded-[20px] p-6 ${className}`}>
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-white text-base font-semibold mb-1">{title}</h3>
|
||||
{subtitle && (
|
||||
<p className="text-[#71717a] text-sm">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-4 h-4">
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-[200px]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChartCard
|
||||
263
frontend/components/dashboard/Dashboard.tsx
Normal file
263
frontend/components/dashboard/Dashboard.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Sidebar from '../ui/Sidebar'
|
||||
import useNavigationStore from '../../app/store/navigationStore'
|
||||
import ChartCard from './ChartCard'
|
||||
import AreaChart from './AreaChart'
|
||||
import BarChart from './BarChart'
|
||||
import DetectorChart from './DetectorChart'
|
||||
import detectorsData from '../../data/detectors.json'
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const router = useRouter()
|
||||
const { currentObject, setCurrentSubmenu, closeMonitoring, closeFloorNavigation, closeNotifications } = useNavigationStore()
|
||||
const objectId = currentObject?.id
|
||||
const objectTitle = currentObject?.title
|
||||
|
||||
const handleBackClick = () => {
|
||||
router.push('/objects')
|
||||
}
|
||||
|
||||
interface DetectorData {
|
||||
detector_id: number
|
||||
name: string
|
||||
object: string
|
||||
status: string
|
||||
type: string
|
||||
location: string
|
||||
floor: number
|
||||
checked?: boolean
|
||||
notifications: Array<{
|
||||
id: number
|
||||
type: string
|
||||
message: string
|
||||
timestamp: string
|
||||
acknowledged: boolean
|
||||
priority: string
|
||||
}>
|
||||
}
|
||||
|
||||
const detectorsArray = Object.values(detectorsData.detectors).filter(
|
||||
(detector: DetectorData) => objectId ? detector.object === objectId : true
|
||||
)
|
||||
|
||||
// Статусы
|
||||
const statusCounts = detectorsArray.reduce((acc: { critical: number; warning: number; normal: number }, detector: DetectorData) => {
|
||||
if (detector.status === '#b3261e') acc.critical++
|
||||
else if (detector.status === '#fd7c22') acc.warning++
|
||||
else if (detector.status === '#00ff00') acc.normal++
|
||||
return acc
|
||||
}, { critical: 0, warning: 0, normal: 0 })
|
||||
|
||||
const handleNavigationClick = () => {
|
||||
// Close all submenus before navigating
|
||||
closeMonitoring()
|
||||
closeFloorNavigation()
|
||||
closeNotifications()
|
||||
setCurrentSubmenu(null)
|
||||
router.push('/navigation')
|
||||
}
|
||||
|
||||
// No custom sidebar handling needed - using unified navigation service
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-[#0e111a]">
|
||||
<Sidebar
|
||||
activeItem={1} // Dashboard
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
<header className="bg-[#161824] border-b border-gray-700 px-6 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleBackClick}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
aria-label="Назад к объектам"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<nav className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-400">Объекты</span>
|
||||
<span className="text-gray-600">/</span>
|
||||
<span className="text-white">{objectTitle || 'Объект'}</span>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 p-6 overflow-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-white text-2xl font-semibold mb-6">Объект {objectId?.replace('object_', '')}</h1>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<button
|
||||
className="flex items-center gap-6 rounded-[10px] px-4 py-[18px] bg-[rgb(22,24,36)] text-white"
|
||||
>
|
||||
<span className="text-sm font-medium">Датчики : все</span>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3 ml-auto">
|
||||
<button
|
||||
onClick={handleNavigationClick}
|
||||
className="rounded-[10px] px-4 py-[18px] bg-gray-600 text-gray-300 hover:bg-[rgb(22,24,36)] hover:text-white transition-colors"
|
||||
>
|
||||
<span className="text-sm font-medium">Навигация</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 bg-[rgb(22,24,36)] rounded-lg px-3 py-2">
|
||||
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
<span className="text-white text-sm font-medium">Период</span>
|
||||
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Карты-графики */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[18px]">
|
||||
<ChartCard
|
||||
title="Показатель"
|
||||
subtitle="За последние 6 месяцев"
|
||||
>
|
||||
<AreaChart />
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Статистика"
|
||||
subtitle="Данные за период"
|
||||
>
|
||||
<BarChart />
|
||||
</ChartCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Список детекторов */}
|
||||
<div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-white text-2xl font-semibold">Тренды</h2>
|
||||
<div className="bg-[#161824] rounded-lg px-3 py-2 flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-white text-sm font-medium">Месяц</span>
|
||||
<svg className="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Таблица */}
|
||||
<div className="bg-[#161824] rounded-[20px] p-6">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-700">
|
||||
<th className="text-left text-white font-medium py-3">Детектор</th>
|
||||
<th className="text-left text-white font-medium py-3">Статус</th>
|
||||
<th className="text-left text-white font-medium py-3">Местоположение</th>
|
||||
<th className="text-left text-white font-medium py-3">Проверен</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detectorsArray.map((detector: DetectorData) => (
|
||||
<tr key={detector.detector_id} className="border-b border-gray-800">
|
||||
<td className="py-3 text-white text-sm">{detector.name}</td>
|
||||
<td className="py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full`}
|
||||
style={{ backgroundColor: detector.status }}
|
||||
></div>
|
||||
<span className="text-sm text-gray-300">
|
||||
{detector.status === '#b3261e' ? 'Критическое' :
|
||||
detector.status === '#fd7c22' ? 'Предупреждение' : 'Норма'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 text-gray-400 text-sm">{detector.location}</td>
|
||||
<td className="py-3">
|
||||
{detector.checked ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-sm text-green-500">Да</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500">Нет</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Статы */}
|
||||
<div className="mt-6 grid grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-white">{detectorsArray.length}</div>
|
||||
<div className="text-sm text-gray-400">Всего</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-500">{statusCounts.normal}</div>
|
||||
<div className="text-sm text-gray-400">Норма</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-orange-500">{statusCounts.warning}</div>
|
||||
<div className="text-sm text-gray-400">Предупреждения</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-red-500">{statusCounts.critical}</div>
|
||||
<div className="text-sm text-gray-400">Критические</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Графики с аналитикой */}
|
||||
<div className="mt-6 grid grid-cols-1 lg:grid-cols-4 gap-[18px]">
|
||||
<ChartCard
|
||||
title="Тренды детекторов"
|
||||
subtitle="За последний месяц"
|
||||
>
|
||||
<DetectorChart type="line" />
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Статистика по месяцам"
|
||||
subtitle="Активность детекторов"
|
||||
>
|
||||
<DetectorChart type="bar" />
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Анализ производительности"
|
||||
subtitle="Эффективность работы"
|
||||
>
|
||||
<DetectorChart type="line" />
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Сводка по статусам"
|
||||
subtitle="Распределение состояний"
|
||||
>
|
||||
<DetectorChart type="bar" />
|
||||
</ChartCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
103
frontend/components/dashboard/DetectorChart.tsx
Normal file
103
frontend/components/dashboard/DetectorChart.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
interface DetectorDataPoint {
|
||||
value: number
|
||||
label?: string
|
||||
timestamp?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
interface DetectorChartProps {
|
||||
className?: string
|
||||
data?: DetectorDataPoint[]
|
||||
type?: 'line' | 'bar'
|
||||
}
|
||||
|
||||
const DetectorChart: React.FC<DetectorChartProps> = ({
|
||||
className = '',
|
||||
type = 'line'
|
||||
}) => {
|
||||
if (type === 'bar') {
|
||||
const barData = [
|
||||
{ value: 85, label: 'Янв' },
|
||||
{ value: 70, label: 'Фев' },
|
||||
{ value: 90, label: 'Мар' },
|
||||
{ value: 65, label: 'Апр' },
|
||||
{ value: 80, label: 'Май' },
|
||||
{ value: 95, label: 'Июн' }
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={`w-full h-full ${className}`}>
|
||||
<svg className="w-full h-full" viewBox="0 0 400 200">
|
||||
<g>
|
||||
{barData.map((bar, index) => {
|
||||
const barWidth = 50
|
||||
const barSpacing = 15
|
||||
const x = index * (barWidth + barSpacing) + 20
|
||||
const barHeight = (bar.value / 100) * 150
|
||||
const y = 160 - barHeight
|
||||
|
||||
return (
|
||||
<g key={index}>
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={barWidth}
|
||||
height={barHeight}
|
||||
fill="rgb(42, 157, 144)"
|
||||
rx="4"
|
||||
ry="4"
|
||||
/>
|
||||
<text
|
||||
x={x + barWidth / 2}
|
||||
y={180}
|
||||
textAnchor="middle"
|
||||
fill="#71717a"
|
||||
fontSize="12"
|
||||
>
|
||||
{bar.label}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`w-full h-full ${className}`}>
|
||||
<svg className="w-full h-full" viewBox="0 0 400 200">
|
||||
<defs>
|
||||
<linearGradient id="detectorGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="rgb(231, 110, 80)" stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor="rgb(231, 110, 80)" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M20,150 L80,120 L140,100 L200,80 L260,90 L320,70 L380,60 L380,180 L20,180 Z"
|
||||
fill="url(#detectorGradient)"
|
||||
/>
|
||||
<path
|
||||
d="M20,150 L80,120 L140,100 L200,80 L260,90 L320,70 L380,60"
|
||||
stroke="rgb(231, 110, 80)"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
/>
|
||||
<circle cx="20" cy="150" r="3" fill="rgb(231, 110, 80)" />
|
||||
<circle cx="80" cy="120" r="3" fill="rgb(231, 110, 80)" />
|
||||
<circle cx="140" cy="100" r="3" fill="rgb(231, 110, 80)" />
|
||||
<circle cx="200" cy="80" r="3" fill="rgb(231, 110, 80)" />
|
||||
<circle cx="260" cy="90" r="3" fill="rgb(231, 110, 80)" />
|
||||
<circle cx="320" cy="70" r="3" fill="rgb(231, 110, 80)" />
|
||||
<circle cx="380" cy="60" r="3" fill="rgb(231, 110, 80)" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DetectorChart
|
||||
@@ -1,23 +1,21 @@
|
||||
'use client'
|
||||
|
||||
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
|
||||
97
frontend/components/navigation/DetectorMenu.tsx
Normal file
97
frontend/components/navigation/DetectorMenu.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
interface DetectorType {
|
||||
detector_id: number
|
||||
name: string
|
||||
object: string
|
||||
status: string
|
||||
checked: boolean
|
||||
type: string
|
||||
location: string
|
||||
floor: number
|
||||
}
|
||||
|
||||
interface DetectorMenuProps {
|
||||
detector: DetectorType
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
getStatusText: (status: string) => string
|
||||
}
|
||||
|
||||
const DetectorMenu: React.FC<DetectorMenuProps> = ({ detector, isOpen, onClose, getStatusText }) => {
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="absolute left-[500px] top-0 bg-[#161824] border-r border-gray-700 z-30 w-[454px]" style={{height: 'calc(100% - 73px)', top: '73px'}}>
|
||||
<div className="h-full overflow-auto p-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-white text-lg font-medium">
|
||||
Датч.{detector.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Отчет
|
||||
</button>
|
||||
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
История
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detector Information Table */}
|
||||
<div className="space-y-0 border border-[rgb(30,31,36)] rounded-lg overflow-hidden">
|
||||
<div className="flex">
|
||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Маркировка по проекту</div>
|
||||
<div className="text-white text-sm">{detector.name}</div>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Тип детектора</div>
|
||||
<div className="text-white text-sm">{detector.type}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex border-t border-[rgb(30,31,36)]">
|
||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Местоположение</div>
|
||||
<div className="text-white text-sm">{detector.location}</div>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Статус</div>
|
||||
<div className="text-white text-sm">{getStatusText(detector.status)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex border-t border-[rgb(30,31,36)]">
|
||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Временная метка</div>
|
||||
<div className="text-white text-sm">Сегодня, 14:30</div>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="text-white text-sm text-right">Вчера</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DetectorMenu
|
||||
206
frontend/components/navigation/FloorNavigation.tsx
Normal file
206
frontend/components/navigation/FloorNavigation.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
interface DetectorsDataType {
|
||||
detectors: Record<string, DetectorType>
|
||||
}
|
||||
|
||||
interface FloorNavigationProps {
|
||||
objectId?: string
|
||||
detectorsData: DetectorsDataType
|
||||
onDetectorMenuClick: (detector: DetectorType) => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
interface DetectorType {
|
||||
detector_id: number
|
||||
name: string
|
||||
object: string
|
||||
status: string
|
||||
checked: boolean
|
||||
type: string
|
||||
location: string
|
||||
floor: number
|
||||
notifications: Array<{
|
||||
id: number
|
||||
type: string
|
||||
message: string
|
||||
timestamp: string
|
||||
acknowledged: boolean
|
||||
priority: string
|
||||
}>
|
||||
}
|
||||
|
||||
const FloorNavigation: React.FC<FloorNavigationProps> = ({ objectId, detectorsData, onDetectorMenuClick, onClose }) => {
|
||||
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set())
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
|
||||
// конвертация детекторов в array и фильтруем по objectId и тексту запроса
|
||||
const detectorsArray = Object.values(detectorsData.detectors) as DetectorType[]
|
||||
let filteredDetectors = objectId
|
||||
? detectorsArray.filter(detector => detector.object === objectId)
|
||||
: detectorsArray
|
||||
|
||||
// Фильтр-поиск
|
||||
if (searchTerm) {
|
||||
filteredDetectors = filteredDetectors.filter(detector =>
|
||||
detector.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
detector.location.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
// Группиовка детекторов по этажам
|
||||
const detectorsByFloor = filteredDetectors.reduce((acc, detector) => {
|
||||
const floor = detector.floor
|
||||
if (!acc[floor]) {
|
||||
acc[floor] = []
|
||||
}
|
||||
acc[floor].push(detector)
|
||||
return acc
|
||||
}, {} as Record<number, DetectorType[]>)
|
||||
|
||||
// Сортировка этажей
|
||||
const sortedFloors = Object.keys(detectorsByFloor)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)
|
||||
|
||||
const toggleFloor = (floor: number) => {
|
||||
const newExpanded = new Set(expandedFloors)
|
||||
if (newExpanded.has(floor)) {
|
||||
newExpanded.delete(floor)
|
||||
} else {
|
||||
newExpanded.add(floor)
|
||||
}
|
||||
setExpandedFloors(newExpanded)
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case '#b3261e': return 'bg-red-500'
|
||||
case '#fd7c22': return 'bg-orange-500'
|
||||
case '#00ff00': return 'bg-green-500'
|
||||
default: return 'bg-gray-500'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case '#b3261e': return 'Критический'
|
||||
case '#fd7c22': return 'Предупреждение'
|
||||
case '#00ff00': return 'Норма'
|
||||
default: return 'Неизвестно'
|
||||
}
|
||||
}
|
||||
|
||||
const handleDetectorMenuClick = (detector: DetectorType) => {
|
||||
onDetectorMenuClick(detector)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl">
|
||||
<div className="bg-[rgb(22,24,36)] rounded-[12px] p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-white text-2xl font-semibold">Навигация по этажам</h2>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск детекторов..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full bg-[rgb(27,30,40)] text-white placeholder-gray-400 px-4 py-2 rounded-lg border border-gray-600 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
<svg className="absolute right-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-4 py-2 rounded-lg transition-colors flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4" />
|
||||
</svg>
|
||||
Фильтр
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{sortedFloors.map(floor => {
|
||||
const floorDetectors = detectorsByFloor[floor]
|
||||
const isExpanded = expandedFloors.has(floor)
|
||||
|
||||
return (
|
||||
<div key={floor} className="bg-[rgb(27,30,40)] rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleFloor(floor)}
|
||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-[rgb(53,58,70)] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-white font-medium">{floor} этаж</span>
|
||||
<span className="text-gray-400 text-sm">({floorDetectors.length} детекторов)</span>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-5 h-5 text-gray-400 transition-transform ${
|
||||
isExpanded ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* суб-меню с детекторами */}
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-3 space-y-2">
|
||||
{floorDetectors.map(detector => (
|
||||
<div
|
||||
key={detector.detector_id}
|
||||
className="bg-[rgb(53,58,70)] rounded-md p-3 flex items-center justify-between"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="text-white text-sm font-medium">{detector.name}</div>
|
||||
<div className="text-gray-400 text-xs">{detector.location}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-3 h-3 rounded-full ${getStatusColor(detector.status)}`}></div>
|
||||
<span className="text-xs text-gray-300">{getStatusText(detector.status)}</span>
|
||||
{detector.checked && (
|
||||
<svg className="w-4 h-4 text-green-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDetectorMenuClick(detector)}
|
||||
className="w-6 h-6 bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] rounded-full flex items-center justify-center transition-colors relative"
|
||||
>
|
||||
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FloorNavigation
|
||||
69
frontend/components/navigation/Monitoring.tsx
Normal file
69
frontend/components/navigation/Monitoring.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface MonitoringProps {
|
||||
objectId?: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const Monitoring: React.FC<MonitoringProps> = ({ onClose }) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="bg-[rgb(22,24,36)] rounded-[12px] p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-white text-2xl font-semibold">Зоны мониторинга</h2>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-[rgb(158,168,183)] rounded-lg p-3 h-[200px] flex items-center justify-center">
|
||||
<div className="w-full h-full bg-gray-300 rounded flex items-center justify-center">
|
||||
<Image
|
||||
src="/images/test_image.png"
|
||||
alt="Object Model"
|
||||
width={200}
|
||||
height={200}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
style={{ height: 'auto' }}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[1, 2, 3, 4, 5, 6].map((zone) => (
|
||||
<div key={zone} className="flex-1 bg-gray-300 rounded-lg h-[120px] flex items-center justify-center">
|
||||
<div className="w-full h-full bg-gray-200 rounded flex items-center justify-center">
|
||||
<Image
|
||||
src="/images/test_image.png"
|
||||
alt={`Зона ${zone}`}
|
||||
width={120}
|
||||
height={120}
|
||||
className="max-w-full max-h-full object-contain opacity-50"
|
||||
style={{ height: 'auto' }}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Monitoring;
|
||||
175
frontend/components/notifications/NotificationDetectorInfo.tsx
Normal file
175
frontend/components/notifications/NotificationDetectorInfo.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
interface DetectorInfoType {
|
||||
detector_id: number
|
||||
name: string
|
||||
object: string
|
||||
status: string
|
||||
type: string
|
||||
location: string
|
||||
floor: number
|
||||
checked: boolean
|
||||
notifications: Array<{
|
||||
id: number
|
||||
type: string
|
||||
message: string
|
||||
timestamp: string
|
||||
acknowledged: boolean
|
||||
priority: string
|
||||
}>
|
||||
}
|
||||
|
||||
interface NotificationDetectorInfoProps {
|
||||
detectorData: DetectorInfoType
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const NotificationDetectorInfo: React.FC<NotificationDetectorInfoProps> = ({ detectorData, onClose }) => {
|
||||
const detectorInfo = detectorData
|
||||
|
||||
if (!detectorInfo) {
|
||||
return (
|
||||
<div className="w-full max-w-4xl">
|
||||
<div className="bg-[rgb(22,24,36)] rounded-[12px] p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-white text-2xl font-semibold">Информация о детекторе</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-gray-400">Детектор не найден</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
if (status === '#b3261e') return 'text-red-400'
|
||||
if (status === '#fd7c22') return 'text-orange-400'
|
||||
if (status === '#4caf50') return 'text-green-400'
|
||||
return 'text-gray-400'
|
||||
}
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority.toLowerCase()) {
|
||||
case 'high': return 'text-red-400'
|
||||
case 'medium': return 'text-orange-400'
|
||||
case 'low': return 'text-green-400'
|
||||
default: return 'text-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
// Get the latest notification for this detector
|
||||
const latestNotification = detectorInfo.notifications && detectorInfo.notifications.length > 0
|
||||
? detectorInfo.notifications.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())[0]
|
||||
: null
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-white text-lg font-medium">
|
||||
Датч.{detectorInfo.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Отчет
|
||||
</button>
|
||||
<button className="bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] text-white px-3 py-2 rounded-[10px] text-sm font-medium transition-colors flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
История
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Табличка с детекторами */}
|
||||
<div className="space-y-0 border border-[rgb(30,31,36)] rounded-lg overflow-hidden">
|
||||
<div className="flex">
|
||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Маркировка по проекту</div>
|
||||
<div className="text-white text-sm">{detectorInfo.name}</div>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Тип детектора</div>
|
||||
<div className="text-white text-sm">{detectorInfo.type}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex border-t border-[rgb(30,31,36)]">
|
||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Местоположение</div>
|
||||
<div className="text-white text-sm">{detectorInfo.location}</div>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Статус</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: detectorInfo.status }}
|
||||
></div>
|
||||
<span className={`text-sm font-medium ${getStatusColor(detectorInfo.status)}`}>
|
||||
{detectorInfo.status === '#b3261e' ? 'Критический' :
|
||||
detectorInfo.status === '#fd7c22' ? 'Предупреждение' :
|
||||
detectorInfo.status === '#4caf50' ? 'Нормальный' : 'Неизвестно'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{latestNotification && (
|
||||
<div className="flex border-t border-[rgb(30,31,36)]">
|
||||
<div className="flex-1 p-4 border-r border-[rgb(30,31,36)]">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Последнее уведомление</div>
|
||||
<div className="text-white text-sm">{formatDate(latestNotification.timestamp)}</div>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Приоритет</div>
|
||||
<div className={`text-sm font-medium ${getPriorityColor(latestNotification.priority)}`}>
|
||||
{latestNotification.priority}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{latestNotification && (
|
||||
<div className="border-t border-[rgb(30,31,36)] p-4">
|
||||
<div className="text-[rgb(113,113,122)] text-sm font-medium mb-1">Сообщение</div>
|
||||
<div className="text-white text-sm">{latestNotification.message}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotificationDetectorInfo
|
||||
134
frontend/components/notifications/Notifications.tsx
Normal file
134
frontend/components/notifications/Notifications.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { IoClose } from 'react-icons/io5'
|
||||
|
||||
interface NotificationType {
|
||||
id: number
|
||||
detector_id: number
|
||||
detector_name: string
|
||||
type: string
|
||||
status: string
|
||||
message: string
|
||||
timestamp: string
|
||||
location: string
|
||||
object: string
|
||||
acknowledged: boolean
|
||||
priority: string
|
||||
}
|
||||
|
||||
interface DetectorType {
|
||||
detector_id: number
|
||||
name: string
|
||||
object: string
|
||||
status: string
|
||||
type: string
|
||||
location: string
|
||||
floor: number
|
||||
checked: boolean
|
||||
notifications: Array<{
|
||||
id: number
|
||||
type: string
|
||||
message: string
|
||||
timestamp: string
|
||||
acknowledged: boolean
|
||||
priority: string
|
||||
}>
|
||||
}
|
||||
|
||||
interface DetectorsDataType {
|
||||
detectors: Record<string, DetectorType>
|
||||
}
|
||||
|
||||
interface NotificationsProps {
|
||||
objectId?: string
|
||||
detectorsData: DetectorsDataType
|
||||
onNotificationClick: (notification: NotificationType) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const Notifications: React.FC<NotificationsProps> = ({ objectId, detectorsData, onNotificationClick, onClose }) => {
|
||||
const allNotifications = React.useMemo(() => {
|
||||
const notifications: NotificationType[] = [];
|
||||
Object.values(detectorsData.detectors).forEach(detector => {
|
||||
if (detector.notifications && detector.notifications.length > 0) {
|
||||
detector.notifications.forEach(notification => {
|
||||
notifications.push({
|
||||
...notification,
|
||||
detector_id: detector.detector_id,
|
||||
detector_name: detector.name,
|
||||
location: detector.location,
|
||||
object: detector.object,
|
||||
status: detector.status
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
return notifications;
|
||||
}, [detectorsData]);
|
||||
|
||||
// сортировка по objectId
|
||||
const filteredNotifications = objectId
|
||||
? allNotifications.filter(notification => notification.object.toString() === objectId.toString())
|
||||
: allNotifications
|
||||
|
||||
// сортировка по timestamp
|
||||
const sortedNotifications = [...filteredNotifications].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||
|
||||
const getStatusColor = (type: string) => {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'critical': return 'bg-red-500'
|
||||
case 'warning': return 'bg-orange-500'
|
||||
case 'info': return 'bg-green-500'
|
||||
default: return 'bg-gray-500'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full bg-[#161824] flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-700">
|
||||
<h2 className="text-white text-lg font-medium">Уведомления</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<IoClose className="w-5 h-5 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Список уведомлений*/}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{sortedNotifications.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<p className="text-gray-400">Уведомления не найдены</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sortedNotifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className="bg-[rgb(53,58,70)] rounded-md p-3 flex items-center justify-between hover:bg-[rgb(63,68,80)] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-3 h-3 rounded-full ${getStatusColor(notification.type)}`}></div>
|
||||
<div>
|
||||
<div className="text-white text-sm font-medium">{notification.detector_name}</div>
|
||||
<div className="text-gray-400 text-xs">{notification.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onNotificationClick(notification)}
|
||||
className="w-6 h-6 bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] rounded-full flex items-center justify-center transition-colors relative"
|
||||
>
|
||||
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Notifications
|
||||
103
frontend/components/objects/ObjectCard.tsx
Normal file
103
frontend/components/objects/ObjectCard.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Image from 'next/image'
|
||||
import { useNavigationService } from '@/services/navigationService'
|
||||
interface ObjectData {
|
||||
object_id: string
|
||||
title: string
|
||||
description: string
|
||||
image: string
|
||||
location: string
|
||||
floors?: number
|
||||
area?: string
|
||||
type?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
interface ObjectCardProps {
|
||||
object: ObjectData
|
||||
onSelect?: (objectId: string) => void
|
||||
isSelected?: boolean
|
||||
}
|
||||
|
||||
// Иконка редактирования
|
||||
const EditIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ObjectCard: React.FC<ObjectCardProps> = ({ object, onSelect, isSelected = false }) => {
|
||||
const navigationService = useNavigationService()
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (onSelect) {
|
||||
onSelect(object.object_id)
|
||||
}
|
||||
// Навигация к дашборду с выбранным объектом
|
||||
navigationService.selectObjectAndGoToDashboard(object.object_id, object.title)
|
||||
}
|
||||
|
||||
const handleEditClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
console.log('Edit object:', object.object_id)
|
||||
// Логика редактирования объекта
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`flex flex-col w-full min-h-[414px] h-[414px] sm:h-auto sm:min-h-[350px] items-start gap-4 p-4 sm:p-6 relative bg-[#161824] rounded-[20px] overflow-hidden cursor-pointer transition-all duration-200 hover:bg-[#1a1d2e] ${
|
||||
isSelected ? 'ring-2 ring-blue-500' : ''
|
||||
}`}
|
||||
onClick={handleCardClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
handleCardClick()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<header className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-2 relative self-stretch w-full flex-[0_0_auto]">
|
||||
<div className="flex-col items-start flex-1 grow flex relative min-w-0">
|
||||
<h2 className="self-stretch mt-[-1.00px] font-medium text-white text-lg leading-7 relative tracking-[0] break-words">
|
||||
{object.title}
|
||||
</h2>
|
||||
<p className="self-stretch font-normal text-[#71717a] text-sm leading-5 relative tracking-[0] break-words">
|
||||
{object.description}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="inline-flex flex-shrink-0 bg-[#3193f5] h-10 items-center justify-center gap-2 px-3 sm:px-4 py-2 relative rounded-md transition-colors duration-200 hover:bg-[#2563eb] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 w-full sm:w-auto"
|
||||
aria-label={`Изменить ${object.title}`}
|
||||
onClick={handleEditClick}
|
||||
>
|
||||
<EditIcon className="!relative !w-4 !h-4 text-white flex-shrink-0" />
|
||||
<span className="font-medium text-white text-sm leading-5 relative tracking-[0] sm:whitespace-nowrap">
|
||||
Изменить
|
||||
</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Изображение объекта */}
|
||||
<div className="relative flex-1 self-stretch w-full grow bg-[#f1f1f1] rounded-lg overflow-hidden min-h-[200px] sm:min-h-[250px]">
|
||||
<Image
|
||||
className="absolute w-full h-full top-0 left-0 object-cover"
|
||||
alt={object.title}
|
||||
src={object.image}
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
onError={(e) => {
|
||||
// Заглушка при ошибке загрузки изображения
|
||||
const target = e.target as HTMLImageElement
|
||||
target.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDUyIiBoZWlnaHQ9IjMwMiIgdmlld0JveD0iMCAwIDQ1MiAzMDIiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSI0NTIiIGhlaWdodD0iMzAyIiBmaWxsPSIjRjFGMUYxIi8+CjxwYXRoIGQ9Ik0yMjYgMTUxTDI0NiAxMzFMMjY2IDE1MUwyNDYgMTcxTDIyNiAxNTFaIiBmaWxsPSIjOTk5OTk5Ii8+Cjx0ZXh0IHg9IjIyNiIgeT0iMTkwIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjOTk5OTk5IiBmb250LXNpemU9IjE0Ij7QntCx0YrQtdC60YI8L3RleHQ+Cjwvc3ZnPgo='
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export default ObjectCard
|
||||
export type { ObjectData, ObjectCardProps }
|
||||
102
frontend/components/objects/ObjectGallery.tsx
Normal file
102
frontend/components/objects/ObjectGallery.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import ObjectCard, { ObjectData } from './ObjectCard'
|
||||
|
||||
interface ObjectGalleryProps {
|
||||
objects: ObjectData[]
|
||||
title?: string
|
||||
onObjectSelect?: (objectId: string) => void
|
||||
selectedObjectId?: string | null
|
||||
className?: string
|
||||
}
|
||||
|
||||
const BackIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ObjectGallery: React.FC<ObjectGalleryProps> = ({
|
||||
objects,
|
||||
title = 'Объекты',
|
||||
onObjectSelect,
|
||||
selectedObjectId,
|
||||
className = ''
|
||||
}) => {
|
||||
|
||||
const handleObjectSelect = (objectId: string) => {
|
||||
if (onObjectSelect) {
|
||||
onObjectSelect(objectId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBackClick = () => {
|
||||
console.log('Back clicked')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col items-start relative bg-[#0e111a] min-h-screen ${className}`}>
|
||||
<main className="relative self-stretch w-full">
|
||||
<div className="flex flex-col w-full items-start gap-6 p-4 sm:p-8 lg:p-16">
|
||||
<header className="flex flex-col items-start gap-9 relative self-stretch w-full flex-[0_0_auto]">
|
||||
<nav className="items-center gap-4 self-stretch w-full flex-[0_0_auto] flex relative">
|
||||
<button
|
||||
className="flex w-10 bg-[#161824] h-10 items-center justify-center gap-2 px-2 py-2 relative rounded-md transition-colors duration-200 hover:bg-[#1a1d2e] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
|
||||
aria-label="Назад"
|
||||
onClick={handleBackClick}
|
||||
>
|
||||
<BackIcon className="relative w-5 h-5 text-white" />
|
||||
</button>
|
||||
|
||||
<div className="inline-flex flex-wrap items-center gap-2.5 relative flex-[0_0_auto]">
|
||||
<div className="inline-flex items-center justify-center gap-2.5 relative flex-[0_0_auto]">
|
||||
<span className="relative w-fit mt-[-1.00px] font-normal text-white text-sm tracking-[0] leading-5 whitespace-nowrap">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-start gap-4 relative self-stretch w-full flex-[0_0_auto]">
|
||||
<h1 className="relative w-fit mt-[-1.00px] font-semibold text-white text-2xl tracking-[0] leading-8 whitespace-nowrap">
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
|
||||
</header>
|
||||
|
||||
{/* Галерея объектов */}
|
||||
{objects.length > 0 ? (
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-[18px] w-full">
|
||||
{objects.map((object) => (
|
||||
<ObjectCard
|
||||
key={object.object_id}
|
||||
object={object}
|
||||
onSelect={handleObjectSelect}
|
||||
isSelected={selectedObjectId === object.object_id}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-64 w-full">
|
||||
<div className="text-center">
|
||||
<div className="text-[#71717a] mb-2">
|
||||
<svg className="w-12 h-12 mx-auto" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white mb-2">Объекты не найдены</h3>
|
||||
<p className="text-[#71717a]">Нет доступных объектов</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ObjectGallery
|
||||
export type { ObjectGalleryProps }
|
||||
288
frontend/components/reports/ReportsList.tsx
Normal file
288
frontend/components/reports/ReportsList.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
|
||||
interface NotificationType {
|
||||
id: number;
|
||||
type: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
acknowledged: boolean;
|
||||
priority: string;
|
||||
detector_id: number;
|
||||
detector_name: string;
|
||||
location: string;
|
||||
object: string;
|
||||
}
|
||||
|
||||
interface DetectorType {
|
||||
detector_id: number
|
||||
name: string
|
||||
object: string
|
||||
status: string
|
||||
type: string
|
||||
location: string
|
||||
floor: number
|
||||
checked: boolean
|
||||
notifications: Array<{
|
||||
id: number
|
||||
type: string
|
||||
message: string
|
||||
timestamp: string
|
||||
acknowledged: boolean
|
||||
priority: string
|
||||
}>
|
||||
}
|
||||
|
||||
interface DetectorsDataType {
|
||||
detectors: Record<string, DetectorType>
|
||||
}
|
||||
|
||||
interface ReportsListProps {
|
||||
objectId?: string;
|
||||
detectorsData: DetectorsDataType;
|
||||
}
|
||||
|
||||
const ReportsList: React.FC<ReportsListProps> = ({ detectorsData }) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [priorityFilter] = useState('all');
|
||||
const [acknowledgedFilter] = useState('all');
|
||||
|
||||
const allNotifications = useMemo(() => {
|
||||
const notifications: NotificationType[] = [];
|
||||
Object.values(detectorsData.detectors).forEach(detector => {
|
||||
if (detector.notifications && detector.notifications.length > 0) {
|
||||
detector.notifications.forEach(notification => {
|
||||
notifications.push({
|
||||
...notification,
|
||||
detector_id: detector.detector_id,
|
||||
detector_name: detector.name,
|
||||
location: detector.location,
|
||||
object: detector.object
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
return notifications;
|
||||
}, [detectorsData]);
|
||||
|
||||
const filteredDetectors = useMemo(() => {
|
||||
return allNotifications.filter(notification => {
|
||||
const matchesSearch = notification.detector_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
notification.location.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
notification.message.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesStatus = statusFilter === 'all' || notification.type === statusFilter;
|
||||
const matchesPriority = priorityFilter === 'all' || notification.priority === priorityFilter;
|
||||
const matchesAcknowledged = acknowledgedFilter === 'all' ||
|
||||
(acknowledgedFilter === 'acknowledged' && notification.acknowledged) ||
|
||||
(acknowledgedFilter === 'unacknowledged' && !notification.acknowledged);
|
||||
|
||||
return matchesSearch && matchesStatus && matchesPriority && matchesAcknowledged;
|
||||
});
|
||||
}, [allNotifications, searchTerm, statusFilter, priorityFilter, acknowledgedFilter]);
|
||||
|
||||
const getStatusColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'critical': return '#b3261e';
|
||||
case 'warning': return '#fd7c22';
|
||||
case 'info': return '#00ff00';
|
||||
default: return '#666';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high': return '#b3261e';
|
||||
case 'medium': return '#fd7c22';
|
||||
case 'low': return '#00ff00';
|
||||
default: return '#666';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusCounts = () => {
|
||||
const counts = {
|
||||
total: allNotifications.length,
|
||||
critical: allNotifications.filter(d => d.type === 'critical').length,
|
||||
warning: allNotifications.filter(d => d.type === 'warning').length,
|
||||
info: allNotifications.filter(d => d.type === 'info').length,
|
||||
acknowledged: allNotifications.filter(d => d.acknowledged).length,
|
||||
unacknowledged: allNotifications.filter(d => !d.acknowledged).length
|
||||
};
|
||||
return counts;
|
||||
};
|
||||
|
||||
const counts = getStatusCounts();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Поиск и сортировка*/}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setStatusFilter('all')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
statusFilter === 'all'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-[#161824] text-gray-300 hover:bg-[#1f2937]'
|
||||
}`}
|
||||
>
|
||||
Все ({allNotifications.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('critical')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
statusFilter === 'critical'
|
||||
? 'bg-red-600 text-white'
|
||||
: 'bg-[#161824] text-gray-300 hover:bg-[#1f2937]'
|
||||
}`}
|
||||
>
|
||||
Критические ({allNotifications.filter(d => d.type === 'critical').length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('warning')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
statusFilter === 'warning'
|
||||
? 'bg-orange-600 text-white'
|
||||
: 'bg-[#161824] text-gray-300 hover:bg-[#1f2937]'
|
||||
}`}
|
||||
>
|
||||
Предупреждения ({allNotifications.filter(d => d.type === 'warning').length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('info')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
statusFilter === 'info'
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-[#161824] text-gray-300 hover:bg-[#1f2937]'
|
||||
}`}
|
||||
>
|
||||
Информация ({allNotifications.filter(d => d.type === 'info').length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск детекторов..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="bg-[#161824] text-white placeholder-gray-400 px-4 py-2 rounded-lg border border-gray-600 focus:border-blue-500 focus:outline-none w-64"
|
||||
/>
|
||||
<svg className="absolute right-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Табличка с детекторами*/}
|
||||
<div className="bg-[#161824] rounded-[20px] p-6">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-700">
|
||||
<th className="text-left text-white font-medium py-3">Детектор</th>
|
||||
<th className="text-left text-white font-medium py-3">Статус</th>
|
||||
<th className="text-left text-white font-medium py-3">Сообщение</th>
|
||||
<th className="text-left text-white font-medium py-3">Местоположение</th>
|
||||
<th className="text-left text-white font-medium py-3">Приоритет</th>
|
||||
<th className="text-left text-white font-medium py-3">Подтверждено</th>
|
||||
<th className="text-left text-white font-medium py-3">Время</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredDetectors.map((detector) => (
|
||||
<tr key={detector.id} className="border-b border-gray-700 hover:bg-gray-800/50 transition-colors">
|
||||
<td className="py-4">
|
||||
<div className="text-sm font-medium text-white">{detector.detector_name}</div>
|
||||
<div className="text-sm text-gray-400">ID: {detector.detector_id}</div>
|
||||
</td>
|
||||
<td className="py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: getStatusColor(detector.type) }}
|
||||
></div>
|
||||
<span className="text-sm text-gray-300">
|
||||
{detector.type === 'critical' ? 'Критический' :
|
||||
detector.type === 'warning' ? 'Предупреждение' : 'Информация'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4">
|
||||
<div className="text-sm text-white">{detector.message}</div>
|
||||
</td>
|
||||
<td className="py-4">
|
||||
<div className="text-sm text-white">{detector.location}</div>
|
||||
</td>
|
||||
<td className="py-4">
|
||||
<span
|
||||
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
|
||||
style={{ backgroundColor: getPriorityColor(detector.priority) }}
|
||||
>
|
||||
{detector.priority === 'high' ? 'Высокий' :
|
||||
detector.priority === 'medium' ? 'Средний' : 'Низкий'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
detector.acknowledged
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{detector.acknowledged ? 'Да' : 'Нет'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4">
|
||||
<div className="text-sm text-gray-300">
|
||||
{new Date(detector.timestamp).toLocaleString('ru-RU')}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredDetectors.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-8 text-center text-gray-400">
|
||||
Детекторы не найдены
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<div className="bg-[#161824] p-4 rounded-lg">
|
||||
<div className="text-2xl font-bold text-white">{counts.total}</div>
|
||||
<div className="text-sm text-gray-400">Всего</div>
|
||||
</div>
|
||||
<div className="bg-[#161824] p-4 rounded-lg">
|
||||
<div className="text-2xl font-bold text-red-400">{counts.critical}</div>
|
||||
<div className="text-sm text-gray-400">Критические</div>
|
||||
</div>
|
||||
<div className="bg-[#161824] p-4 rounded-lg">
|
||||
<div className="text-2xl font-bold text-orange-400">{counts.warning}</div>
|
||||
<div className="text-sm text-gray-400">Предупреждения</div>
|
||||
</div>
|
||||
<div className="bg-[#161824] p-4 rounded-lg">
|
||||
<div className="text-2xl font-bold text-green-400">{counts.info}</div>
|
||||
<div className="text-sm text-gray-400">Информация</div>
|
||||
</div>
|
||||
<div className="bg-[#161824] p-4 rounded-lg">
|
||||
<div className="text-2xl font-bold text-blue-400">{counts.acknowledged}</div>
|
||||
<div className="text-sm text-gray-400">Подтверждено</div>
|
||||
</div>
|
||||
<div className="bg-[#161824] p-4 rounded-lg">
|
||||
<div className="text-2xl font-bold text-yellow-400">{counts.unacknowledged}</div>
|
||||
<div className="text-sm text-gray-400">Не подтверждено</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportsList;
|
||||
54
frontend/components/ui/ExportMenu.tsx
Normal file
54
frontend/components/ui/ExportMenu.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
interface ExportMenuProps {
|
||||
onExport: (format: 'csv' | 'pdf') => void
|
||||
}
|
||||
|
||||
const ExportMenu: React.FC<ExportMenuProps> = ({ onExport }) => {
|
||||
const [selectedFormat, setSelectedFormat] = useState<'csv' | 'pdf'>('csv')
|
||||
|
||||
const handleExport = () => {
|
||||
onExport(selectedFormat)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 bg-[#161824] rounded-lg p-3 border border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="format"
|
||||
value="csv"
|
||||
checked={selectedFormat === 'csv'}
|
||||
onChange={(e) => setSelectedFormat(e.target.value as 'csv' | 'pdf')}
|
||||
className="w-3 h-3 text-blue-600"
|
||||
/>
|
||||
<span className="text-gray-300 text-sm">CSV</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="format"
|
||||
value="pdf"
|
||||
checked={selectedFormat === 'pdf'}
|
||||
onChange={(e) => setSelectedFormat(e.target.value as 'csv' | 'pdf')}
|
||||
className="w-3 h-3 text-blue-600"
|
||||
/>
|
||||
<span className="text-gray-300 text-sm">PDF</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded transition-colors"
|
||||
>
|
||||
Экспорт
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExportMenu
|
||||
72
frontend/components/ui/LoadingSpinner.tsx
Normal file
72
frontend/components/ui/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
progress?: number // 0-100
|
||||
size?: number // diameter in pixels
|
||||
strokeWidth?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
progress = 0,
|
||||
size = 120,
|
||||
strokeWidth = 8,
|
||||
className = ''
|
||||
}) => {
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = radius * 2 * Math.PI
|
||||
const strokeDasharray = circumference
|
||||
const strokeDashoffset = circumference - (progress / 100) * circumference
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col items-center justify-center ${className}`}>
|
||||
<div className="relative" style={{ width: size, height: size }}>
|
||||
{/* Background circle */}
|
||||
<svg
|
||||
className="transform -rotate-90"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke="rgba(255, 255, 255, 0.1)"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="transparent"
|
||||
/>
|
||||
{/* Progress circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke="#389ee8"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="transparent"
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-300 ease-out"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Percentage text */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-white text-xl font-semibold">
|
||||
{Math.round(progress)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading text */}
|
||||
<div className="mt-4 text-white text-base font-medium">
|
||||
Loading Model...
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoadingSpinner
|
||||
474
frontend/components/ui/Sidebar.tsx
Normal file
474
frontend/components/ui/Sidebar.tsx
Normal file
@@ -0,0 +1,474 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useRouter, usePathname } from 'next/navigation'
|
||||
import Image from 'next/image'
|
||||
import useUIStore from '../../app/store/uiStore'
|
||||
import useNavigationStore from '../../app/store/navigationStore'
|
||||
import { useNavigationService } from '@/services/navigationService'
|
||||
|
||||
interface NavigationItem {
|
||||
id: number
|
||||
label: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
navigationItems?: NavigationItem[]
|
||||
logoSrc?: string
|
||||
userInfo?: {
|
||||
name: string
|
||||
role: string
|
||||
avatar?: string
|
||||
}
|
||||
activeItem?: number | null
|
||||
onCustomItemClick?: (itemId: number) => boolean
|
||||
}
|
||||
|
||||
// Иконки-заглушки
|
||||
const BookOpen = ({ className }: { className?: string }) => (
|
||||
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2L3 7v10c0 5.55 3.84 10 9 10s9-4.45 9-10V7l-9-5z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const Bot = ({ className }: { className?: string }) => (
|
||||
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const SquareTerminal = ({ className }: { className?: string }) => (
|
||||
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M4 6h16v2H4zm0 5h16v2H4zm0 5h16v2H4z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const CircleDot = ({ className }: { className?: string }) => (
|
||||
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const BellDot = ({ className }: { className?: string }) => (
|
||||
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C8.13 2 5 5.13 5 9v7l-2 2v1h16v-1l-2-2V9c0-3.87-3.13-7-7-7z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const History = ({ className }: { className?: string }) => (
|
||||
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const Settings2 = ({ className }: { className?: string }) => (
|
||||
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const Monitor = ({ className }: { className?: string }) => (
|
||||
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M20 3H4c-1.1 0-2 .9-2 2v11c0 1.1.9 2 2 2h3l-1 1v1h12v-1l-1-1h3c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 13H4V5h16v11z" />
|
||||
<circle cx="8" cy="10" r="2" />
|
||||
<circle cx="16" cy="10" r="2" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const Building = ({ className }: { className?: string }) => (
|
||||
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2L2 7v10c0 5.55 3.84 10 9 10s9-4.45 9-10V7l-9-5zM12 4.14L18 7.69V17c0 3.31-2.69 6-6 6s-6-2.69-6-6V7.69L12 4.14z" />
|
||||
<rect x="8" y="10" width="2" height="2" />
|
||||
<rect x="14" y="10" width="2" height="2" />
|
||||
<rect x="8" y="14" width="2" height="2" />
|
||||
<rect x="14" y="14" width="2" height="2" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// основные routes
|
||||
const mainNavigationItems: NavigationItem[] = [
|
||||
{
|
||||
id: 1,
|
||||
icon: BookOpen,
|
||||
label: 'Дашборд'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
icon: Bot,
|
||||
label: 'Навигация по зданию'
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
icon: History,
|
||||
label: 'История тревог'
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
icon: Settings2,
|
||||
label: 'Отчеты'
|
||||
}
|
||||
]
|
||||
|
||||
// суб-меню под "Навигация по зданию"
|
||||
const navigationSubItems: NavigationItem[] = [
|
||||
{
|
||||
id: 3,
|
||||
icon: Monitor,
|
||||
label: 'Зоны Мониторинга'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
icon: Building,
|
||||
label: 'Навигация по этажам'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
icon: BellDot,
|
||||
label: 'Уведомления'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
icon: CircleDot,
|
||||
label: 'Сенсоры'
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
icon: SquareTerminal,
|
||||
label: 'Список датчиков'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({
|
||||
logoSrc,
|
||||
userInfo = {
|
||||
name: 'Александр',
|
||||
role: 'Администратор'
|
||||
},
|
||||
activeItem: propActiveItem,
|
||||
onCustomItemClick
|
||||
}) => {
|
||||
const navigationService = useNavigationService()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const [internalActiveItem, setInternalActiveItem] = useState<number | null>(null)
|
||||
const [isHydrated, setIsHydrated] = useState(false)
|
||||
const [manuallyToggled, setManuallyToggled] = useState(false)
|
||||
const activeItem = propActiveItem !== undefined ? propActiveItem : internalActiveItem
|
||||
const {
|
||||
isSidebarCollapsed: isCollapsed,
|
||||
toggleSidebar,
|
||||
isNavigationSubMenuExpanded: showNavigationSubItems,
|
||||
setNavigationSubMenuExpanded: setShowNavigationSubItems,
|
||||
toggleNavigationSubMenu
|
||||
} = useUIStore()
|
||||
|
||||
const {
|
||||
openMonitoring,
|
||||
openFloorNavigation,
|
||||
openNotifications,
|
||||
closeMonitoring,
|
||||
closeFloorNavigation,
|
||||
closeNotifications,
|
||||
showMonitoring,
|
||||
showFloorNavigation,
|
||||
showNotifications
|
||||
} = useNavigationStore()
|
||||
|
||||
useEffect(() => {
|
||||
setIsHydrated(true)
|
||||
}, [])
|
||||
|
||||
// Чек если суб-меню активны
|
||||
const isNavigationSubItemActive = activeItem && [3, 4, 5, 6, 7].includes(activeItem)
|
||||
const shouldShowNavigationAsActive = activeItem === 2 || isNavigationSubItemActive
|
||||
|
||||
// Авто-расткрытие меню, если суб-меню стало активным (только если не было ручного переключения)
|
||||
useEffect(() => {
|
||||
if (isNavigationSubItemActive && !showNavigationSubItems && !manuallyToggled) {
|
||||
setShowNavigationSubItems(true)
|
||||
}
|
||||
}, [isNavigationSubItemActive, showNavigationSubItems, manuallyToggled, setShowNavigationSubItems])
|
||||
|
||||
const handleItemClick = (itemId: number) => {
|
||||
let handled = false
|
||||
|
||||
// Handle submenu items directly with navigation store
|
||||
switch (itemId) {
|
||||
case 2: // Navigation - only navigate to page, don't open submenus
|
||||
if (pathname !== '/navigation') {
|
||||
router.push('/navigation')
|
||||
}
|
||||
handled = true
|
||||
break
|
||||
case 3: // Monitoring
|
||||
if (pathname !== '/navigation') {
|
||||
// Navigate to navigation page first, then open monitoring
|
||||
router.push('/navigation')
|
||||
setTimeout(() => openMonitoring(), 100)
|
||||
} else if (showMonitoring) {
|
||||
// Close if already open
|
||||
closeMonitoring()
|
||||
} else {
|
||||
openMonitoring()
|
||||
}
|
||||
handled = true
|
||||
break
|
||||
case 4: // Floor Navigation
|
||||
if (pathname !== '/navigation') {
|
||||
// Navigate to navigation page first, then open floor navigation
|
||||
router.push('/navigation')
|
||||
setTimeout(() => openFloorNavigation(), 100)
|
||||
} else if (showFloorNavigation) {
|
||||
// Close if already open
|
||||
closeFloorNavigation()
|
||||
} else {
|
||||
openFloorNavigation()
|
||||
}
|
||||
handled = true
|
||||
break
|
||||
case 5: // Notifications
|
||||
if (pathname !== '/navigation') {
|
||||
// Navigate to navigation page first, then open notifications
|
||||
router.push('/navigation')
|
||||
setTimeout(() => openNotifications(), 100)
|
||||
} else if (showNotifications) {
|
||||
// Close if already open
|
||||
closeNotifications()
|
||||
} else {
|
||||
openNotifications()
|
||||
}
|
||||
handled = true
|
||||
break
|
||||
default:
|
||||
// For other items, use navigation service
|
||||
if (navigationService) {
|
||||
handled = navigationService.handleSidebarItemClick(itemId, pathname)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
// Update internal active item state
|
||||
if (propActiveItem === undefined) {
|
||||
setInternalActiveItem(itemId)
|
||||
}
|
||||
|
||||
// Call custom handler if provided (for additional logic)
|
||||
if (onCustomItemClick) {
|
||||
onCustomItemClick(itemId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`flex flex-col items-start gap-6 relative bg-[#161824] transition-all duration-300 h-screen ${
|
||||
isCollapsed ? 'w-16' : 'w-64'
|
||||
}`}
|
||||
role="navigation"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<header className="flex items-center gap-2 pt-2 pb-2 px-4 relative self-stretch w-full flex-[0_0_auto] bg-[#161824]">
|
||||
{!isCollapsed && (
|
||||
<div className="relative w-[169.83px] h-[34px]">
|
||||
{logoSrc && (
|
||||
<Image
|
||||
className="absolute w-12 h-[33px] top-0 left-0"
|
||||
alt="AerBIM Monitor Logo"
|
||||
src={logoSrc}
|
||||
width={48}
|
||||
height={33}
|
||||
/>
|
||||
)}
|
||||
<div className="absolute w-[99px] top-[21px] left-[50px] [font-family:'Open_Sans-Regular',Helvetica] font-normal text-[#389ee8] text-[13px] tracking-[0] leading-[13px] whitespace-nowrap">
|
||||
AMS HT Viewer
|
||||
</div>
|
||||
<div className="absolute top-px left-[50px] [font-family:'Open_Sans-SemiBold',Helvetica] font-semibold text-[#f1f6fa] text-[15px] tracking-[0] leading-[15px] whitespace-nowrap">
|
||||
AerBIM Monitor
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<nav className="flex flex-col items-end gap-2 relative flex-1 self-stretch w-full grow">
|
||||
<div className="flex flex-col items-end gap-2 px-4 py-2 relative self-stretch w-full flex-[0_0_auto]">
|
||||
<ul className="flex flex-col items-start gap-3 relative self-stretch w-full flex-[0_0_auto]" role="list">
|
||||
{mainNavigationItems.map((item) => {
|
||||
const IconComponent = item.icon
|
||||
const isActive = item.id === 2 ? shouldShowNavigationAsActive : activeItem === item.id
|
||||
|
||||
return (
|
||||
<li key={item.id} className="flex-col flex items-center relative self-stretch w-full" role="listitem">
|
||||
<button
|
||||
className={`gap-2 pt-2 pr-2 pb-2 pl-2 rounded-md flex h-9 items-center relative self-stretch w-full transition-colors duration-200 hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset ${
|
||||
isActive ? 'bg-gray-700' : ''
|
||||
}`}
|
||||
onClick={() => handleItemClick(item.id)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
type="button"
|
||||
>
|
||||
<IconComponent
|
||||
className="!relative !w-5 !h-5 text-white"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
<span className="flex-1 [font-family:'Inter-Regular',Helvetica] font-normal text-white text-sm leading-[14px] relative tracking-[0] overflow-hidden text-ellipsis [display:-webkit-box] [-webkit-line-clamp:1] [-webkit-box-orient:vertical] text-left">
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
{item.id === 2 && !isCollapsed && (
|
||||
<div
|
||||
className="p-1 hover:bg-gray-600 rounded transition-colors duration-200 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setManuallyToggled(true)
|
||||
// Close all active submenus when collapsing navigation menu
|
||||
if (showNavigationSubItems || isNavigationSubItemActive) {
|
||||
closeMonitoring()
|
||||
closeFloorNavigation()
|
||||
closeNotifications()
|
||||
}
|
||||
toggleNavigationSubMenu()
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setManuallyToggled(true)
|
||||
// Close all active submenus when collapsing navigation menu
|
||||
if (showNavigationSubItems || isNavigationSubItemActive) {
|
||||
closeMonitoring()
|
||||
closeFloorNavigation()
|
||||
closeNotifications()
|
||||
}
|
||||
toggleNavigationSubMenu()
|
||||
}
|
||||
}}
|
||||
aria-label={isHydrated ? (showNavigationSubItems || isNavigationSubItemActive ? 'Collapse navigation menu' : 'Expand navigation menu') : 'Toggle navigation menu'}
|
||||
>
|
||||
<svg
|
||||
className={`!relative !w-4 !h-4 text-white transition-transform duration-200 ${
|
||||
isHydrated && (showNavigationSubItems || isNavigationSubItemActive) ? 'rotate-90' : ''
|
||||
}`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Суб-меню */}
|
||||
{item.id === 2 && !isCollapsed && (showNavigationSubItems || isNavigationSubItemActive) && (
|
||||
<ul className="flex flex-col items-start gap-1 mt-1 ml-6 relative w-full" role="list">
|
||||
{navigationSubItems.map((subItem) => {
|
||||
const SubIconComponent = subItem.icon
|
||||
const isSubActive = activeItem === subItem.id
|
||||
|
||||
return (
|
||||
<li key={subItem.id} className="flex-col flex h-8 items-center relative self-stretch w-full" role="listitem">
|
||||
<button
|
||||
className={`gap-2 pt-1.5 pr-2 pb-1.5 pl-2 rounded-md flex h-8 items-center relative self-stretch w-full transition-colors duration-200 hover:bg-gray-600 focus:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset ${
|
||||
isSubActive ? 'bg-gray-600' : ''
|
||||
}`}
|
||||
onClick={() => handleItemClick(subItem.id)}
|
||||
aria-current={isSubActive ? 'page' : undefined}
|
||||
type="button"
|
||||
>
|
||||
<SubIconComponent
|
||||
className="!relative !w-4 !h-4 text-gray-300"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="flex-1 [font-family:'Inter-Regular',Helvetica] font-normal text-gray-300 text-sm leading-[14px] relative tracking-[0] overflow-hidden text-ellipsis [display:-webkit-box] [-webkit-line-clamp:1] [-webkit-box-orient:vertical] text-left">
|
||||
{subItem.label}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<button
|
||||
className="!relative !w-6 !h-6 p-1 rounded hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all duration-200"
|
||||
onClick={() => {
|
||||
// If navigation submenu is open, first collapse it (which closes all submenus)
|
||||
if (showNavigationSubItems) {
|
||||
setShowNavigationSubItems(false)
|
||||
setManuallyToggled(true)
|
||||
// Close all active submenus when collapsing navigation menu
|
||||
closeMonitoring()
|
||||
closeFloorNavigation()
|
||||
closeNotifications()
|
||||
}
|
||||
// Always collapse the sidebar
|
||||
toggleSidebar()
|
||||
}}
|
||||
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
type="button"
|
||||
>
|
||||
<svg className={`!relative !w-4 !h-4 text-white transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`} fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{!isCollapsed && (
|
||||
<footer className="flex w-64 items-center gap-2 pt-2 pr-2 pb-2 pl-2 mt-auto bg-[#161824]">
|
||||
<div className="inline-flex flex-[0_0_auto] items-center gap-2 relative rounded-md">
|
||||
<div className="inline-flex items-center relative flex-[0_0_auto]">
|
||||
<div
|
||||
className="relative w-8 h-8 rounded-lg bg-white"
|
||||
role="img"
|
||||
aria-label="User avatar"
|
||||
>
|
||||
{userInfo.avatar && (
|
||||
<Image
|
||||
src={userInfo.avatar}
|
||||
alt="User avatar"
|
||||
className="w-full h-full rounded-lg object-cover"
|
||||
fill
|
||||
sizes="32px"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex p-2 flex-1 grow items-center gap-2 relative rounded-md">
|
||||
<div className="flex flex-col items-start justify-center gap-0.5 relative flex-1 grow">
|
||||
<div className="self-stretch mt-[-1.00px] [font-family:'Inter-SemiBold',Helvetica] font-semibold text-white text-sm leading-[14px] relative tracking-[0] overflow-hidden text-ellipsis [display:-webkit-box] [-webkit-line-clamp:1] [-webkit-box-orient:vertical]">
|
||||
{userInfo.name}
|
||||
</div>
|
||||
<div className="self-stretch [font-family:'Inter-Regular',Helvetica] font-normal text-[#71717a] text-[10px] leading-[10px] relative tracking-[0] overflow-hidden text-ellipsis [display:-webkit-box] [-webkit-line-clamp:1] [-webkit-box-orient:vertical]">
|
||||
{userInfo.role}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="relative w-4 h-4 aspect-[1] p-1 rounded hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-200"
|
||||
aria-label="User menu"
|
||||
type="button"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</footer>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export default Sidebar
|
||||
Reference in New Issue
Block a user