From c3723dec9000167466ca038fb7cfdba076f2b744 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Wed, 11 Mar 2026 22:45:11 +0300 Subject: [PATCH] Add hotel registration flow on login page and staff users management page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/App.tsx | 2 + src/components/layout/Sidebar.tsx | 5 +- src/pages/LoginPage.tsx | 584 ++++++++++++++++++++++++------ src/pages/UsersPage.tsx | 536 +++++++++++++++++++++++++++ 4 files changed, 1015 insertions(+), 112 deletions(-) create mode 100644 src/pages/UsersPage.tsx diff --git a/src/App.tsx b/src/App.tsx index af141fb..d24875c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,7 @@ import { RoomServicePage } from './pages/RoomServicePage' import { RentalPage } from './pages/RentalPage' import { RoomCategoriesPage } from './pages/RoomCategoriesPage' import { DocumentsPage } from './pages/DocumentsPage' +import { UsersPage } from './pages/UsersPage' export default function App() { return ( @@ -59,6 +60,7 @@ export default function App() { } /> } /> } /> + } /> {/* Admin routes */} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index bc2c360..ef3c0b9 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,7 +1,7 @@ import { NavLink, useNavigate } from 'react-router-dom' import { 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' import { useAuth } from '../../contexts/AuthContext' import { useModules } from '../../contexts/ModulesContext' @@ -156,7 +156,8 @@ export function Sidebar({ open, onClose }: SidebarProps) {

Управление

- + + {isModuleActive('channel-manager') && ( 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 - return - } + 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 ( + <> +

+ Добро пожаловать +

+

+ Войдите в аккаунт для доступа к панели управления +

+ +
+
+ + setEmail(e.target.value)} required + /> +
+
+ +
+ setPassword(e.target.value)} required + /> + +
+
+ + {error && ( +
+ {error} +
+ )} + + +
+ + {/* Demo hint */} +
+

+ Демо-аккаунты (пароль: demo): +

+
+ {DEMO_EMAILS.map(e => ( + + ))} +
+
+ +

+ Нет аккаунта?{' '} + +

+ + ) +} + +// ── 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('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>({}) + + const isIp = legalType === 'ip' + + const validateStep1 = () => { + const e: Record = {} + 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 = {} + 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 }) => ( +
+ + {children} + {hint && !error &&

{hint}

} + {error && ( +

+ {error} +

+ )} +
+ ) + + if (step === 'done') { + return ( +
+
+ +
+

+ Заявка отправлена! +

+

+ Мы получили данные отеля {hotelName}. +

+

+ На адрес {email} отправлено + письмо с подтверждением. После проверки данных менеджер HotelSync активирует аккаунт в течение одного рабочего дня. +

+ +
+ ) + } + + return ( + <> +
+
+

+ {step === 1 ? 'Регистрация отеля' : 'Данные аккаунта'} +

+

Шаг {step} из 2

+
+ {/* Step indicator */} +
+ {[1, 2].map(s => ( +
+ {s < step ? : s} +
+ ))} +
+
+ + {/* ── Step 1: Hotel + Legal entity ── */} + {step === 1 && ( +
+ + setHotelName(e.target.value)} + /> + + +
+
+ +

Юридическое лицо

+
+ + {/* Legal type selector */} +
+ +
+ {LEGAL_TYPES.map(lt => ( + + ))} +
+
+ +
+ + setLegalName(e.target.value)} + /> + + +
+ + setInn(e.target.value.replace(/\D/g, ''))} + /> + + {!isIp && ( + + setKpp(e.target.value.replace(/\D/g, ''))} + /> + + )} +
+ + + setAddress(e.target.value)} + /> + +
+
+ + + +

+ Уже есть аккаунт?{' '} + +

+
+ )} + + {/* ── Step 2: Account ── */} + {step === 2 && ( +
+
+ + setFirstName(e.target.value)} + /> + + + setLastName(e.target.value)} + /> + +
+ + + setEmail(e.target.value)} + /> + + + + setPhone(e.target.value)} + /> + + + +
+ setPassword(e.target.value)} + /> + +
+ {password && ( +
+ {[ + password.length >= 8, + /[A-Z]/.test(password), + /[0-9]/.test(password), + ].map((ok, i) => ( +
+ ))} +
+ )} + + + +
+ setConfirm(e.target.value)} + /> + +
+
+ +

+ Нажимая «Создать аккаунт», вы соглашаетесь с{' '} + условиями использования{' '} + и{' '} + политикой конфиденциальности +

