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, 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' // ── Types ────────────────────────────────────────────────────────────────────── type StaffRole = 'hotel_admin' | 'hotel_manager' | 'housekeeper' | 'receptionist' | 'accountant' | 'security' | 'technician' interface StaffUser { id: string firstName: string lastName: string email: string phone?: string role: StaffRole position: string isActive: boolean createdAt: string lastLogin?: string avatarColor: string } interface ModulePermission { key: string label: string description?: string } interface RolePermissions { id: string name: string color: string isSystem: boolean permissions: Record homePage?: string | null notificationSettings: Record } // ── Constants ────────────────────────────────────────────────────────────────── const ROLE_META: Record = { hotel_admin: { label: 'Сис. администратор', color: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300', icon: Shield }, hotel_manager: { label: 'Менеджер', color: 'bg-brand-100 text-brand-700 dark:bg-brand-900/30 dark:text-brand-300', icon: Shield }, receptionist: { label: 'Ресепшн', color: 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300', icon: UserIcon }, housekeeper: { label: 'Горничная', color: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300', icon: Sparkles }, accountant: { label: 'Бухгалтер', color: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300', icon: UserIcon }, security: { label: 'Охрана', color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300', icon: Shield }, technician: { label: 'Тех. специалист', color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300', icon: Wrench }, } const AVATAR_COLORS = [ '#4F46E5', '#059669', '#2563EB', '#7C3AED', '#DC2626', '#D97706', '#DB2777', '#0891B2', ] const DEFAULT_POSITIONS: Record = { hotel_admin: ['Генеральный директор', 'Владелец отеля', 'Управляющий директор'], hotel_manager: ['Управляющий', 'Заместитель управляющего', 'Менеджер смены'], receptionist: ['Старший администратор', 'Администратор', 'Ночной администратор'], housekeeper: ['Старшая горничная', 'Горничная', 'Уборщик'], accountant: ['Главный бухгалтер', 'Бухгалтер', 'Финансовый менеджер'], security: ['Начальник охраны', 'Охранник', 'Контролёр доступа'], technician: ['Технический специалист', 'Электрик', 'Сантехник', 'Инженер по оборудованию'], } 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: 'Ограничения и стоп-продажи' }, ], }, { group: 'Операции', modules: [ { 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: 'Финансовые и операционные отчёты' }, ], }, { group: 'Маркетинг и каналы', modules: [ { 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: 'schedule', label: 'График работы', description: 'Расписание смен сотрудников' }, { key: 'documents', label: 'Документы', description: 'Шаблоны договоров и документов' }, { key: 'settings', label: 'Настройки', description: 'Настройки отеля, оплат, системы' }, ], }, ] const ALL_MODULE_KEYS = MODULE_GROUPS.flatMap(g => g.modules.map(m => m.key)) const allPerms = (v: boolean) => Object.fromEntries(ALL_MODULE_KEYS.map(k => [k, v])) const NOTIFICATION_GROUPS: { group: string; items: { key: string; label: string; description: string }[] }[] = [ { group: 'Операции', items: [ { key: 'room_service_order', label: 'Новый заказ Room Service', description: 'Уведомление о новом заказе в номер' }, { key: 'new_booking', label: 'Новая бронь через сайт', description: 'Бронирование через онлайн-виджет' }, ], }, { group: 'Общение', items: [ { key: 'unread_messages', label: 'Непрочитанные сообщения', description: 'Чат без ответа более 15 минут' }, ], }, { group: 'Отзывы', items: [ { key: 'new_review', label: 'Новый отзыв гостя', description: 'Прямой отзыв, QR или email-ссылка' }, ], }, ] // Permission key → route path (for home page selector) const PERM_TO_ROUTE: Record = { calendar: '/calendar', bookings: '/bookings', guests: '/guests', rooms: '/rooms', availability: '/availability', housekeeping: '/housekeeping', maintenance: '/maintenance', room_service: '/room-service', rental: '/rental', pos: '/pos', tariffs: '/tariffs', pricing: '/dynamic-pricing', discounts: '/discounts', loyalty: '/loyalty', reports: '/reports', channels: '/channels', reviews: '/reviews', website: '/website', floor_map: '/floor-map', equipment: '/equipment', wifi: '/wifi', ttlock: '/ttlock', users: '/users', schedule: '/schedule', documents: '/documents', settings: '/settings', } const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [ { id: 'rp_hotel_admin', name: 'Сис. администратор', color: '#7C3AED', isSystem: true, permissions: allPerms(true), notificationSettings: {}, }, { id: 'rp_manager', name: 'Менеджер', color: '#4F46E5', isSystem: true, permissions: allPerms(true), notificationSettings: {}, }, { id: 'rp_receptionist', name: 'Ресепшн', color: '#0EA5E9', isSystem: true, permissions: { ...allPerms(false), calendar: true, bookings: true, guests: true, rooms: true, availability: true, housekeeping: true, room_service: true, rental: true, pos: true, reviews: true, reports: true, documents: true, website: true, }, notificationSettings: {}, }, { id: 'rp_housekeeper', name: 'Горничная', color: '#10B981', isSystem: true, permissions: { ...allPerms(false), calendar: true, housekeeping: true, rooms: true, maintenance: true, }, notificationSettings: {}, }, { id: 'rp_accountant', name: 'Бухгалтер', color: '#7C3AED', isSystem: true, permissions: { ...allPerms(false), calendar: true, reports: true, pos: true, discounts: true, tariffs: true, pricing: true, loyalty: true, documents: true, }, notificationSettings: {}, }, { id: 'rp_security', name: 'Охрана', color: '#F97316', isSystem: true, permissions: { ...allPerms(false), calendar: true, bookings: true, }, notificationSettings: {}, }, { id: 'rp_technician', name: 'Тех. специалист', color: '#D97706', isSystem: true, permissions: { ...allPerms(false), calendar: true, housekeeping: true, rooms: true, maintenance: true, floor_map: true, equipment: true, ttlock: true, }, notificationSettings: {}, }, ] // ── Helpers ──────────────────────────────────────────────────────────────────── function mapRole(r: string): StaffRole { if (r === 'housekeeper') return 'housekeeper' if (r === 'receptionist') return 'receptionist' if (r === 'accountant') return 'accountant' if (r === 'security') return 'security' if (r === 'technician') return 'technician' if (r === 'hotel_admin') return 'hotel_admin' if (r.startsWith('custom_')) return r as StaffRole return 'hotel_manager' } function toStaffUser(u: User): StaffUser { const parts = u.name.trim().split(' ') const firstName = parts[0] ?? '' const lastName = parts.slice(1).join(' ') const role = mapRole(u.role) const colorIndex = Math.abs(u.id.charCodeAt(0) + u.id.charCodeAt(1)) % AVATAR_COLORS.length return { id: u.id, firstName, lastName, email: u.email, phone: u.phone ?? '', role, position: u.position ?? DEFAULT_POSITIONS[role]?.[0] ?? '', isActive: u.active ?? true, createdAt: u.createdAt?.slice(0, 10) ?? '', avatarColor: AVATAR_COLORS[colorIndex], } } // ── User Modal ───────────────────────────────────────────────────────────────── function UserModal({ open, user, onClose, onSave, }: { open: boolean user?: StaffUser onClose: () => void onSave: (u: StaffUser, password?: string) => void }) { const { savedPermissions } = useRolePermissions() const customRoles = savedPermissions.filter(r => !r.isSystem) const [form, setForm] = useState({ firstName: user?.firstName ?? '', lastName: user?.lastName ?? '', email: user?.email ?? '', phone: user?.phone ?? '', role: (user?.role ?? 'receptionist') as StaffRole, position: user?.position ?? '', isActive: user?.isActive ?? true, avatarColor: user?.avatarColor ?? AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)], }) const [password, setPassword] = useState('') const [showPass, setShowPass] = useState(false) const [errors, setErrors] = useState>({}) const isNew = !user const set = (k: K, v: typeof form[K]) => setForm(prev => ({ ...prev, [k]: v })) const validate = () => { const e: Record = {} if (!form.firstName.trim()) e.firstName = 'Введите имя' if (!form.lastName.trim()) e.lastName = 'Введите фамилию' if (!form.email.trim()) e.email = 'Введите email' if (!form.position.trim()) e.position = 'Введите должность' if (isNew && password.length > 0 && password.length < 6) e.password = 'Минимум 6 символов' setErrors(e) return Object.keys(e).length === 0 } const handleSave = () => { if (!validate()) return onSave({ ...form, id: user?.id ?? '', createdAt: user?.createdAt ?? new Date().toISOString().slice(0, 10), lastLogin: user?.lastLogin, }, password || undefined) } const suggestions = DEFAULT_POSITIONS[form.role as keyof typeof DEFAULT_POSITIONS] ?? [] return ( } >
{/* Avatar color + name */}
{(form.firstName[0] ?? '?').toUpperCase()}
{AVATAR_COLORS.map(c => (
set('firstName', e.target.value)} /> {errors.firstName &&

{errors.firstName}

}
set('lastName', e.target.value)} /> {errors.lastName &&

{errors.lastName}

}
set('email', e.target.value)} /> {errors.email &&

{errors.email}

}
set('phone', e.target.value)} />
{/* Role */}
{form.role === 'hotel_admin' ? (
Сис. администратор — роль нельзя изменить
) : (
{(Object.entries(ROLE_META) as [StaffRole, typeof ROLE_META[StaffRole]][]) .filter(([id]) => id !== 'hotel_admin') .map(([id, meta]) => ( ))}
{customRoles.length > 0 && (
{customRoles.map(r => ( ))}
)}
)}
{/* Position */}
set('position', e.target.value)} /> {errors.position &&

{errors.position}

}
{suggestions.map(s => ( ))}
{/* Password */}
{isNew && (

Оставьте пустым — сотрудник получит приглашение на email и сам задаст пароль

)}
setPassword(e.target.value)} />
{errors.password &&

{errors.password}

}
{/* Active toggle */}

