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:
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Search, Filter, Plus, ArrowUpDown } from 'lucide-react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Search, Plus, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-react'
|
||||
import { MOCK_BOOKINGS, MOCK_ROOMS } from '../data/mockData'
|
||||
import type { Booking, BookingStatus } from '../types'
|
||||
import { cn, BOOKING_STATUS_BADGE, BOOKING_STATUS_LABELS, SOURCE_COLORS, SOURCE_LABELS, formatCurrency, nightsCount } from '../lib/utils'
|
||||
@@ -17,12 +17,32 @@ const STATUS_FILTERS: { label: string; value: BookingStatus | 'all' }[] = [
|
||||
{ label: 'Отменены', value: 'cancelled' },
|
||||
]
|
||||
|
||||
type SortKey = 'guestName' | 'checkIn' | 'checkOut' | 'status' | 'source' | 'totalAmount'
|
||||
|
||||
const COLUMNS: { key: SortKey | null; label: string }[] = [
|
||||
{ key: 'guestName', label: 'Гость' },
|
||||
{ key: null, label: 'Номер' },
|
||||
{ key: 'checkIn', label: 'Заезд' },
|
||||
{ key: 'checkOut', label: 'Выезд' },
|
||||
{ key: 'status', label: 'Статус' },
|
||||
{ key: 'source', label: 'Источник' },
|
||||
{ key: 'totalAmount', label: 'Сумма' },
|
||||
{ key: null, label: '' },
|
||||
]
|
||||
|
||||
export function BookingsPage() {
|
||||
const [bookings, setBookings] = useState<Booking[]>(MOCK_BOOKINGS)
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<BookingStatus | 'all'>('all')
|
||||
const [selected, setSelected] = useState<Booking | null>(null)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [sortKey, setSortKey] = useState<SortKey | null>(null)
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
||||
|
||||
const handleSort = (key: SortKey) => {
|
||||
if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc')
|
||||
else { setSortKey(key); setSortDir('asc') }
|
||||
}
|
||||
|
||||
const filtered = bookings.filter(b => {
|
||||
const matchSearch = search === '' ||
|
||||
@@ -33,6 +53,21 @@ export function BookingsPage() {
|
||||
return matchSearch && matchStatus
|
||||
})
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (!sortKey) return filtered
|
||||
return [...filtered].sort((a, b) => {
|
||||
const av = a[sortKey]
|
||||
const bv = b[sortKey]
|
||||
let cmp = 0
|
||||
if (typeof av === 'string' && typeof bv === 'string') {
|
||||
cmp = av.localeCompare(bv, 'ru')
|
||||
} else {
|
||||
cmp = (av as number) - (bv as number)
|
||||
}
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}, [filtered, sortKey, sortDir])
|
||||
|
||||
const room = (id: string) => MOCK_ROOMS.find(r => r.id === id)
|
||||
|
||||
return (
|
||||
@@ -41,7 +76,7 @@ export function BookingsPage() {
|
||||
<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">{filtered.length} из {bookings.length}</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{sorted.length} из {bookings.length}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
@@ -88,15 +123,31 @@ export function BookingsPage() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-700/40">
|
||||
{['Гость', 'Номер', 'Заезд', 'Выезд', 'Статус', 'Источник', 'Сумма', ''].map(h => (
|
||||
<th key={h} className="text-left px-4 py-3 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">
|
||||
{h}
|
||||
{COLUMNS.map(col => (
|
||||
<th
|
||||
key={col.label}
|
||||
className="text-left px-4 py-3 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide"
|
||||
>
|
||||
{col.key ? (
|
||||
<button
|
||||
onClick={() => handleSort(col.key!)}
|
||||
className="flex items-center gap-1 hover:text-slate-700 dark:hover:text-slate-200 transition-colors"
|
||||
>
|
||||
{col.label}
|
||||
{sortKey === col.key
|
||||
? sortDir === 'asc'
|
||||
? <ArrowUp size={12} className="text-brand-600" />
|
||||
: <ArrowDown size={12} className="text-brand-600" />
|
||||
: <ArrowUpDown size={12} className="opacity-30" />
|
||||
}
|
||||
</button>
|
||||
) : col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(b => {
|
||||
{sorted.map(b => {
|
||||
const r = room(b.roomId)
|
||||
const nights = nightsCount(b.checkIn, b.checkOut)
|
||||
return (
|
||||
@@ -152,7 +203,7 @@ export function BookingsPage() {
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{filtered.length === 0 && (
|
||||
{sorted.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
|
||||
Бронирования не найдены
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Booking } from '../types'
|
||||
|
||||
export function CalendarPage() {
|
||||
const [bookings, setBookings] = useState<Booking[]>(MOCK_BOOKINGS)
|
||||
const [fadingBookings, setFadingBookings] = useState<Set<string>>(new Set())
|
||||
|
||||
const handleCreate = (data: Partial<Booking>) => {
|
||||
setBookings(prev => [...prev, data as Booking])
|
||||
@@ -12,6 +13,17 @@ export function CalendarPage() {
|
||||
|
||||
const handleUpdate = (id: string, data: Partial<Booking>) => {
|
||||
setBookings(prev => prev.map(b => b.id === id ? { ...b, ...data } : b))
|
||||
if (data.status === 'cancelled') {
|
||||
setFadingBookings(prev => new Set([...prev, id]))
|
||||
setTimeout(() => {
|
||||
setBookings(prev => prev.filter(b => b.id !== id))
|
||||
setFadingBookings(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(id)
|
||||
return next
|
||||
})
|
||||
}, 900)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -30,6 +42,7 @@ export function CalendarPage() {
|
||||
bookings={bookings}
|
||||
onBookingCreate={handleCreate}
|
||||
onBookingUpdate={handleUpdate}
|
||||
fadingBookingIds={fadingBookings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,10 @@ import { useAuth } from '../contexts/AuthContext'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
const DEMO_ACCOUNTS = [
|
||||
{ label: 'Менеджер отеля', email: 'manager@grand-palace.ru', role: 'hotel_manager' },
|
||||
{ label: 'Горничная', email: 'cleaner@grand-palace.ru', role: 'housekeeper' },
|
||||
{ label: 'Супер-администратор', email: 'admin@hotelsync.io', role: 'super_admin' },
|
||||
const DEMO_EMAILS = [
|
||||
'manager@grand-palace.ru',
|
||||
'cleaner@grand-palace.ru',
|
||||
'admin@hotelsync.io',
|
||||
]
|
||||
|
||||
export function LoginPage() {
|
||||
@@ -16,8 +16,8 @@ export function LoginPage() {
|
||||
const { theme, toggle } = useTheme()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [email, setEmail] = useState('manager@grand-palace.ru')
|
||||
const [password, setPassword] = useState('demo')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPass, setShowPass] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
@@ -41,12 +41,6 @@ export function LoginPage() {
|
||||
else navigate('/calendar')
|
||||
}
|
||||
|
||||
const fillDemo = (acc: typeof DEMO_ACCOUNTS[number]) => {
|
||||
setEmail(acc.email)
|
||||
setPassword('demo')
|
||||
setError('')
|
||||
}
|
||||
|
||||
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 */}
|
||||
@@ -88,7 +82,6 @@ export function LoginPage() {
|
||||
|
||||
{/* Right panel — login form */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Theme toggle */}
|
||||
<div className="flex justify-end p-4">
|
||||
<button onClick={toggle} className="btn-ghost p-2">
|
||||
{theme === 'light' ? <Moon size={18} /> : <Sun size={18} />}
|
||||
@@ -114,29 +107,6 @@ export function LoginPage() {
|
||||
Войдите в свой аккаунт для доступа к панели управления
|
||||
</p>
|
||||
|
||||
{/* Demo account chips */}
|
||||
<div className="mb-5">
|
||||
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">
|
||||
Демо-аккаунты:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DEMO_ACCOUNTS.map(acc => (
|
||||
<button
|
||||
key={acc.email}
|
||||
onClick={() => fillDemo(acc)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
|
||||
email === acc.email
|
||||
? '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 hover:border-brand-400',
|
||||
)}
|
||||
>
|
||||
{acc.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
@@ -194,9 +164,28 @@ export function LoginPage() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-xs text-slate-400 dark:text-slate-500 mt-6">
|
||||
Пароль для демо: <code className="bg-slate-100 dark:bg-slate-700 px-1.5 py-0.5 rounded">demo</code>
|
||||
</p>
|
||||
{/* 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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user