From 512e3c66594ff207d9a35c98079f708b8064691b Mon Sep 17 00:00:00 2001 From: HotelSync Date: Fri, 17 Apr 2026 12:16:15 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20full=20role=20permissions=20system=20?= =?UTF-8?q?=E2=80=94=20new=20modules,=20API=20persistence,=20sidebar=20con?= =?UTF-8?q?text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/migrations/080_role_permissions.sql | 13 ++ backend/src/app.ts | 2 + backend/src/routes/role-permissions.ts | 110 ++++++++++++ src/App.tsx | 3 + src/components/layout/Sidebar.tsx | 54 +++--- src/contexts/RolePermissionsContext.tsx | 104 +++++++++++ src/lib/api.ts | 16 ++ src/pages/UsersPage.tsx | 190 ++++++++++++++++---- 8 files changed, 431 insertions(+), 61 deletions(-) create mode 100644 backend/migrations/080_role_permissions.sql create mode 100644 backend/src/routes/role-permissions.ts create mode 100644 src/contexts/RolePermissionsContext.tsx diff --git a/backend/migrations/080_role_permissions.sql b/backend/migrations/080_role_permissions.sql new file mode 100644 index 0000000..9673560 --- /dev/null +++ b/backend/migrations/080_role_permissions.sql @@ -0,0 +1,13 @@ +-- Custom role permissions per hotel +CREATE TABLE IF NOT EXISTS role_permissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE, + role_key VARCHAR(50) NOT NULL, + name VARCHAR(100) NOT NULL, + color VARCHAR(20) NOT NULL DEFAULT '#6B7280', + is_system BOOLEAN NOT NULL DEFAULT false, + permissions JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(hotel_id, role_key) +); diff --git a/backend/src/app.ts b/backend/src/app.ts index e5d3738..b195d2c 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -47,6 +47,7 @@ import paymentMethodsRoutes from './routes/paymentMethods' import paymentGatewaysRoutes from './routes/paymentGateways' import publicWidgetRoutes from './routes/publicWidget' import yookassaWebhookRoutes from './routes/yookassaWebhook' +import rolePermissionsRoutes from './routes/role-permissions' import { setupAgentWsRoute } from './agent-ws' import { startJobs } from './jobs' import { initWebPush } from './push' @@ -147,6 +148,7 @@ export async function buildApp() { await fastify.register(paymentGatewaysRoutes) await fastify.register(publicWidgetRoutes) await fastify.register(yookassaWebhookRoutes) + await fastify.register(rolePermissionsRoutes) await fastify.register(setupAgentWsRoute) startJobs() diff --git a/backend/src/routes/role-permissions.ts b/backend/src/routes/role-permissions.ts new file mode 100644 index 0000000..45b8839 --- /dev/null +++ b/backend/src/routes/role-permissions.ts @@ -0,0 +1,110 @@ +import { FastifyPluginAsync } from 'fastify' +import { db } from '../db' + +type SlugParam = { Params: { slug: string } } +type SlugRoleParam = { Params: { slug: string; roleKey: string } } + +const rolePermissionsRoute: FastifyPluginAsync = async (fastify) => { + const getHotelId = async (slug: string): Promise => { + const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug]) + return rows[0]?.id ?? null + } + + const canAccess = (userSlug: string | null, role: string, slug: string) => + role === 'super_admin' || userSlug === slug + + const canManage = (role: string) => + ['manager', 'hotel_admin', 'super_admin'].includes(role) + + // ── GET /api/hotels/:slug/role-permissions ───────────────────────────────── + fastify.get( + '/api/hotels/:slug/role-permissions', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const { rows } = await db.query( + `SELECT id, hotel_id, role_key, name, color, is_system, permissions, created_at, updated_at + FROM role_permissions + WHERE hotel_id = $1 + ORDER BY is_system DESC, name`, + [hotelId], + ) + return rows + }, + ) + + // ── PUT /api/hotels/:slug/role-permissions/:roleKey ──────────────────────── + fastify.put } + }>( + '/api/hotels/:slug/role-permissions/:roleKey', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + if (!canManage(request.user.role)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const { slug, roleKey } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const { name, color, isSystem = false, permissions } = request.body + + const { rows } = await db.query( + `INSERT INTO role_permissions (hotel_id, role_key, name, color, is_system, permissions) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (hotel_id, role_key) DO UPDATE + SET name = EXCLUDED.name, + color = EXCLUDED.color, + permissions = EXCLUDED.permissions, + updated_at = NOW() + RETURNING *`, + [hotelId, roleKey, name, color, isSystem, JSON.stringify(permissions)], + ) + return rows[0] + }, + ) + + // ── DELETE /api/hotels/:slug/role-permissions/:roleKey ───────────────────── + fastify.delete( + '/api/hotels/:slug/role-permissions/:roleKey', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + if (!canManage(request.user.role)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const { slug, roleKey } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + // System roles cannot be deleted + const { rows: existing } = await db.query( + 'SELECT is_system FROM role_permissions WHERE hotel_id = $1 AND role_key = $2', + [hotelId, roleKey], + ) + if (existing[0]?.is_system) { + return reply.code(403).send({ error: 'Нельзя удалить системную роль' }) + } + + const { rowCount } = await db.query( + 'DELETE FROM role_permissions WHERE hotel_id = $1 AND role_key = $2', + [hotelId, roleKey], + ) + if (!rowCount) return reply.code(404).send({ error: 'Role not found' }) + return reply.code(204).send() + }, + ) +} + +export default rolePermissionsRoute diff --git a/src/App.tsx b/src/App.tsx index 0d4abfd..9fd78a1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -54,12 +54,14 @@ import { PayDepositPage } from './pages/PayDepositPage' import { BookingWidgetStandalonePage } from './pages/BookingWidgetStandalonePage' import { BookingConfirmPage } from './pages/BookingConfirmPage' import { ModuleGuard } from './components/ModuleGuard' +import { RolePermissionsProvider } from './contexts/RolePermissionsContext' export default function App() { return ( + @@ -125,6 +127,7 @@ export default function App() { + diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index ae95281..bb51e06 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -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 = { - 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 = { + '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') && } )} - {activeModuleItems.filter(m => can(m.id)).map(m => ( + {activeModuleItems.filter(m => can(modulePermKey(m.id))).map(m => ( toggleGroup('management')} /> {groups.management && (
{can('users') && } {can('users') && } + {can('documents') && } {can('loyalty') && } {can('maintenance') && } - {can('rooms') && } + {can('floor_map') && } {can('channels') && isModuleActive('channel-manager') && ( toggleGroup('settingsGroup')} /> {groups.settingsGroup && (
- - - - - - - - - - + {can('settings') && } + {can('settings') && } + {can('settings') && } + {can('equipment') && } + {can('wifi') && } + {can('ttlock') && } + {can('settings') && } + {can('settings') && } + {can('settings') && } + {can('settings') && }
)} diff --git a/src/contexts/RolePermissionsContext.tsx b/src/contexts/RolePermissionsContext.tsx new file mode 100644 index 0000000..87e23a5 --- /dev/null +++ b/src/contexts/RolePermissionsContext.tsx @@ -0,0 +1,104 @@ +import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react' +import { useAuth } from './AuthContext' +import { api } from '../lib/api' + +// ── Default permissions for system roles (fallback if no DB data) ───────────── + +export const DEFAULT_ROLE_PERMS: Record = { + hotel_admin: ['*'], + manager: ['*'], + receptionist: [ + 'calendar', 'bookings', 'guests', 'rooms', 'availability', + 'housekeeping', 'room_service', 'rental', + 'pos', 'reviews', 'reports', + 'documents', 'website', + ], + housekeeper: ['calendar', 'housekeeping', 'maintenance', 'rooms'], + accountant: ['calendar', 'reports', 'pos', 'discounts', 'tariffs', 'pricing', 'loyalty', 'documents'], + security: ['calendar', 'bookings'], + technician: ['calendar', 'housekeeping', 'maintenance', 'rooms', 'floor_map', 'equipment', 'ttlock'], +} + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface SavedRolePermission { + id: string + hotelId: string + roleKey: string + name: string + color: string + isSystem: boolean + permissions: Record +} + +interface RolePermissionsContextValue { + /** Check if the current user has a given permission */ + can: (permission: string) => boolean + /** Raw saved permissions for all roles (used by RolesTab editor) */ + savedPermissions: SavedRolePermission[] + /** Re-fetch from API */ + reload: () => Promise + loading: boolean +} + +// ── Context ─────────────────────────────────────────────────────────────────── + +const RolePermissionsContext = createContext({ + can: () => false, + savedPermissions: [], + reload: async () => {}, + loading: false, +}) + +export function RolePermissionsProvider({ children }: { children: ReactNode }) { + const { user } = useAuth() + const [savedPermissions, setSavedPermissions] = useState([]) + const [loading, setLoading] = useState(false) + + const reload = useCallback(async () => { + if (!user?.hotelSlug) return + setLoading(true) + try { + const data = await api.rolePermissions.list(user.hotelSlug) + setSavedPermissions(data) + } catch { + // silently fall back to defaults + } finally { + setLoading(false) + } + }, [user?.hotelSlug]) + + useEffect(() => { + reload() + }, [reload]) + + const can = useCallback((permission: string): boolean => { + const role = user?.role ?? 'housekeeper' + + // super_admin: full access everywhere + if (role === 'super_admin') return true + + // hotel_admin and manager: full access to all hotel modules + if (role === 'hotel_admin' || role === 'manager') return true + + // Check saved permissions from DB for this role + const saved = savedPermissions.find(rp => rp.roleKey === role) + if (saved) { + return saved.permissions[permission] === true + } + + // Fall back to hardcoded defaults + const perms = DEFAULT_ROLE_PERMS[role] ?? [] + return perms.includes('*') || perms.includes(permission) + }, [user?.role, savedPermissions]) + + return ( + + {children} + + ) +} + +export function useRolePermissions() { + return useContext(RolePermissionsContext) +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 3aa4741..9930f74 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -302,6 +302,22 @@ export const api = { req('DELETE', `/api/hotels/${slug}/users/${id}`), }, + // ── Role Permissions ────────────────────────────────────────────────────── + rolePermissions: { + list: (slug: string) => + req( + 'GET', `/api/hotels/${slug}/role-permissions`), + + save: (slug: string, roleKey: string, data: { + name: string; color: string; isSystem: boolean; permissions: Record + }) => + req( + 'PUT', `/api/hotels/${slug}/role-permissions/${roleKey}`, data), + + delete: (slug: string, roleKey: string) => + req('DELETE', `/api/hotels/${slug}/role-permissions/${roleKey}`), + }, + // ── Schedule ────────────────────────────────────────────────────────────── schedule: { list: (slug: string, from: string, to: string) => diff --git a/src/pages/UsersPage.tsx b/src/pages/UsersPage.tsx index d68a961..6bf75b5 100644 --- a/src/pages/UsersPage.tsx +++ b/src/pages/UsersPage.tsx @@ -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(editableRoles) - const [selectedRoleId, setSelectedRoleId] = useState(editableRoles[0].id) + const { user: currentUser } = useAuth() + const { reload: reloadContext } = useRolePermissions() + const slug = currentUser?.hotelSlug ?? '' + + const [roles, setRoles] = useState(DEFAULT_EDITABLE) + const [selectedRoleId, setSelectedRoleId] = useState(DEFAULT_EDITABLE[0].id) const [newRoleName, setNewRoleName] = useState('') const [addingRole, setAddingRole] = useState(false) const [deleteId, setDeleteId] = useState(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 ( +
+ {/* Save bar */} +
+

+ Настройте доступ к разделам для каждой роли +

+ +
+ + {loadError && ( +
+ Не удалось загрузить настройки из базы данных. Отображаются значения по умолчанию. +
+ )} +
{/* Role list */}
@@ -676,6 +800,8 @@ function RolesTab() {
)} +
+ {/* Delete role confirm */} {deleteId && (