Активный сотрудник

Неактивные не могут войти в систему

) } // 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() { 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, homePage: s.homePage ?? def.homePage, notificationSettings: s.notificationSettings ?? {} } }) // 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 }, homePage: r.homePage ?? null, notificationSettings: r.notificationSettings ?? {}, })) setRoles([...merged, ...custom]) } } catch { setLoadError(true) } }, [slug]) useEffect(() => { loadRoles() }, [loadRoles]) const selectedRole = roles.find(r => r.id === selectedRoleId) ?? roles[0] const togglePerm = (key: string) => { setRoles(prev => prev.map(r => r.id === selectedRoleId ? { ...r, permissions: { ...r.permissions, [key]: !r.permissions[key] } } : r )) } const setAllPerms = (v: boolean) => { setRoles(prev => prev.map(r => r.id === selectedRoleId ? { ...r, permissions: allPerms(v) } : r )) } const setHomePage = (page: string | null) => { setRoles(prev => prev.map(r => r.id === selectedRoleId ? { ...r, homePage: page } : r )) } const toggleNotif = (key: string) => { setRoles(prev => prev.map(r => { if (r.id !== selectedRoleId) return r const current = r.notificationSettings[key] ?? true return { ...r, notificationSettings: { ...r.notificationSettings, [key]: !current } } })) } // ── 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, homePage: role.homePage ?? null, notificationSettings: role.notificationSettings, }) })) 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 = async () => { const name = newRoleName.trim() if (!name) return const colors = ['#EC4899', '#14B8A6', '#F59E0B', '#6366F1', '#84CC16'] const key = `custom_${Date.now()}` const newRole: RolePermissions = { id: `rp_${key}`, name, color: colors[roles.length % colors.length], isSystem: false, permissions: allPerms(false), notificationSettings: {}, } setRoles(prev => [...prev, newRole]) setSelectedRoleId(newRole.id) setNewRoleName('') setAddingRole(false) // Persist immediately so the role survives a page refresh if (slug) { try { await api.rolePermissions.save(slug, key, { name: newRole.name, color: newRole.color, isSystem: false, permissions: newRole.permissions, homePage: null, notificationSettings: {}, }) await reloadContext() } catch (err) { console.error('Failed to save new role', err) } } } 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) { setSelectedRoleId(roles.find(r => r.id !== id)?.id ?? '') } } const activeCount = Object.values(selectedRole?.permissions ?? {}).filter(Boolean).length const totalCount = ALL_MODULE_KEYS.length return (
{/* Save bar */}

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

