Files
hotelsync/src/components/layout/Sidebar.tsx
HotelSync cbddbe6027 feat: collapsible sidebar groups with favorites + chat visibility toggle
- Sidebar: collapsible groups (Основное/Цены/Сервис/Управление/Настройки/Разработчикам) with chevron headers, state persisted in localStorage 'sidebarGroups'
- Sidebar: Избранное group at top, star icon on nav item hover to add/remove favorites, stored in localStorage 'sidebarFavorites'
- Sidebar: active route auto-opens its group on navigation
- SettingsPage: 'Скрыть чат' toggle in Внешний вид section, stored in localStorage 'chatHidden'
- AppLayout: conditionally renders ChatWidget based on chatHidden, listens for storage events for reactive updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 10:39:33 +03:00

442 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
import { useState, useEffect } from 'react'
import {
CalendarDays, BookOpen, BedDouble, Globe, Settings,
FileText, LayoutDashboard, Building2, Users, X, Hotel, Puzzle, CalendarRange, Map, LayoutGrid, UserCog, UsersRound,
TrendingUp, Tag, Award, Wrench, Utensils, CalendarClock, Zap, ChevronRight, Star,
} from 'lucide-react'
import { useAuth } from '../../contexts/AuthContext'
import { useModules } from '../../contexts/ModulesContext'
import { MODULES_DATA } from '../../data/modulesData'
import { cn } from '../../lib/utils'
interface SidebarProps {
open: boolean
onClose: () => void
}
// ── Favorites helpers ──────────────────────────────────────────────────────
function loadFavorites(): string[] {
try {
return JSON.parse(localStorage.getItem('sidebarFavorites') || '[]')
} catch {
return []
}
}
function saveFavorites(favs: string[]) {
localStorage.setItem('sidebarFavorites', JSON.stringify(favs))
}
// ── Group state helpers ────────────────────────────────────────────────────
type GroupKey = 'basics' | 'prices' | 'service' | 'management' | 'settingsGroup' | 'devGroup'
const DEFAULT_GROUPS: Record<GroupKey, boolean> = {
basics: true,
prices: true,
service: true,
management: true,
settingsGroup: true,
devGroup: true,
}
function loadGroups(): Record<GroupKey, boolean> {
try {
const stored = JSON.parse(localStorage.getItem('sidebarGroups') || '{}')
return { ...DEFAULT_GROUPS, ...stored }
} catch {
return { ...DEFAULT_GROUPS }
}
}
function saveGroups(groups: Record<GroupKey, boolean>) {
localStorage.setItem('sidebarGroups', JSON.stringify(groups))
}
// ── NavItem with star ──────────────────────────────────────────────────────
function NavItem({
to,
icon: Icon,
label,
onClick,
favorites,
onToggleFavorite,
}: {
to: string
icon: React.ElementType
label: string
onClick?: () => void
favorites: string[]
onToggleFavorite: (path: string) => void
}) {
const isFav = favorites.includes(to)
return (
<div className="relative group/item">
<NavLink
to={to}
onClick={onClick}
className={({ isActive }) => cn('sidebar-link', isActive && 'active')}
>
<Icon size={18} />
<span>{label}</span>
</NavLink>
<button
className="absolute right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover/item:opacity-100 transition-opacity p-0.5"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onToggleFavorite(to) }}
title={isFav ? 'Убрать из избранного' : 'Добавить в избранное'}
>
<Star
size={12}
className={isFav ? 'fill-yellow-400 text-yellow-400' : 'text-slate-400'}
/>
</button>
</div>
)
}
// ── Group header ───────────────────────────────────────────────────────────
function GroupHeader({
label,
isOpen,
onToggle,
}: {
label: string
isOpen: boolean
onToggle: () => void
}) {
return (
<button
onClick={onToggle}
className="w-full flex items-center justify-between px-3 py-1.5 text-xs font-semibold text-slate-400 uppercase tracking-wider hover:text-slate-600 dark:hover:text-slate-300 transition-colors mt-2"
>
<span>{label}</span>
<ChevronRight size={12} className={cn('transition-transform', isOpen ? 'rotate-90' : '')} />
</button>
)
}
// ── Main Sidebar ───────────────────────────────────────────────────────────
export function Sidebar({ open, onClose }: SidebarProps) {
const { user } = useAuth()
const { statuses } = useModules()
const navigate = useNavigate()
const location = useLocation()
const isAdmin = user?.role === 'super_admin'
const isModuleActive = (id: string) => statuses[id] === 'active' || statuses[id] === 'trial'
// Role-based permission map
const ROLE_PERMS: Record<string, string[]> = {
hotel_admin: ['*'],
manager: ['*'],
receptionist: ['calendar','bookings','guests','rooms','availability','housekeeping','room_service','rental','pos','reviews','reports'],
housekeeper: ['calendar','housekeeping','maintenance','rooms'],
accountant: ['calendar','reports','pos','discounts','tariffs','pricing','loyalty'],
security: ['calendar','bookings'],
technician: ['calendar','housekeeping','maintenance','rooms'],
}
const can = (permission: string) => {
const role = user?.role ?? 'housekeeper'
const perms = ROLE_PERMS[role] ?? []
return perms.includes('*') || perms.includes(permission)
}
// Core modules with dedicated nav positions (not shown in generic module list)
const CORE_MODULE_IDS = ['channel-manager', 'rental']
// Module items for the "Сервис" sidebar section
const activeModuleItems = MODULES_DATA.filter(
m => m.sidebarItem &&
!m.sidebarItem.showForHousekeeper &&
!CORE_MODULE_IDS.includes(m.id) &&
isModuleActive(m.id),
)
// ── Favorites state ──────────────────────────────────────────────────────
const [favorites, setFavorites] = useState<string[]>(loadFavorites)
const toggleFavorite = (path: string) => {
setFavorites(prev => {
const next = prev.includes(path) ? prev.filter(p => p !== path) : [...prev, path]
saveFavorites(next)
return next
})
}
// ── Group open/close state ───────────────────────────────────────────────
const [groups, setGroups] = useState<Record<GroupKey, boolean>>(loadGroups)
const toggleGroup = (key: GroupKey) => {
setGroups(prev => {
const next = { ...prev, [key]: !prev[key] }
saveGroups(next)
return next
})
}
// Auto-open the group containing the currently active route
useEffect(() => {
const path = location.pathname
const groupPaths: Record<GroupKey, string[]> = {
basics: ['/calendar', '/bookings', '/guests', '/rooms', '/room-categories', '/availability'],
prices: ['/tariffs', '/dynamic-pricing', '/discounts', '/rental'],
service: ['/housekeeping', '/technical', ...activeModuleItems.map(m => m.sidebarItem!.path)],
management: ['/users', '/schedule', '/loyalty', '/maintenance', '/floor-map', '/channels'],
settingsGroup:['/modules', '/settings'],
devGroup: ['/api-docs'],
}
const matchedGroup = (Object.keys(groupPaths) as GroupKey[]).find(
key => groupPaths[key].some(p => path === p || path.startsWith(p + '/')),
)
if (matchedGroup) {
setGroups(prev => {
if (prev[matchedGroup]) return prev
const next = { ...prev, [matchedGroup]: true }
saveGroups(next)
return next
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname])
// All items metadata for favorites lookup
const allItems: { to: string; icon: React.ElementType; label: string }[] = [
// basics
{ to: '/calendar', icon: CalendarDays, label: 'Шахматка' },
{ to: '/bookings', icon: BookOpen, label: 'Бронирования' },
{ to: '/guests', icon: UsersRound, label: 'Гости' },
{ to: '/rooms', icon: BedDouble, label: 'Номера' },
{ to: '/room-categories', icon: LayoutGrid, label: 'Категории номеров' },
{ to: '/availability', icon: CalendarRange, label: 'Доступность' },
// prices
{ to: '/tariffs', icon: Utensils, label: 'Тарифы' },
{ to: '/dynamic-pricing', icon: TrendingUp, label: 'Динамические цены' },
{ to: '/discounts', icon: Tag, label: 'Скидки' },
{ to: '/rental', icon: CalendarClock, label: 'Аренда объектов' },
// service
{ to: '/housekeeping', icon: MODULES_DATA.find(m => m.id === 'housekeeping')?.sidebarItem?.icon ?? Wrench, label: 'Уборка' },
{ to: '/technical', icon: Zap, label: 'Тех. задачи' },
...activeModuleItems.map(m => ({ to: m.sidebarItem!.path, icon: m.sidebarItem!.icon, label: m.sidebarItem!.label })),
// management
{ to: '/users', icon: UserCog, label: 'Сотрудники' },
{ to: '/schedule', icon: CalendarClock, label: 'График работы' },
{ to: '/loyalty', icon: Award, label: 'Лояльность' },
{ to: '/maintenance', icon: Wrench, label: 'Тех. перерывы' },
{ to: '/floor-map', icon: Map, label: 'План этажей' },
{ to: '/channels', icon: Globe, label: 'Каналы' },
// settings
{ to: '/modules', icon: Puzzle, label: 'Модули' },
{ to: '/settings', icon: Settings, label: 'Настройки' },
// dev
{ to: '/api-docs', icon: FileText, label: 'API & Документация' },
]
const navItemProps = { favorites, onToggleFavorite: toggleFavorite, onClick: onClose }
return (
<>
{open && (
<div
className="fixed inset-0 bg-black/40 z-40 lg:hidden"
onClick={onClose}
/>
)}
<aside className={cn(
'fixed left-0 top-0 bottom-0 z-50 w-64 flex flex-col',
'bg-white dark:bg-slate-800',
'border-r border-slate-200 dark:border-slate-700',
'transition-transform duration-200 ease-out',
'lg:translate-x-0 lg:static lg:z-auto',
open ? 'translate-x-0' : '-translate-x-full',
)}>
{/* Logo */}
<div className="h-16 flex items-center justify-between px-4 border-b border-slate-200 dark:border-slate-700 shrink-0">
<div className="flex items-center gap-2.5 cursor-pointer" onClick={() => navigate('/')}>
<div className="w-8 h-8 rounded-lg bg-brand-600 flex items-center justify-center shrink-0">
<Hotel size={16} className="text-white" />
</div>
<span className="text-lg font-bold text-slate-900 dark:text-slate-100">
Hotel<span className="text-brand-600">Sync</span>
</span>
</div>
<button onClick={onClose} className="lg:hidden btn-ghost p-1.5">
<X size={16} />
</button>
</div>
{/* Hotel name */}
{!isAdmin && user?.hotelName && (
<div className="mx-3 mt-3 px-3 py-2.5 rounded-lg bg-slate-50 dark:bg-slate-700/50 border border-slate-200 dark:border-slate-600">
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium mb-0.5">Текущий отель</p>
<div className="flex items-center gap-1.5">
<Building2 size={14} className="text-brand-600 shrink-0" />
<span className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">
{user.hotelName}
</span>
</div>
</div>
)}
{/* Nav */}
<nav className="flex-1 overflow-y-auto px-3 py-3">
{isAdmin ? (
<>
<p className="px-3 pt-2 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
Администрирование
</p>
<NavItem to="/admin" icon={LayoutDashboard} label="Дашборд" {...navItemProps} />
<NavItem to="/admin/hotels" icon={Building2} label="Отели" {...navItemProps} />
<NavItem to="/admin/users" icon={Users} label="Пользователи" {...navItemProps} />
</>
) : (
<>
{/* ── Избранное ── */}
{favorites.length > 0 && (
<div className="mb-1">
<div className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-amber-500 uppercase tracking-wider">
<Star size={11} className="fill-amber-400 text-amber-400" />
<span>Избранное</span>
</div>
{favorites.map(path => {
const item = allItems.find(i => i.to === path)
if (!item) return null
return (
<NavItem
key={path}
to={item.to}
icon={item.icon}
label={item.label}
{...navItemProps}
/>
)
})}
</div>
)}
{/* ── Основное ── */}
<GroupHeader label="Основное" isOpen={groups.basics} onToggle={() => toggleGroup('basics')} />
{groups.basics && (
<div className="space-y-0.5">
{can('calendar') && <NavItem to="/calendar" icon={CalendarDays} label="Шахматка" {...navItemProps} />}
{can('bookings') && <NavItem to="/bookings" icon={BookOpen} label="Бронирования" {...navItemProps} />}
{can('guests') && <NavItem to="/guests" icon={UsersRound} label="Гости" {...navItemProps} />}
{can('rooms') && <NavItem to="/rooms" icon={BedDouble} label="Номера" {...navItemProps} />}
{can('rooms') && <NavItem to="/room-categories" icon={LayoutGrid} label="Категории номеров" {...navItemProps} />}
{can('availability') && <NavItem to="/availability" icon={CalendarRange} label="Доступность" {...navItemProps} />}
</div>
)}
{/* ── Цены ── */}
{(can('tariffs') || can('pricing') || can('discounts') || (can('rental') && isModuleActive('rental'))) && (
<>
<GroupHeader label="Цены" isOpen={groups.prices} onToggle={() => toggleGroup('prices')} />
{groups.prices && (
<div className="space-y-0.5">
{can('tariffs') && <NavItem to="/tariffs" icon={Utensils} label="Тарифы" {...navItemProps} />}
{can('pricing') && <NavItem to="/dynamic-pricing" icon={TrendingUp} label="Динамические цены" {...navItemProps} />}
{can('discounts') && <NavItem to="/discounts" icon={Tag} label="Скидки" {...navItemProps} />}
{can('rental') && isModuleActive('rental') && (
<NavItem to="/rental" icon={CalendarClock} label="Аренда объектов" {...navItemProps} />
)}
</div>
)}
</>
)}
{/* ── Сервис ── */}
{(can('housekeeping') && isModuleActive('housekeeping') || activeModuleItems.some(m => can(m.id))) && (
<>
<GroupHeader label="Сервис" isOpen={groups.service} onToggle={() => toggleGroup('service')} />
{groups.service && (
<div className="space-y-0.5">
{can('housekeeping') && isModuleActive('housekeeping') && (
<>
<NavItem
to="/housekeeping"
icon={MODULES_DATA.find(m => m.id === 'housekeeping')!.sidebarItem!.icon}
label="Уборка"
{...navItemProps}
/>
{can('maintenance') && <NavItem to="/technical" icon={Zap} label="Тех. задачи" {...navItemProps} />}
</>
)}
{activeModuleItems.filter(m => can(m.id)).map(m => (
<NavItem
key={m.id}
to={m.sidebarItem!.path}
icon={m.sidebarItem!.icon}
label={m.sidebarItem!.label}
{...navItemProps}
/>
))}
</div>
)}
</>
)}
{/* ── Управление ── */}
{(can('users') || can('loyalty') || can('maintenance') || can('rooms') || (can('channels') && isModuleActive('channel-manager'))) && (
<>
<GroupHeader label="Управление" isOpen={groups.management} onToggle={() => toggleGroup('management')} />
{groups.management && (
<div className="space-y-0.5">
{can('users') && <NavItem to="/users" icon={UserCog} label="Сотрудники" {...navItemProps} />}
{can('users') && <NavItem to="/schedule" icon={CalendarClock} label="График работы" {...navItemProps} />}
{can('loyalty') && <NavItem to="/loyalty" icon={Award} label="Лояльность" {...navItemProps} />}
{can('maintenance') && <NavItem to="/maintenance" icon={Wrench} label="Тех. перерывы" {...navItemProps} />}
{can('rooms') && <NavItem to="/floor-map" icon={Map} label="План этажей" {...navItemProps} />}
{can('channels') && isModuleActive('channel-manager') && (
<NavItem
to="/channels"
icon={MODULES_DATA.find(m => m.id === 'channel-manager')!.sidebarItem!.icon}
label="Каналы"
{...navItemProps}
/>
)}
</div>
)}
</>
)}
{/* ── Настройки ── */}
{can('settings') && (
<>
<GroupHeader label="Настройки" isOpen={groups.settingsGroup} onToggle={() => toggleGroup('settingsGroup')} />
{groups.settingsGroup && (
<div className="space-y-0.5">
<NavItem to="/modules" icon={Puzzle} label="Модули" {...navItemProps} />
<NavItem to="/settings" icon={Settings} label="Настройки" {...navItemProps} />
</div>
)}
</>
)}
{/* ── Разработчикам ── */}
{can('settings') && (
<>
<GroupHeader label="Разработчикам" isOpen={groups.devGroup} onToggle={() => toggleGroup('devGroup')} />
{groups.devGroup && (
<div className="space-y-0.5">
<NavItem to="/api-docs" icon={FileText} label="API & Документация" {...navItemProps} />
</div>
)}
</>
)}
</>
)}
</nav>
<div className="px-4 py-3 border-t border-slate-200 dark:border-slate-700 shrink-0">
<p className="text-xs text-slate-400 dark:text-slate-500">HotelSync v0.1.0 SaaS PMS</p>
</div>
</aside>
</>
)
}