Add Availability Calendar, Modules system, and route refactoring
- Add AvailabilityPage with price grid (categories × dates), drag-select, channel prices, EditPanel, PeriodModal, and rate periods tab - Add ModulesContext with localStorage persistence and dynamic sidebar items - Add ModulesPage with WiFi Auth, Payments, TV Welcome, Smart Locks, OLAP Reports, Website Builder, Booking Widget modules - Add ReportsPage (OLAP analytics with KPI cards, charts, tables) - Add WebsitePage and BookingWidgetPage placeholders - Remove hotel slug from URL routing; hotel context from JWT - Add Доступность nav item in Sidebar under Управление - Fix LoginPage redirects and HotelSync branding Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
79
src/App.tsx
79
src/App.tsx
@@ -1,6 +1,7 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { ThemeProvider } from './contexts/ThemeContext'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import { ModulesProvider } from './contexts/ModulesContext'
|
||||
import { AppLayout } from './layouts/AppLayout'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
import { CalendarPage } from './pages/CalendarPage'
|
||||
@@ -10,56 +11,52 @@ import { HousekeepingPage } from './pages/HousekeepingPage'
|
||||
import { ChannelsPage } from './pages/ChannelsPage'
|
||||
import { ApiDocsPage } from './pages/ApiDocsPage'
|
||||
import { SettingsPage } from './pages/SettingsPage'
|
||||
import { ModulesPage } from './pages/ModulesPage'
|
||||
import { ReportsPage } from './pages/ReportsPage'
|
||||
import { WebsitePage } from './pages/WebsitePage'
|
||||
import { BookingWidgetPage } from './pages/BookingWidgetPage'
|
||||
import { AvailabilityPage } from './pages/AvailabilityPage'
|
||||
import { AdminDashboard } from './pages/AdminDashboard'
|
||||
import { useAuth } from './contexts/AuthContext'
|
||||
|
||||
function RoleGuard({
|
||||
children,
|
||||
allow,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
allow: string[]
|
||||
}) {
|
||||
const { user } = useAuth()
|
||||
if (!user || !allow.includes(user.role)) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Public */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<ModulesProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Public */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* Hotel routes */}
|
||||
<Route path="/:hotelSlug" element={<AppLayout />}>
|
||||
<Route index element={<Navigate to="calendar" replace />} />
|
||||
<Route path="calendar" element={<CalendarPage />} />
|
||||
<Route path="bookings" element={<BookingsPage />} />
|
||||
<Route path="rooms" element={<RoomsPage />} />
|
||||
<Route path="housekeeping" element={<HousekeepingPage />} />
|
||||
<Route path="channels" element={<ChannelsPage />} />
|
||||
<Route path="api-docs" element={<ApiDocsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
{/* App routes */}
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/calendar" element={<CalendarPage />} />
|
||||
<Route path="/bookings" element={<BookingsPage />} />
|
||||
<Route path="/rooms" element={<RoomsPage />} />
|
||||
<Route path="/housekeeping" element={<HousekeepingPage />} />
|
||||
<Route path="/channels" element={<ChannelsPage />} />
|
||||
<Route path="/api-docs" element={<ApiDocsPage />} />
|
||||
<Route path="/modules" element={<ModulesPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
{/* Module pages */}
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/website" element={<WebsitePage />} />
|
||||
<Route path="/booking-widget" element={<BookingWidgetPage />} />
|
||||
<Route path="/availability" element={<AvailabilityPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Admin routes */}
|
||||
<Route path="/admin" element={<AppLayout />}>
|
||||
<Route index element={<AdminDashboard />} />
|
||||
<Route path="hotels" element={<AdminDashboard />} />
|
||||
<Route path="users" element={<AdminDashboard />} />
|
||||
</Route>
|
||||
{/* Admin routes */}
|
||||
<Route path="/admin" element={<AppLayout />}>
|
||||
<Route index element={<AdminDashboard />} />
|
||||
<Route path="hotels" element={<AdminDashboard />} />
|
||||
<Route path="users" element={<AdminDashboard />} />
|
||||
</Route>
|
||||
|
||||
{/* Default */}
|
||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ModulesProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
CalendarDays, BookOpen, BedDouble, Sparkles, Globe, Settings,
|
||||
FileText, LayoutDashboard, Building2, Users, X, Hotel,
|
||||
FileText, LayoutDashboard, Building2, Users, X, Hotel, Puzzle, CalendarRange,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import { useModules } from '../../contexts/ModulesContext'
|
||||
import { MODULES_DATA } from '../../data/modulesData'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { MOCK_HOTELS } from '../../data/mockData'
|
||||
|
||||
interface SidebarProps {
|
||||
open: boolean
|
||||
@@ -34,17 +35,19 @@ function NavItem({
|
||||
|
||||
export function Sidebar({ open, onClose }: SidebarProps) {
|
||||
const { user } = useAuth()
|
||||
const { statuses } = useModules()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const hotel = user?.hotelId ? MOCK_HOTELS.find(h => h.id === user.hotelId) : null
|
||||
const slug = hotel?.slug ?? 'grand-palace'
|
||||
|
||||
const isAdmin = user?.role === 'super_admin'
|
||||
const isHousekeeper = user?.role === 'housekeeper'
|
||||
|
||||
// Активные модули у которых есть sidebarItem
|
||||
const activeModuleItems = MODULES_DATA.filter(
|
||||
m => m.sidebarItem && (statuses[m.id] === 'active' || statuses[m.id] === 'trial'),
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile backdrop */}
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 z-40 lg:hidden"
|
||||
@@ -67,25 +70,22 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
||||
<Hotel size={16} className="text-white" />
|
||||
</div>
|
||||
<span className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||
Hotel<span className="text-brand-600">Next</span>
|
||||
Hotel<span className="text-brand-600">Sync</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="lg:hidden btn-ghost p-1.5"
|
||||
>
|
||||
<button onClick={onClose} className="lg:hidden btn-ghost p-1.5">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Hotel selector (non-admin) */}
|
||||
{hotel && (
|
||||
{/* Hotel name */}
|
||||
{!isAdmin && user?.hotelName && (
|
||||
<div className="mx-3 mt-3 px-3 py-2.5 rounded-lg bg-slate-50 dark:bg-slate-700/50 border border-slate-200 dark:border-slate-600">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium mb-0.5">Текущий отель</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Building2 size={14} className="text-brand-600 shrink-0" />
|
||||
<span className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">
|
||||
{hotel.name}
|
||||
{user.hotelName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,39 +98,60 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
||||
<p className="px-3 pt-2 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||
Администрирование
|
||||
</p>
|
||||
<NavItem to="/admin" icon={LayoutDashboard} label="Дашборд" onClick={onClose} />
|
||||
<NavItem to="/admin/hotels" icon={Building2} label="Отели" onClick={onClose} />
|
||||
<NavItem to="/admin/users" icon={Users} label="Пользователи" onClick={onClose} />
|
||||
<NavItem to="/admin" icon={LayoutDashboard} label="Дашборд" onClick={onClose} />
|
||||
<NavItem to="/admin/hotels" icon={Building2} label="Отели" onClick={onClose} />
|
||||
<NavItem to="/admin/users" icon={Users} label="Пользователи" onClick={onClose} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="px-3 pt-2 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||
Основное
|
||||
</p>
|
||||
<NavItem to={`/${slug}/calendar`} icon={CalendarDays} label="Шахматка" onClick={onClose} />
|
||||
<NavItem to="/calendar" icon={CalendarDays} label="Шахматка" onClick={onClose} />
|
||||
{!isHousekeeper && (
|
||||
<NavItem to={`/${slug}/bookings`} icon={BookOpen} label="Бронирования" onClick={onClose} />
|
||||
<NavItem to="/bookings" icon={BookOpen} label="Бронирования" onClick={onClose} />
|
||||
)}
|
||||
<NavItem to={`/${slug}/housekeeping`} icon={Sparkles} label="Уборка" onClick={onClose} />
|
||||
<NavItem to="/housekeeping" icon={Sparkles} label="Уборка" onClick={onClose} />
|
||||
|
||||
{!isHousekeeper && (
|
||||
<>
|
||||
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||
Управление
|
||||
</p>
|
||||
<NavItem to={`/${slug}/rooms`} icon={BedDouble} label="Номера" onClick={onClose} />
|
||||
<NavItem to={`/${slug}/channels`} icon={Globe} label="Каналы" onClick={onClose} />
|
||||
<NavItem to={`/${slug}/settings`} icon={Settings} label="Настройки" onClick={onClose} />
|
||||
<NavItem to="/rooms" icon={BedDouble} label="Номера" onClick={onClose} />
|
||||
<NavItem to="/availability" icon={CalendarRange} label="Доступность" onClick={onClose} />
|
||||
<NavItem to="/channels" icon={Globe} label="Каналы" onClick={onClose} />
|
||||
<NavItem to="/modules" icon={Puzzle} label="Модули" onClick={onClose} />
|
||||
<NavItem to="/settings" icon={Settings} label="Настройки" onClick={onClose} />
|
||||
|
||||
{/* Активные модули с разделами */}
|
||||
{activeModuleItems.length > 0 && (
|
||||
<>
|
||||
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||
Модули
|
||||
</p>
|
||||
{activeModuleItems.map(m => (
|
||||
<NavItem
|
||||
key={m.id}
|
||||
to={m.sidebarItem!.path}
|
||||
icon={m.sidebarItem!.icon}
|
||||
label={m.sidebarItem!.label}
|
||||
onClick={onClose}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||
Разработчикам
|
||||
</p>
|
||||
<NavItem to={`/${slug}/api-docs`} icon={FileText} label="API & Документация" onClick={onClose} />
|
||||
<NavItem to="/api-docs" icon={FileText} label="API & Документация" onClick={onClose} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Version */}
|
||||
<div className="px-4 py-3 border-t border-slate-200 dark:border-slate-700 shrink-0">
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500">HotelSync v0.1.0 • SaaS PMS</p>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { MOCK_USERS } from '../data/mockData'
|
||||
interface AuthContextValue {
|
||||
session: AuthSession | null
|
||||
user: User | null
|
||||
login: (email: string, password: string) => Promise<boolean>
|
||||
login: (email: string, password: string) => Promise<User | null>
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
@@ -17,15 +17,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
return stored ? JSON.parse(stored) : null
|
||||
})
|
||||
|
||||
const login = async (email: string, _password: string): Promise<boolean> => {
|
||||
const login = async (email: string, _password: string): Promise<User | null> => {
|
||||
// Mock authentication — in production, call POST /auth/login
|
||||
await new Promise(r => setTimeout(r, 800))
|
||||
const user = MOCK_USERS.find(u => u.email.toLowerCase() === email.toLowerCase())
|
||||
if (!user) return false
|
||||
if (!user) return null
|
||||
const s: AuthSession = { user, token: 'mock-jwt-token-' + user.id }
|
||||
setSession(s)
|
||||
sessionStorage.setItem('hotelsync-session', JSON.stringify(s))
|
||||
return true
|
||||
return user
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
|
||||
54
src/contexts/ModulesContext.tsx
Normal file
54
src/contexts/ModulesContext.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { createContext, useContext, useState } from 'react'
|
||||
import type { ModuleStatus } from '../data/modulesData'
|
||||
|
||||
// Default statuses for demo
|
||||
const DEFAULT_STATUSES: Record<string, ModuleStatus> = {
|
||||
'wifi-auth': 'active',
|
||||
'payments': 'trial',
|
||||
'tv-welcome': 'inactive',
|
||||
'smart-locks': 'inactive',
|
||||
'olap-reports': 'active', // active в демо — виден в сайдбаре
|
||||
'website-builder':'inactive',
|
||||
'booking-widget': 'inactive',
|
||||
}
|
||||
|
||||
interface ModulesContextValue {
|
||||
statuses: Record<string, ModuleStatus>
|
||||
setStatus: (id: string, status: ModuleStatus) => void
|
||||
isActive: (id: string) => boolean
|
||||
}
|
||||
|
||||
const ModulesContext = createContext<ModulesContextValue | null>(null)
|
||||
|
||||
export function ModulesProvider({ children }: { children: React.ReactNode }) {
|
||||
const [statuses, setStatuses] = useState<Record<string, ModuleStatus>>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('hotelsync-modules')
|
||||
return stored ? { ...DEFAULT_STATUSES, ...JSON.parse(stored) } : DEFAULT_STATUSES
|
||||
} catch {
|
||||
return DEFAULT_STATUSES
|
||||
}
|
||||
})
|
||||
|
||||
const setStatus = (id: string, status: ModuleStatus) => {
|
||||
setStatuses(prev => {
|
||||
const next = { ...prev, [id]: status }
|
||||
localStorage.setItem('hotelsync-modules', JSON.stringify(next))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const isActive = (id: string) => statuses[id] === 'active' || statuses[id] === 'trial'
|
||||
|
||||
return (
|
||||
<ModulesContext.Provider value={{ statuses, setStatus, isActive }}>
|
||||
{children}
|
||||
</ModulesContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useModules() {
|
||||
const ctx = useContext(ModulesContext)
|
||||
if (!ctx) throw new Error('useModules must be used within ModulesProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -63,6 +63,8 @@ export const MOCK_USERS: User[] = [
|
||||
name: 'Елена Смирнова',
|
||||
role: 'hotel_manager',
|
||||
hotelId: 'hotel-1',
|
||||
hotelName: 'Grand Palace Hotel',
|
||||
hotelSlug: 'grand-palace',
|
||||
},
|
||||
{
|
||||
id: 'user-housekeeper-1',
|
||||
@@ -70,6 +72,8 @@ export const MOCK_USERS: User[] = [
|
||||
name: 'Мария Иванова',
|
||||
role: 'housekeeper',
|
||||
hotelId: 'hotel-1',
|
||||
hotelName: 'Grand Palace Hotel',
|
||||
hotelSlug: 'grand-palace',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
205
src/data/modulesData.ts
Normal file
205
src/data/modulesData.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
Wifi, CreditCard, Tv2, KeyRound,
|
||||
BarChart3, Globe, CalendarCheck2,
|
||||
} from 'lucide-react'
|
||||
import type { ElementType } from 'react'
|
||||
|
||||
export type ModuleStatus = 'active' | 'inactive' | 'trial'
|
||||
|
||||
export interface ModuleSidebarItem {
|
||||
path: string
|
||||
label: string
|
||||
icon: ElementType
|
||||
}
|
||||
|
||||
export interface ModuleDef {
|
||||
id: string
|
||||
name: string
|
||||
tagline: string
|
||||
description: string
|
||||
icon: ElementType
|
||||
iconBg: string
|
||||
iconColor: string
|
||||
accentColor: string
|
||||
price: number
|
||||
trialDays?: number
|
||||
features: string[]
|
||||
stats?: { label: string; value: string }[]
|
||||
badge?: string
|
||||
/** Если задан — при активации модуля этот пункт появляется в сайдбаре */
|
||||
sidebarItem?: ModuleSidebarItem
|
||||
}
|
||||
|
||||
export const MODULES_DATA: ModuleDef[] = [
|
||||
{
|
||||
id: 'wifi-auth',
|
||||
name: 'Wi-Fi Авторизация',
|
||||
tagline: 'Captive portal для гостей',
|
||||
description:
|
||||
'Персонализированная страница входа в Wi-Fi с брендингом отеля. Сбор контактов гостей, автовыдача паролей, статистика подключений.',
|
||||
icon: Wifi,
|
||||
iconBg: 'bg-blue-100 dark:bg-blue-900/40',
|
||||
iconColor: 'text-blue-600 dark:text-blue-400',
|
||||
accentColor: 'bg-blue-500',
|
||||
price: 2900,
|
||||
features: [
|
||||
'Брендированная страница входа',
|
||||
'Авторизация по номеру телефона / коду бронирования',
|
||||
'Сбор email для маркетинга',
|
||||
'Ограничение скорости по тарифу номера',
|
||||
'Статистика: устройства, время онлайн, трафик',
|
||||
],
|
||||
stats: [
|
||||
{ label: 'Подключений сегодня', value: '47' },
|
||||
{ label: 'Активных устройств', value: '31' },
|
||||
{ label: 'Конверсия email', value: '68%' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'payments',
|
||||
name: 'Депозиты и страховки',
|
||||
tagline: 'Онлайн-оплата при бронировании',
|
||||
description:
|
||||
'Автоматический сбор страхового депозита и предоплаты. Интеграция с Тинькофф, СБП, ЮKassa. Умное удержание и автовозврат при выезде.',
|
||||
icon: CreditCard,
|
||||
iconBg: 'bg-emerald-100 dark:bg-emerald-900/40',
|
||||
iconColor: 'text-emerald-600 dark:text-emerald-400',
|
||||
accentColor: 'bg-emerald-500',
|
||||
price: 4900,
|
||||
trialDays: 12,
|
||||
badge: 'Популярный',
|
||||
features: [
|
||||
'Приём депозита онлайн при заезде',
|
||||
'Интеграция: Тинькофф, ЮKassa, СБП',
|
||||
'Автоматический возврат при выезде',
|
||||
'Удержание с подтверждением менеджера',
|
||||
'Отчёты и история транзакций',
|
||||
'Уведомления гостю и администратору',
|
||||
],
|
||||
stats: [
|
||||
{ label: 'Собрано за месяц', value: '₽ 184 000' },
|
||||
{ label: 'Активных депозитов', value: '14' },
|
||||
{ label: 'Возвратов сегодня', value: '3' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tv-welcome',
|
||||
name: 'Приветствие на TV',
|
||||
tagline: 'Welcome screen в номере',
|
||||
description:
|
||||
'Персональное приветствие на телевизоре при заезде. Расписание ресторана, прогноз погоды, QR-код для заказа услуг. Поддержка Samsung, LG, Android TV.',
|
||||
icon: Tv2,
|
||||
iconBg: 'bg-violet-100 dark:bg-violet-900/40',
|
||||
iconColor: 'text-violet-600 dark:text-violet-400',
|
||||
accentColor: 'bg-violet-500',
|
||||
price: 3500,
|
||||
badge: 'Новинка',
|
||||
features: [
|
||||
'Персональное приветствие по имени гостя',
|
||||
'Расписание: ресторан, спа, трансфер',
|
||||
'Прогноз погоды и местные новости',
|
||||
'Заказ услуг и room service с TV',
|
||||
'Поддержка Samsung SSSP, LG webOS, Android TV',
|
||||
'Брендирование под стиль отеля',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'smart-locks',
|
||||
name: 'Электронные замки',
|
||||
tagline: 'Интеграция СКУД и смарт-замков',
|
||||
description:
|
||||
'Управление доступом в номера через PMS. Автовыдача кода при заезде, блокировка при выезде. Поддержка Salto, Dormakaba, TTLock и других систем.',
|
||||
icon: KeyRound,
|
||||
iconBg: 'bg-amber-100 dark:bg-amber-900/40',
|
||||
iconColor: 'text-amber-600 dark:text-amber-400',
|
||||
accentColor: 'bg-amber-500',
|
||||
price: 5900,
|
||||
features: [
|
||||
'Автовыдача PIN-кода / мобильного ключа при заезде',
|
||||
'Автоблокировка при выезде или отмене',
|
||||
'История открытий с временными метками',
|
||||
'Поддержка: Salto KS, Dormakaba, TTLock, ASSA ABLOY',
|
||||
'Экстренный доступ для персонала',
|
||||
'Интеграция с мобильным приложением гостя',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'olap-reports',
|
||||
name: 'OLAP Отчёты',
|
||||
tagline: 'Аналитика и бизнес-отчёты',
|
||||
description:
|
||||
'Глубокая аналитика по загрузке, выручке, каналам продаж и гостям. Конструктор отчётов, экспорт в Excel/PDF, автоотправка руководству.',
|
||||
icon: BarChart3,
|
||||
iconBg: 'bg-cyan-100 dark:bg-cyan-900/40',
|
||||
iconColor: 'text-cyan-600 dark:text-cyan-400',
|
||||
accentColor: 'bg-cyan-500',
|
||||
price: 3900,
|
||||
badge: 'Популярный',
|
||||
features: [
|
||||
'Загрузка (OCC), ADR, RevPAR в динамике',
|
||||
'Анализ по источникам бронирования',
|
||||
'Портрет гостя: география, повторные визиты',
|
||||
'Конструктор произвольных отчётов',
|
||||
'Экспорт в Excel, PDF, Google Sheets',
|
||||
'Автоотправка отчётов по расписанию на email',
|
||||
'Сравнение периодов и план/факт',
|
||||
],
|
||||
sidebarItem: {
|
||||
path: '/reports',
|
||||
label: 'Аналитика',
|
||||
icon: BarChart3,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'website-builder',
|
||||
name: 'Конструктор сайта',
|
||||
tagline: 'Сайт отеля без разработчика',
|
||||
description:
|
||||
'Drag & drop конструктор сайта с готовыми шаблонами для отелей. SEO-оптимизация, мультиязычность, интеграция с модулем онлайн-бронирования.',
|
||||
icon: Globe,
|
||||
iconBg: 'bg-rose-100 dark:bg-rose-900/40',
|
||||
iconColor: 'text-rose-600 dark:text-rose-400',
|
||||
accentColor: 'bg-rose-500',
|
||||
price: 4500,
|
||||
features: [
|
||||
'Готовые шаблоны для отелей и апартаментов',
|
||||
'Drag & drop редактор страниц',
|
||||
'Галерея номеров с виртуальными турами',
|
||||
'SEO-оптимизация и sitemap',
|
||||
'Мультиязычность (RU, EN, DE, ZH)',
|
||||
'Интеграция с модулем онлайн-бронирования',
|
||||
'Кастомный домен и SSL',
|
||||
],
|
||||
sidebarItem: {
|
||||
path: '/website',
|
||||
label: 'Сайт отеля',
|
||||
icon: Globe,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'booking-widget',
|
||||
name: 'Онлайн-бронирование',
|
||||
tagline: 'Виджет прямых продаж',
|
||||
description:
|
||||
'Виджет бронирования для сайта отеля с оплатой картой. Прямые продажи без комиссии OTA. Подбор номеров, тарифы, спецпредложения.',
|
||||
icon: CalendarCheck2,
|
||||
iconBg: 'bg-indigo-100 dark:bg-indigo-900/40',
|
||||
iconColor: 'text-indigo-600 dark:text-indigo-400',
|
||||
accentColor: 'bg-indigo-500',
|
||||
price: 3200,
|
||||
features: [
|
||||
'Виджет бронирования на сайт отеля',
|
||||
'Оплата картой, СБП, ЮKassa',
|
||||
'Тарифы и спецпредложения',
|
||||
'Промокоды и программа лояльности',
|
||||
'Подтверждение на email и SMS',
|
||||
'Минимизация комиссий OTA',
|
||||
'Аналитика конверсии',
|
||||
],
|
||||
sidebarItem: {
|
||||
path: '/booking-widget',
|
||||
label: 'Онлайн-бронирование',
|
||||
icon: CalendarCheck2,
|
||||
},
|
||||
},
|
||||
]
|
||||
117
src/data/ratesData.ts
Normal file
117
src/data/ratesData.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { addDays, format, getDay } from 'date-fns'
|
||||
|
||||
export interface RoomCategory {
|
||||
id: string
|
||||
name: string
|
||||
color: string // tailwind bg class
|
||||
textColor: string // tailwind text class
|
||||
basePrice: number
|
||||
extraPersonPrice: number
|
||||
maxPersons: number
|
||||
}
|
||||
|
||||
export interface ChannelRate {
|
||||
channelId: string
|
||||
price: number // цена для этого канала
|
||||
}
|
||||
|
||||
export interface PriceCell {
|
||||
price: number
|
||||
extraPerson: number
|
||||
minNights: number
|
||||
channelPrices: Record<string, number> // channelId -> price
|
||||
closed: boolean // закрыто для продажи
|
||||
}
|
||||
|
||||
export interface RatePeriod {
|
||||
id: string
|
||||
name: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
notes?: string
|
||||
categoryPrices: Record<string, number> // categoryId -> price
|
||||
channelMarkup: Record<string, number> // channelId -> multiplier (1.15 = +15%)
|
||||
extraPersonPrice: number
|
||||
minNights: number
|
||||
daysOfWeek?: number[] // если задано — применяется только для этих дней (0=вс,1=пн...)
|
||||
}
|
||||
|
||||
export const RATE_CHANNELS = [
|
||||
{ id: 'direct', name: 'Прямые', icon: '🏨', color: 'text-brand-600' },
|
||||
{ id: 'booking_com', name: 'Booking.com', icon: '🔵', color: 'text-blue-600' },
|
||||
{ id: 'airbnb', name: 'Airbnb', icon: '🔴', color: 'text-rose-600' },
|
||||
{ id: 'expedia', name: 'Expedia', icon: '🟡', color: 'text-amber-600' },
|
||||
]
|
||||
|
||||
export const ROOM_CATEGORIES: RoomCategory[] = [
|
||||
{ id: 'standard', name: 'Стандарт', color: 'bg-slate-500', textColor: 'text-slate-600', basePrice: 3500, extraPersonPrice: 800, maxPersons: 2 },
|
||||
{ id: 'deluxe', name: 'Делюкс', color: 'bg-brand-500', textColor: 'text-brand-600', basePrice: 5200, extraPersonPrice: 1000, maxPersons: 3 },
|
||||
{ id: 'suite', name: 'Сюит', color: 'bg-violet-500', textColor: 'text-violet-600', basePrice: 8900, extraPersonPrice: 1500, maxPersons: 4 },
|
||||
{ id: 'junior_suite', name: 'Джуниор Сюит', color: 'bg-cyan-500', textColor: 'text-cyan-600', basePrice: 6800, extraPersonPrice: 1200, maxPersons: 4 },
|
||||
{ id: 'penthouse', name: 'Пентхаус', color: 'bg-amber-500', textColor: 'text-amber-600', basePrice: 18000, extraPersonPrice: 2500, maxPersons: 6 },
|
||||
]
|
||||
|
||||
// Дефолтные мультипликаторы каналов (markup к базовой цене)
|
||||
export const DEFAULT_CHANNEL_MARKUP: Record<string, number> = {
|
||||
direct: 1.00,
|
||||
booking_com: 1.00, // net price — Booking забирает свою комиссию сверху
|
||||
airbnb: 1.05,
|
||||
expedia: 1.03,
|
||||
}
|
||||
|
||||
// Генерируем начальную сетку цен на 60 дней
|
||||
export function buildInitialPriceGrid(): Record<string, Record<string, PriceCell>> {
|
||||
const result: Record<string, Record<string, PriceCell>> = {}
|
||||
const today = new Date()
|
||||
|
||||
for (const cat of ROOM_CATEGORIES) {
|
||||
result[cat.id] = {}
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const d = addDays(today, i)
|
||||
const dateStr = format(d, 'yyyy-MM-dd')
|
||||
const dow = getDay(d)
|
||||
const isWeekend = dow === 5 || dow === 6
|
||||
|
||||
const basePrice = Math.round(cat.basePrice * (isWeekend ? 1.25 : 1.0))
|
||||
const channelPrices: Record<string, number> = {}
|
||||
for (const ch of RATE_CHANNELS) {
|
||||
channelPrices[ch.id] = Math.round(basePrice * DEFAULT_CHANNEL_MARKUP[ch.id])
|
||||
}
|
||||
|
||||
result[cat.id][dateStr] = {
|
||||
price: basePrice,
|
||||
extraPerson: cat.extraPersonPrice,
|
||||
minNights: 1,
|
||||
channelPrices,
|
||||
closed: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Демо-периоды
|
||||
export const DEMO_PERIODS: RatePeriod[] = [
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'Праздники 8 марта',
|
||||
startDate: '2026-03-06',
|
||||
endDate: '2026-03-10',
|
||||
categoryPrices: { standard: 4500, deluxe: 6500, suite: 11000, junior_suite: 8500, penthouse: 22000 },
|
||||
channelMarkup: { direct: 1.0, booking_com: 1.0, airbnb: 1.05, expedia: 1.03 },
|
||||
extraPersonPrice: 1200,
|
||||
minNights: 2,
|
||||
notes: 'Повышенный спрос на праздники',
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
name: 'Майские праздники',
|
||||
startDate: '2026-04-30',
|
||||
endDate: '2026-05-10',
|
||||
categoryPrices: { standard: 5000, deluxe: 7200, suite: 12500, junior_suite: 9800, penthouse: 25000 },
|
||||
channelMarkup: { direct: 1.0, booking_com: 1.0, airbnb: 1.08, expedia: 1.05 },
|
||||
extraPersonPrice: 1500,
|
||||
minNights: 3,
|
||||
notes: 'Минимальный заезд 3 ночи',
|
||||
},
|
||||
]
|
||||
899
src/pages/AvailabilityPage.tsx
Normal file
899
src/pages/AvailabilityPage.tsx
Normal file
@@ -0,0 +1,899 @@
|
||||
import { useState, useRef, useCallback, useMemo } from 'react'
|
||||
import { addDays, format, parseISO, isWithinInterval, startOfDay, getDay, isSameDay } from 'date-fns'
|
||||
import { ru } from 'date-fns/locale'
|
||||
import {
|
||||
ChevronLeft, ChevronRight, X, Check, CalendarDays,
|
||||
ListFilter, Plus, Pencil, Trash2, AlertCircle, RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '../lib/utils'
|
||||
import {
|
||||
ROOM_CATEGORIES, RATE_CHANNELS, DEMO_PERIODS,
|
||||
buildInitialPriceGrid, DEFAULT_CHANNEL_MARKUP,
|
||||
} from '../data/ratesData'
|
||||
import type { PriceCell, RatePeriod } from '../data/ratesData'
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const CELL_W = 72
|
||||
const ROW_H = 52
|
||||
const LABEL_W = 172
|
||||
const DAYS = 45
|
||||
const DATE_FMT = 'yyyy-MM-dd'
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtPrice(n: number) {
|
||||
return '₽\u00a0' + n.toLocaleString('ru-RU')
|
||||
}
|
||||
|
||||
function fmtDate(s: string) {
|
||||
return format(parseISO(s), 'd MMM', { locale: ru })
|
||||
}
|
||||
|
||||
function normRange(a: string, b: string): [string, string] {
|
||||
return a <= b ? [a, b] : [b, a]
|
||||
}
|
||||
|
||||
function datesInRange(start: string, end: string): string[] {
|
||||
const [s, e] = normRange(start, end)
|
||||
const result: string[] = []
|
||||
let cur = parseISO(s)
|
||||
const endD = parseISO(e)
|
||||
while (cur <= endD) {
|
||||
result.push(format(cur, DATE_FMT))
|
||||
cur = addDays(cur, 1)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Selection { start: string; end: string }
|
||||
|
||||
// ─── Price Grid ───────────────────────────────────────────────────────────────
|
||||
|
||||
function PriceGrid({
|
||||
dates, prices, selection, dragging,
|
||||
onCellDown, onCellEnter, activeChannel,
|
||||
}: {
|
||||
dates: string[]
|
||||
prices: Record<string, Record<string, PriceCell>>
|
||||
selection: Selection | null
|
||||
dragging: boolean
|
||||
onCellDown: (date: string) => void
|
||||
onCellEnter: (date: string) => void
|
||||
activeChannel: string
|
||||
}) {
|
||||
const today = format(new Date(), DATE_FMT)
|
||||
|
||||
const isSelected = useCallback((date: string) => {
|
||||
if (!selection) return false
|
||||
const [s, e] = normRange(selection.start, selection.end)
|
||||
return date >= s && date <= e
|
||||
}, [selection])
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto" style={{ cursor: dragging ? 'col-resize' : 'default' }}>
|
||||
<div style={{ minWidth: LABEL_W + dates.length * CELL_W }}>
|
||||
|
||||
{/* Date header */}
|
||||
<div className="flex sticky top-0 z-10 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700">
|
||||
<div style={{ width: LABEL_W, minWidth: LABEL_W }}
|
||||
className="shrink-0 flex items-end px-4 pb-2 text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
Категория
|
||||
</div>
|
||||
{dates.map(d => {
|
||||
const dt = parseISO(d)
|
||||
const dow = getDay(dt)
|
||||
const isWe = dow === 0 || dow === 6
|
||||
const isTd = d === today
|
||||
return (
|
||||
<div
|
||||
key={d}
|
||||
style={{ width: CELL_W, minWidth: CELL_W }}
|
||||
className={cn(
|
||||
'shrink-0 flex flex-col items-center justify-end pb-1.5 pt-2 text-center select-none',
|
||||
isWe && 'bg-amber-50/60 dark:bg-amber-900/10',
|
||||
isTd && 'bg-brand-50 dark:bg-brand-900/20',
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
'text-[10px] font-medium uppercase',
|
||||
isWe ? 'text-amber-600 dark:text-amber-400' : 'text-slate-400',
|
||||
)}>
|
||||
{format(dt, 'EEE', { locale: ru })}
|
||||
</span>
|
||||
<span className={cn(
|
||||
'text-sm font-bold leading-tight',
|
||||
isTd ? 'text-brand-600 dark:text-brand-400' : 'text-slate-700 dark:text-slate-300',
|
||||
)}>
|
||||
{format(dt, 'd')}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">
|
||||
{format(dt, 'MMM', { locale: ru })}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Category rows */}
|
||||
{ROOM_CATEGORIES.map(cat => (
|
||||
<div key={cat.id} className="flex border-b border-slate-100 dark:border-slate-700/60">
|
||||
{/* Label */}
|
||||
<div
|
||||
style={{ width: LABEL_W, minWidth: LABEL_W, height: ROW_H }}
|
||||
className="shrink-0 flex items-center gap-2.5 px-4 border-r border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800"
|
||||
>
|
||||
<div className={cn('w-2.5 h-2.5 rounded-full shrink-0', cat.color)} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{cat.name}</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
база: {fmtPrice(cat.basePrice)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cells */}
|
||||
{dates.map(d => {
|
||||
const cell = prices[cat.id]?.[d]
|
||||
const dt = parseISO(d)
|
||||
const dow = getDay(dt)
|
||||
const isWe = dow === 0 || dow === 6
|
||||
const isTd = d === today
|
||||
const isSel = isSelected(d)
|
||||
const price = activeChannel === 'direct'
|
||||
? cell?.price
|
||||
: cell?.channelPrices[activeChannel] ?? cell?.price
|
||||
|
||||
return (
|
||||
<div
|
||||
key={d}
|
||||
style={{ width: CELL_W, minWidth: CELL_W, height: ROW_H }}
|
||||
onMouseDown={() => onCellDown(d)}
|
||||
onMouseEnter={() => onCellEnter(d)}
|
||||
className={cn(
|
||||
'shrink-0 flex flex-col items-center justify-center select-none cursor-pointer',
|
||||
'border-r border-slate-100 dark:border-slate-700/40',
|
||||
'transition-colors',
|
||||
isWe && !isSel && 'bg-amber-50/40 dark:bg-amber-900/10',
|
||||
isTd && !isSel && 'bg-brand-50/40 dark:bg-brand-900/10',
|
||||
isSel
|
||||
? 'bg-brand-100 dark:bg-brand-800/40 ring-inset ring-1 ring-brand-400'
|
||||
: 'hover:bg-slate-50 dark:hover:bg-slate-700/30',
|
||||
cell?.closed && 'opacity-40',
|
||||
)}
|
||||
>
|
||||
{cell?.closed ? (
|
||||
<X size={14} className="text-slate-400" />
|
||||
) : (
|
||||
<>
|
||||
<span className={cn(
|
||||
'text-sm font-semibold leading-tight',
|
||||
isSel ? 'text-brand-700 dark:text-brand-300' : 'text-slate-800 dark:text-slate-200',
|
||||
)}>
|
||||
{price ? price.toLocaleString('ru-RU') : '—'}
|
||||
</span>
|
||||
{cell?.minNights > 1 && (
|
||||
<span className="text-[10px] text-slate-400 leading-tight">
|
||||
min {cell.minNights}н
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Edit Panel ───────────────────────────────────────────────────────────────
|
||||
|
||||
function EditPanel({
|
||||
selection,
|
||||
prices,
|
||||
onApply,
|
||||
onClose,
|
||||
}: {
|
||||
selection: Selection
|
||||
prices: Record<string, Record<string, PriceCell>>
|
||||
onApply: (updates: {
|
||||
startDate: string
|
||||
endDate: string
|
||||
categoryPrices: Record<string, number>
|
||||
extraPerson: number
|
||||
minNights: number
|
||||
channelMarkup: Record<string, number>
|
||||
closed: boolean
|
||||
}) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [s, e] = normRange(selection.start, selection.end)
|
||||
|
||||
// Initial values from first cell of selection
|
||||
const firstCell = prices[ROOM_CATEGORIES[0].id]?.[s]
|
||||
|
||||
const [startDate, setStartDate] = useState(s)
|
||||
const [endDate, setEndDate] = useState(e)
|
||||
const [extraPerson, setExtraPerson] = useState(firstCell?.extraPerson ?? 0)
|
||||
const [minNights, setMinNights] = useState(firstCell?.minNights ?? 1)
|
||||
const [closed, setClosed] = useState(false)
|
||||
const [channelMarkup, setChannelMarkup] = useState<Record<string, number>>(
|
||||
{ ...DEFAULT_CHANNEL_MARKUP },
|
||||
)
|
||||
|
||||
const [catPrices, setCatPrices] = useState<Record<string, number>>(() => {
|
||||
const r: Record<string, number> = {}
|
||||
for (const cat of ROOM_CATEGORIES) {
|
||||
r[cat.id] = prices[cat.id]?.[s]?.price ?? cat.basePrice
|
||||
}
|
||||
return r
|
||||
})
|
||||
|
||||
const nightCount = useMemo(() => {
|
||||
try {
|
||||
return datesInRange(startDate, endDate).length
|
||||
} catch { return 1 }
|
||||
}, [startDate, endDate])
|
||||
|
||||
return (
|
||||
<div className="w-80 shrink-0 border-l border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 flex flex-col overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-200 dark:border-slate-700">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 dark:text-slate-100 text-sm">Редактировать цены</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{nightCount} {nightCount === 1 ? 'день' : 'дней'}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="btn-ghost p-1.5"><X size={15} /></button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-4 space-y-5 overflow-y-auto">
|
||||
|
||||
{/* Date range */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||||
Период
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={e => setStartDate(e.target.value)}
|
||||
className="input text-sm flex-1 py-1.5"
|
||||
/>
|
||||
<span className="text-slate-400 text-sm">—</span>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={e => setEndDate(e.target.value)}
|
||||
className="input text-sm flex-1 py-1.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Close toggle */}
|
||||
<div className="flex items-center justify-between py-2 px-3 rounded-lg bg-slate-50 dark:bg-slate-700/40">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">Закрыто для продажи</p>
|
||||
<p className="text-xs text-slate-400">Все номера недоступны в этот период</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setClosed(v => !v)}
|
||||
className={cn(
|
||||
'relative w-10 h-5 rounded-full transition-colors shrink-0',
|
||||
closed ? 'bg-red-500' : 'bg-slate-200 dark:bg-slate-600',
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
'absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform',
|
||||
closed ? 'translate-x-5' : 'translate-x-0.5',
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Category prices */}
|
||||
{!closed && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||||
Цена по категориям
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{ROOM_CATEGORIES.map(cat => (
|
||||
<div key={cat.id} className="flex items-center gap-2">
|
||||
<div className={cn('w-2 h-2 rounded-full shrink-0', cat.color)} />
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400 w-28 shrink-0 truncate">
|
||||
{cat.name}
|
||||
</span>
|
||||
<div className="relative flex-1">
|
||||
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-xs text-slate-400">₽</span>
|
||||
<input
|
||||
type="number"
|
||||
value={catPrices[cat.id] ?? cat.basePrice}
|
||||
onChange={ev => setCatPrices(p => ({ ...p, [cat.id]: +ev.target.value }))}
|
||||
className="input text-sm py-1.5 pl-6 w-full"
|
||||
min={0}
|
||||
step={100}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extra person */}
|
||||
{!closed && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||||
Доп. место (цена за персону)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm text-slate-400">₽</span>
|
||||
<input
|
||||
type="number"
|
||||
value={extraPerson}
|
||||
onChange={e => setExtraPerson(+e.target.value)}
|
||||
className="input text-sm py-1.5 pl-7"
|
||||
min={0}
|
||||
step={100}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Min nights */}
|
||||
{!closed && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||||
Мин. ночей
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={minNights}
|
||||
onChange={e => setMinNights(Math.max(1, +e.target.value))}
|
||||
className="input text-sm py-1.5 w-24"
|
||||
min={1}
|
||||
max={30}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Channel markup */}
|
||||
{!closed && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||||
Наценка по каналам
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{RATE_CHANNELS.map(ch => {
|
||||
const markup = channelMarkup[ch.id] ?? 1.0
|
||||
const pct = Math.round((markup - 1) * 100)
|
||||
return (
|
||||
<div key={ch.id} className="flex items-center gap-2">
|
||||
<span className="text-sm w-7 shrink-0">{ch.icon}</span>
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400 flex-1 truncate">{ch.name}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
value={pct}
|
||||
onChange={ev => setChannelMarkup(p => ({ ...p, [ch.id]: 1 + +ev.target.value / 100 }))}
|
||||
className="input text-sm py-1 w-16 text-center"
|
||||
step={1}
|
||||
min={-50}
|
||||
max={200}
|
||||
/>
|
||||
<span className="text-xs text-slate-400">%</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-2 flex items-start gap-1">
|
||||
<AlertCircle size={11} className="mt-0.5 shrink-0" />
|
||||
0% = та же цена, +15% = цена выше на 15%
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-slate-200 dark:border-slate-700 flex gap-2">
|
||||
<button
|
||||
onClick={() => onApply({
|
||||
startDate, endDate, categoryPrices: catPrices,
|
||||
extraPerson, minNights, channelMarkup, closed,
|
||||
})}
|
||||
className="btn-primary flex-1 justify-center gap-2 text-sm py-2"
|
||||
>
|
||||
<Check size={14} /> Применить
|
||||
</button>
|
||||
<button onClick={onClose} className="btn-secondary text-sm py-2 px-3">
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Period Modal ─────────────────────────────────────────────────────────────
|
||||
|
||||
function PeriodModal({
|
||||
period,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
period?: RatePeriod
|
||||
onSave: (p: RatePeriod) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [name, setName] = useState(period?.name ?? '')
|
||||
const [startDate, setStartDate] = useState(period?.startDate ?? format(new Date(), DATE_FMT))
|
||||
const [endDate, setEndDate] = useState(period?.endDate ?? format(addDays(new Date(), 7), DATE_FMT))
|
||||
const [notes, setNotes] = useState(period?.notes ?? '')
|
||||
const [minNights, setMinNights] = useState(period?.minNights ?? 1)
|
||||
const [extraPerson, setExtraPerson] = useState(period?.extraPersonPrice ?? 0)
|
||||
const [catPrices, setCatPrices] = useState<Record<string, number>>(
|
||||
period?.categoryPrices ?? Object.fromEntries(ROOM_CATEGORIES.map(c => [c.id, c.basePrice])),
|
||||
)
|
||||
const [markup, setMarkup] = useState<Record<string, number>>(
|
||||
period?.channelMarkup ?? { ...DEFAULT_CHANNEL_MARKUP },
|
||||
)
|
||||
|
||||
const save = () => {
|
||||
if (!name || !startDate || !endDate) return
|
||||
onSave({
|
||||
id: period?.id ?? `p-${Date.now()}`,
|
||||
name, startDate, endDate, notes,
|
||||
categoryPrices: catPrices,
|
||||
channelMarkup: markup,
|
||||
extraPersonPrice: extraPerson,
|
||||
minNights,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-2xl w-full max-w-2xl shadow-xl flex flex-col max-h-[90vh]">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h3 className="font-bold text-slate-900 dark:text-slate-100">
|
||||
{period ? 'Редактировать период' : 'Новый тарифный период'}
|
||||
</h3>
|
||||
<button onClick={onClose} className="btn-ghost p-1.5"><X size={16} /></button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto p-6 space-y-5 flex-1">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
|
||||
Название периода *
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Например: Новогодние праздники"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dates */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
|
||||
Начало *
|
||||
</label>
|
||||
<input type="date" className="input" value={startDate}
|
||||
onChange={e => setStartDate(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
|
||||
Конец *
|
||||
</label>
|
||||
<input type="date" className="input" value={endDate}
|
||||
onChange={e => setEndDate(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category prices */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||||
Цены по категориям
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ROOM_CATEGORIES.map(cat => (
|
||||
<div key={cat.id} className="flex items-center gap-2">
|
||||
<div className={cn('w-2 h-2 rounded-full shrink-0', cat.color)} />
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400 flex-1 truncate min-w-0">
|
||||
{cat.name}
|
||||
</span>
|
||||
<div className="relative w-28 shrink-0">
|
||||
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-xs text-slate-400">₽</span>
|
||||
<input
|
||||
type="number" min={0} step={100}
|
||||
value={catPrices[cat.id] ?? cat.basePrice}
|
||||
onChange={ev => setCatPrices(p => ({ ...p, [cat.id]: +ev.target.value }))}
|
||||
className="input text-sm py-1.5 pl-6 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Extra + min nights */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
|
||||
Доп. место (₽/чел)
|
||||
</label>
|
||||
<input type="number" min={0} step={100} className="input" value={extraPerson}
|
||||
onChange={e => setExtraPerson(+e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
|
||||
Мин. ночей
|
||||
</label>
|
||||
<input type="number" min={1} max={30} className="input" value={minNights}
|
||||
onChange={e => setMinNights(+e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel markup */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||||
Наценка по каналам
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{RATE_CHANNELS.map(ch => {
|
||||
const pct = Math.round(((markup[ch.id] ?? 1) - 1) * 100)
|
||||
return (
|
||||
<div key={ch.id} className="flex items-center gap-2">
|
||||
<span>{ch.icon}</span>
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400 flex-1 truncate">{ch.name}</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<input type="number" step={1} min={-50} max={200}
|
||||
value={pct}
|
||||
onChange={ev => setMarkup(p => ({ ...p, [ch.id]: 1 + +ev.target.value / 100 }))}
|
||||
className="input text-sm py-1 w-16 text-center"
|
||||
/>
|
||||
<span className="text-xs text-slate-400">%</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
|
||||
Примечание
|
||||
</label>
|
||||
<textarea className="input resize-none" rows={2} value={notes}
|
||||
onChange={e => setNotes(e.target.value)} placeholder="Необязательно" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-slate-200 dark:border-slate-700 flex justify-end gap-2">
|
||||
<button onClick={onClose} className="btn-secondary">Отмена</button>
|
||||
<button onClick={save} disabled={!name}
|
||||
className="btn-primary gap-2">
|
||||
<Check size={15} /> Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function AvailabilityPage() {
|
||||
const today = useMemo(() => new Date(), [])
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [tab, setTab] = useState<'grid' | 'periods'>('grid')
|
||||
const [activeChannel, setActiveChannel] = useState('direct')
|
||||
const [prices, setPrices] = useState(() => buildInitialPriceGrid())
|
||||
const [periods, setPeriods] = useState<RatePeriod[]>(DEMO_PERIODS)
|
||||
const [selection, setSelection] = useState<Selection | null>(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [dragStart, setDragStart] = useState<string | null>(null)
|
||||
const [showEditPanel, setShowEditPanel] = useState(false)
|
||||
const [periodModal, setPeriodModal] = useState<RatePeriod | null | 'new'>(null)
|
||||
|
||||
const dates = useMemo(() =>
|
||||
Array.from({ length: DAYS }, (_, i) =>
|
||||
format(addDays(today, offset + i), DATE_FMT),
|
||||
), [today, offset])
|
||||
|
||||
// ── Mouse handlers ──
|
||||
const handleCellDown = (date: string) => {
|
||||
setDragging(true)
|
||||
setDragStart(date)
|
||||
setSelection({ start: date, end: date })
|
||||
setShowEditPanel(false)
|
||||
}
|
||||
|
||||
const handleCellEnter = (date: string) => {
|
||||
if (!dragging || !dragStart) return
|
||||
setSelection({ start: dragStart, end: date })
|
||||
}
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (dragging && selection) {
|
||||
setShowEditPanel(true)
|
||||
}
|
||||
setDragging(false)
|
||||
setDragStart(null)
|
||||
}, [dragging, selection])
|
||||
|
||||
// ── Apply price changes ──
|
||||
const applyPrices = ({
|
||||
startDate, endDate, categoryPrices, extraPerson, minNights, channelMarkup, closed,
|
||||
}: {
|
||||
startDate: string; endDate: string
|
||||
categoryPrices: Record<string, number>
|
||||
extraPerson: number; minNights: number
|
||||
channelMarkup: Record<string, number>
|
||||
closed: boolean
|
||||
}) => {
|
||||
const days = datesInRange(startDate, endDate)
|
||||
setPrices(prev => {
|
||||
const next = { ...prev }
|
||||
for (const cat of ROOM_CATEGORIES) {
|
||||
next[cat.id] = { ...prev[cat.id] }
|
||||
const basePrice = categoryPrices[cat.id] ?? cat.basePrice
|
||||
for (const d of days) {
|
||||
const channelPrices: Record<string, number> = {}
|
||||
for (const ch of RATE_CHANNELS) {
|
||||
channelPrices[ch.id] = Math.round(basePrice * (channelMarkup[ch.id] ?? 1))
|
||||
}
|
||||
next[cat.id][d] = {
|
||||
price: basePrice, extraPerson, minNights, channelPrices, closed,
|
||||
}
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
setShowEditPanel(false)
|
||||
setSelection(null)
|
||||
}
|
||||
|
||||
// ── Apply period ──
|
||||
const applyPeriod = (p: RatePeriod) => {
|
||||
// Apply prices from period
|
||||
applyPrices({
|
||||
startDate: p.startDate,
|
||||
endDate: p.endDate,
|
||||
categoryPrices: p.categoryPrices,
|
||||
extraPerson: p.extraPersonPrice,
|
||||
minNights: p.minNights,
|
||||
channelMarkup: p.channelMarkup,
|
||||
closed: false,
|
||||
})
|
||||
}
|
||||
|
||||
const savePeriod = (p: RatePeriod) => {
|
||||
setPeriods(prev => {
|
||||
const idx = prev.findIndex(x => x.id === p.id)
|
||||
return idx >= 0 ? prev.map(x => x.id === p.id ? p : x) : [...prev, p]
|
||||
})
|
||||
setPeriodModal(null)
|
||||
}
|
||||
|
||||
const deletePeriod = (id: string) => {
|
||||
setPeriods(prev => prev.filter(p => p.id !== id))
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col h-full"
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
{/* ── Top bar ── */}
|
||||
<div className="shrink-0 px-5 py-3 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex bg-slate-100 dark:bg-slate-700 rounded-lg p-0.5">
|
||||
{([['grid', 'Сетка цен'], ['periods', 'Периоды']] as const).map(([t, label]) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
|
||||
tab === t
|
||||
? 'bg-white dark:bg-slate-600 text-slate-900 dark:text-slate-100 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'grid' && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => setOffset(o => o - 14)} className="btn-ghost p-1.5">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOffset(0)}
|
||||
className={cn('btn-ghost text-xs px-2 py-1', offset === 0 && 'text-brand-600 font-semibold')}
|
||||
>
|
||||
Сегодня
|
||||
</button>
|
||||
<button onClick={() => setOffset(o => o + 14)} className="btn-ghost p-1.5">
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Channel selector */}
|
||||
{tab === 'grid' && (
|
||||
<div className="flex items-center gap-1 bg-slate-100 dark:bg-slate-700 rounded-lg p-0.5">
|
||||
{RATE_CHANNELS.map(ch => (
|
||||
<button
|
||||
key={ch.id}
|
||||
onClick={() => setActiveChannel(ch.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium transition-colors',
|
||||
activeChannel === ch.id
|
||||
? 'bg-white dark:bg-slate-600 text-slate-900 dark:text-slate-100 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300',
|
||||
)}
|
||||
>
|
||||
<span>{ch.icon}</span>
|
||||
<span className="hidden sm:inline">{ch.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'periods' && (
|
||||
<button
|
||||
onClick={() => setPeriodModal('new')}
|
||||
className="btn-primary text-sm gap-1.5"
|
||||
>
|
||||
<Plus size={14} /> Добавить период
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Content ── */}
|
||||
<div className="flex-1 overflow-hidden flex">
|
||||
|
||||
{/* Grid tab */}
|
||||
{tab === 'grid' && (
|
||||
<>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* Legend */}
|
||||
<div className="px-4 py-2 bg-slate-50 dark:bg-slate-700/30 border-b border-slate-200 dark:border-slate-700 flex items-center gap-4 text-xs text-slate-500 flex-wrap">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-3 h-3 rounded-sm bg-amber-100 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700 shrink-0" />
|
||||
Выходные
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-3 h-3 rounded-sm bg-brand-100 dark:bg-brand-900/30 border border-brand-200 dark:border-brand-700 shrink-0" />
|
||||
Сегодня
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-3 h-3 rounded-sm bg-brand-200 dark:bg-brand-800/60 border border-brand-400 shrink-0" />
|
||||
Выделено
|
||||
</span>
|
||||
<span className="ml-auto text-slate-400 hidden md:block">
|
||||
Выделите ячейки мышью для редактирования цен
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<PriceGrid
|
||||
dates={dates}
|
||||
prices={prices}
|
||||
selection={selection}
|
||||
dragging={dragging}
|
||||
onCellDown={handleCellDown}
|
||||
onCellEnter={handleCellEnter}
|
||||
activeChannel={activeChannel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Edit panel */}
|
||||
{showEditPanel && selection && (
|
||||
<EditPanel
|
||||
selection={selection}
|
||||
prices={prices}
|
||||
onApply={applyPrices}
|
||||
onClose={() => { setShowEditPanel(false); setSelection(null) }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Periods tab */}
|
||||
{tab === 'periods' && (
|
||||
<div className="flex-1 overflow-auto p-5">
|
||||
{periods.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-center">
|
||||
<CalendarDays size={36} className="text-slate-300 dark:text-slate-600 mb-3" />
|
||||
<p className="text-slate-500 dark:text-slate-400 font-medium">Нет тарифных периодов</p>
|
||||
<p className="text-sm text-slate-400 mt-1">Добавьте период для управления ценами</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-w-3xl">
|
||||
{periods.map(p => (
|
||||
<div key={p.id}
|
||||
className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="font-semibold text-slate-900 dark:text-slate-100">{p.name}</h4>
|
||||
<span className="text-xs bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400 px-2 py-0.5 rounded-full">
|
||||
{fmtDate(p.startDate)} — {fmtDate(p.endDate)}
|
||||
</span>
|
||||
{p.minNights > 1 && (
|
||||
<span className="text-xs bg-brand-50 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 px-2 py-0.5 rounded-full">
|
||||
мин. {p.minNights} н.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{p.notes && (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">{p.notes}</p>
|
||||
)}
|
||||
{/* Category prices */}
|
||||
<div className="flex gap-3 flex-wrap mt-2">
|
||||
{ROOM_CATEGORIES.map(cat => (
|
||||
p.categoryPrices[cat.id] ? (
|
||||
<span key={cat.id} className="text-xs text-slate-600 dark:text-slate-400 flex items-center gap-1">
|
||||
<span className={cn('w-1.5 h-1.5 rounded-full', cat.color)} />
|
||||
{cat.name}: <strong>{fmtPrice(p.categoryPrices[cat.id])}</strong>
|
||||
</span>
|
||||
) : null
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={() => applyPeriod(p)}
|
||||
title="Применить к сетке"
|
||||
className="btn-ghost p-1.5 text-brand-600"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPeriodModal(p)}
|
||||
className="btn-ghost p-1.5"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deletePeriod(p.id)}
|
||||
className="btn-ghost p-1.5 text-red-500 hover:text-red-600"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Period modal */}
|
||||
{periodModal !== null && (
|
||||
<PeriodModal
|
||||
period={periodModal === 'new' ? undefined : periodModal}
|
||||
onSave={savePeriod}
|
||||
onClose={() => setPeriodModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
63
src/pages/BookingWidgetPage.tsx
Normal file
63
src/pages/BookingWidgetPage.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { CalendarCheck2, Code2, CreditCard, Globe } from 'lucide-react'
|
||||
|
||||
export function BookingWidgetPage() {
|
||||
const snippet = `<script src="https://widget.hotelsync.ru/v1/embed.js"
|
||||
data-hotel="grand-palace"
|
||||
data-lang="ru">
|
||||
</script>`
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-indigo-600 flex items-center justify-center shrink-0">
|
||||
<CalendarCheck2 size={20} className="text-white" />
|
||||
</div>
|
||||
<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">Виджет прямых продаж для сайта отеля</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: 'Бронирований через виджет', value: '—', sub: 'нет данных' },
|
||||
{ label: 'Конверсия', value: '—', sub: 'нет данных' },
|
||||
{ label: 'Прямая выручка', value: '—', sub: 'нет данных' },
|
||||
].map(s => (
|
||||
<div key={s.label} className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 text-center">
|
||||
<p className="text-2xl font-bold text-slate-400">{s.value}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1 leading-tight">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Embed code */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code2 size={16} className="text-indigo-500" />
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100 text-sm">Код для вставки на сайт</h3>
|
||||
</div>
|
||||
<pre className="bg-slate-900 text-emerald-400 text-xs rounded-lg p-4 overflow-x-auto font-mono leading-relaxed">
|
||||
{snippet}
|
||||
</pre>
|
||||
<button className="btn-secondary text-sm">Скопировать код</button>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{[
|
||||
{ icon: CalendarCheck2, title: 'Мгновенное подтверждение', desc: 'Email + SMS гостю' },
|
||||
{ icon: CreditCard, title: 'Онлайн-оплата', desc: 'Тинькофф, СБП, ЮKassa' },
|
||||
{ icon: Globe, title: 'Без комиссии OTA', desc: 'Только платёжная комиссия' },
|
||||
].map(f => (
|
||||
<div key={f.title} className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 text-center">
|
||||
<f.icon size={22} className="text-indigo-500 mx-auto mb-2" />
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100 text-sm">{f.title}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{f.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -24,23 +24,21 @@ export function LoginPage() {
|
||||
|
||||
if (user) {
|
||||
if (user.role === 'super_admin') return <Navigate to="/admin" replace />
|
||||
return <Navigate to="/grand-palace/calendar" replace />
|
||||
return <Navigate to="/calendar" replace />
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
const ok = await login(email, password)
|
||||
const loggedIn = await login(email, password)
|
||||
setLoading(false)
|
||||
if (!ok) {
|
||||
if (!loggedIn) {
|
||||
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')
|
||||
if (loggedIn.role === 'super_admin') navigate('/admin')
|
||||
else navigate('/calendar')
|
||||
}
|
||||
|
||||
const fillDemo = (acc: typeof DEMO_ACCOUNTS[number]) => {
|
||||
@@ -105,7 +103,7 @@ export function LoginPage() {
|
||||
<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>
|
||||
Hotel<span className="text-brand-600">Sync</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
232
src/pages/ModulesPage.tsx
Normal file
232
src/pages/ModulesPage.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
CheckCircle2, Lock, Zap, ChevronDown,
|
||||
Star, ArrowUpRight, Puzzle,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '../lib/utils'
|
||||
import { MODULES_DATA } from '../data/modulesData'
|
||||
import { useModules } from '../contexts/ModulesContext'
|
||||
import type { ModuleDef } from '../data/modulesData'
|
||||
import type { ModuleStatus } from '../data/modulesData'
|
||||
|
||||
// ─── Status config ────────────────────────────────────────────────────────────
|
||||
|
||||
const STATUS_CFG: Record<ModuleStatus, { label: string; pill: string }> = {
|
||||
active: { label: 'Подключён', pill: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400' },
|
||||
trial: { label: 'Пробный', pill: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400' },
|
||||
inactive: { label: 'Не подключён', pill: 'bg-slate-100 text-slate-500 dark:bg-slate-700/60 dark:text-slate-400' },
|
||||
}
|
||||
|
||||
// ─── Card ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function ModuleCard({ mod, status }: { mod: ModuleDef; status: ModuleStatus }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const Icon = mod.icon
|
||||
const cfg = STATUS_CFG[status]
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 flex flex-col overflow-hidden shadow-sm hover:shadow-md transition-shadow">
|
||||
<div className={cn('h-1 w-full shrink-0', mod.accentColor)} />
|
||||
|
||||
<div className="p-5 flex flex-col flex-1 gap-4">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={cn('w-10 h-10 rounded-xl flex items-center justify-center shrink-0', mod.iconBg)}>
|
||||
<Icon size={20} className={mod.iconColor} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-slate-900 dark:text-slate-100 leading-tight">
|
||||
{mod.name}
|
||||
</span>
|
||||
{mod.badge && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-brand-50 text-brand-600 dark:bg-brand-900/30 dark:text-brand-400 border border-brand-200 dark:border-brand-800">
|
||||
<Star size={9} />{mod.badge}
|
||||
</span>
|
||||
)}
|
||||
{mod.sidebarItem && status === 'active' && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400">
|
||||
В меню
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{mod.tagline}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn('shrink-0 text-xs font-medium px-2.5 py-1 rounded-full whitespace-nowrap', cfg.pill)}>
|
||||
{cfg.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">
|
||||
{mod.description}
|
||||
</p>
|
||||
|
||||
{/* Stats */}
|
||||
{mod.stats && status !== 'inactive' && (
|
||||
<div className="grid grid-cols-3 gap-2 p-3 rounded-xl bg-slate-50 dark:bg-slate-700/40 border border-slate-100 dark:border-slate-700">
|
||||
{mod.stats.map(s => (
|
||||
<div key={s.label} className="text-center">
|
||||
<p className="text-base font-bold text-slate-900 dark:text-slate-100">{s.value}</p>
|
||||
<p className="text-[11px] text-slate-400 dark:text-slate-500 leading-tight mt-0.5">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trial banner */}
|
||||
{status === 'trial' && mod.trialDays !== undefined && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/60 text-sm text-amber-700 dark:text-amber-400">
|
||||
<Zap size={13} className="shrink-0" />
|
||||
Пробный период: осталось{' '}
|
||||
<strong className="font-semibold">{mod.trialDays} дней</strong>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sidebar hint for inactive modules with sidebar */}
|
||||
{mod.sidebarItem && status === 'inactive' && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-700/40 border border-slate-200 dark:border-slate-700 text-xs text-slate-500 dark:text-slate-400">
|
||||
<Lock size={11} className="shrink-0" />
|
||||
После подключения появится раздел <strong className="text-slate-700 dark:text-slate-300">«{mod.sidebarItem.label}»</strong> в боковом меню
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Features accordion */}
|
||||
<div className="border-t border-slate-100 dark:border-slate-700 pt-3">
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="flex items-center justify-between w-full text-sm text-slate-500 dark:text-slate-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
|
||||
>
|
||||
<span className="font-medium">Возможности модуля</span>
|
||||
<ChevronDown size={15} className={cn('transition-transform duration-200', open && 'rotate-180')} />
|
||||
</button>
|
||||
{open && (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{mod.features.map(f => (
|
||||
<li key={f} className="flex items-start gap-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
<CheckCircle2 size={13} className="mt-0.5 shrink-0 text-emerald-500" />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-slate-100 dark:border-slate-700">
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
₽ {mod.price.toLocaleString('ru-RU')}
|
||||
</span>
|
||||
<span className="text-sm text-slate-400">/мес</span>
|
||||
</div>
|
||||
|
||||
{status === 'active' && (
|
||||
<button className="btn-secondary text-sm flex items-center gap-1.5 py-1.5 px-3">
|
||||
Настройки <ArrowUpRight size={13} />
|
||||
</button>
|
||||
)}
|
||||
{status === 'trial' && (
|
||||
<button className="btn-primary text-sm flex items-center gap-1.5 py-1.5 px-3">
|
||||
Подключить <ArrowUpRight size={13} />
|
||||
</button>
|
||||
)}
|
||||
{status === 'inactive' && (
|
||||
<button className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border border-slate-300 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-brand-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors">
|
||||
<Lock size={12} /> Подключить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ModulesPage() {
|
||||
const { statuses } = useModules()
|
||||
|
||||
const activeCount = MODULES_DATA.filter(m => statuses[m.id] === 'active').length
|
||||
const trialCount = MODULES_DATA.filter(m => statuses[m.id] === 'trial').length
|
||||
const totalCount = MODULES_DATA.length
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-600 flex items-center justify-center shrink-0">
|
||||
<Puzzle size={20} className="text-white" />
|
||||
</div>
|
||||
<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">
|
||||
Дополнительные возможности для вашего отеля
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{
|
||||
value: activeCount,
|
||||
label: 'Активных',
|
||||
sub: `из ${totalCount} доступных`,
|
||||
color: 'text-emerald-600 dark:text-emerald-400',
|
||||
},
|
||||
{
|
||||
value: trialCount,
|
||||
label: 'На пробном периоде',
|
||||
sub: 'бесплатно до окончания',
|
||||
color: 'text-amber-500 dark:text-amber-400',
|
||||
},
|
||||
{
|
||||
value: totalCount - activeCount - trialCount,
|
||||
label: 'Не подключено',
|
||||
sub: 'доступны к активации',
|
||||
color: 'text-slate-400 dark:text-slate-500',
|
||||
},
|
||||
].map(s => (
|
||||
<div key={s.label} className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 px-4 py-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className={cn('text-2xl font-bold', s.color)}>{s.value}</span>
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">{s.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{s.sub}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{MODULES_DATA.map(mod => (
|
||||
<ModuleCard key={mod.id} mod={mod} status={statuses[mod.id] ?? 'inactive'} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-r from-brand-600 to-brand-700 p-6 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="absolute right-0 top-0 w-48 h-48 bg-white/5 rounded-full -translate-y-1/2 translate-x-1/4 pointer-events-none" />
|
||||
<div className="absolute right-12 bottom-0 w-32 h-32 bg-white/5 rounded-full translate-y-1/2 pointer-events-none" />
|
||||
<div className="relative">
|
||||
<p className="font-semibold text-white text-base">Нужен индивидуальный модуль?</p>
|
||||
<p className="text-brand-200 text-sm mt-1">
|
||||
Разработаем интеграцию под ваши задачи — CRM, программа лояльности, своя кассовая система.
|
||||
</p>
|
||||
</div>
|
||||
<button className="relative shrink-0 px-5 py-2.5 rounded-xl bg-white text-brand-700 font-medium text-sm hover:bg-brand-50 transition-colors shadow-sm">
|
||||
Связаться с нами
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
134
src/pages/ReportsPage.tsx
Normal file
134
src/pages/ReportsPage.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { BarChart3, TrendingUp, Users, BedDouble, ArrowUpRight, ArrowDownRight } from 'lucide-react'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
const KPI = [
|
||||
{ label: 'Загрузка (OCC)', value: '73.4%', delta: '+5.2%', up: true },
|
||||
{ label: 'ADR', value: '₽ 4 820', delta: '+8.1%', up: true },
|
||||
{ label: 'RevPAR', value: '₽ 3 538', delta: '+14.3%', up: true },
|
||||
{ label: 'Отменённые', value: '6.2%', delta: '-1.4%', up: true },
|
||||
]
|
||||
|
||||
const CHANNEL_DATA = [
|
||||
{ name: 'Прямые', pct: 38, color: 'bg-brand-500', revenue: '₽ 412 000' },
|
||||
{ name: 'Booking.com', pct: 31, color: 'bg-blue-500', revenue: '₽ 336 200' },
|
||||
{ name: 'Airbnb', pct: 18, color: 'bg-rose-500', revenue: '₽ 195 100' },
|
||||
{ name: 'Другие', pct: 13, color: 'bg-slate-400', revenue: '₽ 140 800' },
|
||||
]
|
||||
|
||||
const MONTHS = ['Окт', 'Ноя', 'Дек', 'Янв', 'Фев', 'Мар']
|
||||
const OCC = [61, 68, 79, 65, 70, 73]
|
||||
const maxOcc = Math.max(...OCC)
|
||||
|
||||
export function ReportsPage() {
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-cyan-600 flex items-center justify-center shrink-0">
|
||||
<BarChart3 size={20} className="text-white" />
|
||||
</div>
|
||||
<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">Март 2026 • Grand Palace Hotel</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{KPI.map(k => (
|
||||
<div key={k.label} className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mb-2">{k.label}</p>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{k.value}</p>
|
||||
<div className={cn('flex items-center gap-1 text-xs font-medium mt-1', k.up ? 'text-emerald-600' : 'text-red-500')}>
|
||||
{k.up ? <ArrowUpRight size={13} /> : <ArrowDownRight size={13} />}
|
||||
{k.delta} vs прошлый месяц
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Occupancy chart */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<TrendingUp size={16} className="text-brand-600" />
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100 text-sm">Загрузка по месяцам</h3>
|
||||
</div>
|
||||
<div className="flex items-end gap-2 h-32">
|
||||
{MONTHS.map((m, i) => (
|
||||
<div key={m} className="flex-1 flex flex-col items-center gap-1">
|
||||
<span className="text-xs font-medium text-slate-700 dark:text-slate-300">{OCC[i]}%</span>
|
||||
<div
|
||||
className="w-full rounded-t-md bg-brand-500 dark:bg-brand-600 transition-all"
|
||||
style={{ height: `${(OCC[i] / maxOcc) * 100}%` }}
|
||||
/>
|
||||
<span className="text-xs text-slate-400">{m}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel breakdown */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Users size={16} className="text-brand-600" />
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100 text-sm">Источники бронирований</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{CHANNEL_DATA.map(c => (
|
||||
<div key={c.name}>
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="text-slate-700 dark:text-slate-300 font-medium">{c.name}</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{c.revenue}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div className={cn('h-full rounded-full', c.color)} style={{ width: `${c.pct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 w-8 text-right">{c.pct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Room stats */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<BedDouble size={16} className="text-brand-600" />
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100 text-sm">Топ номеров по выручке</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-slate-400 uppercase border-b border-slate-100 dark:border-slate-700">
|
||||
<th className="pb-2 pr-4 font-medium">Номер</th>
|
||||
<th className="pb-2 pr-4 font-medium">Тип</th>
|
||||
<th className="pb-2 pr-4 font-medium">Загрузка</th>
|
||||
<th className="pb-2 font-medium">Выручка</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
{[
|
||||
{ n: '401', t: 'Penthouse', occ: '89%', rev: '₽ 96 200' },
|
||||
{ n: '202', t: 'Suite', occ: '84%', rev: '₽ 74 800' },
|
||||
{ n: '302', t: 'Junior Suite', occ: '81%', rev: '₽ 55 100' },
|
||||
{ n: '201', t: 'Deluxe', occ: '78%', rev: '₽ 40 600' },
|
||||
{ n: '101', t: 'Standard', occ: '71%', rev: '₽ 24 900' },
|
||||
].map(r => (
|
||||
<tr key={r.n}>
|
||||
<td className="py-2.5 pr-4 font-medium text-slate-900 dark:text-slate-100">{r.n}</td>
|
||||
<td className="py-2.5 pr-4 text-slate-500">{r.t}</td>
|
||||
<td className="py-2.5 pr-4">
|
||||
<span className="text-emerald-600 font-medium">{r.occ}</span>
|
||||
</td>
|
||||
<td className="py-2.5 font-semibold text-slate-900 dark:text-slate-100">{r.rev}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
src/pages/WebsitePage.tsx
Normal file
65
src/pages/WebsitePage.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Globe, Layout, Paintbrush, Eye, Smartphone, Lock } from 'lucide-react'
|
||||
|
||||
export function WebsitePage() {
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-rose-500 flex items-center justify-center shrink-0">
|
||||
<Globe size={20} className="text-white" />
|
||||
</div>
|
||||
<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">Создайте сайт отеля без разработчика</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Placeholder preview */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
<div className="bg-slate-100 dark:bg-slate-700 px-4 py-3 flex items-center gap-3 border-b border-slate-200 dark:border-slate-600">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-red-400" />
|
||||
<div className="w-3 h-3 rounded-full bg-amber-400" />
|
||||
<div className="w-3 h-3 rounded-full bg-emerald-400" />
|
||||
</div>
|
||||
<div className="flex-1 bg-white dark:bg-slate-600 rounded-md px-3 py-1 text-xs text-slate-400">
|
||||
grand-palace.hotelsync.ru
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-8 text-center space-y-4">
|
||||
<div className="w-16 h-16 rounded-2xl bg-rose-100 dark:bg-rose-900/30 flex items-center justify-center mx-auto">
|
||||
<Paintbrush size={28} className="text-rose-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Редактор сайта
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 max-w-sm mx-auto">
|
||||
Визуальный редактор откроется здесь. Выберите шаблон, настройте цвета, загрузите фото номеров и опубликуйте сайт за 30 минут.
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-3 flex-wrap">
|
||||
<button className="btn-primary flex items-center gap-2">
|
||||
<Layout size={15} /> Выбрать шаблон
|
||||
</button>
|
||||
<button className="btn-secondary flex items-center gap-2">
|
||||
<Eye size={15} /> Предпросмотр
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features list */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{[
|
||||
{ icon: Paintbrush, title: 'Drag & Drop', desc: 'Редактор без кода' },
|
||||
{ icon: Smartphone, title: 'Адаптивный', desc: 'Mobile-first дизайн' },
|
||||
{ icon: Lock, title: 'SSL & домен', desc: 'Бесплатный сертификат' },
|
||||
].map(f => (
|
||||
<div key={f.title} className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 text-center">
|
||||
<f.icon size={22} className="text-rose-500 mx-auto mb-2" />
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100 text-sm">{f.title}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{f.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,8 @@ export interface User {
|
||||
name: string
|
||||
role: Role
|
||||
hotelId: string | null
|
||||
hotelName?: string
|
||||
hotelSlug?: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user