- DB migration 016: add early_checkin_fee, late_checkout_fee to rooms table - Backend rooms: support new fee columns in POST/PATCH - Backend hotel-settings GET: also returns check_in_time, check_out_time - Room type + RoomPayload: add earlyCheckinFee, lateCheckoutFee fields - RoomModal: add fee amount inputs in pricing section - SettingsPage: add toggles for early_checkin_enabled / late_checkout_enabled - BookingDetailPanel: on check-in shows fee warning + adds payment record if current time is before checkInTime and fee is set on the room; same logic for check-out after checkOutTime Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
791 lines
41 KiB
TypeScript
791 lines
41 KiB
TypeScript
import { useState, useEffect } from 'react'
|
||
import { Save, Building2, Bell, Shield, Globe, CreditCard, BedDouble, Mail, MessageSquare, Plus, X as XIcon, Send, CheckCircle2, Copy } from 'lucide-react'
|
||
import { api } from '../lib/api'
|
||
import { useAuth } from '../contexts/AuthContext'
|
||
import type { Hotel } from '../types'
|
||
import { cn, PLAN_COLORS, PLAN_LABELS } from '../lib/utils'
|
||
import { Badge } from '../components/ui/Badge'
|
||
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 { user } = useAuth()
|
||
const slug = user?.hotelSlug ?? ''
|
||
const [section, setSection] = useState('general')
|
||
const [saved, setSaved] = useState(false)
|
||
const [hotel, setHotel] = useState<Hotel | null>(null)
|
||
const { mode, theme, setMode } = useTheme()
|
||
|
||
useEffect(() => {
|
||
if (!slug) return
|
||
api.hotels.get(slug).then(setHotel).catch(console.error)
|
||
}, [slug])
|
||
|
||
const [calendarCompact, setCalendarCompact] = useState(
|
||
() => localStorage.getItem('calendarCompact') === 'true',
|
||
)
|
||
const toggleCalendarCompact = () => {
|
||
setCalendarCompact(v => {
|
||
const next = !v
|
||
localStorage.setItem('calendarCompact', String(next))
|
||
return next
|
||
})
|
||
}
|
||
|
||
const [form, setForm] = useState({
|
||
name: '',
|
||
address: '',
|
||
timezone: 'Europe/Moscow',
|
||
currency: 'RUB',
|
||
checkInTime: '14:00',
|
||
checkOutTime: '12:00',
|
||
})
|
||
|
||
useEffect(() => {
|
||
if (!hotel) return
|
||
setForm({
|
||
name: hotel.name ?? '',
|
||
address: hotel.address ?? '',
|
||
timezone: hotel.timezone ?? 'Europe/Moscow',
|
||
currency: hotel.currency ?? 'RUB',
|
||
checkInTime: hotel.checkInTime ?? '14:00',
|
||
checkOutTime: hotel.checkOutTime ?? '12:00',
|
||
})
|
||
}, [hotel])
|
||
|
||
// Booking / assignment settings
|
||
const [assignmentStrategy, setAssignmentStrategy] = useState<'spread' | 'together' | 'sequential' | 'manual'>('spread')
|
||
const [showBookingSource, setShowBookingSource] = useState(false)
|
||
const [requireGuestDocs, setRequireGuestDocs] = useState(false)
|
||
const [earlyCheckinEnabled, setEarlyCheckinEnabled] = useState(false)
|
||
const [lateCheckoutEnabled, setLateCheckoutEnabled] = useState(false)
|
||
const [hotelSettingsLoaded, setHotelSettingsLoaded] = useState(false)
|
||
|
||
useEffect(() => {
|
||
if (!slug || hotelSettingsLoaded) return
|
||
api.hotelSettings.get(slug)
|
||
.then(s => {
|
||
setRequireGuestDocs(Boolean(s.require_guest_docs))
|
||
setEarlyCheckinEnabled(Boolean(s.early_checkin_enabled))
|
||
setLateCheckoutEnabled(Boolean(s.late_checkout_enabled))
|
||
setHotelSettingsLoaded(true)
|
||
})
|
||
.catch(console.error)
|
||
}, [slug, hotelSettingsLoaded])
|
||
|
||
const toggleRequireGuestDocs = async () => {
|
||
const next = !requireGuestDocs
|
||
setRequireGuestDocs(next)
|
||
try {
|
||
await api.hotelSettings.update(slug, { require_guest_docs: next })
|
||
} catch {
|
||
setRequireGuestDocs(!next)
|
||
}
|
||
}
|
||
|
||
const toggleEarlyCheckin = async () => {
|
||
const next = !earlyCheckinEnabled
|
||
setEarlyCheckinEnabled(next)
|
||
try {
|
||
await api.hotelSettings.update(slug, { early_checkin_enabled: next })
|
||
} catch {
|
||
setEarlyCheckinEnabled(!next)
|
||
}
|
||
}
|
||
|
||
const toggleLateCheckout = async () => {
|
||
const next = !lateCheckoutEnabled
|
||
setLateCheckoutEnabled(next)
|
||
try {
|
||
await api.hotelSettings.update(slug, { late_checkout_enabled: next })
|
||
} catch {
|
||
setLateCheckoutEnabled(!next)
|
||
}
|
||
}
|
||
|
||
// Guest settings
|
||
const [guestTags, setGuestTags] = useState([
|
||
{ id: 'vip', label: 'VIP', color: '#F59E0B' },
|
||
{ id: 'regular', label: 'Постоянный гость', color: '#3B82F6' },
|
||
{ id: 'corp', label: 'Корпоративный', color: '#8B5CF6' },
|
||
{ id: 'honey', label: 'Медовый месяц', color: '#EC4899' },
|
||
{ id: 'bday', label: 'День рождения', color: '#10B981' },
|
||
{ id: 'special', label: 'Особые пожелания', color: '#64748B' },
|
||
])
|
||
const [newTagLabel, setNewTagLabel] = useState('')
|
||
const [newTagColor, setNewTagColor] = useState('#4F46E5')
|
||
|
||
// 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('')
|
||
|
||
// Telegram settings
|
||
const [tgBotToken, setTgBotToken] = useState('')
|
||
const [tgManagers, setTgManagers] = useState<{ id: string; name: string; chatId: string }[]>([])
|
||
const [newTgName, setNewTgName] = useState('')
|
||
const [newTgChatId, setNewTgChatId] = useState('')
|
||
const [tgEvents, setTgEvents] = useState({
|
||
newBooking: true,
|
||
cancellation: true,
|
||
checkIn: true,
|
||
checkOut: false,
|
||
channelError: true,
|
||
payment: false,
|
||
})
|
||
const [tgTestSent, setTgTestSent] = useState(false)
|
||
const [copiedToken, setCopiedToken] = useState(false)
|
||
|
||
const tgBotConfigured = tgBotToken.length > 10
|
||
|
||
const addTgManager = () => {
|
||
if (!newTgName.trim() || !newTgChatId.trim()) return
|
||
setTgManagers(prev => [...prev, { id: `tg-${Date.now()}`, name: newTgName.trim(), chatId: newTgChatId.trim() }])
|
||
setNewTgName('')
|
||
setNewTgChatId('')
|
||
}
|
||
|
||
const sendTgTest = () => {
|
||
setTgTestSent(true)
|
||
setTimeout(() => setTgTestSent(false), 3000)
|
||
}
|
||
|
||
const copyBotLink = () => {
|
||
navigator.clipboard.writeText('https://t.me/HotelSyncBot?start=connect')
|
||
setCopiedToken(true)
|
||
setTimeout(() => setCopiedToken(false), 2000)
|
||
}
|
||
|
||
const handleSave = async () => {
|
||
if (section === 'general') {
|
||
try {
|
||
const updated = await api.hotels.update(slug, {
|
||
name: form.name,
|
||
address: form.address,
|
||
timezone: form.timezone,
|
||
currency: form.currency,
|
||
checkInTime: form.checkInTime,
|
||
checkOutTime: form.checkOutTime,
|
||
})
|
||
setHotel(updated)
|
||
} catch (err) {
|
||
console.error('Failed to save hotel settings', err)
|
||
}
|
||
}
|
||
setSaved(true)
|
||
setTimeout(() => setSaved(false), 2000)
|
||
}
|
||
|
||
return (
|
||
<div className="p-4 md:p-6 max-w-4xl mx-auto">
|
||
<div className="mb-5">
|
||
<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">{hotel?.name ?? '...'}</p>
|
||
</div>
|
||
|
||
<div className="flex gap-5">
|
||
{/* Sidebar nav */}
|
||
<nav className="hidden md:flex flex-col gap-0.5 w-44 shrink-0">
|
||
{SECTIONS.map(s => {
|
||
const Icon = s.icon
|
||
return (
|
||
<button
|
||
key={s.id}
|
||
onClick={() => setSection(s.id)}
|
||
className={cn(
|
||
'flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm font-medium text-left transition-colors',
|
||
section === s.id
|
||
? 'bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300'
|
||
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800',
|
||
)}
|
||
>
|
||
<Icon size={15} />
|
||
{s.label}
|
||
</button>
|
||
)
|
||
})}
|
||
</nav>
|
||
|
||
{/* Mobile nav */}
|
||
<div className="md:hidden w-full mb-4">
|
||
<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>
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Название отеля</label>
|
||
<input type="text" className="input" value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} />
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Адрес</label>
|
||
<input type="text" className="input" value={form.address} onChange={e => setForm(p => ({ ...p, address: e.target.value }))} />
|
||
</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>
|
||
<select className="input" value={form.timezone} onChange={e => setForm(p => ({ ...p, timezone: e.target.value }))}>
|
||
<option value="Europe/Moscow">Москва (UTC+3)</option>
|
||
<option value="Asia/Yekaterinburg">Екатеринбург (UTC+5)</option>
|
||
<option value="Asia/Novosibirsk">Новосибирск (UTC+7)</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Валюта</label>
|
||
<select className="input" value={form.currency} onChange={e => setForm(p => ({ ...p, currency: e.target.value }))}>
|
||
<option value="RUB">₽ RUB</option>
|
||
<option value="USD">$ USD</option>
|
||
<option value="EUR">€ EUR</option>
|
||
</select>
|
||
</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="time" className="input" value={form.checkInTime} onChange={e => setForm(p => ({ ...p, checkInTime: e.target.value }))} />
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Время выезда</label>
|
||
<input type="time" className="input" value={form.checkOutTime} onChange={e => setForm(p => ({ ...p, checkOutTime: e.target.value }))} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* ── BOOKING ── */}
|
||
{section === 'booking' && (
|
||
<>
|
||
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Настройки бронирования</h2>
|
||
|
||
{/* Source 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>
|
||
<Toggle on={showBookingSource} onChange={() => setShowBookingSource(p => !p)} />
|
||
</div>
|
||
|
||
{/* Require guest docs 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>
|
||
<Toggle on={requireGuestDocs} onChange={toggleRequireGuestDocs} />
|
||
</div>
|
||
|
||
{/* Early check-in fee */}
|
||
<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>
|
||
<Toggle on={earlyCheckinEnabled} onChange={toggleEarlyCheckin} />
|
||
</div>
|
||
|
||
{/* Late checkout fee */}
|
||
<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>
|
||
<Toggle on={lateCheckoutEnabled} onChange={toggleLateCheckout} />
|
||
</div>
|
||
|
||
<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>
|
||
<div className="space-y-4">
|
||
<div>
|
||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">Тема интерфейса</p>
|
||
<div className="grid grid-cols-3 gap-3">
|
||
{([
|
||
{ id: 'light', label: 'Светлая', desc: null },
|
||
{ id: 'dark', label: 'Тёмная', desc: null },
|
||
{ id: 'auto', label: 'Авто', desc: 'Следует системной теме устройства' },
|
||
] as const).map(t => (
|
||
<button
|
||
key={t.id}
|
||
onClick={() => setMode(t.id)}
|
||
className={cn(
|
||
'p-4 rounded-xl border-2 transition-all text-left',
|
||
mode === t.id ? 'border-brand-500' : 'border-slate-200 dark:border-slate-600 hover:border-slate-300',
|
||
)}
|
||
>
|
||
{t.id === 'auto' ? (
|
||
<div className="w-full h-14 rounded-lg mb-2 overflow-hidden border border-slate-200 flex">
|
||
<div className="flex-1 bg-white" />
|
||
<div className="flex-1 bg-slate-800" />
|
||
</div>
|
||
) : (
|
||
<div className={cn('w-full h-14 rounded-lg mb-2', t.id === 'light' ? 'bg-white border border-slate-200' : 'bg-slate-800')}>
|
||
<div className={cn('h-3 w-3/4 rounded mx-2 mt-2', t.id === 'light' ? 'bg-slate-200' : 'bg-slate-600')} />
|
||
<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>
|
||
{mode === t.id
|
||
? <p className="text-xs text-brand-600 dark:text-brand-400">Активна</p>
|
||
: t.desc && <p className="text-xs text-slate-400 dark:text-slate-500 leading-tight">{t.desc}</p>
|
||
}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Calendar compact mode */}
|
||
<div className="pt-3 border-t border-slate-100 dark:border-slate-700">
|
||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Настройки календаря</p>
|
||
<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>
|
||
<Toggle on={calendarCompact} onChange={toggleCalendarCompact} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* ── NOTIFICATIONS ── */}
|
||
{section === 'notify' && (
|
||
<>
|
||
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Уведомления</h2>
|
||
|
||
{/* 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>
|
||
<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>
|
||
|
||
{/* Telegram */}
|
||
<div className="pt-2 border-t border-slate-100 dark:border-slate-700">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<Send size={15} className="text-sky-500" />
|
||
<h3 className="text-sm font-semibold text-slate-800 dark:text-slate-200">Telegram-уведомления для менеджеров</h3>
|
||
</div>
|
||
<p className="text-xs text-slate-400 mb-3">
|
||
Менеджеры получают уведомления о бронированиях прямо в Telegram. Каждый менеджер должен написать боту команду <code className="bg-slate-100 dark:bg-slate-700 px-1 rounded">/start</code> и сообщить свой Chat ID.
|
||
</p>
|
||
|
||
{/* How to connect — step instructions */}
|
||
<div className="rounded-xl bg-sky-50 dark:bg-sky-900/20 border border-sky-200 dark:border-sky-800 p-3.5 mb-3 space-y-2">
|
||
<p className="text-xs font-semibold text-sky-700 dark:text-sky-300">Как подключить менеджера:</p>
|
||
<ol className="text-xs text-sky-700 dark:text-sky-300 space-y-1 list-decimal list-inside">
|
||
<li>Менеджер открывает Telegram и переходит в бота</li>
|
||
<li>Пишет <code className="bg-sky-100 dark:bg-sky-900 px-1 rounded">/start</code> — бот ответит его Chat ID</li>
|
||
<li>Менеджер сообщает Chat ID администратору — тот добавляет его ниже</li>
|
||
</ol>
|
||
<button
|
||
onClick={copyBotLink}
|
||
className="flex items-center gap-1.5 text-xs font-medium text-sky-600 dark:text-sky-400 hover:underline"
|
||
>
|
||
{copiedToken ? <CheckCircle2 size={13} className="text-emerald-500" /> : <Copy size={13} />}
|
||
{copiedToken ? 'Скопировано!' : 'Скопировать ссылку на бота'}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Bot token */}
|
||
<div className="mb-3">
|
||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
|
||
Токен бота
|
||
<span className="ml-1 text-slate-400 font-normal">(получить у @BotFather в Telegram)</span>
|
||
</label>
|
||
<input
|
||
type="password"
|
||
className="input text-sm font-mono"
|
||
placeholder="123456789:AAExx..."
|
||
value={tgBotToken}
|
||
onChange={e => setTgBotToken(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
{/* Events to notify */}
|
||
{tgBotConfigured && (
|
||
<>
|
||
<div className="mb-3">
|
||
<p className="text-xs font-medium text-slate-600 dark:text-slate-400 mb-2">Уведомлять о событиях:</p>
|
||
<div className="grid grid-cols-2 gap-1.5">
|
||
{([
|
||
{ key: 'newBooking' as const, label: 'Новое бронирование' },
|
||
{ key: 'cancellation' as const, label: 'Отмена брони' },
|
||
{ key: 'checkIn' as const, label: 'Заезд гостя' },
|
||
{ key: 'checkOut' as const, label: 'Выезд гостя' },
|
||
{ key: 'channelError' as const, label: 'Ошибка канала' },
|
||
{ key: 'payment' as const, label: 'Платёж / оплата' },
|
||
]).map(ev => (
|
||
<label key={ev.key} className="flex items-center gap-2 text-xs cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
className="accent-brand-600"
|
||
checked={tgEvents[ev.key]}
|
||
onChange={() => setTgEvents(p => ({ ...p, [ev.key]: !p[ev.key] }))}
|
||
/>
|
||
<span className="text-slate-700 dark:text-slate-300">{ev.label}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Manager list */}
|
||
<div className="mb-3">
|
||
<p className="text-xs font-medium text-slate-600 dark:text-slate-400 mb-2">
|
||
Менеджеры — получатели уведомлений
|
||
{tgManagers.length > 0 && <span className="ml-1 text-slate-400">({tgManagers.length})</span>}
|
||
</p>
|
||
<div className="space-y-1.5 mb-2">
|
||
{tgManagers.length === 0 && (
|
||
<p className="text-xs text-slate-400 py-2 text-center rounded-lg bg-slate-50 dark:bg-slate-700/30">
|
||
Менеджеры ещё не добавлены
|
||
</p>
|
||
)}
|
||
{tgManagers.map(m => (
|
||
<div key={m.id} className="flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-700/40 text-sm">
|
||
<div className="w-7 h-7 rounded-full bg-sky-100 dark:bg-sky-900/50 flex items-center justify-center shrink-0">
|
||
<span className="text-xs font-bold text-sky-600 dark:text-sky-400">{m.name[0]?.toUpperCase()}</span>
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">{m.name}</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 font-mono">Chat ID: {m.chatId}</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setTgManagers(prev => prev.filter(x => x.id !== m.id))}
|
||
className="p-1 rounded text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||
>
|
||
<XIcon size={13} />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Add manager */}
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="text"
|
||
className="input text-sm flex-1"
|
||
placeholder="Имя менеджера"
|
||
value={newTgName}
|
||
onChange={e => setNewTgName(e.target.value)}
|
||
/>
|
||
<input
|
||
type="text"
|
||
className="input text-sm w-32 font-mono"
|
||
placeholder="Chat ID"
|
||
value={newTgChatId}
|
||
onChange={e => setNewTgChatId(e.target.value)}
|
||
onKeyDown={e => { if (e.key === 'Enter') addTgManager() }}
|
||
/>
|
||
<button onClick={addTgManager} className="btn-secondary px-3">
|
||
<Plus size={14} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Test button */}
|
||
{tgManagers.length > 0 && (
|
||
<button
|
||
onClick={sendTgTest}
|
||
className={cn(
|
||
'flex items-center gap-1.5 text-sm font-medium px-3 py-2 rounded-lg border transition-colors',
|
||
tgTestSent
|
||
? 'bg-emerald-50 dark:bg-emerald-900/20 border-emerald-300 dark:border-emerald-700 text-emerald-700 dark:text-emerald-400'
|
||
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-300 hover:border-sky-400 hover:text-sky-600',
|
||
)}
|
||
>
|
||
{tgTestSent
|
||
? <><CheckCircle2 size={14} /> Тестовое сообщение отправлено!</>
|
||
: <><Send size={14} /> Отправить тестовое сообщение</>
|
||
}
|
||
</button>
|
||
)}
|
||
</>
|
||
)}
|
||
</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>
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Текущий пароль</label>
|
||
<input type="password" className="input" placeholder="••••••••" />
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Новый пароль</label>
|
||
<input type="password" className="input" placeholder="••••••••" />
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Подтверждение</label>
|
||
<input type="password" className="input" placeholder="••••••••" />
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* ── BILLING ── */}
|
||
{section === 'billing' && (
|
||
<>
|
||
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Тарифный план</h2>
|
||
<div className="p-4 rounded-xl bg-slate-50 dark:bg-slate-700/40 flex items-center justify-between mb-4">
|
||
<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 ?? 'starter']}>{PLAN_LABELS[hotel?.plan ?? 'starter']}</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: 'Выделенный менеджер' },
|
||
] as const).map(p => (
|
||
<div key={p.plan} className={cn(
|
||
'p-4 rounded-xl border-2 transition-all',
|
||
hotel?.plan === p.plan ? 'border-brand-500 bg-brand-50/50 dark:bg-brand-900/10' : 'border-slate-200 dark:border-slate-600',
|
||
)}>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<Badge className={PLAN_COLORS[p.plan]}>{PLAN_LABELS[p.plan]}</Badge>
|
||
{hotel?.plan === p.plan && <span className="text-xs text-brand-600 dark:text-brand-400 font-medium">Текущий</span>}
|
||
</div>
|
||
<p className="text-lg font-bold text-slate-900 dark:text-slate-100 mb-3">{p.price}</p>
|
||
<ul className="space-y-1 text-xs text-slate-600 dark:text-slate-400">
|
||
<li>✓ {p.rooms}</li>
|
||
<li>✓ {p.channels}</li>
|
||
<li>✓ {p.support}</li>
|
||
</ul>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Save button */}
|
||
{section !== 'billing' && section !== 'theme' && (
|
||
<div className="pt-2 flex justify-end">
|
||
<button onClick={handleSave} className="btn-primary">
|
||
<Save size={14} />
|
||
{saved ? 'Сохранено!' : 'Сохранить'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|