Initial commit: HotelSync PMS v0.1.0
- React 18 + TypeScript + Vite + Tailwind CSS - Шахматка бронирований (drag-to-book) - Страницы: Calendar, Bookings, Rooms, Housekeeping, Channels, API Docs, Settings - Роли: super_admin, hotel_manager, housekeeper - Светлая/тёмная тема - Docker + Nginx конфигурация - Лендинг hotelsync.ru
This commit is contained in:
257
src/pages/SettingsPage.tsx
Normal file
257
src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { useState } from 'react'
|
||||
import { Save, Building2, Bell, Shield, Globe, CreditCard } from 'lucide-react'
|
||||
import { MOCK_HOTELS } from '../data/mockData'
|
||||
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: 'theme', label: 'Внешний вид', icon: Globe },
|
||||
{ id: 'notify', label: 'Уведомления', icon: Bell },
|
||||
{ id: 'security', label: 'Безопасность', icon: Shield },
|
||||
{ id: 'billing', label: 'Тарифный план', icon: CreditCard },
|
||||
]
|
||||
|
||||
export function SettingsPage() {
|
||||
const [section, setSection] = useState('general')
|
||||
const [saved, setSaved] = useState(false)
|
||||
const hotel = MOCK_HOTELS[0]
|
||||
const { theme, toggle } = useTheme()
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: hotel.name,
|
||||
address: hotel.address,
|
||||
timezone: hotel.timezone,
|
||||
currency: hotel.currency,
|
||||
checkInTime: '14:00',
|
||||
checkOutTime: '12:00',
|
||||
})
|
||||
|
||||
const handleSave = () => {
|
||||
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">
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{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-2 gap-3">
|
||||
{([
|
||||
{ id: 'light', label: 'Светлая', preview: 'bg-white border-2' },
|
||||
{ id: 'dark', label: 'Тёмная', preview: 'bg-slate-900 border-2' },
|
||||
] 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',
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
{theme === t.id && (
|
||||
<p className="text-xs text-brand-600 dark:text-brand-400">Активна</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{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">
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{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]}>
|
||||
{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: 'Выделенный менеджер' },
|
||||
] 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user