diff --git a/src/App.tsx b/src/App.tsx index 49e12a4..5c7da9c 100644 --- a/src/App.tsx +++ b/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 - } - return <>{children} -} export default function App() { return ( - - - {/* Public */} - } /> + + + + {/* Public */} + } /> - {/* Hotel routes */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + {/* App routes */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* Module pages */} + } /> + } /> + } /> + } /> + - {/* Admin routes */} - }> - } /> - } /> - } /> - + {/* Admin routes */} + }> + } /> + } /> + } /> + - {/* Default */} - } /> - } /> - - + } /> + } /> + + + ) diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 9c4f9bd..127d93f 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -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 && (
- HotelNext + HotelSync - - {/* Hotel selector (non-admin) */} - {hotel && ( + {/* Hotel name */} + {!isAdmin && user?.hotelName && (

Текущий отель

- {hotel.name} + {user.hotelName}
@@ -98,39 +98,60 @@ export function Sidebar({ open, onClose }: SidebarProps) {

Администрирование

- - - + + + ) : ( <>

Основное

- + {!isHousekeeper && ( - + )} - + + {!isHousekeeper && ( <>

Управление

- - - + + + + + + + {/* Активные модули с разделами */} + {activeModuleItems.length > 0 && ( + <> +

+ Модули +

+ {activeModuleItems.map(m => ( + + ))} + + )} +

Разработчикам

- + )} )} - {/* Version */}

HotelSync v0.1.0 • SaaS PMS

diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 356e9d8..5b31391 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -5,7 +5,7 @@ import { MOCK_USERS } from '../data/mockData' interface AuthContextValue { session: AuthSession | null user: User | null - login: (email: string, password: string) => Promise + login: (email: string, password: string) => Promise 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 => { + const login = async (email: string, _password: string): Promise => { // 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 = () => { diff --git a/src/contexts/ModulesContext.tsx b/src/contexts/ModulesContext.tsx new file mode 100644 index 0000000..fd9f323 --- /dev/null +++ b/src/contexts/ModulesContext.tsx @@ -0,0 +1,54 @@ +import { createContext, useContext, useState } from 'react' +import type { ModuleStatus } from '../data/modulesData' + +// Default statuses for demo +const DEFAULT_STATUSES: Record = { + '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 + setStatus: (id: string, status: ModuleStatus) => void + isActive: (id: string) => boolean +} + +const ModulesContext = createContext(null) + +export function ModulesProvider({ children }: { children: React.ReactNode }) { + const [statuses, setStatuses] = useState>(() => { + 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 ( + + {children} + + ) +} + +export function useModules() { + const ctx = useContext(ModulesContext) + if (!ctx) throw new Error('useModules must be used within ModulesProvider') + return ctx +} diff --git a/src/data/mockData.ts b/src/data/mockData.ts index b63eb3d..21523e7 100644 --- a/src/data/mockData.ts +++ b/src/data/mockData.ts @@ -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', }, ] diff --git a/src/data/modulesData.ts b/src/data/modulesData.ts new file mode 100644 index 0000000..f442aad --- /dev/null +++ b/src/data/modulesData.ts @@ -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, + }, + }, +] diff --git a/src/data/ratesData.ts b/src/data/ratesData.ts new file mode 100644 index 0000000..c48f5af --- /dev/null +++ b/src/data/ratesData.ts @@ -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 // channelId -> price + closed: boolean // закрыто для продажи +} + +export interface RatePeriod { + id: string + name: string + startDate: string + endDate: string + notes?: string + categoryPrices: Record // categoryId -> price + channelMarkup: Record // 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 = { + direct: 1.00, + booking_com: 1.00, // net price — Booking забирает свою комиссию сверху + airbnb: 1.05, + expedia: 1.03, +} + +// Генерируем начальную сетку цен на 60 дней +export function buildInitialPriceGrid(): Record> { + const result: Record> = {} + 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 = {} + 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 ночи', + }, +] diff --git a/src/pages/AvailabilityPage.tsx b/src/pages/AvailabilityPage.tsx new file mode 100644 index 0000000..aa970fd --- /dev/null +++ b/src/pages/AvailabilityPage.tsx @@ -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> + 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 ( +
+
+ + {/* Date header */} +
+
+ Категория +
+ {dates.map(d => { + const dt = parseISO(d) + const dow = getDay(dt) + const isWe = dow === 0 || dow === 6 + const isTd = d === today + return ( +
+ + {format(dt, 'EEE', { locale: ru })} + + + {format(dt, 'd')} + + + {format(dt, 'MMM', { locale: ru })} + +
+ ) + })} +
+ + {/* Category rows */} + {ROOM_CATEGORIES.map(cat => ( +
+ {/* Label */} +
+
+
+

{cat.name}

+

+ база: {fmtPrice(cat.basePrice)} +

+
+
+ + {/* 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 ( +
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 ? ( + + ) : ( + <> + + {price ? price.toLocaleString('ru-RU') : '—'} + + {cell?.minNights > 1 && ( + + min {cell.minNights}н + + )} + + )} +
+ ) + })} +
+ ))} +
+
+ ) +} + +// ─── Edit Panel ─────────────────────────────────────────────────────────────── + +function EditPanel({ + selection, + prices, + onApply, + onClose, +}: { + selection: Selection + prices: Record> + onApply: (updates: { + startDate: string + endDate: string + categoryPrices: Record + extraPerson: number + minNights: number + channelMarkup: Record + 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>( + { ...DEFAULT_CHANNEL_MARKUP }, + ) + + const [catPrices, setCatPrices] = useState>(() => { + const r: Record = {} + 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 ( +
+ {/* Header */} +
+
+

Редактировать цены

+

{nightCount} {nightCount === 1 ? 'день' : 'дней'}

+
+ +
+ +
+ + {/* Date range */} +
+ +
+ setStartDate(e.target.value)} + className="input text-sm flex-1 py-1.5" + /> + + setEndDate(e.target.value)} + className="input text-sm flex-1 py-1.5" + /> +
+
+ + {/* Close toggle */} +
+
+

Закрыто для продажи

+

Все номера недоступны в этот период

+
+ +
+ + {/* Category prices */} + {!closed && ( +
+ +
+ {ROOM_CATEGORIES.map(cat => ( +
+
+ + {cat.name} + +
+ + setCatPrices(p => ({ ...p, [cat.id]: +ev.target.value }))} + className="input text-sm py-1.5 pl-6 w-full" + min={0} + step={100} + /> +
+
+ ))} +
+
+ )} + + {/* Extra person */} + {!closed && ( +
+ +
+ + setExtraPerson(+e.target.value)} + className="input text-sm py-1.5 pl-7" + min={0} + step={100} + /> +
+
+ )} + + {/* Min nights */} + {!closed && ( +
+ + setMinNights(Math.max(1, +e.target.value))} + className="input text-sm py-1.5 w-24" + min={1} + max={30} + /> +
+ )} + + {/* Channel markup */} + {!closed && ( +
+ +
+ {RATE_CHANNELS.map(ch => { + const markup = channelMarkup[ch.id] ?? 1.0 + const pct = Math.round((markup - 1) * 100) + return ( +
+ {ch.icon} + {ch.name} +
+ 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} + /> + % +
+
+ ) + })} +
+

+ + 0% = та же цена, +15% = цена выше на 15% +

+
+ )} +
+ + {/* Footer */} +
+ + +
+
+ ) +} + +// ─── 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>( + period?.categoryPrices ?? Object.fromEntries(ROOM_CATEGORIES.map(c => [c.id, c.basePrice])), + ) + const [markup, setMarkup] = useState>( + 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 ( +
+
+
+

+ {period ? 'Редактировать период' : 'Новый тарифный период'} +

+ +
+ +
+ {/* Name */} +
+ + setName(e.target.value)} + /> +
+ + {/* Dates */} +
+
+ + setStartDate(e.target.value)} /> +
+
+ + setEndDate(e.target.value)} /> +
+
+ + {/* Category prices */} +
+ +
+ {ROOM_CATEGORIES.map(cat => ( +
+
+ + {cat.name} + +
+ + setCatPrices(p => ({ ...p, [cat.id]: +ev.target.value }))} + className="input text-sm py-1.5 pl-6 w-full" + /> +
+
+ ))} +
+
+ + {/* Extra + min nights */} +
+
+ + setExtraPerson(+e.target.value)} /> +
+
+ + setMinNights(+e.target.value)} /> +
+
+ + {/* Channel markup */} +
+ +
+ {RATE_CHANNELS.map(ch => { + const pct = Math.round(((markup[ch.id] ?? 1) - 1) * 100) + return ( +
+ {ch.icon} + {ch.name} +
+ setMarkup(p => ({ ...p, [ch.id]: 1 + +ev.target.value / 100 }))} + className="input text-sm py-1 w-16 text-center" + /> + % +
+
+ ) + })} +
+
+ + {/* Notes */} +
+ +