Files
hotelsync/src/pages/UsersPage.tsx

1212 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, boolean>
homePage?: string | null
}
// ── Constants ──────────────────────────────────────────────────────────────────
const ROLE_META: Record<StaffRole, { label: string; color: string; icon: React.ElementType }> = {
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<StaffRole, string[]> = {
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]))
// Permission key → route path (for home page selector)
const PERM_TO_ROUTE: Record<string, string> = {
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),
},
{
id: 'rp_manager',
name: 'Менеджер',
color: '#4F46E5',
isSystem: true,
permissions: allPerms(true),
},
{
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,
},
},
{
id: 'rp_housekeeper',
name: 'Горничная',
color: '#10B981',
isSystem: true,
permissions: {
...allPerms(false),
calendar: true, housekeeping: true, rooms: true, maintenance: true,
},
},
{
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,
},
},
{
id: 'rp_security',
name: 'Охрана',
color: '#F97316',
isSystem: true,
permissions: {
...allPerms(false),
calendar: true, bookings: true,
},
},
{
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,
},
},
]
// ── 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'
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 [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<Record<string, string>>({})
const isNew = !user
const set = <K extends keyof typeof form>(k: K, v: typeof form[K]) =>
setForm(prev => ({ ...prev, [k]: v }))
const validate = () => {
const e: Record<string, string> = {}
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 < 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]
return (
<Modal
open={open}
onClose={onClose}
title={isNew ? 'Новый сотрудник' : 'Редактировать сотрудника'}
size="lg"
footer={
<>
<button onClick={onClose} className="btn-secondary">Отмена</button>
<button onClick={handleSave} className="btn-primary">
{isNew ? 'Создать' : 'Сохранить'}
</button>
</>
}
>
<div className="space-y-4">
{/* Avatar color + name */}
<div className="flex items-center gap-4">
<div
className="w-14 h-14 rounded-2xl flex items-center justify-center text-white text-xl font-bold shrink-0"
style={{ backgroundColor: form.avatarColor }}
>
{(form.firstName[0] ?? '?').toUpperCase()}
</div>
<div className="flex gap-1.5 flex-wrap">
{AVATAR_COLORS.map(c => (
<button
key={c}
type="button"
onClick={() => set('avatarColor', c)}
className={cn(
'w-6 h-6 rounded-lg transition-all',
form.avatarColor === c && 'ring-2 ring-offset-1 ring-slate-400',
)}
style={{ backgroundColor: c }}
/>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Имя *</label>
<input type="text" className={cn('input', errors.firstName && 'border-red-400')}
placeholder="Иван" value={form.firstName} onChange={e => set('firstName', e.target.value)} />
{errors.firstName && <p className="mt-1 text-xs text-red-500">{errors.firstName}</p>}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Фамилия *</label>
<input type="text" className={cn('input', errors.lastName && 'border-red-400')}
placeholder="Иванов" value={form.lastName} onChange={e => set('lastName', e.target.value)} />
{errors.lastName && <p className="mt-1 text-xs text-red-500">{errors.lastName}</p>}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Email *</label>
<input type="email" className={cn('input', errors.email && 'border-red-400')}
placeholder="staff@hotel.ru" value={form.email} onChange={e => set('email', e.target.value)} />
{errors.email && <p className="mt-1 text-xs text-red-500">{errors.email}</p>}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Телефон</label>
<input type="tel" className="input"
placeholder="+7 (999) 000-00-00" value={form.phone} onChange={e => set('phone', e.target.value)} />
</div>
</div>
{/* Role */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Роль в системе *</label>
{form.role === 'hotel_admin' ? (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800">
<Lock size={12} className="text-purple-500 dark:text-purple-400 shrink-0" />
<span className="text-xs font-medium text-purple-700 dark:text-purple-300">Сис. администратор роль нельзя изменить</span>
</div>
) : (
<div className="grid grid-cols-3 gap-2">
{(Object.entries(ROLE_META) as [StaffRole, typeof ROLE_META[StaffRole]][])
.filter(([id]) => id !== 'hotel_admin')
.map(([id, meta]) => (
<button
key={id}
type="button"
onClick={() => { set('role', id); set('position', '') }}
className={cn(
'flex items-center gap-1.5 px-2.5 py-2 rounded-lg text-xs font-medium border text-left transition-colors',
form.role === id
? 'bg-brand-600 border-brand-600 text-white'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-brand-400',
)}
>
<meta.icon size={12} />
{meta.label}
</button>
))}
</div>
)}
</div>
{/* Position */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Должность *</label>
<input type="text" className={cn('input mb-1.5', errors.position && 'border-red-400')}
placeholder="Например: Старший администратор" value={form.position}
onChange={e => set('position', e.target.value)} />
{errors.position && <p className="mt-1 text-xs text-red-500">{errors.position}</p>}
<div className="flex flex-wrap gap-1.5 mt-1">
{suggestions.map(s => (
<button
key={s}
type="button"
onClick={() => set('position', s)}
className={cn(
'text-xs px-2 py-0.5 rounded-full border transition-colors',
form.position === s
? 'bg-brand-600 border-brand-600 text-white'
: 'border-slate-200 dark:border-slate-600 text-slate-500 dark:text-slate-400 hover:border-brand-400 hover:text-brand-600',
)}
>
{s}
</button>
))}
</div>
</div>
{/* Password */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
{isNew ? 'Пароль *' : 'Новый пароль (оставьте пустым, чтобы не менять)'}
</label>
<div className="relative">
<input
type={showPass ? 'text' : 'password'}
className={cn('input pr-10', errors.password && 'border-red-400')}
placeholder="••••••••"
value={password}
onChange={e => setPassword(e.target.value)}
/>
<button type="button" onClick={() => setShowPass(v => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600">
{showPass ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
{errors.password && <p className="mt-1 text-xs text-red-500">{errors.password}</p>}
</div>
{/* Active toggle */}
<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>
<button
type="button"
onClick={() => set('isActive', !form.isActive)}
className={cn('relative w-11 h-6 rounded-full transition-colors', form.isActive ? 'bg-brand-600' : 'bg-slate-200 dark:bg-slate-600')}
>
<div className={cn('absolute top-0.5 w-5 h-5 rounded-full bg-white shadow-sm transition-transform', form.isActive ? 'left-[22px]' : 'left-0.5')} />
</button>
</div>
</div>
</Modal>
)
}
// 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<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, homePage: s.homePage ?? def.homePage }
})
// 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,
}))
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
))
}
// ── 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,
})
}))
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_${key}`,
name,
color: colors[roles.length % colors.length],
isSystem: false,
permissions: allPerms(false),
}
setRoles(prev => [...prev, newRole])
setSelectedRoleId(newRole.id)
setNewRoleName('')
setAddingRole(false)
}
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 (
<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">
<div className="card overflow-hidden">
<div className="p-3 border-b border-slate-100 dark:border-slate-700">
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">Роли</p>
</div>
<div className="divide-y divide-slate-100 dark:divide-slate-700">
{roles.map(role => (
<div
key={role.id}
onClick={() => 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',
)}
>
<div className="flex items-center gap-2 min-w-0">
<div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: role.color }} />
<span className={cn(
'text-sm font-medium truncate',
selectedRoleId === role.id
? 'text-brand-700 dark:text-brand-300'
: 'text-slate-700 dark:text-slate-300',
)}>
{role.name}
</span>
</div>
<div className="flex items-center gap-1 shrink-0 ml-1">
{role.isSystem && (
<Lock size={10} className="text-slate-300 dark:text-slate-600" />
)}
{!role.isSystem && (
<button
onClick={e => { e.stopPropagation(); setDeleteId(role.id) }}
className="p-0.5 rounded text-slate-300 hover:text-red-500 transition-colors"
>
<XIcon size={12} />
</button>
)}
</div>
</div>
))}
</div>
{/* Add role */}
<div className="p-3 border-t border-slate-100 dark:border-slate-700">
{addingRole ? (
<div className="flex gap-1.5">
<input
autoFocus
type="text"
className="input text-xs py-1.5 px-2 flex-1 min-w-0"
placeholder="Название роли"
value={newRoleName}
onChange={e => setNewRoleName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') createRole(); if (e.key === 'Escape') setAddingRole(false) }}
/>
<button onClick={createRole} className="p-1.5 rounded-lg bg-brand-600 text-white hover:bg-brand-700 transition-colors shrink-0">
<Check size={12} />
</button>
<button onClick={() => setAddingRole(false)} className="p-1.5 rounded-lg bg-slate-100 dark:bg-slate-700 text-slate-500 hover:text-slate-700 transition-colors shrink-0">
<XIcon size={12} />
</button>
</div>
) : (
<button
onClick={() => setAddingRole(true)}
className="flex items-center gap-1.5 text-xs text-brand-600 dark:text-brand-400 hover:text-brand-700 transition-colors w-full"
>
<Plus size={12} />
Добавить роль
</button>
)}
</div>
</div>
</div>
{/* Permissions editor */}
{selectedRole && (
<div className="flex-1 min-w-0">
<div className="card overflow-hidden">
{/* Header */}
<div className="p-4 border-b border-slate-100 dark:border-slate-700 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: selectedRole.color }} />
<h3 className="font-semibold text-slate-900 dark:text-slate-100">{selectedRole.name}</h3>
<span className="text-xs text-slate-500 dark:text-slate-400">
{activeCount} из {totalCount} разделов
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setAllPerms(false)}
className="text-xs text-slate-500 dark:text-slate-400 hover:text-red-600 transition-colors px-2 py-1 rounded hover:bg-red-50 dark:hover:bg-red-900/20"
>
Снять все
</button>
<button
onClick={() => setAllPerms(true)}
className="text-xs text-slate-500 dark:text-slate-400 hover:text-brand-600 transition-colors px-2 py-1 rounded hover:bg-brand-50 dark:hover:bg-brand-900/20"
>
Выбрать все
</button>
</div>
</div>
{/* Module groups */}
<div className="p-4 space-y-5">
{MODULE_GROUPS.map(group => (
<div key={group.group}>
<p className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wide mb-2">
{group.group}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{group.modules.map(mod => {
const enabled = selectedRole.permissions[mod.key] ?? false
return (
<button
key={mod.key}
type="button"
onClick={() => togglePerm(mod.key)}
className={cn(
'flex items-start gap-3 p-3 rounded-xl border text-left transition-all',
enabled
? 'bg-brand-50 dark:bg-brand-900/20 border-brand-200 dark:border-brand-800'
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600',
)}
>
<div className={cn(
'w-5 h-5 rounded-md border-2 flex items-center justify-center shrink-0 mt-0.5 transition-colors',
enabled
? 'bg-brand-600 border-brand-600'
: 'border-slate-300 dark:border-slate-600',
)}>
{enabled && <Check size={11} className="text-white" />}
</div>
<div>
<p className={cn(
'text-sm font-medium',
enabled
? 'text-brand-700 dark:text-brand-300'
: 'text-slate-700 dark:text-slate-300',
)}>
{mod.label}
</p>
{mod.description && (
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5 leading-relaxed">
{mod.description}
</p>
)}
</div>
</button>
)
})}
</div>
</div>
))}
</div>
{/* Home page selector */}
<div className="px-4 pb-4 border-t border-slate-100 dark:border-slate-700 pt-4">
<div className="flex items-center gap-3">
<div className="flex-1">
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1">
Стартовая страница
</p>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-2">
Страница, которая открывается при входе в систему
</p>
</div>
</div>
<select
value={selectedRole.homePage ?? ''}
onChange={e => setHomePage(e.target.value || null)}
className="input text-sm w-full sm:w-72"
>
<option value=""> По умолчанию для роли </option>
{ALL_MODULE_KEYS
.filter(k => selectedRole.permissions[k] && PERM_TO_ROUTE[k])
.map(k => {
const mod = MODULE_GROUPS.flatMap(g => g.modules).find(m => m.key === k)
return (
<option key={k} value={PERM_TO_ROUTE[k]}>
{mod?.label ?? k}
</option>
)
})
}
</select>
</div>
{selectedRole.isSystem && (
<div className="px-4 pb-4">
<div className="flex items-center gap-2 p-3 rounded-xl bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800">
<Lock size={14} className="text-amber-600 dark:text-amber-400 shrink-0" />
<p className="text-xs text-amber-700 dark:text-amber-300">
Системная роль изменения применяются немедленно для всех сотрудников с этой ролью
</p>
</div>
</div>
)}
</div>
</div>
)}
</div>
{/* Delete role confirm */}
{deleteId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-xl p-6 max-w-sm w-full">
<h3 className="text-lg font-bold text-slate-900 dark:text-slate-100 mb-2">Удалить роль?</h3>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-5">
Сотрудники с этой ролью потеряют связанные права доступа. Действие нельзя отменить.
</p>
<div className="flex gap-3">
<button onClick={() => setDeleteId(null)} className="btn-secondary flex-1 justify-center">Отмена</button>
<button
onClick={() => deleteRole(deleteId)}
className="flex-1 justify-center py-2 px-4 rounded-xl bg-red-600 hover:bg-red-700 text-white text-sm font-medium transition-colors"
>
Удалить
</button>
</div>
</div>
</div>
)}
</div>
)
}
// ── 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 slug = currentUser?.hotelSlug ?? ''
const [tab, setTab] = useState<PageTab>('staff')
const [users, setUsers] = useState<StaffUser[]>([])
const [search, setSearch] = useState('')
const [roleFilter, setRoleFilter] = useState<StaffRole | 'all'>('all')
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<StaffUser | undefined>()
const [deleteId, setDeleteId] = useState<string | null>(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<StaffRole, string> = {
hotel_admin: 'hotel_admin',
hotel_manager: 'manager',
receptionist: 'receptionist',
housekeeper: 'housekeeper',
accountant: 'accountant',
security: 'security',
technician: 'technician',
}
const backendRole = backendRoleMap[u.role] ?? 'manager'
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 stats = {
total: users.length,
active: users.filter(u => u.isActive).length,
}
return (
<div className="p-4 md:p-6 space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">Сотрудники</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">{stats.total} пользователей · {stats.active} активных</p>
</div>
{tab === 'staff' && (
<button className="btn-primary" onClick={openCreate}>
<Plus size={15} />
<span className="hidden sm:inline">Добавить сотрудника</span>
</button>
)}
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-700">
{PAGE_TABS.map(t => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={cn(
'px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
tab === t.id
? 'border-brand-600 text-brand-600 dark:text-brand-400 dark:border-brand-400'
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300',
)}
>
{t.label}
</button>
))}
</div>
{tab === 'staff' && (
<>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{(Object.entries(ROLE_META) as [StaffRole, typeof ROLE_META[StaffRole]][]).map(([role, meta]) => {
const count = users.filter(u => u.role === role).length
return (
<div key={role} className="card p-4 cursor-pointer hover:shadow-card-hover transition-shadow"
onClick={() => setRoleFilter(roleFilter === role ? 'all' : role)}>
<div className="flex items-center gap-2 mb-1">
<meta.icon size={14} className="text-slate-500 dark:text-slate-400" />
<span className={cn('text-xs font-medium px-1.5 py-0.5 rounded-full', meta.color)}>
{meta.label}
</span>
</div>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{count}</p>
</div>
)
})}
</div>
{/* Filters */}
<div className="flex gap-3 flex-wrap">
<div className="relative flex-1 min-w-48">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input type="text" className="input pl-9" placeholder="Поиск по имени, email, должности..."
value={search} onChange={e => setSearch(e.target.value)} />
</div>
<div className="flex gap-1.5 flex-wrap">
<button
onClick={() => setRoleFilter('all')}
className={cn('px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
roleFilter === 'all'
? 'bg-brand-600 text-white border-brand-600'
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600')}
>
Все
</button>
{(Object.entries(ROLE_META) as [StaffRole, typeof ROLE_META[StaffRole]][]).map(([role, meta]) => (
<button
key={role}
onClick={() => setRoleFilter(roleFilter === role ? 'all' : role)}
className={cn('px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
roleFilter === role
? 'bg-brand-600 text-white border-brand-600'
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600')}
>
{meta.label}
</button>
))}
</div>
</div>
{/* Table */}
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50 dark:bg-slate-700/40 border-b border-slate-200 dark:border-slate-700">
<tr>
<th className="text-left text-xs font-semibold text-slate-500 dark:text-slate-400 px-4 py-3 uppercase tracking-wide">Сотрудник</th>
<th className="text-left text-xs font-semibold text-slate-500 dark:text-slate-400 px-4 py-3 uppercase tracking-wide">Роль / Должность</th>
<th className="text-left text-xs font-semibold text-slate-500 dark:text-slate-400 px-4 py-3 uppercase tracking-wide hidden md:table-cell">Контакты</th>
<th className="text-left text-xs font-semibold text-slate-500 dark:text-slate-400 px-4 py-3 uppercase tracking-wide hidden lg:table-cell">Последний вход</th>
<th className="text-left text-xs font-semibold text-slate-500 dark:text-slate-400 px-4 py-3 uppercase tracking-wide">Статус</th>
<th className="px-4 py-3 w-20" />
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
{filtered.map(u => {
const meta = ROLE_META[u.role]
return (
<tr key={u.id} className="hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div
className="w-9 h-9 rounded-xl flex items-center justify-center text-white text-sm font-bold shrink-0"
style={{ backgroundColor: u.avatarColor }}
>
{u.firstName[0]?.toUpperCase()}
</div>
<div>
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
{u.lastName} {u.firstName}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{u.email}</p>
</div>
</div>
</td>
<td className="px-4 py-3">
<span className={cn('text-xs font-medium px-2 py-0.5 rounded-full', meta.color)}>
{meta.label}
</span>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{u.position}</p>
</td>
<td className="px-4 py-3 hidden md:table-cell">
<div className="space-y-0.5">
<div className="flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
<Mail size={11} /> {u.email}
</div>
{u.phone && (
<div className="flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
<Phone size={11} /> {u.phone}
</div>
)}
</div>
</td>
<td className="px-4 py-3 hidden lg:table-cell">
<p className="text-xs text-slate-500 dark:text-slate-400">
{u.lastLogin
? new Date(u.lastLogin).toLocaleDateString('ru-RU')
: '—'}
</p>
</td>
<td className="px-4 py-3">
<span className={cn(
'inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full',
u.isActive
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400',
)}>
{u.isActive
? <><CheckCircle2 size={10} /> Активен</>
: <><AlertCircle size={10} /> Неактивен</>}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1 justify-end">
<button
onClick={() => openEdit(u)}
className="p-1.5 rounded-lg text-slate-400 hover:text-brand-600 hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors"
>
<Pencil size={14} />
</button>
{u.role !== 'hotel_admin' && (
<button
onClick={() => setDeleteId(u.id)}
className="p-1.5 rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
<Trash2 size={14} />
</button>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
{filtered.length === 0 && (
<div className="py-12 text-center">
<UserIcon size={32} className="mx-auto text-slate-300 dark:text-slate-600 mb-2" />
<p className="text-slate-500 dark:text-slate-400">Сотрудники не найдены</p>
</div>
)}
</div>
</div>
{/* User modal */}
<UserModal
key={editing?.id ?? 'new'}
open={modalOpen}
user={editing}
onClose={() => setModalOpen(false)}
onSave={handleSave}
/>
{/* Delete confirm */}
{deleteId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-xl p-6 max-w-sm w-full">
<h3 className="text-lg font-bold text-slate-900 dark:text-slate-100 mb-2">Удалить сотрудника?</h3>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-5">
Сотрудник потеряет доступ к системе. Историю его действий можно будет просмотреть в журнале.
</p>
<div className="flex gap-3">
<button onClick={() => setDeleteId(null)} className="btn-secondary flex-1 justify-center">Отмена</button>
<button onClick={() => handleDelete(deleteId)} className="flex-1 justify-center py-2 px-4 rounded-xl bg-red-600 hover:bg-red-700 text-white text-sm font-medium transition-colors">
Удалить
</button>
</div>
</div>
</div>
)}
</>
)}
{tab === 'roles' && <RolesTab />}
</div>
)
}