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:
119
src/pages/AdminDashboard.tsx
Normal file
119
src/pages/AdminDashboard.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { Building2, Users, TrendingUp, DollarSign, CheckCircle2, AlertCircle, Plus } from 'lucide-react'
|
||||
import { MOCK_HOTELS, MOCK_USERS } from '../data/mockData'
|
||||
import { cn, PLAN_COLORS, PLAN_LABELS } from '../lib/utils'
|
||||
import { Badge } from '../components/ui/Badge'
|
||||
|
||||
export function AdminDashboard() {
|
||||
const activeHotels = MOCK_HOTELS.filter(h => h.isActive).length
|
||||
const totalRooms = MOCK_HOTELS.reduce((s, h) => s + h.roomCount, 0)
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<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">HotelSync SaaS · Обзор платформы</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: 'Всего отелей', value: MOCK_HOTELS.length, icon: Building2, color: 'text-brand-600 dark:text-brand-400', bg: 'bg-brand-50 dark:bg-brand-900/20' },
|
||||
{ label: 'Активных', value: activeHotels, icon: CheckCircle2, color: 'text-emerald-600 dark:text-emerald-400', bg: 'bg-emerald-50 dark:bg-emerald-900/20' },
|
||||
{ label: 'Всего номеров', value: totalRooms, icon: TrendingUp, color: 'text-amber-600 dark:text-amber-400', bg: 'bg-amber-50 dark:bg-amber-900/20' },
|
||||
{ label: 'Пользователей', value: MOCK_USERS.length, icon: Users, color: 'text-violet-600 dark:text-violet-400', bg: 'bg-violet-50 dark:bg-violet-900/20' },
|
||||
].map(s => {
|
||||
const Icon = s.icon
|
||||
return (
|
||||
<div key={s.label} className={cn('card p-4 border-0', s.bg)}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Icon size={18} className={s.color} />
|
||||
</div>
|
||||
<p className={cn('text-2xl font-bold', s.color)}>{s.value}</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">{s.label}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Revenue by plan */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Отели</h3>
|
||||
<button className="btn-primary py-1.5 text-xs">
|
||||
<Plus size={13} />
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{MOCK_HOTELS.map(hotel => (
|
||||
<div key={hotel.id} className="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700/40 transition-colors cursor-pointer">
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-lg flex items-center justify-center text-sm font-bold',
|
||||
hotel.isActive
|
||||
? 'bg-brand-100 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300'
|
||||
: 'bg-slate-100 dark:bg-slate-700 text-slate-500',
|
||||
)}>
|
||||
{hotel.name.charAt(0)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">{hotel.name}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{hotel.address} · {hotel.roomCount} номеров</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge className={PLAN_COLORS[hotel.plan]}>{PLAN_LABELS[hotel.plan]}</Badge>
|
||||
<div className={cn('w-2 h-2 rounded-full', hotel.isActive ? 'bg-emerald-500' : 'bg-slate-400')} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-5">
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100 mb-4">Статистика по тарифам</h3>
|
||||
<div className="space-y-3">
|
||||
{(['starter', 'pro', 'enterprise'] as const).map(plan => {
|
||||
const count = MOCK_HOTELS.filter(h => h.plan === plan).length
|
||||
const pct = Math.round((count / MOCK_HOTELS.length) * 100)
|
||||
return (
|
||||
<div key={plan}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<Badge className={PLAN_COLORS[plan]}>{PLAN_LABELS[plan]}</Badge>
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">{count} отелей ({pct}%)</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-slate-200 dark:bg-slate-700">
|
||||
<div
|
||||
className="h-2 rounded-full bg-brand-500 transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-5 border-t border-slate-200 dark:border-slate-700">
|
||||
<h4 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">Пользователи</h4>
|
||||
<div className="space-y-2">
|
||||
{MOCK_USERS.map(u => (
|
||||
<div key={u.id} className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-brand-600 flex items-center justify-center text-white text-sm font-semibold shrink-0">
|
||||
{u.name.charAt(0)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{u.name}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{u.email}</p>
|
||||
</div>
|
||||
<Badge className="bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300 text-[10px]">
|
||||
{u.role}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
254
src/pages/ApiDocsPage.tsx
Normal file
254
src/pages/ApiDocsPage.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import { useState } from 'react'
|
||||
import { Copy, CheckCheck, ChevronDown, ChevronRight, Lock, Globe } from 'lucide-react'
|
||||
import { cn } from '../lib/utils'
|
||||
import type { ApiEndpoint } from '../types'
|
||||
|
||||
const METHOD_COLORS: Record<string, string> = {
|
||||
GET: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',
|
||||
POST: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
PATCH: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
PUT: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300',
|
||||
DELETE: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
|
||||
}
|
||||
|
||||
const API_ENDPOINTS: ApiEndpoint[] = [
|
||||
// Auth
|
||||
{ method: 'POST', path: '/auth/login', summary: 'Авторизация', tags: ['Auth'], auth: false, requestBody: '{ "email": "string", "password": "string" }', response: '{ "token": "string", "user": {...} }' },
|
||||
{ method: 'POST', path: '/auth/refresh', summary: 'Обновить токен', tags: ['Auth'], auth: true },
|
||||
{ method: 'POST', path: '/auth/logout', summary: 'Выход', tags: ['Auth'], auth: true },
|
||||
|
||||
// Hotels
|
||||
{ method: 'GET', path: '/hotels', summary: 'Список отелей', tags: ['Hotels'], auth: true, roles: ['super_admin'], response: '{ "data": Hotel[], "total": number }' },
|
||||
{ method: 'POST', path: '/hotels', summary: 'Создать отель', tags: ['Hotels'], auth: true, roles: ['super_admin'] },
|
||||
{ method: 'GET', path: '/hotels/:hotelId', summary: 'Получить отель', tags: ['Hotels'], auth: true },
|
||||
{ method: 'PATCH', path: '/hotels/:hotelId', summary: 'Обновить отель', tags: ['Hotels'], auth: true, roles: ['super_admin', 'hotel_manager'] },
|
||||
{ method: 'DELETE', path: '/hotels/:hotelId', summary: 'Удалить отель', tags: ['Hotels'], auth: true, roles: ['super_admin'] },
|
||||
|
||||
// Rooms
|
||||
{ method: 'GET', path: '/hotels/:hotelId/rooms', summary: 'Список номеров', tags: ['Rooms'], auth: true },
|
||||
{ method: 'POST', path: '/hotels/:hotelId/rooms', summary: 'Создать номер', tags: ['Rooms'], auth: true, roles: ['hotel_manager'] },
|
||||
{ method: 'PATCH', path: '/hotels/:hotelId/rooms/:roomId', summary: 'Обновить номер', tags: ['Rooms'], auth: true, roles: ['hotel_manager'] },
|
||||
{ method: 'DELETE', path: '/hotels/:hotelId/rooms/:roomId', summary: 'Удалить номер', tags: ['Rooms'], auth: true, roles: ['hotel_manager'] },
|
||||
|
||||
// Bookings
|
||||
{ method: 'GET', path: '/hotels/:hotelId/bookings', summary: 'Список бронирований (с фильтрацией по датам)', tags: ['Bookings'], auth: true, response: '{ "data": Booking[], "total": number }' },
|
||||
{ method: 'POST', path: '/hotels/:hotelId/bookings', summary: 'Создать бронирование', tags: ['Bookings'], auth: true, requestBody: '{ "roomId": "string", "guestName": "string", "checkIn": "YYYY-MM-DD", "checkOut": "YYYY-MM-DD" }' },
|
||||
{ method: 'GET', path: '/hotels/:hotelId/bookings/:bookingId', summary: 'Получить бронирование', tags: ['Bookings'], auth: true },
|
||||
{ method: 'PATCH', path: '/hotels/:hotelId/bookings/:bookingId', summary: 'Обновить бронирование', tags: ['Bookings'], auth: true },
|
||||
{ method: 'DELETE', path: '/hotels/:hotelId/bookings/:bookingId', summary: 'Отменить бронирование', tags: ['Bookings'], auth: true },
|
||||
|
||||
// Channels
|
||||
{ method: 'GET', path: '/hotels/:hotelId/channels', summary: 'Список каналов', tags: ['Channels'], auth: true },
|
||||
{ method: 'PATCH', path: '/hotels/:hotelId/channels/:channelId', summary: 'Настроить канал', tags: ['Channels'], auth: true, roles: ['hotel_manager'] },
|
||||
{ method: 'POST', path: '/hotels/:hotelId/channels/:channelId/sync', summary: 'Запустить синхронизацию', tags: ['Channels'], auth: true, roles: ['hotel_manager'] },
|
||||
|
||||
// Housekeeping
|
||||
{ method: 'GET', path: '/hotels/:hotelId/housekeeping/tasks', summary: 'Список задач', tags: ['Housekeeping'], auth: true },
|
||||
{ method: 'POST', path: '/hotels/:hotelId/housekeeping/tasks', summary: 'Создать задачу', tags: ['Housekeeping'], auth: true },
|
||||
{ method: 'PATCH', path: '/hotels/:hotelId/housekeeping/tasks/:taskId', summary: 'Обновить задачу', tags: ['Housekeeping'], auth: true },
|
||||
|
||||
// Webhooks
|
||||
{ method: 'POST', path: '/webhooks/booking-com', summary: 'Webhook Booking.com', tags: ['Webhooks'], auth: false, description: 'Endpoint для получения событий от Booking.com' },
|
||||
{ method: 'POST', path: '/webhooks/airbnb', summary: 'Webhook Airbnb', tags: ['Webhooks'], auth: false },
|
||||
]
|
||||
|
||||
const ALL_TAGS = [...new Set(API_ENDPOINTS.flatMap(e => e.tags))]
|
||||
|
||||
export function ApiDocsPage() {
|
||||
const [activeTag, setActiveTag] = useState<string>('Bookings')
|
||||
const [expandedEndpoint, setExpandedEndpoint] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState<string | null>(null)
|
||||
|
||||
const filtered = API_ENDPOINTS.filter(e => e.tags.includes(activeTag))
|
||||
|
||||
const copy = (text: string, key: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopied(key)
|
||||
setTimeout(() => setCopied(null), 1500)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 max-w-5xl mx-auto space-y-5">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-600 flex items-center justify-center">
|
||||
<Globe size={18} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">HotelSync API</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">REST API v1.0 · Base URL: <code className="text-brand-600 dark:text-brand-400">https://api.hotelsync.io/v1</code></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auth info */}
|
||||
<div className="card p-4 border-brand-200 dark:border-brand-800/50 bg-brand-50/50 dark:bg-brand-900/10">
|
||||
<div className="flex gap-3">
|
||||
<Lock size={16} className="text-brand-600 dark:text-brand-400 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-brand-800 dark:text-brand-300 text-sm">Аутентификация</p>
|
||||
<p className="text-sm text-brand-700 dark:text-brand-400">
|
||||
Все запросы (кроме <code>/auth/login</code> и webhooks) требуют заголовка:
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<code className="text-xs bg-white dark:bg-slate-800 border border-brand-200 dark:border-brand-700 text-brand-700 dark:text-brand-300 px-3 py-1.5 rounded-lg flex-1">
|
||||
Authorization: Bearer <access_token>
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copy('Authorization: Bearer <access_token>', 'auth')}
|
||||
className="btn-ghost p-1.5 text-brand-600"
|
||||
>
|
||||
{copied === 'auth' ? <CheckCheck size={14} /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-brand-600 dark:text-brand-400 mt-1">
|
||||
Также требуется: <code>X-Hotel-Id: <hotelId></code> для отельных запросов
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tag nav */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ALL_TAGS.map(tag => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => setActiveTag(tag)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
|
||||
activeTag === tag
|
||||
? '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',
|
||||
)}
|
||||
>
|
||||
{tag}
|
||||
<span className="ml-1.5 text-xs opacity-60">
|
||||
{API_ENDPOINTS.filter(e => e.tags.includes(tag)).length}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Endpoints */}
|
||||
<div className="space-y-2">
|
||||
{filtered.map((endpoint, i) => {
|
||||
const key = `${endpoint.method}-${endpoint.path}`
|
||||
const isOpen = expandedEndpoint === key
|
||||
return (
|
||||
<div key={key} className="card overflow-hidden">
|
||||
<button
|
||||
onClick={() => setExpandedEndpoint(isOpen ? null : key)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3.5 text-left hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors"
|
||||
>
|
||||
<span className={cn('badge font-mono font-bold w-16 justify-center shrink-0', METHOD_COLORS[endpoint.method])}>
|
||||
{endpoint.method}
|
||||
</span>
|
||||
<code className="text-sm text-slate-700 dark:text-slate-300 flex-1">
|
||||
{endpoint.path}
|
||||
</code>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400 hidden sm:block">
|
||||
{endpoint.summary}
|
||||
</span>
|
||||
{!endpoint.auth && (
|
||||
<span className="badge bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400 text-[10px]">
|
||||
Public
|
||||
</span>
|
||||
)}
|
||||
{isOpen ? <ChevronDown size={14} className="text-slate-400 shrink-0" /> : <ChevronRight size={14} className="text-slate-400 shrink-0" />}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="px-4 pb-4 pt-0 border-t border-slate-100 dark:border-slate-700 space-y-3 animate-fade-in">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400 pt-3">
|
||||
{endpoint.description ?? endpoint.summary}
|
||||
</p>
|
||||
|
||||
{endpoint.roles && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400 mb-1.5">Доступные роли</p>
|
||||
<div className="flex gap-1.5">
|
||||
{endpoint.roles.map(r => (
|
||||
<span key={r} className="badge bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300">
|
||||
{r}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{endpoint.requestBody && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400">Request Body</p>
|
||||
<button onClick={() => copy(endpoint.requestBody!, key + '-req')} className="btn-ghost p-1 text-xs gap-1">
|
||||
{copied === key + '-req' ? <CheckCheck size={11} /> : <Copy size={11} />}
|
||||
Копировать
|
||||
</button>
|
||||
</div>
|
||||
<pre className="text-xs bg-slate-900 dark:bg-slate-950 text-emerald-400 rounded-lg p-3 overflow-x-auto">
|
||||
{endpoint.requestBody}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{endpoint.response && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400">Response 200</p>
|
||||
<button onClick={() => copy(endpoint.response!, key + '-res')} className="btn-ghost p-1 text-xs gap-1">
|
||||
{copied === key + '-res' ? <CheckCheck size={11} /> : <Copy size={11} />}
|
||||
Копировать
|
||||
</button>
|
||||
</div>
|
||||
<pre className="text-xs bg-slate-900 dark:bg-slate-950 text-blue-400 rounded-lg p-3 overflow-x-auto">
|
||||
{endpoint.response}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Try it */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400 mb-1.5">cURL пример</p>
|
||||
<div className="flex items-start gap-2">
|
||||
<pre className="text-xs bg-slate-900 dark:bg-slate-950 text-slate-300 rounded-lg p-3 overflow-x-auto flex-1 whitespace-pre-wrap">
|
||||
{`curl -X ${endpoint.method} \\
|
||||
https://api.hotelsync.io/v1${endpoint.path} \\${endpoint.auth ? `
|
||||
-H "Authorization: Bearer <token>" \\
|
||||
-H "X-Hotel-Id: <hotelId>" \\` : ''}
|
||||
-H "Content-Type: application/json"`}
|
||||
</pre>
|
||||
<button
|
||||
onClick={() => copy(`curl -X ${endpoint.method} https://api.hotelsync.io/v1${endpoint.path}`, key + '-curl')}
|
||||
className="btn-ghost p-1.5 shrink-0"
|
||||
>
|
||||
{copied === key + '-curl' ? <CheckCheck size={13} /> : <Copy size={13} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Rate limits */}
|
||||
<div className="card p-5">
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100 mb-3">Лимиты запросов</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm">
|
||||
{[
|
||||
{ plan: 'Starter', limit: '1 000 / час', color: 'text-slate-600 dark:text-slate-400' },
|
||||
{ plan: 'Pro', limit: '10 000 / час', color: 'text-brand-600 dark:text-brand-400' },
|
||||
{ plan: 'Enterprise', limit: 'Неограничено', color: 'text-amber-600 dark:text-amber-400' },
|
||||
].map(r => (
|
||||
<div key={r.plan} className="text-center p-3 rounded-lg bg-slate-50 dark:bg-slate-700/40">
|
||||
<p className="font-bold text-slate-900 dark:text-slate-100">{r.plan}</p>
|
||||
<p className={cn('font-semibold', r.color)}>{r.limit}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
195
src/pages/BookingsPage.tsx
Normal file
195
src/pages/BookingsPage.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { useState } from 'react'
|
||||
import { Search, Filter, Plus, 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'
|
||||
import { Badge } from '../components/ui/Badge'
|
||||
import { BookingModal } from '../components/bookings/BookingModal'
|
||||
import { BookingDetailPanel } from '../components/bookings/BookingDetailPanel'
|
||||
import { format, addDays } from 'date-fns'
|
||||
|
||||
const STATUS_FILTERS: { label: string; value: BookingStatus | 'all' }[] = [
|
||||
{ label: 'Все', value: 'all' },
|
||||
{ label: 'Подтверждённые', value: 'confirmed' },
|
||||
{ label: 'Заселены', value: 'checked_in' },
|
||||
{ label: 'Запросы', value: 'inquiry' },
|
||||
{ label: 'Выехали', value: 'checked_out' },
|
||||
{ label: 'Отменены', value: 'cancelled' },
|
||||
]
|
||||
|
||||
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 filtered = bookings.filter(b => {
|
||||
const matchSearch = search === '' ||
|
||||
b.guestName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
b.guestEmail.toLowerCase().includes(search.toLowerCase()) ||
|
||||
b.id.includes(search)
|
||||
const matchStatus = statusFilter === 'all' || b.status === statusFilter
|
||||
return matchSearch && matchStatus
|
||||
})
|
||||
|
||||
const room = (id: string) => MOCK_ROOMS.find(r => r.id === id)
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="btn-primary"
|
||||
>
|
||||
<Plus size={15} />
|
||||
<span className="hidden sm:inline">Новое</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
className="input pl-9"
|
||||
placeholder="Поиск по гостю, email, ID..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{STATUS_FILTERS.map(f => (
|
||||
<button
|
||||
key={f.value}
|
||||
onClick={() => setStatusFilter(f.value)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
|
||||
statusFilter === f.value
|
||||
? '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',
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<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}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(b => {
|
||||
const r = room(b.roomId)
|
||||
const nights = nightsCount(b.checkIn, b.checkOut)
|
||||
return (
|
||||
<tr
|
||||
key={b.id}
|
||||
className="border-b border-slate-100 dark:border-slate-700/50 hover:bg-slate-50 dark:hover:bg-slate-700/30 cursor-pointer transition-colors"
|
||||
onClick={() => setSelected(b)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">{b.guestName}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{b.guestEmail}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">
|
||||
{r ? `№${r.number}` : '—'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{r?.type}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">
|
||||
{new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short' }).format(new Date(b.checkIn))}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">
|
||||
<span>{new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short' }).format(new Date(b.checkOut))}</span>
|
||||
<span className="ml-1.5 text-xs text-slate-400">{nights}н</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge className={BOOKING_STATUS_BADGE[b.status]}>
|
||||
{BOOKING_STATUS_LABELS[b.status]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge className={SOURCE_COLORS[b.source]}>
|
||||
{SOURCE_LABELS[b.source]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-semibold text-slate-900 dark:text-slate-100">
|
||||
{formatCurrency(b.totalAmount)}
|
||||
</p>
|
||||
{b.paidAmount < b.totalAmount && (
|
||||
<p className="text-xs text-red-500">
|
||||
-{formatCurrency(b.totalAmount - b.paidAmount)}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-brand-600 dark:text-brand-400 text-xs font-medium">
|
||||
Открыть →
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
|
||||
Бронирования не найдены
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create modal */}
|
||||
{showCreateModal && (
|
||||
<BookingModal
|
||||
open
|
||||
draft={{
|
||||
roomId: MOCK_ROOMS[0].id,
|
||||
checkIn: format(new Date(), 'yyyy-MM-dd'),
|
||||
checkOut: format(addDays(new Date(), 1), 'yyyy-MM-dd'),
|
||||
}}
|
||||
rooms={MOCK_ROOMS}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSave={(data) => {
|
||||
setBookings(prev => [...prev, data as Booking])
|
||||
setShowCreateModal(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Detail panel */}
|
||||
{selected && (
|
||||
<BookingDetailPanel
|
||||
booking={selected}
|
||||
room={room(selected.roomId)}
|
||||
onClose={() => setSelected(null)}
|
||||
onUpdate={(id, data) => {
|
||||
setBookings(prev => prev.map(b => b.id === id ? { ...b, ...data } : b))
|
||||
setSelected(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
src/pages/CalendarPage.tsx
Normal file
37
src/pages/CalendarPage.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useState } from 'react'
|
||||
import { BookingCalendar } from '../components/calendar/BookingCalendar'
|
||||
import { MOCK_ROOMS, MOCK_BOOKINGS } from '../data/mockData'
|
||||
import type { Booking } from '../types'
|
||||
|
||||
export function CalendarPage() {
|
||||
const [bookings, setBookings] = useState<Booking[]>(MOCK_BOOKINGS)
|
||||
|
||||
const handleCreate = (data: Partial<Booking>) => {
|
||||
setBookings(prev => [...prev, data as Booking])
|
||||
}
|
||||
|
||||
const handleUpdate = (id: string, data: Partial<Booking>) => {
|
||||
setBookings(prev => prev.map(b => b.id === id ? { ...b, ...data } : b))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shrink-0">
|
||||
<h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Шахматка бронирований
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Нажмите и потяните по ячейкам для создания бронирования
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<BookingCalendar
|
||||
rooms={MOCK_ROOMS}
|
||||
bookings={bookings}
|
||||
onBookingCreate={handleCreate}
|
||||
onBookingUpdate={handleUpdate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
202
src/pages/ChannelsPage.tsx
Normal file
202
src/pages/ChannelsPage.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useState } from 'react'
|
||||
import { RefreshCw, CheckCircle2, XCircle, Clock, Globe, AlertTriangle } from 'lucide-react'
|
||||
import { MOCK_CHANNELS } from '../data/mockData'
|
||||
import type { Channel, SyncStatus } from '../types'
|
||||
import { cn } from '../lib/utils'
|
||||
import { Badge } from '../components/ui/Badge'
|
||||
|
||||
const CHANNEL_ICONS: Record<string, string> = {
|
||||
booking_com: '🔵',
|
||||
airbnb: '🔴',
|
||||
expedia: '🟡',
|
||||
vrbo: '🟢',
|
||||
}
|
||||
|
||||
function SyncStatusBadge({ status }: { status: SyncStatus }) {
|
||||
const map: Record<SyncStatus, { icon: React.ElementType; label: string; cls: string }> = {
|
||||
idle: { icon: Clock, label: 'Не настроен', cls: 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300' },
|
||||
syncing: { icon: RefreshCw, label: 'Синхронизация', cls: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' },
|
||||
success: { icon: CheckCircle2, label: 'Синхронизирован', cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300' },
|
||||
error: { icon: XCircle, label: 'Ошибка', cls: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' },
|
||||
}
|
||||
const { icon: Icon, label, cls } = map[status]
|
||||
return (
|
||||
<Badge className={cn(cls, 'gap-1')}>
|
||||
<Icon size={10} className={status === 'syncing' ? 'animate-spin' : ''} />
|
||||
{label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChannelsPage() {
|
||||
const [channels, setChannels] = useState<Channel[]>(MOCK_CHANNELS)
|
||||
|
||||
const triggerSync = (id: string) => {
|
||||
setChannels(prev => prev.map(c =>
|
||||
c.id === id ? { ...c, lastSyncStatus: 'syncing' } : c,
|
||||
))
|
||||
setTimeout(() => {
|
||||
setChannels(prev => prev.map(c =>
|
||||
c.id === id
|
||||
? { ...c, lastSyncStatus: 'success', lastSyncAt: new Date().toISOString(), bookingsImported: c.bookingsImported + Math.floor(Math.random() * 3) }
|
||||
: c,
|
||||
))
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const toggleChannel = (id: string) => {
|
||||
setChannels(prev => prev.map(c =>
|
||||
c.id === id ? { ...c, isEnabled: !c.isEnabled } : c,
|
||||
))
|
||||
}
|
||||
|
||||
const totalImported = channels.reduce((s, c) => s + c.bookingsImported, 0)
|
||||
const activeCount = channels.filter(c => c.isEnabled).length
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-5">
|
||||
{/* Header */}
|
||||
<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">
|
||||
Синхронизация с OTA площадками
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: 'Активных каналов', value: activeCount, icon: Globe },
|
||||
{ label: 'Всего каналов', value: channels.length, icon: Globe },
|
||||
{ label: 'Импортировано броней', value: totalImported, icon: CheckCircle2 },
|
||||
{ label: 'Последняя синхронизация', value: 'Сегодня', icon: RefreshCw },
|
||||
].map(s => (
|
||||
<div key={s.label} className="card p-4">
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{s.value}</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Channels */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{channels.map(channel => (
|
||||
<ChannelCard
|
||||
key={channel.id}
|
||||
channel={channel}
|
||||
onSync={() => triggerSync(channel.id)}
|
||||
onToggle={() => toggleChannel(channel.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="card p-4 border-amber-200 dark:border-amber-800/50 bg-amber-50 dark:bg-amber-900/10">
|
||||
<div className="flex gap-3">
|
||||
<AlertTriangle size={18} className="text-amber-600 dark:text-amber-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-amber-800 dark:text-amber-300">Демо-режим</p>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-400 mt-0.5">
|
||||
В демо-режиме синхронизация имитируется. В продакшн-версии используется webhook-интеграция
|
||||
с реальными API каналов через менеджер каналов (Staah, SiteMinder, HotelRunner и др.)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChannelCard({ channel, onSync, onToggle }: {
|
||||
channel: Channel
|
||||
onSync: () => void
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const isSyncing = channel.lastSyncStatus === 'syncing'
|
||||
|
||||
return (
|
||||
<div className={cn('card p-5 transition-all', !channel.isEnabled && 'opacity-60')}>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-11 h-11 rounded-xl bg-slate-100 dark:bg-slate-700 flex items-center justify-center text-2xl">
|
||||
{CHANNEL_ICONS[channel.name]}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 dark:text-slate-100">{channel.displayName}</p>
|
||||
<SyncStatusBadge status={channel.lastSyncStatus} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggle */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
'relative w-11 h-6 rounded-full transition-colors',
|
||||
channel.isEnabled ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600',
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'absolute top-0.5 w-5 h-5 rounded-full bg-white shadow-sm transition-transform',
|
||||
channel.isEnabled ? 'left-[22px]' : 'left-0.5',
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{channel.isEnabled && (
|
||||
<div className="space-y-2.5">
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">Импортировано броней</p>
|
||||
<p className="font-semibold text-slate-900 dark:text-slate-100">{channel.bookingsImported}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">Последняя синхронизация</p>
|
||||
<p className="font-semibold text-slate-900 dark:text-slate-100">
|
||||
{channel.lastSyncAt
|
||||
? new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(new Date(channel.lastSyncAt))
|
||||
: '—'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{channel.mappings.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1.5">Маппинг номеров ({channel.mappings.length})</p>
|
||||
<div className="space-y-1">
|
||||
{channel.mappings.slice(0, 2).map(m => (
|
||||
<div key={m.localRoomId} className="flex items-center justify-between text-xs">
|
||||
<span className="text-slate-600 dark:text-slate-400">№{
|
||||
// get room number from localRoomId
|
||||
m.localRoomId.replace('r', '')
|
||||
}</span>
|
||||
<span className="text-slate-400">→</span>
|
||||
<span className="text-slate-600 dark:text-slate-400 truncate max-w-32">{m.channelRoomName}</span>
|
||||
</div>
|
||||
))}
|
||||
{channel.mappings.length > 2 && (
|
||||
<p className="text-xs text-slate-400">+{channel.mappings.length - 2} ещё</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onSync}
|
||||
disabled={isSyncing}
|
||||
className="w-full btn-secondary text-xs py-2 justify-center gap-2"
|
||||
>
|
||||
<RefreshCw size={13} className={isSyncing ? 'animate-spin' : ''} />
|
||||
{isSyncing ? 'Синхронизация...' : 'Синхронизировать сейчас'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!channel.isEnabled && (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 text-center py-2">
|
||||
Канал отключён. Включите для настройки интеграции.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
181
src/pages/HousekeepingPage.tsx
Normal file
181
src/pages/HousekeepingPage.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useState } from 'react'
|
||||
import { CheckCircle2, Clock, AlertCircle, Sparkles, User } from 'lucide-react'
|
||||
import { MOCK_HK_TASKS } from '../data/mockData'
|
||||
import type { HousekeepingTask } from '../types'
|
||||
import { cn } from '../lib/utils'
|
||||
import { Badge } from '../components/ui/Badge'
|
||||
|
||||
type Column = 'pending' | 'in_progress' | 'done'
|
||||
|
||||
const COLUMNS: { id: Column; label: string; icon: React.ElementType; color: string }[] = [
|
||||
{ id: 'pending', label: 'Ожидают', icon: Clock, color: 'text-amber-600 dark:text-amber-400' },
|
||||
{ id: 'in_progress', label: 'В процессе', icon: Sparkles, color: 'text-blue-600 dark:text-blue-400' },
|
||||
{ id: 'done', label: 'Готово', icon: CheckCircle2, color: 'text-emerald-600 dark:text-emerald-400' },
|
||||
]
|
||||
|
||||
const PRIORITY_COLORS = {
|
||||
high: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
|
||||
normal: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300',
|
||||
low: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
|
||||
}
|
||||
|
||||
const PRIORITY_LABELS = { high: 'Срочно', normal: 'Обычный', low: 'Низкий' }
|
||||
|
||||
const TYPE_COLORS = {
|
||||
cleaning: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
inspection: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
maintenance: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300',
|
||||
}
|
||||
|
||||
const TYPE_LABELS = {
|
||||
cleaning: 'Уборка',
|
||||
inspection: 'Проверка',
|
||||
maintenance: 'Ремонт',
|
||||
}
|
||||
|
||||
export function HousekeepingPage() {
|
||||
const [tasks, setTasks] = useState<HousekeepingTask[]>(MOCK_HK_TASKS)
|
||||
|
||||
const updateStatus = (id: string, status: HousekeepingTask['status']) => {
|
||||
setTasks(prev => prev.map(t =>
|
||||
t.id === id
|
||||
? { ...t, status, completedAt: status === 'done' ? new Date().toISOString() : undefined }
|
||||
: t,
|
||||
))
|
||||
}
|
||||
|
||||
const total = tasks.length
|
||||
const done = tasks.filter(t => t.status === 'done').length
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-5">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">Уборка и обслуживание</h1>
|
||||
<div className="flex items-center gap-3 mt-1.5">
|
||||
<div className="flex-1 bg-slate-200 dark:bg-slate-700 rounded-full h-2">
|
||||
<div
|
||||
className="h-2 rounded-full bg-emerald-500 transition-all duration-500"
|
||||
style={{ width: `${(done / total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400 shrink-0">
|
||||
{done} / {total} завершено
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kanban columns */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{COLUMNS.map(col => {
|
||||
const colTasks = tasks.filter(t => t.status === col.id)
|
||||
const Icon = col.icon
|
||||
return (
|
||||
<div key={col.id} className="space-y-3">
|
||||
{/* Column header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon size={16} className={col.color} />
|
||||
<h3 className="font-semibold text-slate-700 dark:text-slate-300">
|
||||
{col.label}
|
||||
</h3>
|
||||
<span className="ml-auto text-xs font-bold px-2 py-0.5 rounded-full bg-slate-200 dark:bg-slate-700 text-slate-600 dark:text-slate-400">
|
||||
{colTasks.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="space-y-2.5">
|
||||
{colTasks.map(task => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onStatusChange={updateStatus}
|
||||
/>
|
||||
))}
|
||||
{colTasks.length === 0 && (
|
||||
<div className="rounded-xl border-2 border-dashed border-slate-200 dark:border-slate-700 py-8 text-center">
|
||||
<p className="text-sm text-slate-400 dark:text-slate-500">Пусто</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskCard({ task, onStatusChange }: {
|
||||
task: HousekeepingTask
|
||||
onStatusChange: (id: string, status: HousekeepingTask['status']) => void
|
||||
}) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'card p-3.5 space-y-2.5 transition-all',
|
||||
task.status === 'done' && 'opacity-70',
|
||||
)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
№{task.roomNumber}
|
||||
</span>
|
||||
<Badge className={PRIORITY_COLORS[task.priority]}>
|
||||
{PRIORITY_LABELS[task.priority]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
<Badge className={TYPE_COLORS[task.type]}>
|
||||
{TYPE_LABELS[task.type]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{task.notes && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 bg-slate-50 dark:bg-slate-700/40 rounded-lg p-2">
|
||||
{task.notes}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{task.assignedToName && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400">
|
||||
<User size={11} />
|
||||
{task.assignedToName}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.completedAt && (
|
||||
<p className="text-xs text-emerald-600 dark:text-emerald-400">
|
||||
Завершено в {new Date(task.completedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-1.5 pt-1">
|
||||
{task.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => onStatusChange(task.id, 'in_progress')}
|
||||
className="flex-1 text-xs py-1.5 rounded-lg bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 font-medium hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors"
|
||||
>
|
||||
Начать
|
||||
</button>
|
||||
)}
|
||||
{task.status === 'in_progress' && (
|
||||
<button
|
||||
onClick={() => onStatusChange(task.id, 'done')}
|
||||
className="flex-1 text-xs py-1.5 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300 font-medium hover:bg-emerald-100 dark:hover:bg-emerald-900/30 transition-colors"
|
||||
>
|
||||
Завершить
|
||||
</button>
|
||||
)}
|
||||
{task.status === 'done' && (
|
||||
<button
|
||||
onClick={() => onStatusChange(task.id, 'pending')}
|
||||
className="flex-1 text-xs py-1.5 rounded-lg bg-slate-50 dark:bg-slate-700 text-slate-600 dark:text-slate-400 font-medium hover:bg-slate-100 dark:hover:bg-slate-600 transition-colors"
|
||||
>
|
||||
Вернуть
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
207
src/pages/LoginPage.tsx
Normal file
207
src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useState } from 'react'
|
||||
import { Navigate, useNavigate } from 'react-router-dom'
|
||||
import { Hotel, Eye, EyeOff, Sun, Moon, AlertCircle } from 'lucide-react'
|
||||
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' },
|
||||
]
|
||||
|
||||
export function LoginPage() {
|
||||
const { user, login } = useAuth()
|
||||
const { theme, toggle } = useTheme()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [email, setEmail] = useState('manager@grand-palace.ru')
|
||||
const [password, setPassword] = useState('demo')
|
||||
const [showPass, setShowPass] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
if (user) {
|
||||
if (user.role === 'super_admin') return <Navigate to="/admin" replace />
|
||||
return <Navigate to="/grand-palace/calendar" replace />
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
const ok = await login(email, password)
|
||||
setLoading(false)
|
||||
if (!ok) {
|
||||
setError('Неверный email или пароль')
|
||||
return
|
||||
}
|
||||
// Redirect based on role
|
||||
const user = DEMO_ACCOUNTS.find(a => a.email === email)
|
||||
if (user?.role === 'super_admin') navigate('/admin')
|
||||
else navigate('/grand-palace/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 */}
|
||||
<div className="hidden lg:flex flex-col justify-between w-1/2 bg-brand-600 dark:bg-brand-800 p-12">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-white/20 flex items-center justify-center">
|
||||
<Hotel size={22} className="text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-white">HotelSync</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-white leading-tight mb-4">
|
||||
Современная PMS система<br />для вашего отеля
|
||||
</h1>
|
||||
<p className="text-brand-200 text-lg leading-relaxed">
|
||||
Управляйте бронированиями, номерным фондом и уборкой в одном месте.
|
||||
Синхронизация с Booking.com, Airbnb и другими каналами.
|
||||
</p>
|
||||
|
||||
<div className="mt-10 grid grid-cols-3 gap-6">
|
||||
{[
|
||||
{ value: '500+', label: 'Отелей' },
|
||||
{ value: '1M+', label: 'Бронирований' },
|
||||
{ value: '99.9%', label: 'Аптайм' },
|
||||
].map(s => (
|
||||
<div key={s.label}>
|
||||
<p className="text-3xl font-bold text-white">{s.value}</p>
|
||||
<p className="text-brand-200 text-sm">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-brand-200 text-sm">
|
||||
© 2026 HotelSync · SaaS PMS Platform
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center justify-center px-6">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Mobile logo */}
|
||||
<div className="lg:hidden flex items-center gap-2.5 mb-8">
|
||||
<div className="w-9 h-9 rounded-xl bg-brand-600 flex items-center justify-center">
|
||||
<Hotel size={18} className="text-white" />
|
||||
</div>
|
||||
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
Hotel<span className="text-brand-600">Next</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-1">
|
||||
Добро пожаловать
|
||||
</h2>
|
||||
<p className="text-slate-500 dark:text-slate-400 mb-8">
|
||||
Войдите в свой аккаунт для доступа к панели управления
|
||||
</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>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
className="input"
|
||||
placeholder="email@example.com"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Пароль
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPass ? 'text' : 'password'}
|
||||
className="input pr-10"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPass(v => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
{showPass ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">
|
||||
<AlertCircle size={15} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full justify-center py-2.5"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
) : 'Войти'}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
180
src/pages/RoomsPage.tsx
Normal file
180
src/pages/RoomsPage.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useState } from 'react'
|
||||
import { BedDouble, Users, Wifi, Plus, Search } from 'lucide-react'
|
||||
import { MOCK_ROOMS } from '../data/mockData'
|
||||
import type { Room } from '../types'
|
||||
import { cn, ROOM_STATUS_COLORS, ROOM_STATUS_LABELS, HK_STATUS_COLORS, HK_STATUS_LABELS, formatCurrency } from '../lib/utils'
|
||||
import { Badge } from '../components/ui/Badge'
|
||||
|
||||
export function RoomsPage() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [floorFilter, setFloorFilter] = useState<number | 'all'>('all')
|
||||
|
||||
const floors = [...new Set(MOCK_ROOMS.map(r => r.floor))].sort()
|
||||
|
||||
const filtered = MOCK_ROOMS.filter(r => {
|
||||
const matchSearch = search === '' ||
|
||||
r.number.includes(search) ||
|
||||
r.type.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(r.name ?? '').toLowerCase().includes(search.toLowerCase())
|
||||
const matchFloor = floorFilter === 'all' || r.floor === floorFilter
|
||||
return matchSearch && matchFloor
|
||||
})
|
||||
|
||||
const stats = {
|
||||
available: MOCK_ROOMS.filter(r => r.status === 'available').length,
|
||||
occupied: MOCK_ROOMS.filter(r => r.status === 'occupied').length,
|
||||
maintenance: MOCK_ROOMS.filter(r => r.status === 'maintenance').length,
|
||||
dirty: MOCK_ROOMS.filter(r => r.housekeepingStatus === 'dirty' || r.housekeepingStatus === 'cleaning').length,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-5">
|
||||
{/* Header */}
|
||||
<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">{MOCK_ROOMS.length} номеров</p>
|
||||
</div>
|
||||
<button className="btn-primary">
|
||||
<Plus size={15} />
|
||||
<span className="hidden sm:inline">Добавить номер</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: 'Свободных', value: stats.available, color: 'text-emerald-600 dark:text-emerald-400', bg: 'bg-emerald-50 dark:bg-emerald-900/20' },
|
||||
{ label: 'Занятых', value: stats.occupied, color: 'text-brand-600 dark:text-brand-400', bg: 'bg-brand-50 dark:bg-brand-900/20' },
|
||||
{ label: 'На ремонте', value: stats.maintenance, color: 'text-orange-600 dark:text-orange-400', bg: 'bg-orange-50 dark:bg-orange-900/20' },
|
||||
{ label: 'Нужна уборка', value: stats.dirty, color: 'text-red-600 dark:text-red-400', bg: 'bg-red-50 dark:bg-red-900/20' },
|
||||
].map(s => (
|
||||
<div key={s.label} className={cn('card p-4', s.bg, 'border-0')}>
|
||||
<p className={cn('text-2xl font-bold', s.color)}>{s.value}</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<div className="relative flex-1 min-w-48">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
className="input pl-9"
|
||||
placeholder="Поиск номера..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={() => setFloorFilter('all')}
|
||||
className={cn('px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
|
||||
floorFilter === 'all'
|
||||
? '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',
|
||||
)}
|
||||
>
|
||||
Все этажи
|
||||
</button>
|
||||
{floors.map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFloorFilter(f)}
|
||||
className={cn('px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
|
||||
floorFilter === f
|
||||
? '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',
|
||||
)}
|
||||
>
|
||||
{f} эт.
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Room grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{filtered.map(room => (
|
||||
<RoomCard key={room.id} room={room} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RoomCard({ room }: { room: Room }) {
|
||||
return (
|
||||
<div className="card p-4 hover:shadow-card-hover transition-shadow cursor-pointer group">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{room.number}
|
||||
</span>
|
||||
{room.name && (
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">{room.name}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">{room.type} · Этаж {room.floor}</span>
|
||||
</div>
|
||||
<div className={cn('w-3 h-3 rounded-full mt-1', {
|
||||
'bg-emerald-500': room.status === 'available',
|
||||
'bg-brand-500': room.status === 'occupied',
|
||||
'bg-orange-500': room.status === 'maintenance',
|
||||
'bg-slate-400': room.status === 'blocked',
|
||||
})} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
<Badge className={ROOM_STATUS_COLORS[room.status]}>
|
||||
{ROOM_STATUS_LABELS[room.status]}
|
||||
</Badge>
|
||||
<Badge className={HK_STATUS_COLORS[room.housekeepingStatus]}>
|
||||
{HK_STATUS_LABELS[room.housekeepingStatus]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-slate-500 dark:text-slate-400 mb-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<BedDouble size={12} />
|
||||
{room.bedType}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Users size={12} />
|
||||
до {room.maxGuests}
|
||||
</div>
|
||||
{room.amenities.includes('Wi-Fi') && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Wifi size={12} />
|
||||
Wi-Fi
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-base font-bold text-slate-900 dark:text-slate-100">
|
||||
{formatCurrency(room.baseRate)}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400 dark:text-slate-500">/ночь</span>
|
||||
</div>
|
||||
|
||||
{room.amenities.length > 0 && (
|
||||
<div className="mt-2.5 pt-2.5 border-t border-slate-100 dark:border-slate-700">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{room.amenities.slice(0, 3).map(a => (
|
||||
<span key={a} className="text-[10px] bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400 px-1.5 py-0.5 rounded">
|
||||
{a}
|
||||
</span>
|
||||
))}
|
||||
{room.amenities.length > 3 && (
|
||||
<span className="text-[10px] text-slate-400 px-1">+{room.amenities.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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