переделана логика загрузки модели, замена страницы Объекты на другой внешний вид, добавление в меню пункта Объекты
This commit is contained in:
504
frontend/components/ui/Sidebar.tsx — копия 3
Normal file
504
frontend/components/ui/Sidebar.tsx — копия 3
Normal file
@@ -0,0 +1,504 @@
|
||||
'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'
|
||||
import useUserStore from '../../app/store/userStore'
|
||||
import { signOut } from 'next-auth/react'
|
||||
|
||||
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 IconWrapper = ({ src, alt, className }: { src: string; alt: string; className?: string }) => (
|
||||
<div className={`relative ${className}`}>
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={20}
|
||||
height={20}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
const BookOpen = ({ className }: { className?: string }) => (
|
||||
<IconWrapper src="/icons/BookOpen.png" alt="Dashboard" className={className} />
|
||||
)
|
||||
|
||||
const Bot = ({ className }: { className?: string }) => (
|
||||
<IconWrapper src="/icons/Bot.png" alt="Navigation" className={className} />
|
||||
)
|
||||
|
||||
const SquareTerminal = ({ className }: { className?: string }) => (
|
||||
<IconWrapper src="/icons/SquareTerminal.png" alt="Terminal" className={className} />
|
||||
)
|
||||
|
||||
const CircleDot = ({ className }: { className?: string }) => (
|
||||
<IconWrapper src="/icons/CircleDot.png" alt="Sensors" className={className} />
|
||||
)
|
||||
|
||||
const BellDot = ({ className }: { className?: string }) => (
|
||||
<IconWrapper src="/icons/BellDot.png" alt="Notifications" className={className} />
|
||||
)
|
||||
|
||||
const History = ({ className }: { className?: string }) => (
|
||||
<IconWrapper src="/icons/History.png" alt="History" className={className} />
|
||||
)
|
||||
|
||||
const Settings2 = ({ className }: { className?: string }) => (
|
||||
<IconWrapper src="/icons/Settings2.png" alt="Settings" className={className} />
|
||||
)
|
||||
|
||||
const Monitor = ({ className }: { className?: string }) => (
|
||||
<IconWrapper src="/icons/Bot.png" alt="Monitor" className={className} />
|
||||
)
|
||||
|
||||
const Building = ({ className }: { className?: string }) => (
|
||||
<IconWrapper src="/icons/BookOpen.png" alt="Building" className={className} />
|
||||
)
|
||||
|
||||
// основные 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 { 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,
|
||||
openNotifications,
|
||||
openSensors,
|
||||
openListOfDetectors,
|
||||
closeSensors,
|
||||
closeListOfDetectors,
|
||||
closeMonitoring,
|
||||
closeFloorNavigation,
|
||||
closeNotifications,
|
||||
showMonitoring,
|
||||
showFloorNavigation,
|
||||
showNotifications,
|
||||
showSensors,
|
||||
showListOfDetectors
|
||||
} = 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
|
||||
|
||||
// Управление суб-меню через navigationStore (суб-меню - работают как отдельные элементы, но не страницы)
|
||||
switch (itemId) {
|
||||
case 2:
|
||||
if (pathname !== '/navigation') {
|
||||
router.push('/navigation')
|
||||
}
|
||||
handled = true
|
||||
break
|
||||
case 3: // Monitoring
|
||||
if (pathname !== '/navigation') {
|
||||
router.push('/navigation')
|
||||
setTimeout(() => openMonitoring(), 100)
|
||||
} else if (showMonitoring) {
|
||||
closeMonitoring()
|
||||
} else {
|
||||
openMonitoring()
|
||||
}
|
||||
handled = true
|
||||
break
|
||||
case 4: // Floor Navigation
|
||||
if (pathname !== '/navigation') {
|
||||
router.push('/navigation')
|
||||
setTimeout(() => openFloorNavigation(), 100)
|
||||
} else if (showFloorNavigation) {
|
||||
closeFloorNavigation()
|
||||
} else {
|
||||
openFloorNavigation()
|
||||
}
|
||||
handled = true
|
||||
break
|
||||
case 5: // Notifications
|
||||
if (pathname !== '/navigation') {
|
||||
router.push('/navigation')
|
||||
setTimeout(() => openNotifications(), 100)
|
||||
} else if (showNotifications) {
|
||||
closeNotifications()
|
||||
} else {
|
||||
openNotifications()
|
||||
}
|
||||
handled = true
|
||||
break
|
||||
case 6: // Sensors
|
||||
if (pathname !== '/navigation') {
|
||||
router.push('/navigation')
|
||||
setTimeout(() => openSensors(), 100)
|
||||
} else if (showSensors) {
|
||||
closeSensors()
|
||||
} else {
|
||||
openSensors()
|
||||
}
|
||||
handled = true
|
||||
break
|
||||
case 7: // Detector List
|
||||
if (pathname !== '/navigation') {
|
||||
router.push('/navigation')
|
||||
setTimeout(() => openListOfDetectors(), 100)
|
||||
} else if (showListOfDetectors) {
|
||||
closeListOfDetectors()
|
||||
} else {
|
||||
openListOfDetectors()
|
||||
}
|
||||
handled = true
|
||||
break
|
||||
default:
|
||||
// Для остального используем routes
|
||||
if (navigationService) {
|
||||
handled = navigationService.handleSidebarItemClick(itemId, pathname)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
if (propActiveItem === undefined) {
|
||||
setInternalActiveItem(itemId)
|
||||
}
|
||||
|
||||
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">
|
||||
<Image
|
||||
className="w-auto h-[33px]"
|
||||
alt="AerBIM Monitor Logo"
|
||||
src={logoSrc || "/icons/logo.png"}
|
||||
width={169}
|
||||
height={33}
|
||||
/>
|
||||
</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-all 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-gradient-to-r from-blue-600 to-cyan-500 shadow-lg shadow-blue-500/30' : ''
|
||||
}`}
|
||||
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.5 hover:bg-gray-600 rounded-md transition-colors duration-200 cursor-pointer bg-gray-700/50 border border-gray-600/50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setManuallyToggled(true)
|
||||
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)
|
||||
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 drop-shadow-sm ${
|
||||
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-all 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-gradient-to-r from-blue-600 to-cyan-500 shadow-lg shadow-blue-500/30' : ''
|
||||
}`}
|
||||
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-8 !h-8 p-1.5 rounded-lg hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all duration-200 bg-gray-800/60 border border-gray-600/40 shadow-lg hover:shadow-xl"
|
||||
onClick={() => {
|
||||
// Убираем суб-меню перед сворачиванием сайдбара
|
||||
if (showNavigationSubItems) {
|
||||
setShowNavigationSubItems(false)
|
||||
setManuallyToggled(true)
|
||||
|
||||
closeMonitoring()
|
||||
closeFloorNavigation()
|
||||
closeNotifications()
|
||||
}
|
||||
// Убираем сайд-бар
|
||||
toggleSidebar()
|
||||
}}
|
||||
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
type="button"
|
||||
>
|
||||
<svg className={`!relative !w-5 !h-5 text-white transition-transform duration-200 drop-shadow-sm ${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"
|
||||
>
|
||||
{uiUserInfo.avatar && (
|
||||
<Image
|
||||
src={uiUserInfo.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]">
|
||||
{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]">
|
||||
{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="Logout"
|
||||
title="Выйти"
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export default Sidebar
|
||||
Reference in New Issue
Block a user