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:
@@ -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 {
|
import {
|
||||||
CalendarDays, BookOpen, BedDouble, Globe, Settings,
|
CalendarDays, BookOpen, BedDouble, Globe, Settings,
|
||||||
FileText, LayoutDashboard, Building2, Users, X, Hotel, Puzzle, CalendarRange, Map, LayoutGrid, UserCog, UsersRound,
|
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'
|
} from 'lucide-react'
|
||||||
import { useAuth } from '../../contexts/AuthContext'
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
import { useModules } from '../../contexts/ModulesContext'
|
import { useModules } from '../../contexts/ModulesContext'
|
||||||
@@ -14,15 +15,64 @@ interface SidebarProps {
|
|||||||
onClose: () => void
|
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({
|
function NavItem({
|
||||||
to, icon: Icon, label, onClick,
|
to,
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
favorites,
|
||||||
|
onToggleFavorite,
|
||||||
}: {
|
}: {
|
||||||
to: string
|
to: string
|
||||||
icon: React.ElementType
|
icon: React.ElementType
|
||||||
label: string
|
label: string
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
|
favorites: string[]
|
||||||
|
onToggleFavorite: (path: string) => void
|
||||||
}) {
|
}) {
|
||||||
|
const isFav = favorites.includes(to)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="relative group/item">
|
||||||
<NavLink
|
<NavLink
|
||||||
to={to}
|
to={to}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -31,19 +81,53 @@ function NavItem({
|
|||||||
<Icon size={18} />
|
<Icon size={18} />
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
</NavLink>
|
</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) {
|
export function Sidebar({ open, onClose }: SidebarProps) {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const { statuses } = useModules()
|
const { statuses } = useModules()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
const isAdmin = user?.role === 'super_admin'
|
const isAdmin = user?.role === 'super_admin'
|
||||||
|
|
||||||
const isModuleActive = (id: string) => statuses[id] === 'active' || statuses[id] === 'trial'
|
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[]> = {
|
const ROLE_PERMS: Record<string, string[]> = {
|
||||||
hotel_admin: ['*'],
|
hotel_admin: ['*'],
|
||||||
manager: ['*'],
|
manager: ['*'],
|
||||||
@@ -62,7 +146,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
|||||||
// Core modules with dedicated nav positions (not shown in generic module list)
|
// Core modules with dedicated nav positions (not shown in generic module list)
|
||||||
const CORE_MODULE_IDS = ['channel-manager', 'rental']
|
const CORE_MODULE_IDS = ['channel-manager', 'rental']
|
||||||
|
|
||||||
// Module items for the "Модули" sidebar section
|
// Module items for the "Сервис" sidebar section
|
||||||
const activeModuleItems = MODULES_DATA.filter(
|
const activeModuleItems = MODULES_DATA.filter(
|
||||||
m => m.sidebarItem &&
|
m => m.sidebarItem &&
|
||||||
!m.sidebarItem.showForHousekeeper &&
|
!m.sidebarItem.showForHousekeeper &&
|
||||||
@@ -70,6 +154,90 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
|||||||
isModuleActive(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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{open && (
|
{open && (
|
||||||
@@ -116,57 +284,86 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Nav */}
|
{/* 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 ? (
|
{isAdmin ? (
|
||||||
<>
|
<>
|
||||||
<p className="px-3 pt-2 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
<p className="px-3 pt-2 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||||
Администрирование
|
Администрирование
|
||||||
</p>
|
</p>
|
||||||
<NavItem to="/admin" icon={LayoutDashboard} label="Дашборд" onClick={onClose} />
|
<NavItem to="/admin" icon={LayoutDashboard} label="Дашборд" {...navItemProps} />
|
||||||
<NavItem to="/admin/hotels" icon={Building2} label="Отели" onClick={onClose} />
|
<NavItem to="/admin/hotels" icon={Building2} label="Отели" {...navItemProps} />
|
||||||
<NavItem to="/admin/users" icon={Users} label="Пользователи" onClick={onClose} />
|
<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">
|
<GroupHeader label="Основное" isOpen={groups.basics} onToggle={() => toggleGroup('basics')} />
|
||||||
Основное
|
{groups.basics && (
|
||||||
</p>
|
<div className="space-y-0.5">
|
||||||
{can('calendar') && <NavItem to="/calendar" icon={CalendarDays} label="Шахматка" onClick={onClose} />}
|
{can('calendar') && <NavItem to="/calendar" icon={CalendarDays} label="Шахматка" {...navItemProps} />}
|
||||||
{can('bookings') && <NavItem to="/bookings" icon={BookOpen} label="Бронирования" onClick={onClose} />}
|
{can('bookings') && <NavItem to="/bookings" icon={BookOpen} label="Бронирования" {...navItemProps} />}
|
||||||
{can('guests') && <NavItem to="/guests" icon={UsersRound} label="Гости" onClick={onClose} />}
|
{can('guests') && <NavItem to="/guests" icon={UsersRound} label="Гости" {...navItemProps} />}
|
||||||
{can('rooms') && <NavItem to="/rooms" icon={BedDouble} label="Номера" onClick={onClose} />}
|
{can('rooms') && <NavItem to="/rooms" icon={BedDouble} label="Номера" {...navItemProps} />}
|
||||||
{can('rooms') && <NavItem to="/room-categories" icon={LayoutGrid} label="Категории номеров" onClick={onClose} />}
|
{can('rooms') && <NavItem to="/room-categories" icon={LayoutGrid} label="Категории номеров" {...navItemProps} />}
|
||||||
{can('availability') && <NavItem to="/availability" icon={CalendarRange} label="Доступность" onClick={onClose} />}
|
{can('availability') && <NavItem to="/availability" icon={CalendarRange} label="Доступность" {...navItemProps} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Цены ── */}
|
{/* ── Цены ── */}
|
||||||
{(can('tariffs') || can('pricing') || can('discounts') || (can('rental') && isModuleActive('rental'))) && (
|
{(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">
|
<>
|
||||||
Цены и тарифы
|
<GroupHeader label="Цены" isOpen={groups.prices} onToggle={() => toggleGroup('prices')} />
|
||||||
</p>
|
{groups.prices && (
|
||||||
)}
|
<div className="space-y-0.5">
|
||||||
{can('tariffs') && <NavItem to="/tariffs" icon={Utensils} label="Тарифы" onClick={onClose} />}
|
{can('tariffs') && <NavItem to="/tariffs" icon={Utensils} label="Тарифы" {...navItemProps} />}
|
||||||
{can('pricing') && <NavItem to="/dynamic-pricing" icon={TrendingUp} label="Динамические цены" onClick={onClose} />}
|
{can('pricing') && <NavItem to="/dynamic-pricing" icon={TrendingUp} label="Динамические цены" {...navItemProps} />}
|
||||||
{can('discounts')&& <NavItem to="/discounts" icon={Tag} label="Скидки" onClick={onClose} />}
|
{can('discounts') && <NavItem to="/discounts" icon={Tag} label="Скидки" {...navItemProps} />}
|
||||||
{can('rental') && isModuleActive('rental') && (
|
{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)) )) && (
|
{(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">
|
<>
|
||||||
Модули
|
<GroupHeader label="Сервис" isOpen={groups.service} onToggle={() => toggleGroup('service')} />
|
||||||
</p>
|
{groups.service && (
|
||||||
)}
|
<div className="space-y-0.5">
|
||||||
{can('housekeeping') && isModuleActive('housekeeping') && (
|
{can('housekeeping') && isModuleActive('housekeeping') && (
|
||||||
<>
|
<>
|
||||||
<NavItem
|
<NavItem
|
||||||
to="/housekeeping"
|
to="/housekeeping"
|
||||||
icon={MODULES_DATA.find(m => m.id === 'housekeeping')!.sidebarItem!.icon}
|
icon={MODULES_DATA.find(m => m.id === 'housekeeping')!.sidebarItem!.icon}
|
||||||
label="Уборка"
|
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 => (
|
{activeModuleItems.filter(m => can(m.id)).map(m => (
|
||||||
@@ -175,37 +372,60 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
|||||||
to={m.sidebarItem!.path}
|
to={m.sidebarItem!.path}
|
||||||
icon={m.sidebarItem!.icon}
|
icon={m.sidebarItem!.icon}
|
||||||
label={m.sidebarItem!.label}
|
label={m.sidebarItem!.label}
|
||||||
onClick={onClose}
|
{...navItemProps}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Управление ── */}
|
{/* ── Управление ── */}
|
||||||
{(can('users') || can('loyalty') || can('maintenance') || can('rooms') || can('channels') || can('settings')) && (
|
{(can('users') || can('loyalty') || can('maintenance') || can('rooms') || (can('channels') && isModuleActive('channel-manager'))) && (
|
||||||
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
<>
|
||||||
Управление
|
<GroupHeader label="Управление" isOpen={groups.management} onToggle={() => toggleGroup('management')} />
|
||||||
</p>
|
{groups.management && (
|
||||||
)}
|
<div className="space-y-0.5">
|
||||||
{can('users') && <NavItem to="/users" icon={UserCog} label="Сотрудники" onClick={onClose} />}
|
{can('users') && <NavItem to="/users" icon={UserCog} label="Сотрудники" {...navItemProps} />}
|
||||||
{can('users') && <NavItem to="/schedule" icon={CalendarClock} label="График работы" onClick={onClose} />}
|
{can('users') && <NavItem to="/schedule" icon={CalendarClock} label="График работы" {...navItemProps} />}
|
||||||
{can('loyalty') && <NavItem to="/loyalty" icon={Award} label="Лояльность" onClick={onClose} />}
|
{can('loyalty') && <NavItem to="/loyalty" icon={Award} label="Лояльность" {...navItemProps} />}
|
||||||
{can('maintenance')&& <NavItem to="/maintenance" icon={Wrench} label="Тех. перерывы" onClick={onClose} />}
|
{can('maintenance') && <NavItem to="/maintenance" icon={Wrench} label="Тех. перерывы" {...navItemProps} />}
|
||||||
{can('rooms') && <NavItem to="/floor-map" icon={Map} label="План этажей" onClick={onClose} />}
|
{can('rooms') && <NavItem to="/floor-map" icon={Map} label="План этажей" {...navItemProps} />}
|
||||||
{can('channels') && isModuleActive('channel-manager') && (
|
{can('channels') && isModuleActive('channel-manager') && (
|
||||||
<NavItem
|
<NavItem
|
||||||
to="/channels"
|
to="/channels"
|
||||||
icon={MODULES_DATA.find(m => m.id === 'channel-manager')!.sidebarItem!.icon}
|
icon={MODULES_DATA.find(m => m.id === 'channel-manager')!.sidebarItem!.icon}
|
||||||
label="Каналы"
|
label="Каналы"
|
||||||
onClick={onClose}
|
{...navItemProps}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{can('settings') && <NavItem to="/modules" icon={Puzzle} label="Модули" onClick={onClose} />}
|
</div>
|
||||||
{can('settings') && <NavItem to="/settings" icon={Settings} label="Настройки" onClick={onClose} />}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Настройки ── */}
|
||||||
{can('settings') && (
|
{can('settings') && (
|
||||||
<>
|
<>
|
||||||
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
<GroupHeader label="Настройки" isOpen={groups.settingsGroup} onToggle={() => toggleGroup('settingsGroup')} />
|
||||||
Разработчикам
|
{groups.settingsGroup && (
|
||||||
</p>
|
<div className="space-y-0.5">
|
||||||
<NavItem to="/api-docs" icon={FileText} label="API & Документация" onClick={onClose} />
|
<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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Outlet, Navigate } from 'react-router-dom'
|
import { Outlet, Navigate } from 'react-router-dom'
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Sidebar } from '../components/layout/Sidebar'
|
import { Sidebar } from '../components/layout/Sidebar'
|
||||||
import { Topbar } from '../components/layout/Topbar'
|
import { Topbar } from '../components/layout/Topbar'
|
||||||
import { ChatWidget } from '../components/chat/ChatWidget'
|
import { ChatWidget } from '../components/chat/ChatWidget'
|
||||||
@@ -8,6 +8,17 @@ import { useAuth } from '../contexts/AuthContext'
|
|||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
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 />
|
if (!user) return <Navigate to="/login" replace />
|
||||||
|
|
||||||
@@ -22,7 +33,7 @@ export function AppLayout() {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ChatWidget />
|
{!chatHidden && <ChatWidget />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
const [form, setForm] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
address: '',
|
address: '',
|
||||||
@@ -588,6 +600,18 @@ export function SettingsPage() {
|
|||||||
<Toggle on={calendarCompact} onChange={toggleCalendarCompact} />
|
<Toggle on={calendarCompact} onChange={toggleCalendarCompact} />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user