- Add notificationSettings JSONB to role_permissions (migration 089)
- sendPushForNotification() — pushes only to users with the notif type enabled
- reviews.ts — push + in-app on new direct and QR reviews
- room-service.ts — use sendPushForNotification('room_service_order')
- publicWidget.ts — push + in-app on new online booking
- jobs.ts — runUnreadMessagesJob() every 15 min, deduped by in-process set
- UsersPage: NOTIFICATION_GROUPS UI in roles tab with per-type toggles
- api.ts / RolePermissionsContext: notificationSettings in types and save()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1340 lines
60 KiB
TypeScript
1340 lines
60 KiB
TypeScript
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
|
||
notificationSettings: Record<string, boolean>
|
||
}
|
||
|
||
// ── 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]))
|
||
|
||
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<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),
|
||
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<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 > 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 (
|
||
<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="space-y-2">
|
||
<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>
|
||
{customRoles.length > 0 && (
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{customRoles.map(r => (
|
||
<button
|
||
key={r.roleKey}
|
||
type="button"
|
||
onClick={() => { set('role', r.roleKey as StaffRole); 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 === r.roleKey
|
||
? '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',
|
||
)}
|
||
>
|
||
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: r.color }} />
|
||
{r.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</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>
|
||
{isNew && (
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 mb-2">
|
||
Оставьте пустым — сотрудник получит приглашение на email и сам задаст пароль
|
||
</p>
|
||
)}
|
||
<div className="relative">
|
||
<input
|
||
type={showPass ? 'text' : 'password'}
|
||
className={cn('input pr-10', errors.password && 'border-red-400')}
|
||
placeholder={isNew ? 'Оставьте пустым для отправки приглашения' : '••••••••'}
|
||
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, 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 (
|
||
<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>
|
||
|
||
{/* Notification settings */}
|
||
<div className="px-4 pb-4 border-t border-slate-100 dark:border-slate-700 pt-4 space-y-4">
|
||
<div>
|
||
<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-3">
|
||
Какие push и in-app уведомления получают сотрудники с этой ролью
|
||
</p>
|
||
</div>
|
||
{NOTIFICATION_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.items.map(item => {
|
||
const enabled = selectedRole.notificationSettings[item.key] ?? true
|
||
return (
|
||
<button
|
||
key={item.key}
|
||
type="button"
|
||
onClick={() => toggleNotif(item.key)}
|
||
className={cn(
|
||
'flex items-start gap-3 p-3 rounded-xl border text-left transition-all',
|
||
enabled
|
||
? 'bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-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-emerald-600 border-emerald-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-emerald-700 dark:text-emerald-300'
|
||
: 'text-slate-700 dark:text-slate-300',
|
||
)}>
|
||
{item.label}
|
||
</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5 leading-relaxed">
|
||
{item.description}
|
||
</p>
|
||
</div>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</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 { savedPermissions } = useRolePermissions()
|
||
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<string, string> = {
|
||
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 (
|
||
<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">{totalUsers} пользователей · {activeUsers} активных</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' && (
|
||
<>
|
||
{/* 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 ? meta.color : 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300'
|
||
)}>
|
||
{meta ? meta.label : (savedPermissions.find(r => r.roleKey === u.role)?.name ?? u.role)}
|
||
</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>
|
||
)
|
||
}
|