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>
This commit is contained in:
2026-03-26 00:05:14 +03:00
parent b3d143f1dd
commit 588ecb0f2a
11 changed files with 232 additions and 38 deletions

View File

@@ -16,6 +16,7 @@ interface AvailabilityCat {
name: string
color: string // hex color
basePrice: number
hourlyBasePrice: number
}
function buildPriceGrid(
@@ -82,7 +83,7 @@ interface Selection { start: string; end: string }
function PriceGrid({
dates, prices, selection, dragging,
onCellDown, onCellEnter, activeChannel,
cellEditMode, selectedCat, categories,
cellEditMode, selectedCat, categories, hourlyMode,
}: {
dates: string[]
prices: Record<string, Record<string, PriceCell>>
@@ -94,6 +95,7 @@ function PriceGrid({
cellEditMode: boolean
selectedCat: string | null
categories: AvailabilityCat[]
hourlyMode: boolean
}) {
const today = format(new Date(), DATE_FMT)
@@ -183,9 +185,11 @@ function PriceGrid({
const isWe = dow === 0 || dow === 6
const isTd = d === today
const isSel = isSelected(d, cat.id)
const price = activeChannel === 'direct'
? cell?.price
: cell?.channelPrices[activeChannel] ?? cell?.price
const price = hourlyMode
? (cell?.hourlyPrice ?? cat.hourlyBasePrice ?? 0)
: (activeChannel === 'direct'
? cell?.price
: cell?.channelPrices[activeChannel] ?? cell?.price)
return (
<div
@@ -212,10 +216,14 @@ function PriceGrid({
<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>
{cell?.fromPeriod && !isSel && (
{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>
@@ -247,6 +255,7 @@ function EditPanel({
onClose,
onlyCategoryId,
categories,
hourlyMode,
}: {
selection: Selection
prices: Record<string, Record<string, PriceCell>>
@@ -259,10 +268,12 @@ function EditPanel({
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)
@@ -278,6 +289,7 @@ function EditPanel({
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 },
)
@@ -394,6 +406,27 @@ function EditPanel({
</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>
@@ -476,6 +509,7 @@ function EditPanel({
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"
>
@@ -677,6 +711,7 @@ export function AvailabilityPage() {
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)
@@ -704,14 +739,14 @@ export function AvailabilityPage() {
id: r.id, number: r.number, categoryId: r.categoryId,
allowHourly: r.allowHourly, hourlyRate: r.hourlyRate, baseRate: r.baseRate,
})))
// Build AvailabilityCat[] from real categories, derive basePrice from rooms
// 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 basePrice = catRooms.length > 0
? Math.min(...catRooms.map(r => r.baseRate))
: 3000
return { id: cat.id, name: cat.name, color: cat.color || '#6366f1', basePrice }
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
@@ -724,7 +759,7 @@ export function AvailabilityPage() {
}
return Array.from(typeMap.entries()).map(([name, price], i) => ({
id: name.toLowerCase().replace(/\s+/g, '_'),
name, color: colors[i % colors.length], basePrice: price,
name, color: colors[i % colors.length], basePrice: price, hourlyBasePrice: 0,
}))
})()
@@ -738,6 +773,7 @@ export function AvailabilityPage() {
name: 'Без категории',
color: '#94a3b8',
basePrice,
hourlyBasePrice: 0,
}]
}
@@ -791,6 +827,7 @@ export function AvailabilityPage() {
minNights: o.minNights,
channelPrices: o.channelPrices,
closed: o.closed,
hourlyPrice: o.hourlyPrice ?? undefined,
}
}
setPrices(grid)
@@ -838,7 +875,7 @@ export function AvailabilityPage() {
// ── Apply price changes ──
const applyPrices = ({
startDate, endDate, categoryPrices, extraPerson, minNights, channelMarkup, closed,
onlyCategoryId,
onlyCategoryId, hourlyPrice,
}: {
startDate: string; endDate: string
categoryPrices: Record<string, number>
@@ -846,17 +883,30 @@ export function AvailabilityPage() {
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) {
@@ -870,6 +920,7 @@ export function AvailabilityPage() {
category_id: cat.id, date: d, price: basePrice,
extra_person: extraPerson, min_nights: minNights,
channel_prices: channelPrices, closed,
hourly_price: hourlyPrice ?? null,
})
}
}
@@ -882,6 +933,7 @@ export function AvailabilityPage() {
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
@@ -1080,6 +1132,21 @@ export function AvailabilityPage() {
{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>
@@ -1169,6 +1236,7 @@ export function AvailabilityPage() {
cellEditMode={cellEditMode}
selectedCat={selectedCat}
categories={roomCategories}
hourlyMode={hourlyMode}
/>
)}
</div>
@@ -1182,6 +1250,7 @@ export function AvailabilityPage() {
onClose={() => { setShowEditPanel(false); setSelection(null); setSelectedCat(null) }}
onlyCategoryId={cellEditMode && selectedCat ? selectedCat : undefined}
categories={roomCategories}
hourlyMode={hourlyMode}
/>
)}
</>