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:
@@ -1,11 +1,12 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Plus, Pencil, Trash2, Search, Shield, User as UserIcon,
|
||||
Sparkles, Mail, Phone, Eye, EyeOff, CheckCircle2, AlertCircle,
|
||||
Lock, Check, X as XIcon, Wrench,
|
||||
Lock, Check, X as XIcon, Wrench, Save, Loader2,
|
||||
} from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useRolePermissions } from '../contexts/RolePermissionsContext'
|
||||
import type { User } from '../types'
|
||||
import { cn } from '../lib/utils'
|
||||
import { Modal } from '../components/ui/Modal'
|
||||
@@ -73,47 +74,57 @@ const MODULE_GROUPS: { group: string; modules: ModulePermission[] }[] = [
|
||||
{
|
||||
group: 'Фронт-офис',
|
||||
modules: [
|
||||
{ key: 'calendar', label: 'Календарь', description: 'Просмотр и управление сеткой бронирований' },
|
||||
{ key: 'bookings', label: 'Бронирования', description: 'Создание, редактирование, отмена броней' },
|
||||
{ key: 'guests', label: 'Гости', description: 'Карточки гостей, история, лояльность' },
|
||||
{ key: 'rooms', label: 'Номерной фонд', description: 'Номера, категории, тарифы' },
|
||||
{ key: 'availability',label: 'Доступность', description: 'Ограничения и стоп-продажи' },
|
||||
{ key: 'calendar', label: 'Календарь', description: 'Просмотр и управление сеткой бронирований' },
|
||||
{ key: 'bookings', label: 'Бронирования', description: 'Создание, редактирование, отмена броней' },
|
||||
{ key: 'guests', label: 'Гости', description: 'Карточки гостей, история, лояльность' },
|
||||
{ key: 'rooms', label: 'Номерной фонд', description: 'Номера, категории, план этажей' },
|
||||
{ key: 'availability', label: 'Доступность', description: 'Ограничения и стоп-продажи' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Операции',
|
||||
modules: [
|
||||
{ key: 'housekeeping', label: 'Горничные', description: 'Задания на уборку, статусы номеров' },
|
||||
{ key: 'maintenance', label: 'Техобслуживание', description: 'Заявки на ремонт и обслуживание' },
|
||||
{ key: 'room_service', label: 'Сервис в номер', description: 'Заказы еды и услуг в номер' },
|
||||
{ key: 'rental', label: 'Аренда объектов', description: 'Корт, баня, конференц-залы' },
|
||||
{ key: 'housekeeping', label: 'Горничные', description: 'Задания на уборку, статусы номеров' },
|
||||
{ key: 'maintenance', label: 'Техобслуживание', description: 'Заявки на ремонт и обслуживание' },
|
||||
{ key: 'room_service', label: 'Сервис в номер', description: 'Заказы еды и услуг в номер' },
|
||||
{ key: 'rental', label: 'Аренда объектов', description: 'Корт, баня, конференц-залы' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Продажи и финансы',
|
||||
modules: [
|
||||
{ key: 'pos', label: 'Кассовый терминал', description: 'POS, смены, оплаты' },
|
||||
{ key: 'tariffs', label: 'Тарифы', description: 'Тарифы и ценовые планы' },
|
||||
{ key: 'pricing', label: 'Динамические цены', description: 'Правила динамического ценообразования' },
|
||||
{ key: 'discounts', label: 'Скидки', description: 'Промокоды и скидочные кампании' },
|
||||
{ key: 'loyalty', label: 'Лояльность', description: 'Баллы, уровни, программа лояльности' },
|
||||
{ key: 'reports', label: 'Отчёты', description: 'Финансовые и операционные отчёты' },
|
||||
{ key: 'pos', label: 'Кассовый терминал', description: 'POS, смены, оплаты' },
|
||||
{ key: 'tariffs', label: 'Тарифы', description: 'Тарифы и ценовые планы' },
|
||||
{ key: 'pricing', label: 'Динамические цены', description: 'Правила динамического ценообразования' },
|
||||
{ key: 'discounts', label: 'Скидки', description: 'Промокоды и скидочные кампании' },
|
||||
{ key: 'loyalty', label: 'Лояльность', description: 'Баллы, уровни, программа лояльности' },
|
||||
{ key: 'reports', label: 'Отчёты', description: 'Финансовые и операционные отчёты' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Маркетинг и каналы',
|
||||
modules: [
|
||||
{ key: 'channels', label: 'Каналы продаж', description: 'OTA, менеджер каналов' },
|
||||
{ key: 'reviews', label: 'Отзывы', description: 'Управление отзывами гостей' },
|
||||
{ key: 'website', label: 'Сайт и виджет', description: 'Сайт отеля и виджет бронирования' },
|
||||
{ key: 'channels', label: 'Каналы продаж', description: 'OTA, менеджер каналов' },
|
||||
{ key: 'reviews', label: 'Отзывы', description: 'Управление отзывами гостей' },
|
||||
{ key: 'website', label: 'Сайт и виджет', description: 'Сайт отеля и виджет бронирования' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Технологии',
|
||||
modules: [
|
||||
{ key: 'floor_map', label: 'План этажей', description: 'Интерактивная карта этажей отеля' },
|
||||
{ key: 'equipment', label: 'Оборудование', description: 'Управление инвентарём и оборудованием' },
|
||||
{ key: 'wifi', label: 'WiFi авторизация', description: 'Captive portal, настройки WiFi-сети' },
|
||||
{ key: 'ttlock', label: 'Электронные замки', description: 'Управление TTLock-замками и кодами' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Администрирование',
|
||||
modules: [
|
||||
{ key: 'users', label: 'Сотрудники', description: 'Управление пользователями и ролями' },
|
||||
{ key: 'documents', label: 'Документы', description: 'Шаблоны договоров и документов' },
|
||||
{ key: 'settings', label: 'Настройки', description: 'Настройки отеля и системы' },
|
||||
{ key: 'users', label: 'Сотрудники', description: 'Управление пользователями и ролями' },
|
||||
{ key: 'schedule', label: 'График работы', description: 'Расписание смен сотрудников' },
|
||||
{ key: 'documents', label: 'Документы', description: 'Шаблоны договоров и документов' },
|
||||
{ key: 'settings', label: 'Настройки', description: 'Настройки отеля, оплат, системы' },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -145,9 +156,10 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
|
||||
isSystem: true,
|
||||
permissions: {
|
||||
...allPerms(false),
|
||||
calendar: true, bookings: true, guests: true, rooms: true,
|
||||
calendar: true, bookings: true, guests: true, rooms: true, availability: true,
|
||||
housekeeping: true, room_service: true, rental: true,
|
||||
pos: true, reviews: true,
|
||||
pos: true, reviews: true, reports: true,
|
||||
documents: true, website: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -157,7 +169,7 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
|
||||
isSystem: true,
|
||||
permissions: {
|
||||
...allPerms(false),
|
||||
housekeeping: true, rooms: true, maintenance: true,
|
||||
calendar: true, housekeeping: true, rooms: true, maintenance: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -167,7 +179,9 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
|
||||
isSystem: true,
|
||||
permissions: {
|
||||
...allPerms(false),
|
||||
calendar: true,
|
||||
reports: true, pos: true, discounts: true, tariffs: true, pricing: true, loyalty: true,
|
||||
documents: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -187,7 +201,9 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
|
||||
isSystem: true,
|
||||
permissions: {
|
||||
...allPerms(false),
|
||||
calendar: true,
|
||||
housekeeping: true, rooms: true, maintenance: true,
|
||||
floor_map: true, equipment: true, ttlock: true,
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -442,16 +458,63 @@ function UserModal({
|
||||
)
|
||||
}
|
||||
|
||||
// hotel_admin always has all permissions — excluded from the editable list
|
||||
const DEFAULT_EDITABLE = INITIAL_ROLE_PERMISSIONS.filter(r => r.id !== 'rp_hotel_admin')
|
||||
|
||||
// ── Roles & Permissions Tab ────────────────────────────────────────────────────
|
||||
|
||||
function RolesTab() {
|
||||
// hotel_admin always has all permissions — excluded from the editable list
|
||||
const editableRoles = INITIAL_ROLE_PERMISSIONS.filter(r => r.id !== 'rp_hotel_admin')
|
||||
const [roles, setRoles] = useState<RolePermissions[]>(editableRoles)
|
||||
const [selectedRoleId, setSelectedRoleId] = useState<string>(editableRoles[0].id)
|
||||
const { user: currentUser } = useAuth()
|
||||
const { reload: reloadContext } = useRolePermissions()
|
||||
const slug = currentUser?.hotelSlug ?? ''
|
||||
|
||||
const [roles, setRoles] = useState<RolePermissions[]>(DEFAULT_EDITABLE)
|
||||
const [selectedRoleId, setSelectedRoleId] = useState<string>(DEFAULT_EDITABLE[0].id)
|
||||
const [newRoleName, setNewRoleName] = useState('')
|
||||
const [addingRole, setAddingRole] = useState(false)
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveOk, setSaveOk] = useState(false)
|
||||
const [loadError, setLoadError] = useState(false)
|
||||
|
||||
// ── Load from API ──────────────────────────────────────────────────────────
|
||||
const loadRoles = useCallback(async () => {
|
||||
if (!slug) return
|
||||
try {
|
||||
const saved = await api.rolePermissions.list(slug)
|
||||
if (saved.length > 0) {
|
||||
// Merge: start from INITIAL defaults, override with saved data, append custom roles
|
||||
const byKey = Object.fromEntries(saved.map(r => [r.roleKey, r]))
|
||||
|
||||
const merged: RolePermissions[] = DEFAULT_EDITABLE.map(def => {
|
||||
const roleKey = def.id.replace('rp_', '')
|
||||
const s = byKey[roleKey]
|
||||
if (!s) return def
|
||||
// Ensure all module keys are present (new modules default to false)
|
||||
const fullPerms = { ...allPerms(false), ...s.permissions }
|
||||
return { ...def, permissions: fullPerms }
|
||||
})
|
||||
|
||||
// Custom roles (not in system list)
|
||||
const systemKeys = new Set(DEFAULT_EDITABLE.map(d => d.id.replace('rp_', '')))
|
||||
const custom = saved
|
||||
.filter(r => !r.isSystem && !systemKeys.has(r.roleKey))
|
||||
.map(r => ({
|
||||
id: `rp_${r.roleKey}`,
|
||||
name: r.name,
|
||||
color: r.color,
|
||||
isSystem: false,
|
||||
permissions: { ...allPerms(false), ...r.permissions },
|
||||
}))
|
||||
|
||||
setRoles([...merged, ...custom])
|
||||
}
|
||||
} catch {
|
||||
setLoadError(true)
|
||||
}
|
||||
}, [slug])
|
||||
|
||||
useEffect(() => { loadRoles() }, [loadRoles])
|
||||
|
||||
const selectedRole = roles.find(r => r.id === selectedRoleId) ?? roles[0]
|
||||
|
||||
@@ -471,12 +534,38 @@ function RolesTab() {
|
||||
))
|
||||
}
|
||||
|
||||
// ── Save to API ────────────────────────────────────────────────────────────
|
||||
const handleSave = async () => {
|
||||
if (!slug || saving) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await Promise.all(roles.map(role => {
|
||||
const roleKey = role.id.replace('rp_', '')
|
||||
return api.rolePermissions.save(slug, roleKey, {
|
||||
name: role.name,
|
||||
color: role.color,
|
||||
isSystem: role.isSystem,
|
||||
permissions: role.permissions,
|
||||
})
|
||||
}))
|
||||
setSaveOk(true)
|
||||
setTimeout(() => setSaveOk(false), 2500)
|
||||
await reloadContext()
|
||||
} catch (err) {
|
||||
console.error('Failed to save role permissions', err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Create / Delete ────────────────────────────────────────────────────────
|
||||
const createRole = () => {
|
||||
const name = newRoleName.trim()
|
||||
if (!name) return
|
||||
const colors = ['#EC4899', '#14B8A6', '#F59E0B', '#6366F1', '#84CC16']
|
||||
const key = `custom_${Date.now()}`
|
||||
const newRole: RolePermissions = {
|
||||
id: `rp_${Date.now()}`,
|
||||
id: `rp_${key}`,
|
||||
name,
|
||||
color: colors[roles.length % colors.length],
|
||||
isSystem: false,
|
||||
@@ -488,7 +577,11 @@ function RolesTab() {
|
||||
setAddingRole(false)
|
||||
}
|
||||
|
||||
const deleteRole = (id: string) => {
|
||||
const deleteRole = async (id: string) => {
|
||||
const roleKey = id.replace('rp_', '')
|
||||
if (slug) {
|
||||
try { await api.rolePermissions.delete(slug, roleKey) } catch { /* may not exist yet */ }
|
||||
}
|
||||
setRoles(prev => prev.filter(r => r.id !== id))
|
||||
setDeleteId(null)
|
||||
if (selectedRoleId === id) {
|
||||
@@ -500,6 +593,37 @@ function RolesTab() {
|
||||
const totalCount = ALL_MODULE_KEYS.length
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Save bar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Настройте доступ к разделам для каждой роли
|
||||
</p>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all',
|
||||
saveOk
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'btn-primary',
|
||||
)}
|
||||
>
|
||||
{saving
|
||||
? <Loader2 size={14} className="animate-spin" />
|
||||
: saveOk
|
||||
? <Check size={14} />
|
||||
: <Save size={14} />}
|
||||
{saving ? 'Сохранение…' : saveOk ? 'Сохранено!' : 'Сохранить изменения'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadError && (
|
||||
<div className="p-3 rounded-xl bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 text-xs text-amber-700 dark:text-amber-300">
|
||||
Не удалось загрузить настройки из базы данных. Отображаются значения по умолчанию.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-5 items-start">
|
||||
{/* Role list */}
|
||||
<div className="w-full sm:w-52 shrink-0">
|
||||
@@ -676,6 +800,8 @@ function RolesTab() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Delete role confirm */}
|
||||
{deleteId && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
|
||||
Reference in New Issue
Block a user