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