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

@@ -35,6 +35,7 @@ const categories: FastifyPluginAsync = async (fastify) => {
fastify.post<SlugParam & { Body: {
name: string; description?: string; color?: string
amenities?: string[]; photos?: string[]; sort_order?: number
base_price?: number; allow_hourly?: boolean; hourly_base_price?: number
} }>(
'/api/hotels/:slug/categories',
{ onRequest: [fastify.authenticate] },
@@ -44,11 +45,17 @@ const categories: FastifyPluginAsync = async (fastify) => {
return reply.code(403).send({ error: 'Forbidden' })
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { name, description = '', color = '#4F46E5', amenities = [], photos = [], sort_order = 0 } = request.body
const {
name, description = '', color = '#4F46E5', amenities = [], photos = [], sort_order = 0,
base_price = 0, allow_hourly = false, hourly_base_price = 0,
} = request.body
const { rows } = await db.query(
`INSERT INTO room_categories (hotel_id, name, description, color, amenities, photos, sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
[hotelId, name, description, color, amenities, photos, sort_order],
`INSERT INTO room_categories
(hotel_id, name, description, color, amenities, photos, sort_order,
base_price, allow_hourly, hourly_base_price)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`,
[hotelId, name, description, color, amenities, photos, sort_order,
base_price, allow_hourly, hourly_base_price],
)
return reply.code(201).send(rows[0])
},
@@ -58,6 +65,7 @@ const categories: FastifyPluginAsync = async (fastify) => {
fastify.patch<SlugIdParam & { Body: {
name?: string; description?: string; color?: string
amenities?: string[]; photos?: string[]; sort_order?: number
base_price?: number; allow_hourly?: boolean; hourly_base_price?: number
} }>(
'/api/hotels/:slug/categories/:id',
{ onRequest: [fastify.authenticate] },
@@ -71,12 +79,15 @@ const categories: FastifyPluginAsync = async (fastify) => {
const sets: string[] = ['updated_at = NOW()']
const vals: unknown[] = [hotelId, id]
let i = 3
if (b.name !== undefined) { sets.push(`name = $${i++}`); vals.push(b.name) }
if (b.description !== undefined) { sets.push(`description = $${i++}`); vals.push(b.description) }
if (b.color !== undefined) { sets.push(`color = $${i++}`); vals.push(b.color) }
if (b.amenities !== undefined) { sets.push(`amenities = $${i++}`); vals.push(b.amenities) }
if (b.photos !== undefined) { sets.push(`photos = $${i++}`); vals.push(b.photos) }
if (b.sort_order !== undefined) { sets.push(`sort_order = $${i++}`); vals.push(b.sort_order) }
if (b.name !== undefined) { sets.push(`name = $${i++}`); vals.push(b.name) }
if (b.description !== undefined) { sets.push(`description = $${i++}`); vals.push(b.description) }
if (b.color !== undefined) { sets.push(`color = $${i++}`); vals.push(b.color) }
if (b.amenities !== undefined) { sets.push(`amenities = $${i++}`); vals.push(b.amenities) }
if (b.photos !== undefined) { sets.push(`photos = $${i++}`); vals.push(b.photos) }
if (b.sort_order !== undefined) { sets.push(`sort_order = $${i++}`); vals.push(b.sort_order) }
if (b.base_price !== undefined) { sets.push(`base_price = $${i++}`); vals.push(b.base_price) }
if (b.allow_hourly !== undefined) { sets.push(`allow_hourly = $${i++}`); vals.push(b.allow_hourly) }
if (b.hourly_base_price !== undefined) { sets.push(`hourly_base_price = $${i++}`); vals.push(b.hourly_base_price) }
const { rows } = await db.query(
`UPDATE room_categories SET ${sets.join(', ')} WHERE hotel_id=$1 AND id=$2 RETURNING *`,
vals,

View File

@@ -24,7 +24,7 @@ const rateOverrides: FastifyPluginAsync = async (fastify) => {
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { rows } = await db.query(
`SELECT category_id, to_char(date, 'YYYY-MM-DD') AS date,
price, extra_person, min_nights, channel_prices, closed
price, extra_person, min_nights, channel_prices, closed, hourly_price
FROM rate_overrides
WHERE hotel_id = $1
ORDER BY date, category_id`,
@@ -40,6 +40,7 @@ const rateOverrides: FastifyPluginAsync = async (fastify) => {
category_id: string; date: string; price: number
extra_person?: number; min_nights?: number
channel_prices?: Record<string, number>; closed?: boolean
hourly_price?: number | null
}>
} }>(
'/api/hotels/:slug/rate-overrides/bulk',
@@ -54,13 +55,11 @@ const rateOverrides: FastifyPluginAsync = async (fastify) => {
const { overrides } = request.body
if (!overrides?.length) return { count: 0 }
// Build parameterized bulk upsert:
// $1 = hotelId, then per row: category_id, date, price, extra_person, min_nights, channel_prices, closed
const vals: unknown[] = [hotelId]
const rowPlaceholders: string[] = []
let idx = 2
for (const o of overrides) {
rowPlaceholders.push(`($1, $${idx}, $${idx+1}, $${idx+2}, $${idx+3}, $${idx+4}, $${idx+5}, $${idx+6})`)
rowPlaceholders.push(`($1, $${idx}, $${idx+1}, $${idx+2}, $${idx+3}, $${idx+4}, $${idx+5}, $${idx+6}, $${idx+7})`)
vals.push(
o.category_id,
o.date,
@@ -69,13 +68,14 @@ const rateOverrides: FastifyPluginAsync = async (fastify) => {
o.min_nights ?? 1,
JSON.stringify(o.channel_prices ?? {}),
o.closed ?? false,
o.hourly_price ?? null,
)
idx += 7
idx += 8
}
await db.query(
`INSERT INTO rate_overrides
(hotel_id, category_id, date, price, extra_person, min_nights, channel_prices, closed)
(hotel_id, category_id, date, price, extra_person, min_nights, channel_prices, closed, hourly_price)
VALUES ${rowPlaceholders.join(', ')}
ON CONFLICT (hotel_id, category_id, date) DO UPDATE SET
price = EXCLUDED.price,
@@ -83,6 +83,7 @@ const rateOverrides: FastifyPluginAsync = async (fastify) => {
min_nights = EXCLUDED.min_nights,
channel_prices = EXCLUDED.channel_prices,
closed = EXCLUDED.closed,
hourly_price = COALESCE(EXCLUDED.hourly_price, rate_overrides.hourly_price),
updated_at = NOW()`,
vals,
)