Add role permissions, loyalty accrual rules, guest statuses, and room service guest page

- UsersPage: add "Роли и права" tab with per-module permission editor per role; create/delete custom roles
- LoyaltyPage: add "Правила начисления" tab with togglable accrual rules editor; add "Ручное начисление" tab with guest search by name/phone/email, add/deduct points with reason and history; fix banner text (HotelSync is a PMS, not a hotel chain)
- GuestsPage: add "Статусы гостей" tab (moved from Settings)
- SettingsPage: remove Guests section; add compact calendar toggle in Appearance; toggle persists via localStorage
- BookingCalendar: read compact mode default from localStorage
- App.tsx: add public route /room-service/:slug → GuestRoomServicePage
- GuestRoomServicePage: new public guest-facing page for ordering room service with menu, cart, payment, and order status tracking
- RoomServicePage: QR/link sharing, payment mode toggles, new order notifications
- RentalPage/rentalData: buffer time between bookings (bufferMinutes field)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 20:13:22 +03:00
parent a4e484cc0d
commit d661bbbc84
10 changed files with 2002 additions and 487 deletions

View File

@@ -1,7 +1,8 @@
import { useState } from 'react'
import {
Plus, Pencil, Trash2, Search, Shield, User as UserIcon,
Sparkles, Mail, Phone, X as XIcon, Eye, EyeOff, CheckCircle2, AlertCircle,
Sparkles, Mail, Phone, Eye, EyeOff, CheckCircle2, AlertCircle,
Lock, Check, X as XIcon,
} from 'lucide-react'
import { cn } from '../lib/utils'
import { Modal } from '../components/ui/Modal'
@@ -17,21 +18,35 @@ interface StaffUser {
email: string
phone?: string
role: StaffRole
position: string // свободная должность, напр. "Старший администратор"
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>
}
// ── Constants ──────────────────────────────────────────────────────────────────
const ROLE_META: Record<StaffRole, { label: string; color: string; icon: React.ElementType }> = {
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 },
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 },
}
const AVATAR_COLORS = [
@@ -47,6 +62,112 @@ const DEFAULT_POSITIONS: Record<StaffRole, string[]> = {
security: ['Начальник охраны', 'Охранник', 'Контролёр доступа'],
}
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: 'users', 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 INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
{
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,
housekeeping: true, room_service: true, rental: true,
pos: true, reviews: true,
},
},
{
id: 'rp_housekeeper',
name: 'Горничная',
color: '#10B981',
isSystem: true,
permissions: {
...allPerms(false),
housekeeping: true, rooms: true, maintenance: true,
},
},
{
id: 'rp_accountant',
name: 'Бухгалтер',
color: '#7C3AED',
isSystem: true,
permissions: {
...allPerms(false),
reports: true, pos: true, discounts: true, tariffs: true, pricing: true, loyalty: true,
},
},
{
id: 'rp_security',
name: 'Охрана',
color: '#F97316',
isSystem: true,
permissions: {
...allPerms(false),
calendar: true, bookings: true,
},
},
]
// ── Mock data ──────────────────────────────────────────────────────────────────
const MOCK_USERS: StaffUser[] = [
@@ -98,13 +219,13 @@ function UserModal({
onSave: (u: StaffUser) => 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,
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('')
@@ -296,9 +417,272 @@ function UserModal({
)
}
// ── Roles & Permissions Tab ────────────────────────────────────────────────────
function RolesTab() {
const [roles, setRoles] = useState<RolePermissions[]>(INITIAL_ROLE_PERMISSIONS)
const [selectedRoleId, setSelectedRoleId] = useState<string>(INITIAL_ROLE_PERMISSIONS[0].id)
const [newRoleName, setNewRoleName] = useState('')
const [addingRole, setAddingRole] = useState(false)
const [deleteId, setDeleteId] = useState<string | null>(null)
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 createRole = () => {
const name = newRoleName.trim()
if (!name) return
const colors = ['#EC4899', '#14B8A6', '#F59E0B', '#6366F1', '#84CC16']
const newRole: RolePermissions = {
id: `rp_${Date.now()}`,
name,
color: colors[roles.length % colors.length],
isSystem: false,
permissions: allPerms(false),
}
setRoles(prev => [...prev, newRole])
setSelectedRoleId(newRole.id)
setNewRoleName('')
setAddingRole(false)
}
const deleteRole = (id: string) => {
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="flex gap-5 items-start">
{/* Role list */}
<div className="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>
{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>
)}
{/* 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 [tab, setTab] = useState<PageTab>('staff')
const [users, setUsers] = useState<StaffUser[]>(MOCK_USERS)
const [search, setSearch] = useState('')
const [roleFilter, setRoleFilter] = useState<StaffRole | 'all'>('all')
@@ -333,9 +717,8 @@ export function UsersPage() {
}
const stats = {
total: users.length,
active: users.filter(u => u.isActive).length,
managers: users.filter(u => u.role === 'hotel_manager').length,
total: users.length,
active: users.filter(u => u.isActive).length,
}
return (
@@ -346,191 +729,217 @@ export function UsersPage() {
<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>
<button className="btn-primary" onClick={openCreate}>
<Plus size={15} />
<span className="hidden sm:inline">Добавить сотрудника</span>
</button>
</div>
{/* 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')}
>
Все
{tab === 'staff' && (
<button className="btn-primary" onClick={openCreate}>
<Plus size={15} />
<span className="hidden sm:inline">Добавить сотрудника</span>
</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>
<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>
{/* 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>
{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>
{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>
</div>
{/* User modal */}
<UserModal
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">
Удалить
<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>
</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>
<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
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>
)
}