{loadError && (
Не удалось загрузить настройки из базы данных. Отображаются значения по умолчанию.
)}
{/* Role list */}

Роли

{roles.map(role => (
setSelectedRoleId(role.id)} className={cn( 'flex items-center justify-between px-3 py-2.5 cursor-pointer transition-colors', selectedRoleId === role.id ? 'bg-brand-50 dark:bg-brand-900/20' : 'hover:bg-slate-50 dark:hover:bg-slate-700/30', )} >
{role.name}
{role.isSystem && ( )} {!role.isSystem && ( )}
))}
{/* Add role */}
{addingRole ? (
setNewRoleName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') createRole(); if (e.key === 'Escape') setAddingRole(false) }} />
) : ( )}
{/* Permissions editor */} {selectedRole && (
{/* Header */}

{selectedRole.name}

{activeCount} из {totalCount} разделов
{/* Module groups */}
{MODULE_GROUPS.map(group => (

{group.group}

{group.modules.map(mod => { const enabled = selectedRole.permissions[mod.key] ?? false return ( ) })}
))}
{/* Home page selector */}

Стартовая страница

Страница, которая открывается при входе в систему

{/* Notification settings */}

Настройки уведомлений

Какие push и in-app уведомления получают сотрудники с этой ролью

{NOTIFICATION_GROUPS.map(group => (

{group.group}

{group.items.map(item => { const enabled = selectedRole.notificationSettings[item.key] ?? true return ( ) })}
))}
{selectedRole.isSystem && (

Системная роль — изменения применяются немедленно для всех сотрудников с этой ролью

)}
)}
{/* Delete role confirm */} {deleteId && (

Удалить роль?

Сотрудники с этой ролью потеряют связанные права доступа. Действие нельзя отменить.

)}
) } // ── Page ─────────────────────────────────────────────────────────────────────── const PAGE_TABS = [ { id: 'staff', label: 'Сотрудники' }, { id: 'roles', label: 'Роли и права' }, ] as const type PageTab = typeof PAGE_TABS[number]['id'] export function UsersPage() { const { user: currentUser } = useAuth() const { savedPermissions } = useRolePermissions() const slug = currentUser?.hotelSlug ?? '' const [tab, setTab] = useState('staff') const [users, setUsers] = useState([]) const [search, setSearch] = useState('') const [roleFilter, setRoleFilter] = useState('all') const [modalOpen, setModalOpen] = useState(false) const [editing, setEditing] = useState() const [deleteId, setDeleteId] = useState(null) useEffect(() => { if (!slug) return api.users.list(slug) .then(list => setUsers(list.map(toStaffUser))) .catch(console.error) }, [slug]) const filtered = users.filter(u => { const matchSearch = !search || `${u.firstName} ${u.lastName}`.toLowerCase().includes(search.toLowerCase()) || u.email.toLowerCase().includes(search.toLowerCase()) || u.position.toLowerCase().includes(search.toLowerCase()) const matchRole = roleFilter === 'all' || u.role === roleFilter return matchSearch && matchRole }) const openCreate = () => { setEditing(undefined); setModalOpen(true) } const openEdit = (u: StaffUser) => { setEditing(u); setModalOpen(true) } const handleSave = async (u: StaffUser, password?: string) => { const fullName = `${u.firstName} ${u.lastName}`.trim() const backendRoleMap: Record = { hotel_admin: 'hotel_admin', hotel_manager: 'manager', receptionist: 'receptionist', housekeeper: 'housekeeper', accountant: 'accountant', security: 'security', technician: 'technician', } const backendRole = backendRoleMap[u.role] ?? u.role try { if (!u.id) { const created = await api.users.create(slug, { email: u.email, name: fullName, password: password ?? '', role: backendRole, phone: u.phone || undefined, position: u.position || undefined, }) setUsers(prev => [...prev, toStaffUser(created)]) } else { const upd: Partial<{ name: string; email: string; password: string; role: string; phone: string; position: string; active: boolean }> = { name: fullName, email: u.email, role: backendRole, phone: u.phone || undefined, position: u.position || undefined, active: u.isActive, } if (password) upd.password = password const updated = await api.users.update(slug, u.id, upd) setUsers(prev => prev.map(x => x.id === u.id ? { ...toStaffUser(updated), lastLogin: x.lastLogin } : x)) } setModalOpen(false) } catch (err) { console.error('Failed to save user', err) } } const handleDelete = async (id: string) => { try { await api.users.delete(slug, id) setUsers(prev => prev.filter(u => u.id !== id)) setDeleteId(null) } catch (err) { console.error('Failed to delete user', err) } } const totalUsers = users.length const activeUsers = users.filter(u => u.isActive).length return (
{/* Header */}

