Add hotel registration flow on login page and staff users management page

LoginPage:
- Toggle between login and registration mode
- 2-step registration: step 1 (hotel name + legal entity: type ООО/ИП/АО/ПАО/Другое, company name, INN, KPP hidden for ИП, legal address), step 2 (first/last name, email, phone, password with strength indicator, confirm)
- Step indicator dots, validation with inline errors, success screen after submit
- Left panel shows benefits list when in registration mode
- «Зарегистрировать отель» / «Войти» toggle links

UsersPage (/users):
- Staff list table: avatar with custom color, name, role badge, position, contacts, last login, active status
- Roles: hotel_manager, receptionist, housekeeper, accountant, security — each with label+color
- Stats cards by role (clickable to filter)
- Search by name/email/position + role filter buttons
- UserModal: avatar color picker, name/email/phone, role selector (6 roles), position field with quick-pick suggestions per role, password field, active toggle
- Delete confirmation dialog
- Sidebar: «Сотрудники» link added to Управление section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 22:45:11 +03:00
parent 401825c01e
commit c3723dec90
4 changed files with 1015 additions and 112 deletions

View File

@@ -1,6 +1,9 @@
import { useState } from 'react'
import { Navigate, useNavigate } from 'react-router-dom'
import { Hotel, Eye, EyeOff, Sun, Moon, AlertCircle } from 'lucide-react'
import {
Hotel, Eye, EyeOff, Sun, Moon, AlertCircle,
Building2, ChevronRight, ChevronLeft, CheckCircle2, User,
} from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { useTheme } from '../contexts/ThemeContext'
import { cn } from '../lib/utils'
@@ -11,21 +14,26 @@ const DEMO_EMAILS = [
'admin@hotelsync.io',
]
export function LoginPage() {
const { user, login } = useAuth()
const { theme, toggle } = useTheme()
const navigate = useNavigate()
type LegalType = 'ooo' | 'ip' | 'ao' | 'pao' | 'other'
const [email, setEmail] = useState('')
const LEGAL_TYPES: { id: LegalType; label: string }[] = [
{ id: 'ooo', label: 'ООО' },
{ id: 'ip', label: 'ИП' },
{ id: 'ao', label: 'АО' },
{ id: 'pao', label: 'ПАО' },
{ id: 'other', label: 'Другое'},
]
// ── Login form ─────────────────────────────────────────────────────────────────
function LoginForm({ onSwitch }: { onSwitch: () => void }) {
const { login } = useAuth()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPass, setShowPass] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
if (user) {
if (user.role === 'super_admin') return <Navigate to="/admin" replace />
return <Navigate to="/calendar" replace />
}
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -33,14 +41,438 @@ export function LoginPage() {
setLoading(true)
const loggedIn = await login(email, password)
setLoading(false)
if (!loggedIn) {
setError('Неверный email или пароль')
return
}
if (!loggedIn) { setError('Неверный email или пароль'); return }
if (loggedIn.role === 'super_admin') navigate('/admin')
else navigate('/calendar')
}
return (
<>
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-1">
Добро пожаловать
</h2>
<p className="text-slate-500 dark:text-slate-400 mb-8">
Войдите в аккаунт для доступа к панели управления
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Email</label>
<input
type="email" className="input" placeholder="email@example.com"
value={email} onChange={e => setEmail(e.target.value)} required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Пароль</label>
<div className="relative">
<input
type={showPass ? 'text' : 'password'} className="input pr-10"
placeholder="••••••••" value={password}
onChange={e => setPassword(e.target.value)} required
/>
<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>
</div>
{error && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">
<AlertCircle size={15} /> {error}
</div>
)}
<button type="submit" disabled={loading} className="btn-primary w-full justify-center py-2.5">
{loading
? <span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
: 'Войти'}
</button>
</form>
{/* Demo hint */}
<div className="mt-5 p-3 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">
Демо-аккаунты (пароль: <code className="bg-slate-100 dark:bg-slate-700 px-1 rounded">demo</code>):
</p>
<div className="flex flex-col gap-1">
{DEMO_EMAILS.map(e => (
<button key={e} onClick={() => { setEmail(e); setPassword('demo'); setError('') }}
className={cn(
'text-left text-xs px-2 py-1 rounded transition-colors',
email === e
? 'text-brand-700 dark:text-brand-300 bg-brand-50 dark:bg-brand-900/30 font-medium'
: 'text-slate-500 dark:text-slate-400 hover:text-brand-600 dark:hover:text-brand-400',
)}
>{e}</button>
))}
</div>
</div>
<p className="mt-6 text-center text-sm text-slate-500 dark:text-slate-400">
Нет аккаунта?{' '}
<button onClick={onSwitch} className="text-brand-600 dark:text-brand-400 font-medium hover:underline">
Зарегистрировать отель
</button>
</p>
</>
)
}
// ── Register form ──────────────────────────────────────────────────────────────
function RegisterForm({ onSwitch }: { onSwitch: () => void }) {
const [step, setStep] = useState<1 | 2 | 'done'>(1)
// Step 1 — Hotel + Legal entity
const [hotelName, setHotelName] = useState('')
const [legalType, setLegalType] = useState<LegalType>('ooo')
const [legalName, setLegalName] = useState('')
const [inn, setInn] = useState('')
const [kpp, setKpp] = useState('')
const [address, setAddress] = useState('')
// Step 2 — Account
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [email, setEmail] = useState('')
const [phone, setPhone] = useState('')
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [showPass, setShowPass] = useState(false)
const [showConfirm, setShowConfirm] = useState(false)
const [loading, setLoading] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const isIp = legalType === 'ip'
const validateStep1 = () => {
const e: Record<string, string> = {}
if (!hotelName.trim()) e.hotelName = 'Введите название отеля'
if (!legalName.trim()) e.legalName = 'Введите наименование юрлица'
if (!inn.trim()) e.inn = 'Введите ИНН'
else if (!/^\d{10,12}$/.test(inn.replace(/\s/g, '')))
e.inn = 'ИНН — 10 цифр (юрлицо) или 12 (ИП)'
if (!isIp && !kpp.trim()) e.kpp = 'Введите КПП'
setErrors(e)
return Object.keys(e).length === 0
}
const validateStep2 = () => {
const e: Record<string, string> = {}
if (!firstName.trim()) e.firstName = 'Введите имя'
if (!lastName.trim()) e.lastName = 'Введите фамилию'
if (!email.trim()) e.email = 'Введите email'
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) e.email = 'Некорректный email'
if (password.length < 8) e.password = 'Минимум 8 символов'
if (password !== confirm) e.confirm = 'Пароли не совпадают'
setErrors(e)
return Object.keys(e).length === 0
}
const nextStep = () => {
if (validateStep1()) setStep(2)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!validateStep2()) return
setLoading(true)
// Simulate API call
await new Promise(r => setTimeout(r, 1200))
setLoading(false)
setStep('done')
}
const Field = ({
label, error, children, hint,
}: { label: string; error?: string; children: React.ReactNode; hint?: string }) => (
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">{label}</label>
{children}
{hint && !error && <p className="mt-1 text-xs text-slate-400">{hint}</p>}
{error && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400 flex items-center gap-1">
<AlertCircle size={11} /> {error}
</p>
)}
</div>
)
if (step === 'done') {
return (
<div className="text-center py-6">
<div className="w-16 h-16 rounded-full bg-emerald-100 dark:bg-emerald-900/40 flex items-center justify-center mx-auto mb-5">
<CheckCircle2 size={32} className="text-emerald-600 dark:text-emerald-400" />
</div>
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-2">
Заявка отправлена!
</h2>
<p className="text-slate-500 dark:text-slate-400 mb-2">
Мы получили данные отеля <span className="font-semibold text-slate-700 dark:text-slate-300">{hotelName}</span>.
</p>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-8">
На адрес <span className="font-medium text-slate-700 dark:text-slate-300">{email}</span> отправлено
письмо с подтверждением. После проверки данных менеджер HotelSync активирует аккаунт в течение одного рабочего дня.
</p>
<button onClick={onSwitch} className="btn-primary w-full justify-center py-2.5">
Перейти к входу
</button>
</div>
)
}
return (
<>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
{step === 1 ? 'Регистрация отеля' : 'Данные аккаунта'}
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Шаг {step} из 2</p>
</div>
{/* Step indicator */}
<div className="flex items-center gap-1.5">
{[1, 2].map(s => (
<div key={s} className={cn(
'w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold transition-all',
step === s
? 'bg-brand-600 text-white'
: s < step
? 'bg-emerald-500 text-white'
: 'bg-slate-200 dark:bg-slate-700 text-slate-500 dark:text-slate-400',
)}>
{s < step ? <CheckCircle2 size={14} /> : s}
</div>
))}
</div>
</div>
{/* ── Step 1: Hotel + Legal entity ── */}
{step === 1 && (
<div className="space-y-4">
<Field label="Название отеля *" error={errors.hotelName}>
<input
type="text" className={cn('input', errors.hotelName && 'border-red-400 focus:ring-red-400')}
placeholder="Grand Palace Hotel"
value={hotelName} onChange={e => setHotelName(e.target.value)}
/>
</Field>
<div className="pt-1 border-t border-slate-100 dark:border-slate-700">
<div className="flex items-center gap-2 mb-3">
<Building2 size={14} className="text-slate-500" />
<p className="text-sm font-semibold text-slate-700 dark:text-slate-300">Юридическое лицо</p>
</div>
{/* Legal type selector */}
<div className="mb-4">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Организационно-правовая форма *
</label>
<div className="flex gap-1.5 flex-wrap">
{LEGAL_TYPES.map(lt => (
<button
key={lt.id}
type="button"
onClick={() => setLegalType(lt.id)}
className={cn(
'px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
legalType === lt.id
? 'bg-brand-600 border-brand-600 text-white'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-300 hover:border-brand-400',
)}
>
{lt.label}
</button>
))}
</div>
</div>
<div className="space-y-3">
<Field
label={isIp ? 'ФИО индивидуального предпринимателя *' : 'Полное наименование организации *'}
error={errors.legalName}
>
<input
type="text"
className={cn('input', errors.legalName && 'border-red-400 focus:ring-red-400')}
placeholder={isIp ? 'Иванов Иван Иванович' : 'Общество с ограниченной ответственностью «Название»'}
value={legalName} onChange={e => setLegalName(e.target.value)}
/>
</Field>
<div className={cn('grid gap-3', isIp ? 'grid-cols-1' : 'grid-cols-2')}>
<Field
label="ИНН *"
error={errors.inn}
hint={isIp ? '12 цифр' : '10 цифр'}
>
<input
type="text"
className={cn('input font-mono', errors.inn && 'border-red-400 focus:ring-red-400')}
placeholder={isIp ? '123456789012' : '1234567890'}
maxLength={isIp ? 12 : 10}
value={inn} onChange={e => setInn(e.target.value.replace(/\D/g, ''))}
/>
</Field>
{!isIp && (
<Field label="КПП *" error={errors.kpp} hint="9 цифр">
<input
type="text"
className={cn('input font-mono', errors.kpp && 'border-red-400 focus:ring-red-400')}
placeholder="123456789"
maxLength={9}
value={kpp} onChange={e => setKpp(e.target.value.replace(/\D/g, ''))}
/>
</Field>
)}
</div>
<Field label="Юридический адрес" error={errors.address}>
<input
type="text" className="input"
placeholder="г. Москва, ул. Примерная, д. 1"
value={address} onChange={e => setAddress(e.target.value)}
/>
</Field>
</div>
</div>
<button type="button" onClick={nextStep} className="btn-primary w-full justify-center py-2.5 mt-2">
Далее
<ChevronRight size={16} />
</button>
<p className="text-center text-sm text-slate-500 dark:text-slate-400">
Уже есть аккаунт?{' '}
<button onClick={onSwitch} className="text-brand-600 dark:text-brand-400 font-medium hover:underline">
Войти
</button>
</p>
</div>
)}
{/* ── Step 2: Account ── */}
{step === 2 && (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<Field label="Имя *" error={errors.firstName}>
<input
type="text"
className={cn('input', errors.firstName && 'border-red-400 focus:ring-red-400')}
placeholder="Иван"
value={firstName} onChange={e => setFirstName(e.target.value)}
/>
</Field>
<Field label="Фамилия *" error={errors.lastName}>
<input
type="text"
className={cn('input', errors.lastName && 'border-red-400 focus:ring-red-400')}
placeholder="Иванов"
value={lastName} onChange={e => setLastName(e.target.value)}
/>
</Field>
</div>
<Field label="Рабочий email *" error={errors.email}>
<input
type="email"
className={cn('input', errors.email && 'border-red-400 focus:ring-red-400')}
placeholder="director@myhotel.ru"
value={email} onChange={e => setEmail(e.target.value)}
/>
</Field>
<Field label="Телефон" error={errors.phone}>
<input
type="tel" className="input"
placeholder="+7 (999) 000-00-00"
value={phone} onChange={e => setPhone(e.target.value)}
/>
</Field>
<Field label="Пароль *" error={errors.password} hint="Минимум 8 символов">
<div className="relative">
<input
type={showPass ? 'text' : 'password'}
className={cn('input pr-10', errors.password && 'border-red-400 focus:ring-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>
{password && (
<div className="mt-1.5 flex gap-1">
{[
password.length >= 8,
/[A-Z]/.test(password),
/[0-9]/.test(password),
].map((ok, i) => (
<div key={i} className={cn('h-1 flex-1 rounded-full transition-colors', ok ? 'bg-emerald-500' : 'bg-slate-200 dark:bg-slate-600')} />
))}
</div>
)}
</Field>
<Field label="Подтверждение пароля *" error={errors.confirm}>
<div className="relative">
<input
type={showConfirm ? 'text' : 'password'}
className={cn('input pr-10', errors.confirm && 'border-red-400 focus:ring-red-400')}
placeholder="••••••••"
value={confirm} onChange={e => setConfirm(e.target.value)}
/>
<button type="button" onClick={() => setShowConfirm(v => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600">
{showConfirm ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</Field>
<p className="text-xs text-slate-400 dark:text-slate-500">
Нажимая «Создать аккаунт», вы соглашаетесь с{' '}
<span className="text-brand-600 dark:text-brand-400 cursor-pointer hover:underline">условиями использования</span>{' '}
и{' '}
<span className="text-brand-600 dark:text-brand-400 cursor-pointer hover:underline">политикой конфиденциальности</span>
</p>
<div className="flex gap-3">
<button
type="button"
onClick={() => { setErrors({}); setStep(1) }}
className="btn-secondary flex items-center gap-1.5"
>
<ChevronLeft size={16} />
Назад
</button>
<button type="submit" disabled={loading} className="btn-primary flex-1 justify-center py-2.5">
{loading
? <span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
: <><User size={15} /> Создать аккаунт</>
}
</button>
</div>
</form>
)}
</>
)
}
// ── Page ───────────────────────────────────────────────────────────────────────
export function LoginPage() {
const { user } = useAuth()
const { theme, toggle } = useTheme()
const [mode, setMode] = useState<'login' | 'register'>('login')
if (user) {
if (user.role === 'super_admin') return <Navigate to="/admin" replace />
return <Navigate to="/calendar" replace />
}
return (
<div className="min-h-screen bg-gradient-to-br from-brand-50 via-white to-slate-100 dark:from-slate-900 dark:via-slate-900 dark:to-brand-950 flex">
{/* Left panel — branding */}
@@ -63,8 +495,8 @@ export function LoginPage() {
<div className="mt-10 grid grid-cols-3 gap-6">
{[
{ value: '500+', label: 'Отелей' },
{ value: '1M+', label: 'Бронирований' },
{ value: '500+', label: 'Отелей' },
{ value: '1M+', label: 'Бронирований' },
{ value: '99.9%', label: 'Аптайм' },
].map(s => (
<div key={s.label}>
@@ -73,14 +505,28 @@ export function LoginPage() {
</div>
))}
</div>
{/* Registration benefits highlight */}
{mode === 'register' && (
<div className="mt-10 space-y-3">
{[
'Бесплатный период 14 дней без ввода карты',
'Настройка за 15 минут — импорт из любой PMS',
'Поддержка на русском языке 24/7',
].map(b => (
<div key={b} className="flex items-start gap-2.5">
<CheckCircle2 size={16} className="text-brand-200 shrink-0 mt-0.5" />
<p className="text-brand-100 text-sm">{b}</p>
</div>
))}
</div>
)}
</div>
<p className="text-brand-200 text-sm">
© 2026 HotelSync · SaaS PMS Platform
</p>
<p className="text-brand-200 text-sm">© 2026 HotelSync · SaaS PMS Platform</p>
</div>
{/* Right panel — login form */}
{/* Right panel */}
<div className="flex-1 flex flex-col">
<div className="flex justify-end p-4">
<button onClick={toggle} className="btn-ghost p-2">
@@ -88,7 +534,7 @@ export function LoginPage() {
</button>
</div>
<div className="flex-1 flex items-center justify-center px-6">
<div className="flex-1 flex items-center justify-center px-6 py-6 overflow-y-auto">
<div className="w-full max-w-md">
{/* Mobile logo */}
<div className="lg:hidden flex items-center gap-2.5 mb-8">
@@ -100,92 +546,10 @@ export function LoginPage() {
</span>
</div>
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-1">
Добро пожаловать
</h2>
<p className="text-slate-500 dark:text-slate-400 mb-8">
Войдите в свой аккаунт для доступа к панели управления
</p>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Email
</label>
<input
type="email"
className="input"
placeholder="email@example.com"
value={email}
onChange={e => setEmail(e.target.value)}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Пароль
</label>
<div className="relative">
<input
type={showPass ? 'text' : 'password'}
className="input pr-10"
placeholder="••••••••"
value={password}
onChange={e => setPassword(e.target.value)}
required
/>
<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>
</div>
{error && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">
<AlertCircle size={15} />
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="btn-primary w-full justify-center py-2.5"
>
{loading ? (
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : 'Войти'}
</button>
</form>
{/* Demo hint */}
<div className="mt-6 p-3 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">
Демо-аккаунты (пароль: <code className="bg-slate-100 dark:bg-slate-700 px-1 rounded">demo</code>):
</p>
<div className="flex flex-col gap-1">
{DEMO_EMAILS.map(e => (
<button
key={e}
onClick={() => { setEmail(e); setPassword('demo'); setError('') }}
className={cn(
'text-left text-xs px-2 py-1 rounded transition-colors',
email === e
? 'text-brand-700 dark:text-brand-300 bg-brand-50 dark:bg-brand-900/30 font-medium'
: 'text-slate-500 dark:text-slate-400 hover:text-brand-600 dark:hover:text-brand-400',
)}
>
{e}
</button>
))}
</div>
</div>
{mode === 'login'
? <LoginForm onSwitch={() => setMode('register')} />
: <RegisterForm onSwitch={() => setMode('login')} />
}
</div>
</div>
</div>