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:
@@ -25,6 +25,7 @@ import { RoomServicePage } from './pages/RoomServicePage'
|
|||||||
import { RentalPage } from './pages/RentalPage'
|
import { RentalPage } from './pages/RentalPage'
|
||||||
import { RoomCategoriesPage } from './pages/RoomCategoriesPage'
|
import { RoomCategoriesPage } from './pages/RoomCategoriesPage'
|
||||||
import { DocumentsPage } from './pages/DocumentsPage'
|
import { DocumentsPage } from './pages/DocumentsPage'
|
||||||
|
import { UsersPage } from './pages/UsersPage'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -59,6 +60,7 @@ export default function App() {
|
|||||||
<Route path="/rental" element={<RentalPage />} />
|
<Route path="/rental" element={<RentalPage />} />
|
||||||
<Route path="/room-categories" element={<RoomCategoriesPage />} />
|
<Route path="/room-categories" element={<RoomCategoriesPage />} />
|
||||||
<Route path="/documents" element={<DocumentsPage />} />
|
<Route path="/documents" element={<DocumentsPage />} />
|
||||||
|
<Route path="/users" element={<UsersPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Admin routes */}
|
{/* Admin routes */}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NavLink, useNavigate } from 'react-router-dom'
|
import { NavLink, useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
CalendarDays, BookOpen, BedDouble, Globe, Settings,
|
CalendarDays, BookOpen, BedDouble, Globe, Settings,
|
||||||
FileText, LayoutDashboard, Building2, Users, X, Hotel, Puzzle, CalendarRange, Map, LayoutGrid,
|
FileText, LayoutDashboard, Building2, Users, X, Hotel, Puzzle, CalendarRange, Map, LayoutGrid, UserCog,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAuth } from '../../contexts/AuthContext'
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
import { useModules } from '../../contexts/ModulesContext'
|
import { useModules } from '../../contexts/ModulesContext'
|
||||||
@@ -156,6 +156,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
|||||||
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||||
Управление
|
Управление
|
||||||
</p>
|
</p>
|
||||||
|
<NavItem to="/users" icon={UserCog} label="Сотрудники" onClick={onClose} />
|
||||||
<NavItem to="/floor-map" icon={Map} label="План этажей" onClick={onClose} />
|
<NavItem to="/floor-map" icon={Map} label="План этажей" onClick={onClose} />
|
||||||
{isModuleActive('channel-manager') && (
|
{isModuleActive('channel-manager') && (
|
||||||
<NavItem
|
<NavItem
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Navigate, useNavigate } from 'react-router-dom'
|
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 { useAuth } from '../contexts/AuthContext'
|
||||||
import { useTheme } from '../contexts/ThemeContext'
|
import { useTheme } from '../contexts/ThemeContext'
|
||||||
import { cn } from '../lib/utils'
|
import { cn } from '../lib/utils'
|
||||||
@@ -11,9 +14,19 @@ const DEMO_EMAILS = [
|
|||||||
'admin@hotelsync.io',
|
'admin@hotelsync.io',
|
||||||
]
|
]
|
||||||
|
|
||||||
export function LoginPage() {
|
type LegalType = 'ooo' | 'ip' | 'ao' | 'pao' | 'other'
|
||||||
const { user, login } = useAuth()
|
|
||||||
const { theme, toggle } = useTheme()
|
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 navigate = useNavigate()
|
||||||
|
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
@@ -22,25 +35,444 @@ export function LoginPage() {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
if (user) {
|
|
||||||
if (user.role === 'super_admin') return <Navigate to="/admin" replace />
|
|
||||||
return <Navigate to="/calendar" replace />
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const loggedIn = await login(email, password)
|
const loggedIn = await login(email, password)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
if (!loggedIn) {
|
if (!loggedIn) { setError('Неверный email или пароль'); return }
|
||||||
setError('Неверный email или пароль')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (loggedIn.role === 'super_admin') navigate('/admin')
|
if (loggedIn.role === 'super_admin') navigate('/admin')
|
||||||
else navigate('/calendar')
|
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 (
|
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">
|
<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 */}
|
{/* Left panel — branding */}
|
||||||
@@ -73,14 +505,28 @@ export function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<p className="text-brand-200 text-sm">
|
<p className="text-brand-200 text-sm">© 2026 HotelSync · SaaS PMS Platform</p>
|
||||||
© 2026 HotelSync · SaaS PMS Platform
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right panel — login form */}
|
{/* Right panel */}
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
<div className="flex justify-end p-4">
|
<div className="flex justify-end p-4">
|
||||||
<button onClick={toggle} className="btn-ghost p-2">
|
<button onClick={toggle} className="btn-ghost p-2">
|
||||||
@@ -88,7 +534,7 @@ export function LoginPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div className="w-full max-w-md">
|
||||||
{/* Mobile logo */}
|
{/* Mobile logo */}
|
||||||
<div className="lg:hidden flex items-center gap-2.5 mb-8">
|
<div className="lg:hidden flex items-center gap-2.5 mb-8">
|
||||||
@@ -100,92 +546,10 @@ export function LoginPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-1">
|
{mode === 'login'
|
||||||
Добро пожаловать
|
? <LoginForm onSwitch={() => setMode('register')} />
|
||||||
</h2>
|
: <RegisterForm onSwitch={() => setMode('login')} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
536
src/pages/UsersPage.tsx
Normal file
536
src/pages/UsersPage.tsx
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Plus, Pencil, Trash2, Search, Shield, User as UserIcon,
|
||||||
|
Sparkles, Mail, Phone, X as XIcon, Eye, EyeOff, CheckCircle2, AlertCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { cn } from '../lib/utils'
|
||||||
|
import { Modal } from '../components/ui/Modal'
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type StaffRole = 'hotel_manager' | 'housekeeper' | 'receptionist' | 'accountant' | 'security'
|
||||||
|
|
||||||
|
interface StaffUser {
|
||||||
|
id: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
email: string
|
||||||
|
phone?: string
|
||||||
|
role: StaffRole
|
||||||
|
position: string // свободная должность, напр. "Старший администратор"
|
||||||
|
isActive: boolean
|
||||||
|
createdAt: string
|
||||||
|
lastLogin?: string
|
||||||
|
avatarColor: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 },
|
||||||
|
}
|
||||||
|
|
||||||
|
const AVATAR_COLORS = [
|
||||||
|
'#4F46E5', '#059669', '#2563EB', '#7C3AED',
|
||||||
|
'#DC2626', '#D97706', '#DB2777', '#0891B2',
|
||||||
|
]
|
||||||
|
|
||||||
|
const DEFAULT_POSITIONS: Record<StaffRole, string[]> = {
|
||||||
|
hotel_manager: ['Управляющий', 'Заместитель управляющего', 'Менеджер смены'],
|
||||||
|
receptionist: ['Старший администратор', 'Администратор', 'Ночной администратор'],
|
||||||
|
housekeeper: ['Старшая горничная', 'Горничная', 'Уборщик'],
|
||||||
|
accountant: ['Главный бухгалтер', 'Бухгалтер', 'Финансовый менеджер'],
|
||||||
|
security: ['Начальник охраны', 'Охранник', 'Контролёр доступа'],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mock data ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MOCK_USERS: StaffUser[] = [
|
||||||
|
{
|
||||||
|
id: 'u1', firstName: 'Александр', lastName: 'Петров',
|
||||||
|
email: 'manager@grand-palace.ru', phone: '+7 (999) 123-45-67',
|
||||||
|
role: 'hotel_manager', position: 'Управляющий',
|
||||||
|
isActive: true, createdAt: '2025-01-15', lastLogin: '2026-03-11',
|
||||||
|
avatarColor: '#4F46E5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'u2', firstName: 'Мария', lastName: 'Сидорова',
|
||||||
|
email: 'reception@grand-palace.ru', phone: '+7 (999) 234-56-78',
|
||||||
|
role: 'receptionist', position: 'Старший администратор',
|
||||||
|
isActive: true, createdAt: '2025-03-01', lastLogin: '2026-03-10',
|
||||||
|
avatarColor: '#059669',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'u3', firstName: 'Наталья', lastName: 'Козлова',
|
||||||
|
email: 'cleaner@grand-palace.ru',
|
||||||
|
role: 'housekeeper', position: 'Старшая горничная',
|
||||||
|
isActive: true, createdAt: '2025-04-10', lastLogin: '2026-03-11',
|
||||||
|
avatarColor: '#2563EB',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'u4', firstName: 'Дмитрий', lastName: 'Волков',
|
||||||
|
email: 'night@grand-palace.ru', phone: '+7 (999) 345-67-89',
|
||||||
|
role: 'receptionist', position: 'Ночной администратор',
|
||||||
|
isActive: false, createdAt: '2025-06-20',
|
||||||
|
avatarColor: '#7C3AED',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'u5', firstName: 'Елена', lastName: 'Морозова',
|
||||||
|
email: 'accounting@grand-palace.ru',
|
||||||
|
role: 'accountant', position: 'Главный бухгалтер',
|
||||||
|
isActive: true, createdAt: '2025-02-01', lastLogin: '2026-03-09',
|
||||||
|
avatarColor: '#D97706',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ── User Modal ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function UserModal({
|
||||||
|
open, user, onClose, onSave,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
user?: StaffUser
|
||||||
|
onClose: () => void
|
||||||
|
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,
|
||||||
|
avatarColor: user?.avatarColor ?? AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)],
|
||||||
|
})
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [showPass, setShowPass] = useState(false)
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
const isNew = !user
|
||||||
|
|
||||||
|
const set = <K extends keyof typeof form>(k: K, v: typeof form[K]) =>
|
||||||
|
setForm(prev => ({ ...prev, [k]: v }))
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
const e: Record<string, string> = {}
|
||||||
|
if (!form.firstName.trim()) e.firstName = 'Введите имя'
|
||||||
|
if (!form.lastName.trim()) e.lastName = 'Введите фамилию'
|
||||||
|
if (!form.email.trim()) e.email = 'Введите email'
|
||||||
|
if (!form.position.trim()) e.position = 'Введите должность'
|
||||||
|
if (isNew && password.length < 6) e.password = 'Минимум 6 символов'
|
||||||
|
setErrors(e)
|
||||||
|
return Object.keys(e).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!validate()) return
|
||||||
|
onSave({
|
||||||
|
...form,
|
||||||
|
id: user?.id ?? `u-${Date.now()}`,
|
||||||
|
createdAt: user?.createdAt ?? new Date().toISOString().slice(0, 10),
|
||||||
|
lastLogin: user?.lastLogin,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions = DEFAULT_POSITIONS[form.role]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={isNew ? 'Новый сотрудник' : 'Редактировать сотрудника'}
|
||||||
|
size="lg"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button onClick={onClose} className="btn-secondary">Отмена</button>
|
||||||
|
<button onClick={handleSave} className="btn-primary">
|
||||||
|
{isNew ? 'Создать' : 'Сохранить'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Avatar color + name */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
className="w-14 h-14 rounded-2xl flex items-center justify-center text-white text-xl font-bold shrink-0"
|
||||||
|
style={{ backgroundColor: form.avatarColor }}
|
||||||
|
>
|
||||||
|
{(form.firstName[0] ?? '?').toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
|
{AVATAR_COLORS.map(c => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => set('avatarColor', c)}
|
||||||
|
className={cn(
|
||||||
|
'w-6 h-6 rounded-lg transition-all',
|
||||||
|
form.avatarColor === c && 'ring-2 ring-offset-1 ring-slate-400',
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Имя *</label>
|
||||||
|
<input type="text" className={cn('input', errors.firstName && 'border-red-400')}
|
||||||
|
placeholder="Иван" value={form.firstName} onChange={e => set('firstName', e.target.value)} />
|
||||||
|
{errors.firstName && <p className="mt-1 text-xs text-red-500">{errors.firstName}</p>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Фамилия *</label>
|
||||||
|
<input type="text" className={cn('input', errors.lastName && 'border-red-400')}
|
||||||
|
placeholder="Иванов" value={form.lastName} onChange={e => set('lastName', e.target.value)} />
|
||||||
|
{errors.lastName && <p className="mt-1 text-xs text-red-500">{errors.lastName}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Email *</label>
|
||||||
|
<input type="email" className={cn('input', errors.email && 'border-red-400')}
|
||||||
|
placeholder="staff@hotel.ru" value={form.email} onChange={e => set('email', e.target.value)} />
|
||||||
|
{errors.email && <p className="mt-1 text-xs text-red-500">{errors.email}</p>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Телефон</label>
|
||||||
|
<input type="tel" className="input"
|
||||||
|
placeholder="+7 (999) 000-00-00" value={form.phone} onChange={e => set('phone', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Роль в системе *</label>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Position */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Должность *</label>
|
||||||
|
<input type="text" className={cn('input mb-1.5', errors.position && 'border-red-400')}
|
||||||
|
placeholder="Например: Старший администратор" value={form.position}
|
||||||
|
onChange={e => set('position', e.target.value)} />
|
||||||
|
{errors.position && <p className="mt-1 text-xs text-red-500">{errors.position}</p>}
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||||
|
{suggestions.map(s => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
onClick={() => set('position', s)}
|
||||||
|
className={cn(
|
||||||
|
'text-xs px-2 py-0.5 rounded-full border transition-colors',
|
||||||
|
form.position === s
|
||||||
|
? 'bg-brand-600 border-brand-600 text-white'
|
||||||
|
: 'border-slate-200 dark:border-slate-600 text-slate-500 dark:text-slate-400 hover:border-brand-400 hover:text-brand-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||||
|
{isNew ? 'Пароль *' : 'Новый пароль (оставьте пустым, чтобы не менять)'}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPass ? 'text' : 'password'}
|
||||||
|
className={cn('input pr-10', errors.password && 'border-red-400')}
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => setShowPass(v => !v)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600">
|
||||||
|
{showPass ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{errors.password && <p className="mt-1 text-xs text-red-500">{errors.password}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active toggle */}
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-lg bg-slate-50 dark:bg-slate-700/40">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">Активный сотрудник</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">Неактивные не могут войти в систему</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => set('isActive', !form.isActive)}
|
||||||
|
className={cn('relative w-11 h-6 rounded-full transition-colors', form.isActive ? 'bg-brand-600' : 'bg-slate-200 dark:bg-slate-600')}
|
||||||
|
>
|
||||||
|
<div className={cn('absolute top-0.5 w-5 h-5 rounded-full bg-white shadow-sm transition-transform', form.isActive ? 'left-[22px]' : 'left-0.5')} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function UsersPage() {
|
||||||
|
const [users, setUsers] = useState<StaffUser[]>(MOCK_USERS)
|
||||||
|
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)
|
||||||
|
|
||||||
|
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 = (u: StaffUser) => {
|
||||||
|
setUsers(prev => {
|
||||||
|
const idx = prev.findIndex(x => x.id === u.id)
|
||||||
|
if (idx >= 0) { const next = [...prev]; next[idx] = u; return next }
|
||||||
|
return [...prev, u]
|
||||||
|
})
|
||||||
|
setModalOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
setUsers(prev => prev.filter(u => u.id !== id))
|
||||||
|
setDeleteId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: users.length,
|
||||||
|
active: users.filter(u => u.isActive).length,
|
||||||
|
managers: users.filter(u => u.role === 'hotel_manager').length,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 md:p-6 space-y-5">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">Сотрудники</h1>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">{stats.total} пользователей · {stats.active} активных</p>
|
||||||
|
</div>
|
||||||
|
<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')}
|
||||||
|
>
|
||||||
|
Все
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user