Сотрудники

{totalUsers} пользователей · {activeUsers} активных

{tab === 'staff' && ( )}
{/* Tabs */}
{PAGE_TABS.map(t => ( ))}
{tab === 'staff' && ( <> {/* Filters */}
setSearch(e.target.value)} />
{(Object.entries(ROLE_META) as [StaffRole, typeof ROLE_META[StaffRole]][]).map(([role, meta]) => ( ))}
{/* Table */}
{filtered.map(u => { const meta = ROLE_META[u.role] return ( ) })}
Сотрудник Роль / Должность Контакты Последний вход Статус
{u.firstName[0]?.toUpperCase()}

{u.lastName} {u.firstName}

{u.email}

{meta ? meta.label : (savedPermissions.find(r => r.roleKey === u.role)?.name ?? u.role)}

{u.position}

{u.email}
{u.phone && (
{u.phone}
)}

{u.lastLogin ? new Date(u.lastLogin).toLocaleDateString('ru-RU') : '—'}

{u.isActive ? <> Активен : <> Неактивен}
{u.role !== 'hotel_admin' && ( )}
{filtered.length === 0 && (

Сотрудники не найдены

)}
{/* User modal */} setModalOpen(false)} onSave={handleSave} /> {/* Delete confirm */} {deleteId && (

Удалить сотрудника?

Сотрудник потеряет доступ к системе. Историю его действий можно будет просмотреть в журнале.

)} )} {tab === 'roles' && }
) }