feat: full role permissions system — new modules, API persistence, sidebar context

- Backend: migration 080_role_permissions table (hotel-scoped, upsert)
- Backend: routes GET/PUT/DELETE /api/hotels/:slug/role-permissions
- Frontend: RolePermissionsContext — loads saved perms from API, provides can()
- Frontend: Sidebar uses context can() instead of hardcoded ROLE_PERMS
- Frontend: fixed module ID→permKey mapping (room-service, olap-reports, website-builder)
- Frontend: Documents page added to Управление nav (was missing)
- Frontend: equipment/wifi/ttlock get own permission keys (not bundled under 'settings')
- Frontend: floor_map gets own permission key (not bundled under 'rooms')
- Frontend: new module group 'Технологии': wifi, equipment, ttlock, floor_map
- Frontend: 'schedule' added to Администрирование module group
- Updated INITIAL_ROLE_PERMISSIONS: receptionist+availability+reports+documents+website, accountant+documents, technician+floor_map+equipment+ttlock

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 12:16:15 +03:00
parent ea58cffa88
commit 512e3c6659
8 changed files with 431 additions and 61 deletions

View File

@@ -8,6 +8,7 @@ import {
} from 'lucide-react'
import { useAuth } from '../../contexts/AuthContext'
import { useModules } from '../../contexts/ModulesContext'
import { useRolePermissions } from '../../contexts/RolePermissionsContext'
import { MODULES_DATA } from '../../data/modulesData'
import { cn } from '../../lib/utils'
@@ -121,6 +122,7 @@ function GroupHeader({
export function Sidebar({ open, onClose }: SidebarProps) {
const { user } = useAuth()
const { statuses } = useModules()
const { can } = useRolePermissions()
const navigate = useNavigate()
const location = useLocation()
@@ -128,21 +130,14 @@ export function Sidebar({ open, onClose }: SidebarProps) {
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)
// Maps module IDs whose IDs differ from permission keys
const MODULE_PERM_KEY: Record<string, string> = {
'room-service': 'room_service',
'olap-reports': 'reports',
'website-builder':'website',
'channel-manager':'channels',
}
const modulePermKey = (moduleId: string) => MODULE_PERM_KEY[moduleId] ?? moduleId
// Core modules with dedicated nav positions (not shown in generic module list)
const CORE_MODULE_IDS = ['channel-manager', 'rental']
@@ -185,7 +180,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
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'],
management: ['/users', '/schedule', '/documents', '/loyalty', '/maintenance', '/floor-map', '/channels'],
settingsGroup:['/modules', '/settings', '/billing', '/equipment', '/wifi', '/ttlock', '/settings/checklists', '/settings/payments', '/settings/minibar', '/settings/minibar/stock', '/settings/deposit'],
devGroup: ['/api-docs'],
}
@@ -370,7 +365,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
{can('maintenance') && <NavItem to="/technical" icon={Zap} label="Тех. задачи" {...navItemProps} />}
</>
)}
{activeModuleItems.filter(m => can(m.id)).map(m => (
{activeModuleItems.filter(m => can(modulePermKey(m.id))).map(m => (
<NavItem
key={m.id}
to={m.sidebarItem!.path}
@@ -385,16 +380,17 @@ export function Sidebar({ open, onClose }: SidebarProps) {
)}
{/* ── Управление ── */}
{(can('users') || can('loyalty') || can('maintenance') || can('rooms') || (can('channels') && isModuleActive('channel-manager'))) && (
{(can('users') || can('documents') || can('loyalty') || can('maintenance') || can('floor_map') || (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('documents') && <NavItem to="/documents" icon={FileText} 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('floor_map') && <NavItem to="/floor-map" icon={Map} label="План этажей" {...navItemProps} />}
{can('channels') && isModuleActive('channel-manager') && (
<NavItem
to="/channels"
@@ -409,21 +405,21 @@ export function Sidebar({ open, onClose }: SidebarProps) {
)}
{/* ── Настройки ── */}
{can('settings') && (
{(can('settings') || can('equipment') || can('wifi') || can('ttlock')) && (
<>
<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} />
<NavItem to="/billing" icon={CreditCard} label="Тарифы и оплата" {...navItemProps} />
<NavItem to="/equipment" icon={Monitor} label="Оборудование" {...navItemProps} />
<NavItem to="/wifi" icon={Wifi} label="WiFi авторизация" {...navItemProps} />
<NavItem to="/ttlock" icon={KeyRound} label="Эл. замки TTLock" {...navItemProps} />
<NavItem to="/settings/checklists" icon={ListChecks} label="Чек-листы уборки" {...navItemProps} />
<NavItem to="/settings/payments" icon={CreditCard} label="Способы оплаты" {...navItemProps} />
<NavItem to="/settings/minibar" icon={ShoppingCart} label="Минибар" {...navItemProps} />
<NavItem to="/settings/deposit" icon={ShieldCheck} label="Депозит" {...navItemProps} />
{can('settings') && <NavItem to="/modules" icon={Puzzle} label="Модули" {...navItemProps} />}
{can('settings') && <NavItem to="/settings" icon={Settings} label="Настройки" {...navItemProps} />}
{can('settings') && <NavItem to="/billing" icon={CreditCard} label="Тарифы и оплата" {...navItemProps} />}
{can('equipment') && <NavItem to="/equipment" icon={Monitor} label="Оборудование" {...navItemProps} />}
{can('wifi') && <NavItem to="/wifi" icon={Wifi} label="WiFi авторизация" {...navItemProps} />}
{can('ttlock') && <NavItem to="/ttlock" icon={KeyRound} label="Эл. замки TTLock" {...navItemProps} />}
{can('settings') && <NavItem to="/settings/checklists" icon={ListChecks} label="Чек-листы уборки" {...navItemProps} />}
{can('settings') && <NavItem to="/settings/payments" icon={CreditCard} label="Способы оплаты" {...navItemProps} />}
{can('settings') && <NavItem to="/settings/minibar" icon={ShoppingCart} label="Минибар" {...navItemProps} />}
{can('settings') && <NavItem to="/settings/deposit" icon={ShieldCheck} label="Депозит" {...navItemProps} />}
</div>
)}
</>