+ +
+ + +
+ + )} + + ) +} + +// ── 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 + return + } + return (
{/* Left panel — branding */} @@ -63,8 +495,8 @@ export function LoginPage() {
{[ - { value: '500+', label: 'Отелей' }, - { value: '1M+', label: 'Бронирований' }, + { value: '500+', label: 'Отелей' }, + { value: '1M+', label: 'Бронирований' }, { value: '99.9%', label: 'Аптайм' }, ].map(s => (
@@ -73,14 +505,28 @@ export function LoginPage() {
))}
+ + {/* Registration benefits highlight */} + {mode === 'register' && ( +
+ {[ + 'Бесплатный период 14 дней без ввода карты', + 'Настройка за 15 минут — импорт из любой PMS', + 'Поддержка на русском языке 24/7', + ].map(b => ( +
+ +

{b}

+
+ ))} +
+ )}
-

- © 2026 HotelSync · SaaS PMS Platform -

+

© 2026 HotelSync · SaaS PMS Platform

- {/* Right panel — login form */} + {/* Right panel */}
-
+
{/* Mobile logo */}
@@ -100,92 +546,10 @@ export function LoginPage() {
-

- Добро пожаловать -

-

- Войдите в свой аккаунт для доступа к панели управления -

- - {/* Form */} -
-
- - setEmail(e.target.value)} - required - /> -
- -
- -
- setPassword(e.target.value)} - required - /> - -
-
- - {error && ( -
- - {error} -
- )} - - -
- - {/* Demo hint */} -
-

- Демо-аккаунты (пароль: demo): -

-
- {DEMO_EMAILS.map(e => ( - - ))} -
-
+ {mode === 'login' + ? setMode('register')} /> + : setMode('login')} /> + }
diff --git a/src/pages/UsersPage.tsx b/src/pages/UsersPage.tsx new file mode 100644 index 0000000..588629b --- /dev/null +++ b/src/pages/UsersPage.tsx @@ -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 = { + 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 = { + 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>({}) + const isNew = !user + + const set = (k: K, v: typeof form[K]) => + setForm(prev => ({ ...prev, [k]: v })) + + const validate = () => { + const e: Record = {} + 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 ( + + + + + } + > +
+ {/* Avatar color + name */} +
+
+ {(form.firstName[0] ?? '?').toUpperCase()} +
+
+ {AVATAR_COLORS.map(c => ( +
+
+ +
+
+ + set('firstName', e.target.value)} /> + {errors.firstName &&

{errors.firstName}

} +
+
+ + set('lastName', e.target.value)} /> + {errors.lastName &&

{errors.lastName}

} +
+
+ +
+
+ + set('email', e.target.value)} /> + {errors.email &&

{errors.email}

} +
+
+ + set('phone', e.target.value)} /> +
+
+ + {/* Role */} +
+ +
+ {(Object.entries(ROLE_META) as [StaffRole, typeof ROLE_META[StaffRole]][]).map(([id, meta]) => ( + + ))} +
+
+ + {/* Position */} +
+ + set('position', e.target.value)} /> + {errors.position &&

{errors.position}

} +
+ {suggestions.map(s => ( + + ))} +
+
+ + {/* Password */} +
+ +
+ setPassword(e.target.value)} + /> + +
+ {errors.password &&

{errors.password}

} +
+ + {/* Active toggle */} +
+
+

Активный сотрудник

+

Неактивные не могут войти в систему

+
+ +
+
+
+ ) +} + +// ── Page ─────────────────────────────────────────────────────────────────────── + +export function UsersPage() { + const [users, setUsers] = useState(MOCK_USERS) + const [search, setSearch] = useState('') + const [roleFilter, setRoleFilter] = useState('all') + const [modalOpen, setModalOpen] = useState(false) + const [editing, setEditing] = useState() + const [deleteId, setDeleteId] = useState(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 ( +
+ {/* Header */} +
+
+

Сотрудники

+

{stats.total} пользователей · {stats.active} активных

+
+ +
+ + {/* Stats */} +
+ {(Object.entries(ROLE_META) as [StaffRole, typeof ROLE_META[StaffRole]][]).map(([role, meta]) => { + const count = users.filter(u => u.role === role).length + return ( +
setRoleFilter(roleFilter === role ? 'all' : role)}> +
+ + + {meta.label} + +
+

{count}

+
+ ) + })} +
+ + {/* Filters */} +
+
+ + setSearch(e.target.value)} /> +
+
+ + {(Object.entries(ROLE_META) as [StaffRole, typeof ROLE_META[StaffRole]][]).map(([role, meta]) => ( + + ))} +
+
+ + {/* Table */} +
+
+ + + + + + + + + + + + {filtered.map(u => { + const meta = ROLE_META[u.role] + return ( + + + + + + + + + ) + })} + +
СотрудникРоль / ДолжностьКонтактыПоследний входСтатус +
+
+
+ {u.firstName[0]?.toUpperCase()} +
+
+

+ {u.lastName} {u.firstName} +

+

{u.email}

+
+
+
+ + {meta.label} + +

{u.position}

+
+
+
+ {u.email} +
+ {u.phone && ( +
+ {u.phone} +
+ )} +
+
+

+ {u.lastLogin + ? new Date(u.lastLogin).toLocaleDateString('ru-RU') + : '—'} +

+
+ + {u.isActive + ? <> Активен + : <> Неактивен} + + +
+ + +
+
+ + {filtered.length === 0 && ( +
+ +

Сотрудники не найдены

+
+ )} +
+
+ + {/* User modal */} + setModalOpen(false)} + onSave={handleSave} + /> + + {/* Delete confirm */} + {deleteId && ( +
+
+

Удалить сотрудника?

+

+ Сотрудник потеряет доступ к системе. Историю его действий можно будет просмотреть в журнале. +

+
+ + +
+
+
+ )} +
+ ) +}