Improved authentication; added fallbacks to 3D; cleaner dashboard charts
This commit is contained in:
52
frontend/components/auth/AuthGuard.tsx
Normal file
52
frontend/components/auth/AuthGuard.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client'
|
||||
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function AuthGuard({ children }: AuthGuardProps) {
|
||||
const { data: session, status } = useSession()
|
||||
const router = useRouter()
|
||||
const [isChecking, setIsChecking] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'loading') {
|
||||
return // Ждем загрузки сессии
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated' || !session) {
|
||||
console.log('[AuthGuard] Нет активной сессии, перенаправление на логин')
|
||||
router.replace('/login')
|
||||
return
|
||||
}
|
||||
|
||||
if (status === 'authenticated' && session) {
|
||||
console.log('[AuthGuard] Сессия активна, разрешаем доступ')
|
||||
setIsChecking(false)
|
||||
}
|
||||
}, [session, status, router])
|
||||
|
||||
// Показываем загрузку пока проверяем аутентификацию
|
||||
if (status === 'loading' || isChecking) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent"></div>
|
||||
<p className="text-gray-600">Проверка аутентификации...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Если нет сессии, не рендерим детей (будет перенаправление)
|
||||
if (status === 'unauthenticated' || !session) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Если все в порядке, рендерим детей
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -215,28 +215,28 @@ const Dashboard: React.FC = () => {
|
||||
title="Тренды детекторов"
|
||||
subtitle="За последний месяц"
|
||||
>
|
||||
<DetectorChart type="line" />
|
||||
<DetectorChart type="line" data={chartData?.map((d: any) => ({ value: d.value }))} />
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Статистика по месяцам"
|
||||
subtitle="Активность детекторов"
|
||||
>
|
||||
<DetectorChart type="bar" />
|
||||
<DetectorChart type="bar" data={chartData?.map((d: any) => ({ value: d.value }))} />
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Анализ производительности"
|
||||
subtitle="Эффективность работы"
|
||||
>
|
||||
<DetectorChart type="line" />
|
||||
<DetectorChart type="line" data={chartData?.map((d: any) => ({ value: d.value }))} />
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Сводка по статусам"
|
||||
subtitle="Распределение состояний"
|
||||
>
|
||||
<DetectorChart type="bar" />
|
||||
<DetectorChart type="bar" data={chartData?.map((d: any) => ({ value: d.value }))} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,10 +17,11 @@ interface DetectorChartProps {
|
||||
|
||||
const DetectorChart: React.FC<DetectorChartProps> = ({
|
||||
className = '',
|
||||
data,
|
||||
type = 'line'
|
||||
}) => {
|
||||
if (type === 'bar') {
|
||||
const barData = [
|
||||
const defaultBarData = [
|
||||
{ value: 85, label: 'Янв' },
|
||||
{ value: 70, label: 'Фев' },
|
||||
{ value: 90, label: 'Мар' },
|
||||
@@ -29,6 +30,13 @@ const DetectorChart: React.FC<DetectorChartProps> = ({
|
||||
{ value: 95, label: 'Июн' }
|
||||
]
|
||||
|
||||
const barData = (Array.isArray(data) && data.length > 0)
|
||||
? data.slice(0, 6).map((d, i) => ({
|
||||
value: d.value || 0,
|
||||
label: d.label || defaultBarData[i]?.label || `${i + 1}`
|
||||
}))
|
||||
: defaultBarData
|
||||
|
||||
return (
|
||||
<div className={`w-full h-full ${className}`}>
|
||||
<svg className="w-full h-full" viewBox="0 0 400 200">
|
||||
@@ -69,9 +77,40 @@ const DetectorChart: React.FC<DetectorChartProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
// Line chart implementation
|
||||
const defaultLineData = [
|
||||
{ value: 150 },
|
||||
{ value: 120 },
|
||||
{ value: 100 },
|
||||
{ value: 80 },
|
||||
{ value: 90 },
|
||||
{ value: 70 },
|
||||
{ value: 60 }
|
||||
]
|
||||
|
||||
const lineData = (Array.isArray(data) && data.length > 0)
|
||||
? data.slice(0, 7)
|
||||
: defaultLineData
|
||||
|
||||
const maxVal = Math.max(...lineData.map(d => d.value || 0), 1)
|
||||
const width = 400
|
||||
const height = 200
|
||||
const padding = 20
|
||||
const plotHeight = height - 40
|
||||
const stepX = lineData.length > 1 ? (width - 2 * padding) / (lineData.length - 1) : 0
|
||||
|
||||
const points = lineData.map((d, i) => {
|
||||
const x = padding + i * stepX
|
||||
const y = height - padding - ((d.value || 0) / maxVal) * plotHeight
|
||||
return { x, y }
|
||||
})
|
||||
|
||||
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
|
||||
const areaPath = `${linePath} L${width - padding},${height - padding} L${padding},${height - padding} Z`
|
||||
|
||||
return (
|
||||
<div className={`w-full h-full ${className}`}>
|
||||
<svg className="w-full h-full" viewBox="0 0 400 200">
|
||||
<svg className="w-full h-full" viewBox={`0 0 ${width} ${height}`}>
|
||||
<defs>
|
||||
<linearGradient id="detectorGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="rgb(231, 110, 80)" stopOpacity="0.3" />
|
||||
@@ -79,22 +118,18 @@ const DetectorChart: React.FC<DetectorChartProps> = ({
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M20,150 L80,120 L140,100 L200,80 L260,90 L320,70 L380,60 L380,180 L20,180 Z"
|
||||
d={areaPath}
|
||||
fill="url(#detectorGradient)"
|
||||
/>
|
||||
<path
|
||||
d="M20,150 L80,120 L140,100 L200,80 L260,90 L320,70 L380,60"
|
||||
d={linePath}
|
||||
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)" />
|
||||
{points.map((p, i) => (
|
||||
<circle key={i} cx={p.x} cy={p.y} r="3" fill="rgb(231, 110, 80)" />
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -141,7 +141,14 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitializedRef.current || !modelPath || isDisposedRef.current) {
|
||||
if (!isInitializedRef.current || isDisposedRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if modelPath is provided
|
||||
if (!modelPath) {
|
||||
console.warn('[ModelViewer] No model path provided')
|
||||
onError?.('Путь к 3D модели не задан')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -209,13 +216,14 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
}, 500)
|
||||
} else {
|
||||
console.warn('No meshes found in model')
|
||||
onError?.('No geometry found in model')
|
||||
onError?.('В модели не найдена геометрия')
|
||||
setIsLoading(false)
|
||||
}
|
||||
} catch (error) {
|
||||
clearInterval(progressInterval)
|
||||
console.error('Error loading GLTF model:', error)
|
||||
onError?.(`Failed to load model: ${error}`)
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
onError?.(`Ошибка загрузки модели: ${errorMessage}`)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
@@ -239,7 +247,25 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
}
|
||||
|
||||
const allMeshes = importedMeshesRef.current || []
|
||||
const sensorMeshes = allMeshes.filter((m: any) => ((m.id ?? '').includes('IfcSensor') || (m.name ?? '').includes('IfcSensor')))
|
||||
|
||||
// Safeguard: Check if we have any meshes at all
|
||||
if (allMeshes.length === 0) {
|
||||
console.warn('[ModelViewer] No meshes available for sensor matching')
|
||||
highlightLayerRef.current?.removeAllMeshes()
|
||||
chosenMeshRef.current = null
|
||||
setOverlayPos(null)
|
||||
setOverlayData(null)
|
||||
return
|
||||
}
|
||||
|
||||
const sensorMeshes = allMeshes.filter((m: any) => {
|
||||
try {
|
||||
return ((m.id ?? '').includes('IfcSensor') || (m.name ?? '').includes('IfcSensor'))
|
||||
} catch (error) {
|
||||
console.warn('[ModelViewer] Error filtering sensor mesh:', error)
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const chosen = sensorMeshes.find((m: any) => {
|
||||
try {
|
||||
@@ -248,7 +274,8 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
const sid = extras?.Sensor_ID ?? extras?.sensor_id ?? extras?.SERIAL_NUMBER ?? extras?.serial_number
|
||||
if (sid == null) return false
|
||||
return String(sid).trim() === sensorId
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.warn('[ModelViewer] Error matching sensor mesh:', error)
|
||||
return false
|
||||
}
|
||||
})
|
||||
@@ -264,44 +291,52 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
const scene = sceneRef.current!
|
||||
|
||||
if (chosen) {
|
||||
const camera = scene.activeCamera as ArcRotateCamera
|
||||
const bbox = (typeof chosen.getHierarchyBoundingVectors === 'function')
|
||||
? chosen.getHierarchyBoundingVectors()
|
||||
: { min: chosen.getBoundingInfo().boundingBox.minimumWorld, max: chosen.getBoundingInfo().boundingBox.maximumWorld }
|
||||
const center = bbox.min.add(bbox.max).scale(0.5)
|
||||
const size = bbox.max.subtract(bbox.min)
|
||||
const maxDimension = Math.max(size.x, size.y, size.z)
|
||||
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5)
|
||||
try {
|
||||
const camera = scene.activeCamera as ArcRotateCamera
|
||||
const bbox = (typeof chosen.getHierarchyBoundingVectors === 'function')
|
||||
? chosen.getHierarchyBoundingVectors()
|
||||
: { min: chosen.getBoundingInfo().boundingBox.minimumWorld, max: chosen.getBoundingInfo().boundingBox.maximumWorld }
|
||||
const center = bbox.min.add(bbox.max).scale(0.5)
|
||||
const size = bbox.max.subtract(bbox.min)
|
||||
const maxDimension = Math.max(size.x, size.y, size.z)
|
||||
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5)
|
||||
|
||||
scene.stopAnimation(camera)
|
||||
scene.stopAnimation(camera)
|
||||
|
||||
const ease = new CubicEase()
|
||||
ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT)
|
||||
const frameRate = 60
|
||||
const durationMs = 600
|
||||
const totalFrames = Math.round((durationMs / 1000) * frameRate)
|
||||
const ease = new CubicEase()
|
||||
ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT)
|
||||
const frameRate = 60
|
||||
const durationMs = 600
|
||||
const totalFrames = Math.round((durationMs / 1000) * frameRate)
|
||||
|
||||
Animation.CreateAndStartAnimation('camTarget', camera, 'target', frameRate, totalFrames, camera.target.clone(), center.clone(), Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
|
||||
Animation.CreateAndStartAnimation('camRadius', camera, 'radius', frameRate, totalFrames, camera.radius, targetRadius, Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
|
||||
Animation.CreateAndStartAnimation('camTarget', camera, 'target', frameRate, totalFrames, camera.target.clone(), center.clone(), Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
|
||||
Animation.CreateAndStartAnimation('camRadius', camera, 'radius', frameRate, totalFrames, camera.radius, targetRadius, Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
|
||||
|
||||
const hl = highlightLayerRef.current
|
||||
if (hl) {
|
||||
hl.removeAllMeshes()
|
||||
if (chosen instanceof Mesh) {
|
||||
hl.addMesh(chosen, new Color3(1, 1, 0))
|
||||
} else if (chosen instanceof InstancedMesh) {
|
||||
hl.addMesh(chosen.sourceMesh, new Color3(1, 1, 0))
|
||||
} else {
|
||||
const children = typeof (chosen as any)?.getChildMeshes === 'function' ? (chosen as any).getChildMeshes() : []
|
||||
for (const cm of children) {
|
||||
if (cm instanceof Mesh) {
|
||||
hl.addMesh(cm, new Color3(1, 1, 0))
|
||||
const hl = highlightLayerRef.current
|
||||
if (hl) {
|
||||
hl.removeAllMeshes()
|
||||
if (chosen instanceof Mesh) {
|
||||
hl.addMesh(chosen, new Color3(1, 1, 0))
|
||||
} else if (chosen instanceof InstancedMesh) {
|
||||
hl.addMesh(chosen.sourceMesh, new Color3(1, 1, 0))
|
||||
} else {
|
||||
const children = typeof (chosen as any)?.getChildMeshes === 'function' ? (chosen as any).getChildMeshes() : []
|
||||
for (const cm of children) {
|
||||
if (cm instanceof Mesh) {
|
||||
hl.addMesh(cm, new Color3(1, 1, 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
chosenMeshRef.current = chosen
|
||||
setOverlayData({ name: chosen.name, sensorId })
|
||||
} catch (error) {
|
||||
console.error('[ModelViewer] Error focusing on sensor mesh:', error)
|
||||
highlightLayerRef.current?.removeAllMeshes()
|
||||
chosenMeshRef.current = null
|
||||
setOverlayPos(null)
|
||||
setOverlayData(null)
|
||||
}
|
||||
chosenMeshRef.current = chosen
|
||||
setOverlayData({ name: chosen.name, sensorId })
|
||||
} else {
|
||||
highlightLayerRef.current?.removeAllMeshes()
|
||||
chosenMeshRef.current = null
|
||||
@@ -316,15 +351,22 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
const observer = scene.onAfterRenderObservable.add(() => {
|
||||
const chosen = chosenMeshRef.current
|
||||
if (!chosen) return
|
||||
const engine = scene.getEngine()
|
||||
const cam = scene.activeCamera
|
||||
if (!cam) return
|
||||
const center = chosen.getBoundingInfo().boundingBox.centerWorld
|
||||
const world = Matrix.IdentityReadOnly
|
||||
const transform = scene.getTransformMatrix()
|
||||
const viewport = new Viewport(0, 0, engine.getRenderWidth(), engine.getRenderHeight())
|
||||
const projected = Vector3.Project(center, world, transform, viewport)
|
||||
setOverlayPos({ left: projected.x, top: projected.y })
|
||||
|
||||
try {
|
||||
const engine = scene.getEngine()
|
||||
const cam = scene.activeCamera
|
||||
if (!cam) return
|
||||
|
||||
const center = chosen.getBoundingInfo().boundingBox.centerWorld
|
||||
const world = Matrix.IdentityReadOnly
|
||||
const transform = scene.getTransformMatrix()
|
||||
const viewport = new Viewport(0, 0, engine.getRenderWidth(), engine.getRenderHeight())
|
||||
const projected = Vector3.Project(center, world, transform, viewport)
|
||||
setOverlayPos({ left: projected.x, top: projected.y })
|
||||
} catch (error) {
|
||||
console.warn('[ModelViewer] Error updating overlay position:', error)
|
||||
setOverlayPos(null)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
scene.onAfterRenderObservable.remove(observer)
|
||||
@@ -333,20 +375,50 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
|
||||
return (
|
||||
<div className="w-full h-screen relative bg-gray-900 overflow-hidden">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={`w-full h-full outline-none block transition-opacity duration-500 ${
|
||||
showModel ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
/>
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-50">
|
||||
<LoadingSpinner
|
||||
progress={loadingProgress}
|
||||
size={120}
|
||||
strokeWidth={8}
|
||||
/>
|
||||
{!modelPath ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md">
|
||||
<div className="text-amber-400 text-lg font-semibold mb-4">
|
||||
3D модель недоступна
|
||||
</div>
|
||||
<div className="text-gray-300 mb-4">
|
||||
Путь к 3D модели не задан
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
Обратитесь к администратору для настройки модели
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={`w-full h-full outline-none block transition-opacity duration-500 ${
|
||||
showModel ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
/>
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-50">
|
||||
<LoadingSpinner
|
||||
progress={loadingProgress}
|
||||
size={120}
|
||||
strokeWidth={8}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!modelReady && !isLoading && (
|
||||
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-40">
|
||||
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md">
|
||||
<div className="text-gray-400 text-lg font-semibold mb-4">
|
||||
3D модель не загружена
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
Модель не готова к отображению
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{renderOverlay
|
||||
? renderOverlay({ anchor: overlayPos, info: overlayData })
|
||||
@@ -357,7 +429,8 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
{overlayData.sensorId && <div className="opacity-80">ID: {overlayData.sensorId}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ interface FloorNavigationProps {
|
||||
detectorsData: DetectorsDataType
|
||||
onDetectorMenuClick: (detector: DetectorType) => void
|
||||
onClose?: () => void
|
||||
is3DReady?: boolean
|
||||
}
|
||||
|
||||
interface DetectorType {
|
||||
@@ -33,7 +34,7 @@ interface DetectorType {
|
||||
}>
|
||||
}
|
||||
|
||||
const FloorNavigation: React.FC<FloorNavigationProps> = ({ objectId, detectorsData, onDetectorMenuClick, onClose }) => {
|
||||
const FloorNavigation: React.FC<FloorNavigationProps> = ({ objectId, detectorsData, onDetectorMenuClick, onClose, is3DReady = true }) => {
|
||||
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set())
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
|
||||
@@ -95,6 +96,12 @@ const FloorNavigation: React.FC<FloorNavigationProps> = ({ objectId, detectorsDa
|
||||
}
|
||||
|
||||
const handleDetectorMenuClick = (detector: DetectorType) => {
|
||||
// Проверяем валидность данных детектора перед передачей
|
||||
if (!detector || !detector.detector_id || !detector.serial_number) {
|
||||
console.warn('[FloorNavigation] Invalid detector data, skipping menu click:', detector)
|
||||
return
|
||||
}
|
||||
|
||||
onDetectorMenuClick(detector)
|
||||
}
|
||||
|
||||
@@ -184,10 +191,20 @@ const FloorNavigation: React.FC<FloorNavigationProps> = ({ objectId, detectorsDa
|
||||
</svg>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDetectorMenuClick(detector)}
|
||||
onClick={() => {
|
||||
if (is3DReady) {
|
||||
handleDetectorMenuClick(detector)
|
||||
} else {
|
||||
console.warn('[FloorNavigation] 3D model not ready, skipping detector focus')
|
||||
}
|
||||
}}
|
||||
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"
|
||||
title={is3DReady ? "Показать детектор на 3D модели" : "3D модель недоступна"}
|
||||
>
|
||||
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||
{!is3DReady && (
|
||||
<div className="absolute -top-1 -right-1 w-3 h-3 bg-amber-500 rounded-full text-[8px] flex items-center justify-center text-black font-bold">!</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface MonitoringProps {
|
||||
@@ -7,6 +7,8 @@ interface MonitoringProps {
|
||||
}
|
||||
|
||||
const Monitoring: React.FC<MonitoringProps> = ({ onClose }) => {
|
||||
const [objectImageError, setObjectImageError] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="bg-[rgb(22,24,36)] rounded-[12px] p-4 space-y-4">
|
||||
@@ -26,18 +28,26 @@ const Monitoring: React.FC<MonitoringProps> = ({ onClose }) => {
|
||||
|
||||
<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';
|
||||
}}
|
||||
/>
|
||||
{objectImageError ? (
|
||||
<div className="text-center p-4">
|
||||
<div className="text-gray-600 text-sm font-semibold mb-2">
|
||||
Предпросмотр 3D недоступен
|
||||
</div>
|
||||
<div className="text-gray-500 text-xs">
|
||||
Изображение модели не найдено
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<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={() => setObjectImageError(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import Image from 'next/image'
|
||||
import useUIStore from '../../app/store/uiStore'
|
||||
import useNavigationStore from '../../app/store/navigationStore'
|
||||
import { useNavigationService } from '@/services/navigationService'
|
||||
import useUserStore from '../../app/store/userStore'
|
||||
import { signOut } from 'next-auth/react'
|
||||
|
||||
interface NavigationItem {
|
||||
id: number
|
||||
@@ -130,8 +132,8 @@ const navigationSubItems: NavigationItem[] = [
|
||||
const Sidebar: React.FC<SidebarProps> = ({
|
||||
logoSrc,
|
||||
userInfo = {
|
||||
name: 'Александр',
|
||||
role: 'Администратор'
|
||||
name: '—',
|
||||
role: '—'
|
||||
},
|
||||
activeItem: propActiveItem,
|
||||
onCustomItemClick
|
||||
@@ -150,7 +152,35 @@ const Sidebar: React.FC<SidebarProps> = ({
|
||||
setNavigationSubMenuExpanded: setShowNavigationSubItems,
|
||||
toggleNavigationSubMenu
|
||||
} = useUIStore()
|
||||
|
||||
const { user, logout } = useUserStore()
|
||||
|
||||
const roleLabelMap: Record<string, string> = {
|
||||
engineer: 'Инженер',
|
||||
operator: 'Оператор',
|
||||
admin: 'Администратор',
|
||||
}
|
||||
const fullName = [user?.name, user?.surname].filter(Boolean).join(' ').trim()
|
||||
|
||||
const uiUserInfo = {
|
||||
name: fullName || user?.login || userInfo?.name || '—',
|
||||
role: roleLabelMap[(user?.account_type ?? '').toLowerCase()] || userInfo?.role || '—',
|
||||
avatar: user?.image || userInfo?.avatar,
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Logout request failed:', e)
|
||||
} finally {
|
||||
logout()
|
||||
await signOut({ redirect: true, callbackUrl: '/login' })
|
||||
}
|
||||
}
|
||||
const {
|
||||
openMonitoring,
|
||||
openFloorNavigation,
|
||||
@@ -401,9 +431,9 @@ const Sidebar: React.FC<SidebarProps> = ({
|
||||
role="img"
|
||||
aria-label="User avatar"
|
||||
>
|
||||
{userInfo.avatar && (
|
||||
{uiUserInfo.avatar && (
|
||||
<Image
|
||||
src={userInfo.avatar}
|
||||
src={uiUserInfo.avatar}
|
||||
alt="User avatar"
|
||||
className="w-full h-full rounded-lg object-cover"
|
||||
fill
|
||||
@@ -417,21 +447,24 @@ const Sidebar: React.FC<SidebarProps> = ({
|
||||
<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}
|
||||
{uiUserInfo.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}
|
||||
{uiUserInfo.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"
|
||||
aria-label="Logout"
|
||||
title="Выйти"
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<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 className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M10 17l-5-5 5-5v3h8v4h-8v3z" />
|
||||
<path d="M20 3h-8v2h8v14h-8v2h8c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
Reference in New Issue
Block a user