Major UX improvements across multiple pages

Шахматка:
- Date/period picker dropdown on navigation button (choose start date + days window)
- Cancelled bookings fade out with animation after 1 second

Бронирования:
- Clickable column headers with sort asc/desc (Гость, Заезд, Выезд, Статус, Источник, Сумма)

Страница входа:
- Removed role-based account selector — just email + password
- System auto-detects role/hotel from credentials

Настройки:
- New "Бронирование" section with room assignment strategy (spread/together/sequential/manual)
- Notifications: SMTP email config + SMS provider config (SMSC, SMS.ru, МТС, etc.)

Модули:
- Added Housekeeping and Channel Manager as proper modules
- Channel Manager lists: Booking.com, Airbnb, Яндекс Путешествия, Островок, Суточно.ру, OneTwoTrip
- Housekeeping visible to all roles (including housekeeper) via module status
- Sidebar now uses module status to show/hide Уборка and Каналы

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 15:20:05 +03:00
parent 4e615359eb
commit c233590c4b
29 changed files with 2171 additions and 149 deletions

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Save, Building2, Bell, Shield, Globe, CreditCard } from 'lucide-react'
import { Save, Building2, Bell, Shield, Globe, CreditCard, BedDouble, Mail, MessageSquare } from 'lucide-react'
import { MOCK_HOTELS } from '../data/mockData'
import { cn, PLAN_COLORS, PLAN_LABELS } from '../lib/utils'
import { Badge } from '../components/ui/Badge'
@@ -7,12 +7,24 @@ import { useTheme } from '../contexts/ThemeContext'
const SECTIONS = [
{ id: 'general', label: 'Основные', icon: Building2 },
{ id: 'booking', label: 'Бронирование', icon: BedDouble },
{ id: 'theme', label: 'Внешний вид', icon: Globe },
{ id: 'notify', label: 'Уведомления', icon: Bell },
{ id: 'security', label: 'Безопасность', icon: Shield },
{ id: 'billing', label: 'Тарифный план', icon: CreditCard },
]
function Toggle({ on, onChange }: { on: boolean; onChange: () => void }) {
return (
<button
onClick={onChange}
className={cn('relative w-11 h-6 rounded-full transition-colors shrink-0', on ? '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', on ? 'left-[22px]' : 'left-0.5')} />
</button>
)
}
export function SettingsPage() {
const [section, setSection] = useState('general')
const [saved, setSaved] = useState(false)
@@ -28,6 +40,32 @@ export function SettingsPage() {
checkOutTime: '12:00',
})
// Booking / assignment settings
const [assignmentStrategy, setAssignmentStrategy] = useState<'spread' | 'together' | 'sequential' | 'manual'>('spread')
// Notification toggles
const [notifyToggles, setNotifyToggles] = useState({
newBooking: true,
cancellation: true,
channelError: true,
dailyReport: false,
})
// SMTP settings
const [smtp, setSmtp] = useState({
host: '',
port: '587',
user: '',
password: '',
fromEmail: '',
fromName: '',
})
// SMS settings
const [smsProvider, setSmsProvider] = useState('')
const [smsApiKey, setSmsApiKey] = useState('')
const [smsSender, setSmsSender] = useState('')
const handleSave = () => {
setSaved(true)
setTimeout(() => setSaved(false), 2000)
@@ -65,17 +103,15 @@ export function SettingsPage() {
{/* Mobile nav */}
<div className="md:hidden w-full mb-4">
<select
value={section}
onChange={e => setSection(e.target.value)}
className="input"
>
<select value={section} onChange={e => setSection(e.target.value)} className="input">
{SECTIONS.map(s => <option key={s.id} value={s.id}>{s.label}</option>)}
</select>
</div>
{/* Content */}
<div className="flex-1 card p-5 space-y-5">
{/* ── GENERAL ── */}
{section === 'general' && (
<>
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Основная информация</h2>
@@ -120,6 +156,75 @@ export function SettingsPage() {
</>
)}
{/* ── BOOKING ── */}
{section === 'booking' && (
<>
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Настройки бронирования</h2>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Стратегия автоматического расселения
</label>
<p className="text-xs text-slate-400 mb-3">
Как система выбирает номер при автоматическом бронировании (кнопка «Забронировать» без выбора номера вручную)
</p>
<div className="space-y-2">
{([
{
id: 'spread',
label: 'Разброс (шахматный порядок)',
desc: 'Максимальное расстояние между гостями — номера заполняются через один. Рекомендуется для большинства отелей.',
},
{
id: 'together',
label: 'Рядом',
desc: 'Соседние номера подряд. Удобно для семей и групп, которые хотят быть близко.',
},
{
id: 'sequential',
label: 'Последовательно',
desc: 'Следующий свободный номер в порядке нумерации. Упрощает навигацию персонала.',
},
{
id: 'manual',
label: 'Вручную',
desc: 'Менеджер всегда сам выбирает номер при создании брони. Автоподбора нет.',
},
] as const).map(opt => (
<label
key={opt.id}
className={cn(
'flex items-start gap-3 p-3.5 rounded-xl border-2 cursor-pointer transition-all',
assignmentStrategy === opt.id
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
: 'border-slate-200 dark:border-slate-600 hover:border-slate-300 dark:hover:border-slate-500',
)}
>
<input
type="radio"
name="assignment"
value={opt.id}
checked={assignmentStrategy === opt.id}
onChange={() => setAssignmentStrategy(opt.id)}
className="mt-0.5 accent-brand-600"
/>
<div>
<p className={cn(
'text-sm font-medium',
assignmentStrategy === opt.id ? 'text-brand-700 dark:text-brand-300' : 'text-slate-900 dark:text-slate-100',
)}>
{opt.label}
</p>
<p className="text-xs text-slate-400 mt-0.5">{opt.desc}</p>
</div>
</label>
))}
</div>
</div>
</>
)}
{/* ── THEME ── */}
{section === 'theme' && (
<>
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Внешний вид</h2>
@@ -128,17 +233,15 @@ export function SettingsPage() {
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">Тема интерфейса</p>
<div className="grid grid-cols-2 gap-3">
{([
{ id: 'light', label: 'Светлая', preview: 'bg-white border-2' },
{ id: 'dark', label: 'Тёмная', preview: 'bg-slate-900 border-2' },
{ id: 'light', label: 'Светлая' },
{ id: 'dark', label: 'Тёмная' },
] as const).map(t => (
<button
key={t.id}
onClick={() => { if (theme !== t.id) toggle() }}
className={cn(
'p-4 rounded-xl border-2 transition-all text-left',
theme === t.id
? 'border-brand-500'
: 'border-slate-200 dark:border-slate-600 hover:border-slate-300',
theme === t.id ? 'border-brand-500' : 'border-slate-200 dark:border-slate-600 hover:border-slate-300',
)}
>
<div className={cn('w-full h-14 rounded-lg mb-2', t.id === 'light' ? 'bg-white border border-slate-200' : 'bg-slate-800')}>
@@ -146,9 +249,7 @@ export function SettingsPage() {
<div className={cn('h-2 w-1/2 rounded mx-2 mt-1', t.id === 'light' ? 'bg-slate-100' : 'bg-slate-700')} />
</div>
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{t.label}</p>
{theme === t.id && (
<p className="text-xs text-brand-600 dark:text-brand-400">Активна</p>
)}
{theme === t.id && <p className="text-xs text-brand-600 dark:text-brand-400">Активна</p>}
</button>
))}
</div>
@@ -157,30 +258,112 @@ export function SettingsPage() {
</>
)}
{/* ── NOTIFICATIONS ── */}
{section === 'notify' && (
<>
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Уведомления</h2>
<div className="space-y-3">
{[
{ label: 'Новые бронирования', sub: 'Email при создании нового бронирования' },
{ label: 'Отмены', sub: 'Email при отмене бронирования' },
{ label: 'Ошибки синхронизации каналов', sub: 'Уведомление при сбое синхронизации' },
{ label: 'Ежедневный отчёт', sub: 'Сводка загрузки на день в 8:00' },
].map((n, i) => (
<div key={i} className="flex items-center justify-between p-3 rounded-lg bg-slate-50 dark:bg-slate-700/40">
{/* Toggles */}
<div className="space-y-2">
{([
{ key: 'newBooking' as const, label: 'Новые бронирования', sub: 'При создании нового бронирования' },
{ key: 'cancellation' as const, label: 'Отмены', sub: 'При отмене бронирования' },
{ key: 'channelError' as const, label: 'Ошибки синхронизации каналов', sub: 'При сбое синхронизации с OTA' },
{ key: 'dailyReport' as const, label: 'Ежедневный отчёт', sub: 'Сводка загрузки на день в 8:00' },
] as const).map(n => (
<div key={n.key} 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">{n.label}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{n.sub}</p>
</div>
<button className="relative w-11 h-6 rounded-full bg-brand-600">
<div className="absolute top-0.5 left-[22px] w-5 h-5 rounded-full bg-white shadow-sm" />
</button>
<Toggle on={notifyToggles[n.key]} onChange={() => setNotifyToggles(p => ({ ...p, [n.key]: !p[n.key] }))} />
</div>
))}
</div>
{/* SMTP */}
<div className="pt-2 border-t border-slate-100 dark:border-slate-700">
<div className="flex items-center gap-2 mb-1">
<Mail size={15} className="text-slate-500" />
<h3 className="text-sm font-semibold text-slate-800 dark:text-slate-200">Email-уведомления (SMTP)</h3>
</div>
<p className="text-xs text-slate-400 mb-3">
Если не заполнено письма отправляются с ящика HotelSync (<code className="bg-slate-100 dark:bg-slate-700 px-1 rounded">noreply@hotelsync.ru</code>)
</p>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">SMTP-хост</label>
<input type="text" className="input text-sm" placeholder="smtp.gmail.com" value={smtp.host} onChange={e => setSmtp(p => ({ ...p, host: e.target.value }))} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Порт</label>
<input type="number" className="input text-sm" placeholder="587" value={smtp.port} onChange={e => setSmtp(p => ({ ...p, port: e.target.value }))} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Пользователь</label>
<input type="text" className="input text-sm" placeholder="user@gmail.com" value={smtp.user} onChange={e => setSmtp(p => ({ ...p, user: e.target.value }))} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Пароль</label>
<input type="password" className="input text-sm" placeholder="••••••••" value={smtp.password} onChange={e => setSmtp(p => ({ ...p, password: e.target.value }))} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Email отправителя</label>
<input type="email" className="input text-sm" placeholder="hotel@myhotel.ru" value={smtp.fromEmail} onChange={e => setSmtp(p => ({ ...p, fromEmail: e.target.value }))} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Имя отправителя</label>
<input type="text" className="input text-sm" placeholder="Grand Palace Hotel" value={smtp.fromName} onChange={e => setSmtp(p => ({ ...p, fromName: e.target.value }))} />
</div>
</div>
</div>
</div>
{/* SMS */}
<div className="pt-2 border-t border-slate-100 dark:border-slate-700">
<div className="flex items-center gap-2 mb-1">
<MessageSquare size={15} className="text-slate-500" />
<h3 className="text-sm font-semibold text-slate-800 dark:text-slate-200">SMS-уведомления</h3>
</div>
<p className="text-xs text-slate-400 mb-3">
SMS отправляются гостям при подтверждении брони, заезде и выезде
</p>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">SMS-провайдер</label>
<select className="input text-sm" value={smsProvider} onChange={e => setSmsProvider(e.target.value)}>
<option value="">Не настроено</option>
<option value="smsc">SMSC.ru</option>
<option value="smsru">SMS.ru</option>
<option value="mts">МТС Коммуникатор</option>
<option value="beeline">Beeline Business</option>
<option value="smsaero">SMS Aero</option>
</select>
</div>
{smsProvider && (
<>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">API-ключ</label>
<input type="password" className="input text-sm" placeholder="Ваш API-ключ" value={smsApiKey} onChange={e => setSmsApiKey(e.target.value)} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Имя отправителя</label>
<input type="text" className="input text-sm" placeholder="MYHOTEL" maxLength={11} value={smsSender} onChange={e => setSmsSender(e.target.value)} />
<p className="text-xs text-slate-400 mt-1">Латиницей, до 11 символов. Требует регистрации у провайдера.</p>
</div>
</>
)}
</div>
</div>
</>
)}
{/* ── SECURITY ── */}
{section === 'security' && (
<>
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Безопасность</h2>
@@ -201,6 +384,7 @@ export function SettingsPage() {
</>
)}
{/* ── BILLING ── */}
{section === 'billing' && (
<>
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Тарифный план</h2>
@@ -208,18 +392,16 @@ export function SettingsPage() {
<div>
<p className="text-sm text-slate-500 dark:text-slate-400">Текущий план</p>
<div className="flex items-center gap-2 mt-1">
<Badge className={PLAN_COLORS[hotel.plan]}>
{PLAN_LABELS[hotel.plan]}
</Badge>
<Badge className={PLAN_COLORS[hotel.plan]}>{PLAN_LABELS[hotel.plan]}</Badge>
</div>
</div>
<button className="btn-primary">Улучшить план</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{([
{ plan: 'starter', price: '990 ₽/мес', rooms: '10 номеров', channels: '1 канал', support: 'Email' },
{ plan: 'pro', price: '3 490 ₽/мес', rooms: 'До 50 номеров', channels: '5 каналов', support: 'Чат + Email' },
{ plan: 'enterprise', price: 'Договорная', rooms: 'Неограничено', channels: 'Все каналы', support: 'Выделенный менеджер' },
{ plan: 'starter', price: '990 ₽/мес', rooms: '10 номеров', channels: '1 канал', support: 'Email' },
{ plan: 'pro', price: '3 490 ₽/мес', rooms: 'До 50 номеров', channels: '5 каналов', support: 'Чат + Email' },
{ plan: 'enterprise', price: 'Договорная', rooms: 'Неограничено', channels: 'Все каналы', support: 'Выделенный менеджер' },
] as const).map(p => (
<div key={p.plan} className={cn(
'p-4 rounded-xl border-2 transition-all',