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

@@ -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