feat: add hotel_admin (Системный администратор) and technician roles + protect hotel owner from deletion

- Migration 035: extend role constraint to include hotel_admin and technician; migrate existing manager users to hotel_admin
- auth.ts: registration now assigns hotel_admin (not manager) to hotel owner
- seed.ts: demo user manager@grand-palace.ru seeded as hotel_admin
- types.ts: expand JwtPayload role union with all roles
- users.ts: hotel_admin included in access checks; role creation/edit/delete rules enforced; hotel_admin users are undeletable and uneditable (non-super_admin); role cannot be set to hotel_admin via PATCH
- rooms.ts / channels.ts: hotel_admin added to write-access checks
- UsersPage.tsx: hotel_admin and technician added to StaffRole, ROLE_META, DEFAULT_POSITIONS, mapRole, backendRoleMap, INITIAL_ROLE_PERMISSIONS; delete button hidden for hotel_admin; role selector locked for hotel_admin users; hotel_admin excluded from new-user role selector

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 00:39:48 +03:00
parent 943b97c200
commit d5267335a0
8 changed files with 128 additions and 53 deletions

View File

@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'
import {
Plus, Pencil, Trash2, Search, Shield, User as UserIcon,
Sparkles, Mail, Phone, Eye, EyeOff, CheckCircle2, AlertCircle,
Lock, Check, X as XIcon,
Lock, Check, X as XIcon, Wrench,
} from 'lucide-react'
import { api } from '../lib/api'
import { useAuth } from '../contexts/AuthContext'
@@ -12,7 +12,7 @@ import { Modal } from '../components/ui/Modal'
// ── Types ──────────────────────────────────────────────────────────────────────
type StaffRole = 'hotel_manager' | 'housekeeper' | 'receptionist' | 'accountant' | 'security'
type StaffRole = 'hotel_admin' | 'hotel_manager' | 'housekeeper' | 'receptionist' | 'accountant' | 'security' | 'technician'
interface StaffUser {
id: string
@@ -45,11 +45,13 @@ interface RolePermissions {
// ── 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_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 = [
@@ -58,11 +60,13 @@ const AVATAR_COLORS = [
]
const DEFAULT_POSITIONS: Record<StaffRole, string[]> = {
hotel_admin: ['Генеральный директор', 'Владелец отеля', 'Управляющий директор'],
hotel_manager: ['Управляющий', 'Заместитель управляющего', 'Менеджер смены'],
receptionist: ['Старший администратор', 'Администратор', 'Ночной администратор'],
housekeeper: ['Старшая горничная', 'Горничная', 'Уборщик'],
accountant: ['Главный бухгалтер', 'Бухгалтер', 'Финансовый менеджер'],
security: ['Начальник охраны', 'Охранник', 'Контролёр доступа'],
technician: ['Технический специалист', 'Электрик', 'Сантехник', 'Инженер по оборудованию'],
}
const MODULE_GROUPS: { group: string; modules: ModulePermission[] }[] = [
@@ -120,6 +124,13 @@ const allPerms = (v: boolean) =>
Object.fromEntries(ALL_MODULE_KEYS.map(k => [k, v]))
const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
{
id: 'rp_hotel_admin',
name: 'Сис. администратор',
color: '#7C3AED',
isSystem: true,
permissions: allPerms(true),
},
{
id: 'rp_manager',
name: 'Менеджер',
@@ -169,6 +180,16 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
calendar: true, bookings: true,
},
},
{
id: 'rp_technician',
name: 'Тех. специалист',
color: '#D97706',
isSystem: true,
permissions: {
...allPerms(false),
housekeeping: true, rooms: true, maintenance: true,
},
},
]
// ── Helpers ────────────────────────────────────────────────────────────────────
@@ -178,6 +199,8 @@ function mapRole(r: string): StaffRole {
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'
}
@@ -324,24 +347,33 @@ function UserModal({
{/* Role */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Роль в системе *</label>
<div className="grid grid-cols-3 gap-2">
{(Object.entries(ROLE_META) as [StaffRole, typeof ROLE_META[StaffRole]][]).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>
{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 */}
@@ -706,11 +738,16 @@ export function UsersPage() {
const handleSave = async (u: StaffUser, password?: string) => {
const fullName = `${u.firstName} ${u.lastName}`.trim()
const backendRole = u.role === 'housekeeper' ? 'housekeeper'
: u.role === 'receptionist' ? 'receptionist'
: u.role === 'accountant' ? 'accountant'
: u.role === 'security' ? 'security'
: 'manager'
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, {
@@ -915,12 +952,14 @@ export function UsersPage() {
>
<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>
{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>