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:
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -25,6 +25,8 @@ export function CalendarPage() {
|
||||
const [rentalBookings, setRentalBookings] = useState<RentalBookingApi[]>([])
|
||||
const [locks, setLocks] = useState<Map<string, BookingLock>>(new Map())
|
||||
const [priceOverrides, setPriceOverrides] = useState<Record<string, Record<string, number>>>({})
|
||||
const [hourlyPriceOverrides, setHourlyPriceOverrides] = useState<Record<string, Record<string, number>>>({})
|
||||
const [categoryBasePrices, setCategoryBasePrices] = useState<Record<string, { nightlyBase: number; hourlyBase: number }>>({})
|
||||
// roomId → assigneeName for "убирается" tooltip
|
||||
const [cleaningAssignees, setCleaningAssignees] = useState<Record<string, string>>({})
|
||||
// Set of room IDs with active maintenance tasks
|
||||
@@ -36,21 +38,36 @@ export function CalendarPage() {
|
||||
api.rooms.list(slug),
|
||||
api.bookings.list(slug),
|
||||
api.rateOverrides.list(slug).catch(() => []),
|
||||
api.categories.list(slug).catch(() => []),
|
||||
]
|
||||
if (isRentalActive) {
|
||||
fetches.push(api.rental.listObjects(slug))
|
||||
fetches.push(api.rental.listBookings(slug))
|
||||
}
|
||||
Promise.all(fetches).then(([r, b, overrides, ro, rb]) => {
|
||||
Promise.all(fetches).then(([r, b, overridesRaw, catsRaw, ro, rb]) => {
|
||||
setRooms(r as Room[])
|
||||
setBookings(b as Booking[])
|
||||
// Build categoryId → date → price lookup
|
||||
const overrides = overridesRaw as import('../lib/api').RateOverrideApi[]
|
||||
const map: Record<string, Record<string, number>> = {}
|
||||
for (const o of overrides as import('../lib/api').RateOverrideApi[]) {
|
||||
const hourlyMap: Record<string, Record<string, number>> = {}
|
||||
for (const o of overrides) {
|
||||
if (!map[o.categoryId]) map[o.categoryId] = {}
|
||||
map[o.categoryId][o.date] = o.price
|
||||
if (o.hourlyPrice != null && o.hourlyPrice > 0) {
|
||||
if (!hourlyMap[o.categoryId]) hourlyMap[o.categoryId] = {}
|
||||
hourlyMap[o.categoryId][o.date] = o.hourlyPrice
|
||||
}
|
||||
}
|
||||
setPriceOverrides(map)
|
||||
setHourlyPriceOverrides(hourlyMap)
|
||||
// Build category base prices
|
||||
const cats = catsRaw as import('../lib/api').CategoryApi[]
|
||||
const catBases: Record<string, { nightlyBase: number; hourlyBase: number }> = {}
|
||||
for (const cat of cats) {
|
||||
catBases[cat.id] = { nightlyBase: cat.base_price ?? 0, hourlyBase: cat.hourly_base_price ?? 0 }
|
||||
}
|
||||
setCategoryBasePrices(catBases)
|
||||
if (isRentalActive) {
|
||||
setRentalObjects(ro as RentalObjectApi[])
|
||||
setRentalBookings(rb as RentalBookingApi[])
|
||||
@@ -296,6 +313,8 @@ export function CalendarPage() {
|
||||
onDraftCancel={handleDraftCancel}
|
||||
wsConnected={connected}
|
||||
priceOverrides={priceOverrides}
|
||||
hourlyPriceOverrides={hourlyPriceOverrides}
|
||||
categoryBasePrices={categoryBasePrices}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,9 @@ function fromApi(c: CategoryApi): RoomCategory {
|
||||
color: c.color,
|
||||
amenities: c.amenities,
|
||||
photos: c.photos,
|
||||
basePrice: c.base_price,
|
||||
allowHourly: c.allow_hourly,
|
||||
hourlyBasePrice: c.hourly_base_price,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +47,9 @@ function CategoryForm({ category, onClose, onSave }: CategoryFormProps) {
|
||||
const [color, setColor] = useState(category?.color ?? '#4F46E5')
|
||||
const [amenities, setAmenities] = useState<string[]>(category?.amenities ?? [])
|
||||
const [photos, setPhotos] = useState<string[]>(category?.photos ?? [])
|
||||
const [basePrice, setBasePrice] = useState(category?.basePrice ?? 0)
|
||||
const [allowHourly, setAllowHourly] = useState(category?.allowHourly ?? false)
|
||||
const [hourlyBasePrice, setHourlyBasePrice] = useState(category?.hourlyBasePrice ?? 0)
|
||||
const [photoIdx, setPhotoIdx] = useState(0)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState('')
|
||||
@@ -112,13 +118,16 @@ function CategoryForm({ category, onClose, onSave }: CategoryFormProps) {
|
||||
setSaveError('')
|
||||
try {
|
||||
await onSave({
|
||||
id: category?.id ?? `cat-${Date.now()}`,
|
||||
hotelId: category?.hotelId ?? '',
|
||||
name: name.trim(),
|
||||
id: category?.id ?? `cat-${Date.now()}`,
|
||||
hotelId: category?.hotelId ?? '',
|
||||
name: name.trim(),
|
||||
description,
|
||||
color,
|
||||
amenities,
|
||||
photos,
|
||||
basePrice,
|
||||
allowHourly,
|
||||
hourlyBasePrice,
|
||||
})
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : 'Ошибка сохранения')
|
||||
@@ -187,6 +196,47 @@ function CategoryForm({ category, onClose, onSave }: CategoryFormProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Базовая цена за ночь (₽)</label>
|
||||
<input
|
||||
type="number" min={0} className="input"
|
||||
placeholder="0 — брать из номеров"
|
||||
value={basePrice || ''}
|
||||
onChange={e => setBasePrice(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">0 = цена из номеров категории</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Разрешить почасовую бронь</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAllowHourly(v => !v)}
|
||||
className={cn(
|
||||
'relative w-11 h-6 rounded-full transition-colors mt-1',
|
||||
allowHourly ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600',
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
'absolute top-1 left-1 w-4 h-4 rounded-full bg-white shadow transition-transform',
|
||||
allowHourly && 'translate-x-5',
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{allowHourly && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Базовая почасовая ставка (₽/ч)</label>
|
||||
<input
|
||||
type="number" min={0} className="input"
|
||||
placeholder="0 — брать из настроек номеров"
|
||||
value={hourlyBasePrice || ''}
|
||||
onChange={e => setHourlyBasePrice(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Описание</label>
|
||||
<p className="text-xs text-slate-500 mb-1">Отображается в виджете бронирования как описание категории</p>
|
||||
@@ -396,6 +446,9 @@ export function RoomCategoriesPage() {
|
||||
const payload = {
|
||||
name: cat.name, description: cat.description,
|
||||
color: cat.color, amenities: cat.amenities, photos: cat.photos,
|
||||
base_price: cat.basePrice ?? 0,
|
||||
allow_hourly: cat.allowHourly ?? false,
|
||||
hourly_base_price: cat.hourlyBasePrice ?? 0,
|
||||
}
|
||||
const isNew = !categories.find(c => c.id === cat.id)
|
||||
const saved = isNew
|
||||
|
||||
Reference in New Issue
Block a user