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>
This commit is contained in:
2026-03-26 10:39:33 +03:00
parent 7f906cb0a3
commit cbddbe6027
3 changed files with 340 additions and 85 deletions

View File

@@ -1,8 +1,9 @@
import { NavLink, useNavigate } from 'react-router-dom'
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,
TrendingUp, Tag, Award, Wrench, Utensils, CalendarClock, Zap, ChevronRight, Star,
} from 'lucide-react'
import { useAuth } from '../../contexts/AuthContext'
import { useModules } from '../../contexts/ModulesContext'
@@ -14,15 +15,64 @@ interface SidebarProps {
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,
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}
@@ -31,19 +81,53 @@ function NavItem({
<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 (which module keys each role can access)
// Role-based permission map
const ROLE_PERMS: Record<string, string[]> = {
hotel_admin: ['*'],
manager: ['*'],
@@ -62,7 +146,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
// 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
// Module items for the "Сервис" sidebar section
const activeModuleItems = MODULES_DATA.filter(
m => m.sidebarItem &&
!m.sidebarItem.showForHousekeeper &&
@@ -70,6 +154,90 @@ export function Sidebar({ open, onClose }: SidebarProps) {
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 && (
@@ -116,57 +284,86 @@ export function Sidebar({ open, onClose }: SidebarProps) {
)}
{/* Nav */}
<nav className="flex-1 overflow-y-auto px-3 py-3 space-y-0.5">
<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="Дашборд" onClick={onClose} />
<NavItem to="/admin/hotels" icon={Building2} label="Отели" onClick={onClose} />
<NavItem to="/admin/users" icon={Users} label="Пользователи" onClick={onClose} />
<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>
)}
{/* ── Основное ── */}
<p className="px-3 pt-2 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
Основное
</p>
{can('calendar') && <NavItem to="/calendar" icon={CalendarDays} label="Шахматка" onClick={onClose} />}
{can('bookings') && <NavItem to="/bookings" icon={BookOpen} label="Бронирования" onClick={onClose} />}
{can('guests') && <NavItem to="/guests" icon={UsersRound} label="Гости" onClick={onClose} />}
{can('rooms') && <NavItem to="/rooms" icon={BedDouble} label="Номера" onClick={onClose} />}
{can('rooms') && <NavItem to="/room-categories" icon={LayoutGrid} label="Категории номеров" onClick={onClose} />}
{can('availability') && <NavItem to="/availability" icon={CalendarRange} label="Доступность" onClick={onClose} />}
<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'))) && (
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
Цены и тарифы
</p>
)}
{can('tariffs') && <NavItem to="/tariffs" icon={Utensils} label="Тарифы" onClick={onClose} />}
{can('pricing') && <NavItem to="/dynamic-pricing" icon={TrendingUp} label="Динамические цены" onClick={onClose} />}
{can('discounts')&& <NavItem to="/discounts" icon={Tag} label="Скидки" onClick={onClose} />}
<>
<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="Аренда объектов" onClick={onClose} />
<NavItem to="/rental" icon={CalendarClock} label="Аренда объектов" {...navItemProps} />
)}
</div>
)}
</>
)}
{/* ── Модули ── */}
{(can('housekeeping') && isModuleActive('housekeeping') || (activeModuleItems.some(m => can(m.id)) )) && (
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
Модули
</p>
)}
{/* ── Сервис ── */}
{(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="Уборка"
onClick={onClose}
{...navItemProps}
/>
{can('maintenance') && <NavItem to="/technical" icon={Zap} label="Тех. задачи" onClick={onClose} />}
{can('maintenance') && <NavItem to="/technical" icon={Zap} label="Тех. задачи" {...navItemProps} />}
</>
)}
{activeModuleItems.filter(m => can(m.id)).map(m => (
@@ -175,37 +372,60 @@ export function Sidebar({ open, onClose }: SidebarProps) {
to={m.sidebarItem!.path}
icon={m.sidebarItem!.icon}
label={m.sidebarItem!.label}
onClick={onClose}
{...navItemProps}
/>
))}
</div>
)}
</>
)}
{/* ── Управление ── */}
{(can('users') || can('loyalty') || can('maintenance') || can('rooms') || can('channels') || can('settings')) && (
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
Управление
</p>
)}
{can('users') && <NavItem to="/users" icon={UserCog} label="Сотрудники" onClick={onClose} />}
{can('users') && <NavItem to="/schedule" icon={CalendarClock} label="График работы" onClick={onClose} />}
{can('loyalty') && <NavItem to="/loyalty" icon={Award} label="Лояльность" onClick={onClose} />}
{can('maintenance')&& <NavItem to="/maintenance" icon={Wrench} label="Тех. перерывы" onClick={onClose} />}
{can('rooms') && <NavItem to="/floor-map" icon={Map} label="План этажей" onClick={onClose} />}
{(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="Каналы"
onClick={onClose}
{...navItemProps}
/>
)}
{can('settings') && <NavItem to="/modules" icon={Puzzle} label="Модули" onClick={onClose} />}
{can('settings') && <NavItem to="/settings" icon={Settings} label="Настройки" onClick={onClose} />}
</div>
)}
</>
)}
{/* ── Настройки ── */}
{can('settings') && (
<>
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
Разработчикам
</p>
<NavItem to="/api-docs" icon={FileText} label="API & Документация" onClick={onClose} />
<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>
)}
</>
)}
</>

View File

@@ -1,5 +1,5 @@
import { Outlet, Navigate } from 'react-router-dom'
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { Sidebar } from '../components/layout/Sidebar'
import { Topbar } from '../components/layout/Topbar'
import { ChatWidget } from '../components/chat/ChatWidget'
@@ -8,6 +8,17 @@ import { useAuth } from '../contexts/AuthContext'
export function AppLayout() {
const { user } = useAuth()
const [sidebarOpen, setSidebarOpen] = useState(false)
const [chatHidden, setChatHidden] = useState(
() => localStorage.getItem('chatHidden') === 'true',
)
useEffect(() => {
const handleStorage = () => {
setChatHidden(localStorage.getItem('chatHidden') === 'true')
}
window.addEventListener('storage', handleStorage)
return () => window.removeEventListener('storage', handleStorage)
}, [])
if (!user) return <Navigate to="/login" replace />
@@ -22,7 +33,7 @@ export function AppLayout() {
</main>
</div>
<ChatWidget />
{!chatHidden && <ChatWidget />}
</div>
)
}

View File

@@ -51,6 +51,18 @@ export function SettingsPage() {
})
}
const [chatHidden, setChatHidden] = useState(
() => localStorage.getItem('chatHidden') === 'true',
)
const toggleChatHidden = () => {
setChatHidden(v => {
const next = !v
localStorage.setItem('chatHidden', String(next))
window.dispatchEvent(new Event('storage'))
return next
})
}
const [form, setForm] = useState({
name: '',
address: '',
@@ -588,6 +600,18 @@ export function SettingsPage() {
<Toggle on={calendarCompact} onChange={toggleCalendarCompact} />
</div>
</div>
{/* Chat widget visibility */}
<div className="pt-3 border-t border-slate-100 dark:border-slate-700">
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Чат-виджет</p>
<div className="flex items-center justify-between p-3 rounded-lg bg-slate-50 dark:bg-slate-700/40">
<div>
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">Скрыть чат</p>
<p className="text-xs text-slate-500 dark:text-slate-400">Убрать кнопку чата из интерфейса</p>
</div>
<Toggle on={chatHidden} onChange={toggleChatHidden} />
</div>
</div>
</div>
</>
)}