Files
hotelsync/src/pages/AvailabilityPage.tsx
HotelSync 588ecb0f2a feat: hourly pricing + category base prices in booking modal + availability grid
- Add hourly_price column to rate_overrides, base_price/allow_hourly/hourly_base_price to room_categories (migration 033)
- Update categories and rate-overrides backend routes to handle new fields
- AvailabilityPage: hourly/nightly mode toggle, period prices auto-applied to grid with 'П' indicator, confirmation dialog when overriding period prices
- BookingModal: nightly/hourly toggle at top, pricing hierarchy (override → category base → room rate)
- RoomCategoriesPage: base_price/allow_hourly/hourly_base_price fields in category form; inline rooms panel
- CalendarPage + BookingCalendar: pass hourlyPriceOverrides and categoryBasePrices down to BookingModal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 00:05:14 +03:00

1396 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useRef, useCallback, useMemo, useEffect } from 'react'
import { addDays, format, parseISO, getDay, startOfDay } from 'date-fns'
import { ru } from 'date-fns/locale'
import {
ChevronLeft, ChevronRight, X, Check, CalendarDays,
Plus, Pencil, Trash2, AlertCircle, Loader2, MousePointer2, Rows3, ChevronDown, RefreshCw,
} from 'lucide-react'
import { cn } from '../lib/utils'
import { RATE_CHANNELS, DEFAULT_CHANNEL_MARKUP } from '../data/ratesData'
import type { PriceCell, RatePeriod } from '../data/ratesData'
import { useAuth } from '../contexts/AuthContext'
import { api } from '../lib/api'
interface AvailabilityCat {
id: string
name: string
color: string // hex color
basePrice: number
hourlyBasePrice: number
}
function buildPriceGrid(
categories: AvailabilityCat[],
days = 90,
): Record<string, Record<string, PriceCell>> {
const result: Record<string, Record<string, PriceCell>> = {}
const today = new Date()
for (const cat of categories) {
result[cat.id] = {}
for (let i = 0; i < days; i++) {
const d = addDays(today, i)
const dateStr = format(d, 'yyyy-MM-dd')
const channelPrices: Record<string, number> = {}
for (const ch of RATE_CHANNELS) {
channelPrices[ch.id] = Math.round(cat.basePrice * (DEFAULT_CHANNEL_MARKUP[ch.id] ?? 1))
}
result[cat.id][dateStr] = { price: cat.basePrice, extraPerson: 0, minNights: 1, channelPrices, closed: false }
}
}
return result
}
// ─── 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,
cellEditMode, selectedCat, categories, hourlyMode,
}: {
dates: string[]
prices: Record<string, Record<string, PriceCell>>
selection: Selection | null
dragging: boolean
onCellDown: (date: string, catId: string) => void
onCellEnter: (date: string) => void
activeChannel: string
cellEditMode: boolean
selectedCat: string | null
categories: AvailabilityCat[]
hourlyMode: boolean
}) {
const today = format(new Date(), DATE_FMT)
const isSelected = useCallback((date: string, catId: string) => {
if (!selection) return false
const [s, e] = normRange(selection.start, selection.end)
const inRange = date >= s && date <= e
if (cellEditMode && selectedCat) return inRange && catId === selectedCat
return inRange
}, [selection, cellEditMode, selectedCat])
return (
<div className="overflow-x-auto" style={{ cursor: dragging ? 'col-resize' : cellEditMode ? 'crosshair' : '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 */}
{categories.map(cat => {
const isActiveCat = cellEditMode && selectedCat === cat.id
return (
<div key={cat.id} className={cn(
'flex border-b border-slate-100 dark:border-slate-700/60',
isActiveCat && 'bg-brand-50/30 dark:bg-brand-900/10',
)}>
{/* Label */}
<div
style={{ width: LABEL_W, minWidth: LABEL_W, height: ROW_H }}
className={cn(
'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',
isActiveCat && 'bg-brand-50 dark:bg-brand-900/20 border-r-brand-300 dark:border-r-brand-700',
)}
>
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: 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, cat.id)
const price = hourlyMode
? (cell?.hourlyPrice ?? cat.hourlyBasePrice ?? 0)
: (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, cat.id)}
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',
hourlyMode && cell?.hourlyPrice && 'text-violet-600 dark:text-violet-400',
)}>
{price ? price.toLocaleString('ru-RU') : '—'}
</span>
{hourlyMode && (
<span className="text-[9px] text-slate-400 leading-none">/ч</span>
)}
{!hourlyMode && cell?.fromPeriod && !isSel && (
<span className="text-[9px] font-semibold text-violet-500 dark:text-violet-400 leading-none bg-violet-50 dark:bg-violet-900/20 px-1 rounded">
П
</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,
onlyCategoryId,
categories,
hourlyMode,
}: {
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
onlyCategoryId?: string
hourlyPrice?: number | null
}) => void
onClose: () => void
onlyCategoryId?: string
categories: AvailabilityCat[]
hourlyMode: boolean
}) {
const [s, e] = normRange(selection.start, selection.end)
const activeCat = onlyCategoryId
? categories.find(c => c.id === onlyCategoryId) ?? categories[0]
: categories[0]
// Initial values from first cell of selection
const firstCell = prices[activeCat.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 [hourlyPrice, setHourlyPrice] = useState<number>(firstCell?.hourlyPrice ?? activeCat.hourlyBasePrice ?? 0)
const [channelMarkup, setChannelMarkup] = useState<Record<string, number>>(
{ ...DEFAULT_CHANNEL_MARKUP },
)
const [catPrices, setCatPrices] = useState<Record<string, number>>(() => {
const r: Record<string, number> = {}
const catsToInit = onlyCategoryId
? categories.filter(c => c.id === onlyCategoryId)
: categories
for (const cat of catsToInit) {
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-full sm:w-80 shrink-0 border-t sm:border-t-0 sm:border-l border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 flex flex-col overflow-y-auto max-h-72 sm:max-h-none">
{/* 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">
{onlyCategoryId ? (
<span className="flex items-center gap-1.5">
<span className={cn('w-2 h-2 rounded-full shrink-0', activeCat.color)} />
{activeCat.name}
</span>
) : 'Редактировать цены'}
</p>
<p className="text-xs text-slate-400 mt-0.5">
{nightCount} {nightCount === 1 ? 'день' : nightCount < 5 ? 'дня' : 'дней'}
{onlyCategoryId && ' • только эта категория'}
</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">
{onlyCategoryId ? 'Цена' : 'Цена по категориям'}
</label>
<div className="space-y-2">
{(onlyCategoryId ? [activeCat] : categories).map(cat => (
<div key={cat.id} className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: 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>
)}
{/* Hourly price (when hourly mode active) */}
{hourlyMode && !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={hourlyPrice || ''}
onChange={e => setHourlyPrice(+e.target.value)}
placeholder={String(activeCat.hourlyBasePrice || 0)}
className="input text-sm py-1.5 pl-7"
min={0}
step={100}
/>
</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,
onlyCategoryId,
hourlyPrice: hourlyMode ? (hourlyPrice > 0 ? hourlyPrice : null) : null,
})}
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,
categories,
}: {
period?: RatePeriod
onSave: (p: RatePeriod) => void
onClose: () => void
categories: AvailabilityCat[]
}) {
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(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">
{categories.map(cat => (
<div key={cat.id} className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: 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 { user } = useAuth()
const slug = user?.hotelSlug ?? ''
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<Record<string, Record<string, PriceCell>>>({})
const [periods, setPeriods] = useState<RatePeriod[]>([])
const [roomCategories, setRoomCategories] = useState<AvailabilityCat[]>([])
const [allRooms, setAllRooms] = useState<Array<{ id: string; number: string; categoryId?: string; allowHourly?: boolean; hourlyRate?: number; baseRate: number }>>([])
const [editingHourlyRoom, setEditingHourlyRoom] = useState<string | null>(null)
const [hourlyRateInput, setHourlyRateInput] = useState('')
const [hourlyMode, setHourlyMode] = useState(false)
const [loading, setLoading] = useState(true)
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 [cellEditMode, setCellEditMode] = useState(false)
const [selectedCat, setSelectedCat] = useState<string | null>(null)
const [visibleDays, setVisibleDays] = useState(45)
const [showNavPicker, setShowNavPicker] = useState(false)
const [pickerDate, setPickerDate] = useState(format(new Date(), DATE_FMT))
const navPickerRef = useRef<HTMLDivElement>(null)
// Load categories, rooms, rate periods and overrides from API
useEffect(() => {
if (!slug) return
setLoading(true)
Promise.all([
api.categories.list(slug).catch(() => []),
api.rooms.list(slug).catch(() => []),
api.ratePeriods.list(slug).catch(() => []),
api.rateOverrides.list(slug).catch(() => []),
]).then(([cats, rooms, apiPeriods, apiOverrides]) => {
setAllRooms(rooms.map(r => ({
id: r.id, number: r.number, categoryId: r.categoryId,
allowHourly: r.allowHourly, hourlyRate: r.hourlyRate, baseRate: r.baseRate,
})))
// Build AvailabilityCat[] from real categories; use category base_price if set, else derive from rooms
const colors = ['#6366f1','#10b981','#f59e0b','#ef4444','#8b5cf6','#06b6d4']
const availCats: AvailabilityCat[] = cats.map(cat => {
const catRooms = rooms.filter(r => r.categoryId === cat.id)
const roomsMinPrice = catRooms.length > 0 ? Math.min(...catRooms.map(r => r.baseRate)) : 3000
const basePrice = (cat.base_price && cat.base_price > 0) ? cat.base_price : roomsMinPrice
const hourlyBasePrice = cat.hourly_base_price ?? 0
return { id: cat.id, name: cat.name, color: cat.color || '#6366f1', basePrice, hourlyBasePrice }
})
// If no categories, fall back to room types as groups
let finalCats: AvailabilityCat[] = availCats.length > 0 ? availCats : (() => {
const typeMap = new Map<string, number>()
for (const r of rooms) {
if (!r.type) continue
if (!typeMap.has(r.type)) typeMap.set(r.type, r.baseRate)
else typeMap.set(r.type, Math.min(typeMap.get(r.type)!, r.baseRate))
}
return Array.from(typeMap.entries()).map(([name, price], i) => ({
id: name.toLowerCase().replace(/\s+/g, '_'),
name, color: colors[i % colors.length], basePrice: price, hourlyBasePrice: 0,
}))
})()
// Add "Без категории" group for rooms not assigned to any category
const assignedCatIds = new Set(cats.map(c => c.id))
const uncategorized = rooms.filter(r => !r.categoryId || !assignedCatIds.has(r.categoryId))
if (uncategorized.length > 0) {
const basePrice = Math.min(...uncategorized.map(r => r.baseRate))
finalCats = [...finalCats, {
id: '__no_category__',
name: 'Без категории',
color: '#94a3b8',
basePrice,
hourlyBasePrice: 0,
}]
}
setRoomCategories(finalCats)
// Map API rate periods first (needed for grid)
const mappedPeriods: RatePeriod[] = apiPeriods.map(p => ({
id: p.id, name: p.name, startDate: p.startDate, endDate: p.endDate,
notes: p.notes ?? undefined, categoryPrices: p.categoryPrices,
channelMarkup: p.channelMarkup, extraPersonPrice: p.extraPersonPrice,
minNights: p.minNights, daysOfWeek: p.daysOfWeek ?? undefined,
}))
setPeriods(mappedPeriods)
// Build initial grid: base rates → period prices → manual overrides
const grid = buildPriceGrid(finalCats)
// Apply period prices (lower priority than overrides)
const overrideDates = new Set(apiOverrides.map(o => `${o.categoryId}_${o.date}`))
for (const p of mappedPeriods) {
const days = datesInRange(p.startDate, p.endDate)
for (const cat of finalCats) {
const periodPrice = p.categoryPrices[cat.id]
if (!periodPrice) continue
for (const d of days) {
if (overrideDates.has(`${cat.id}_${d}`)) continue // manual override takes priority
if (!grid[cat.id]) continue
const channelPrices: Record<string, number> = {}
for (const ch of Object.keys(grid[cat.id][d]?.channelPrices ?? {})) {
channelPrices[ch] = Math.round(periodPrice * (p.channelMarkup[ch] ?? 1))
}
grid[cat.id][d] = {
price: periodPrice,
extraPerson: p.extraPersonPrice,
minNights: p.minNights,
channelPrices,
closed: false,
fromPeriod: true,
periodName: p.name,
}
}
}
}
// Apply manual overrides on top
for (const o of apiOverrides) {
if (!grid[o.categoryId]) continue
grid[o.categoryId][o.date] = {
price: o.price,
extraPerson: o.extraPerson,
minNights: o.minNights,
channelPrices: o.channelPrices,
closed: o.closed,
hourlyPrice: o.hourlyPrice ?? undefined,
}
}
setPrices(grid)
}).finally(() => setLoading(false))
}, [slug])
useEffect(() => {
if (!showNavPicker) return
const handler = (e: MouseEvent) => {
if (navPickerRef.current && !navPickerRef.current.contains(e.target as Node)) {
setShowNavPicker(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [showNavPicker])
const dates = useMemo(() =>
Array.from({ length: visibleDays }, (_, i) =>
format(addDays(today, offset + i), DATE_FMT),
), [today, offset, visibleDays])
// ── Mouse handlers ──
const handleCellDown = (date: string, catId: string) => {
setDragging(true)
setDragStart(date)
setSelection({ start: date, end: date })
setShowEditPanel(false)
if (cellEditMode) setSelectedCat(catId)
}
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,
onlyCategoryId, hourlyPrice,
}: {
startDate: string; endDate: string
categoryPrices: Record<string, number>
extraPerson: number; minNights: number
channelMarkup: Record<string, number>
closed: boolean
onlyCategoryId?: string
hourlyPrice?: number | null
}) => {
const days = datesInRange(startDate, endDate)
const catsToUpdate = onlyCategoryId
? roomCategories.filter(c => c.id === onlyCategoryId)
: roomCategories
// Check if any selected cells have period prices — require confirmation to override
const hasPeriodCells = catsToUpdate.some(cat =>
days.some(d => prices[cat.id]?.[d]?.fromPeriod)
)
if (hasPeriodCells) {
const ok = window.confirm(
'Некоторые выбранные даты покрыты тарифным периодом.\nПерезаписать цену периода ручной ценой?'
)
if (!ok) return
}
// Build overrides BEFORE setPrices (updater runs async, can't rely on side-effects inside it)
const overrides: Array<{
category_id: string; date: string; price: number
extra_person: number; min_nights: number
channel_prices: Record<string, number>; closed: boolean
hourly_price?: number | null
}> = []
for (const cat of catsToUpdate) {
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))
}
overrides.push({
category_id: cat.id, date: d, price: basePrice,
extra_person: extraPerson, min_nights: minNights,
channel_prices: channelPrices, closed,
hourly_price: hourlyPrice ?? null,
})
}
}
setPrices(prev => {
const next = { ...prev }
for (const o of overrides) {
if (!next[o.category_id]) next[o.category_id] = {}
next[o.category_id] = { ...next[o.category_id] }
next[o.category_id][o.date] = {
price: o.price, extraPerson: o.extra_person, minNights: o.min_nights,
channelPrices: o.channel_prices, closed: o.closed,
hourlyPrice: o.hourly_price ?? undefined,
}
}
return next
})
if (overrides.length > 0) {
api.rateOverrides.bulkUpsert(slug, overrides).catch(err =>
console.error('Failed to save rate overrides:', err)
)
}
setShowEditPanel(false)
setSelection(null)
if (onlyCategoryId) setSelectedCat(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 = async (p: RatePeriod) => {
if (!slug) return
const payload = {
name: p.name,
start_date: p.startDate,
end_date: p.endDate,
notes: p.notes,
category_prices: p.categoryPrices,
channel_markup: p.channelMarkup,
extra_person_price: p.extraPersonPrice,
min_nights: p.minNights,
days_of_week: p.daysOfWeek ?? null,
}
// Check if this is a new (temp) ID (starts with 'p-') or a real UUID
const isNew = !p.id || p.id.startsWith('p-') || p.id.length < 32
if (isNew) {
const created = await api.ratePeriods.create(slug, payload)
setPeriods(prev => [...prev, { ...p, id: created.id }])
} else {
await api.ratePeriods.update(slug, p.id, payload)
setPeriods(prev => prev.map(x => x.id === p.id ? p : x))
}
setPeriodModal(null)
}
const deletePeriod = async (id: string) => {
if (!slug) return
await api.ratePeriods.delete(slug, id).catch(console.error)
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">
{/* Tabs */}
<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 - visibleDays)} className="btn-ghost p-1.5">
<ChevronLeft size={16} />
</button>
{/* Date/period picker trigger */}
<div className="relative" ref={navPickerRef}>
<button
onClick={() => setShowNavPicker(v => !v)}
className={cn(
'btn-ghost flex items-center gap-1 text-xs px-2.5 py-1.5 rounded-lg',
showNavPicker && 'bg-slate-100 dark:bg-slate-700',
)}
>
<CalendarDays size={13} className={offset === 0 ? 'text-brand-600' : 'text-slate-400'} />
<span className={cn('font-medium', offset === 0 ? 'text-brand-600' : 'text-slate-600 dark:text-slate-300')}>
{offset === 0
? 'Сегодня'
: format(addDays(today, offset), 'd MMM', { locale: ru })}
</span>
<span className="text-slate-400">·</span>
<span className="text-slate-400">{visibleDays}д</span>
<ChevronDown size={12} className="text-slate-400" />
</button>
{showNavPicker && (
<div className="absolute top-full left-0 mt-1 z-50 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 shadow-lg p-4 w-64">
{/* Start date */}
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
Начало периода
</p>
<div className="flex gap-2 mb-3">
<input
type="date"
value={pickerDate}
onChange={e => setPickerDate(e.target.value)}
className="input text-sm py-1.5 flex-1"
/>
<button
onClick={() => {
setPickerDate(format(today, DATE_FMT))
setOffset(0)
}}
className="btn-secondary text-xs px-2.5 py-1.5 shrink-0"
>
Сегодня
</button>
</div>
{/* Days count */}
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
Количество дней
</p>
<div className="flex gap-1.5 flex-wrap mb-4">
{[14, 30, 45, 60].map(d => (
<button
key={d}
onClick={() => setVisibleDays(d)}
className={cn(
'px-3 py-1 rounded-lg text-xs font-medium border transition-colors',
visibleDays === d
? 'bg-brand-600 border-brand-600 text-white'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-brand-400',
)}
>
{d}д
</button>
))}
</div>
<button
onClick={() => {
const diff = Math.round(
(parseISO(pickerDate).getTime() - startOfDay(today).getTime()) / 86400000
)
setOffset(diff)
setShowNavPicker(false)
}}
className="btn-primary w-full justify-center text-sm py-1.5"
>
Показать
</button>
</div>
)}
</div>
<button onClick={() => setOffset(o => o + visibleDays)} className="btn-ghost p-1.5">
<ChevronRight size={16} />
</button>
{/* Cell / range mode toggle */}
<button
onClick={() => {
setCellEditMode(v => !v)
setSelectedCat(null)
setSelection(null)
setShowEditPanel(false)
}}
title={cellEditMode ? 'Режим ячейки: редактирует только выбранную категорию' : 'Режим диапазона: редактирует все категории'}
className={cn(
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-colors border',
cellEditMode
? 'bg-brand-50 dark:bg-brand-900/30 border-brand-300 dark:border-brand-700 text-brand-700 dark:text-brand-300'
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-500 hover:text-slate-700 dark:hover:text-slate-300',
)}
>
{cellEditMode ? <MousePointer2 size={13} /> : <Rows3 size={13} />}
{cellEditMode ? 'По ячейке' : 'По диапазону'}
</button>
{/* Hourly / nightly mode toggle */}
<button
onClick={() => setHourlyMode(v => !v)}
title={hourlyMode ? 'Показать ночные цены' : 'Показать почасовые цены'}
className={cn(
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-colors border',
hourlyMode
? 'bg-violet-50 dark:bg-violet-900/30 border-violet-300 dark:border-violet-700 text-violet-700 dark:text-violet-300'
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-500 hover:text-slate-700 dark:hover:text-slate-300',
)}
>
{hourlyMode ? '₽/ч' : '₽/н'}
{hourlyMode ? ' Почасово' : ' По ночам'}
</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 flex-col sm:flex-row">
{/* 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="flex items-center gap-1.5">
<span className="text-[9px] font-bold text-violet-500 bg-violet-50 dark:bg-violet-900/20 px-1 rounded">П</span>
Из периода
</span>
<span className="ml-auto text-slate-400 hidden md:block">
{cellEditMode
? 'Режим ячейки: клик по категории редактирует только её'
: 'Выделите ячейки мышью для редактирования всех категорий'}
</span>
</div>
{loading ? (
<div className="flex items-center justify-center h-48">
<Loader2 size={24} className="animate-spin text-brand-600" />
</div>
) : roomCategories.length === 0 ? (
<div className="flex items-center justify-center h-48 text-slate-400 dark:text-slate-500 text-sm">
Нет категорий номеров. Создайте их в разделе «Категории номеров».
</div>
) : (
<PriceGrid
dates={dates}
prices={prices}
selection={selection}
dragging={dragging}
onCellDown={handleCellDown}
onCellEnter={handleCellEnter}
activeChannel={activeChannel}
cellEditMode={cellEditMode}
selectedCat={selectedCat}
categories={roomCategories}
hourlyMode={hourlyMode}
/>
)}
</div>
{/* Edit panel */}
{showEditPanel && selection && (
<EditPanel
selection={selection}
prices={prices}
onApply={applyPrices}
onClose={() => { setShowEditPanel(false); setSelection(null); setSelectedCat(null) }}
onlyCategoryId={cellEditMode && selectedCat ? selectedCat : undefined}
categories={roomCategories}
hourlyMode={hourlyMode}
/>
)}
</>
)}
{/* 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">
{roomCategories.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="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: 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>
{/* Hourly pricing section (shown when any room has hourly enabled, or always in periods tab) */}
{tab === 'periods' && (() => {
const hourlyRooms = allRooms.filter(r => r.allowHourly)
if (hourlyRooms.length === 0) return null
return (
<div className="shrink-0 border-t border-slate-200 dark:border-slate-700 px-5 py-4">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">Почасовая оплата</h3>
<div className="flex flex-wrap gap-2">
{hourlyRooms.map(r => (
<div key={r.id} className="flex items-center gap-2 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800">
<span className="text-sm text-slate-700 dark:text-slate-300 font-medium">{r.number}</span>
{editingHourlyRoom === r.id ? (
<>
<input
type="number"
className="input text-sm py-1 w-24"
value={hourlyRateInput}
onChange={e => setHourlyRateInput(e.target.value)}
autoFocus
onKeyDown={e => {
if (e.key === 'Enter') {
const rate = parseInt(hourlyRateInput)
if (!isNaN(rate) && rate > 0) {
api.rooms.update(slug, r.id, { hourlyRate: rate }).then(updated => {
setAllRooms(prev => prev.map(x => x.id === r.id ? { ...x, hourlyRate: updated.hourlyRate } : x))
}).catch(console.error)
}
setEditingHourlyRoom(null)
}
if (e.key === 'Escape') setEditingHourlyRoom(null)
}}
/>
<span className="text-xs text-slate-400">/ч</span>
<button className="btn-ghost p-1" onClick={() => setEditingHourlyRoom(null)}><X size={12} /></button>
</>
) : (
<>
<span className="text-sm text-slate-500">{(r.hourlyRate ?? 0).toLocaleString('ru-RU')} /ч</span>
<button
className="btn-ghost p-1"
onClick={() => { setEditingHourlyRoom(r.id); setHourlyRateInput(String(r.hourlyRate ?? 0)) }}
>
<Pencil size={12} />
</button>
</>
)}
</div>
))}
</div>
</div>
)
})()}
{/* Period modal */}
{periodModal !== null && (
<PeriodModal
period={periodModal === 'new' ? undefined : periodModal}
onSave={savePeriod}
onClose={() => setPeriodModal(null)}
categories={roomCategories}
/>
)}
</div>
)
}