feat: calendar hover price tooltip + AvailabilityPage API integration

- Calendar cells: hold mouse 600ms to show price tooltip for that date/room (baseRate + hourly rate if enabled)
- AvailabilityPage: load real categories from API, derive basePrice from room baseRates, fall back to room types if no categories
- Rate periods: persisted to DB via new /api/hotels/:slug/rate-periods endpoint
- New backend migration 021_rate_periods.sql + rate-periods route
- Added api.ratePeriods.{list,create,update,delete} to frontend API client

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 15:05:12 +03:00
parent 63da3d21cc
commit c3a30be74e
6 changed files with 411 additions and 45 deletions

View File

@@ -25,6 +25,7 @@ import hotelSettingsRoutes from './routes/hotel-settings'
import rentalRoutes from './routes/rental'
import categoriesRoutes from './routes/categories'
import tariffsRoutes from './routes/tariffs'
import ratePeriodsRoutes from './routes/rate-periods'
import uploadRoutes from './routes/upload'
export async function buildApp() {
@@ -95,6 +96,7 @@ export async function buildApp() {
await fastify.register(rentalRoutes)
await fastify.register(categoriesRoutes)
await fastify.register(tariffsRoutes)
await fastify.register(ratePeriodsRoutes)
await fastify.register(uploadRoutes)
return fastify

View File

@@ -0,0 +1,139 @@
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
type SlugParam = { Params: { slug: string } }
type SlugIdParam = { Params: { slug: string; id: string } }
const ratePeriods: FastifyPluginAsync = async (fastify) => {
const getHotelId = async (slug: string): Promise<string | null> => {
const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
return rows[0]?.id ?? null
}
const canAccess = (userSlug: string | null, role: string, slug: string) =>
role === 'super_admin' || userSlug === slug
// GET /api/hotels/:slug/rate-periods
fastify.get<SlugParam>(
'/api/hotels/:slug/rate-periods',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug))
return reply.code(403).send({ error: 'Forbidden' })
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { rows } = await db.query(
`SELECT * FROM rate_periods WHERE hotel_id = $1 ORDER BY start_date`,
[hotelId],
)
return rows.map(r => ({
id: r.id,
name: r.name,
startDate: r.start_date,
endDate: r.end_date,
notes: r.notes,
categoryPrices: r.category_prices,
channelMarkup: r.channel_markup,
extraPersonPrice: r.extra_person_price,
minNights: r.min_nights,
daysOfWeek: r.days_of_week,
}))
},
)
// POST /api/hotels/:slug/rate-periods
fastify.post<SlugParam & { Body: {
name: string; start_date: string; end_date: string; notes?: string
category_prices?: Record<string, number>; channel_markup?: Record<string, number>
extra_person_price?: number; min_nights?: number; days_of_week?: number[]
} }>(
'/api/hotels/:slug/rate-periods',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug))
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, start_date, end_date, notes = null,
category_prices = {}, channel_markup = {},
extra_person_price = 0, min_nights = 1, days_of_week = null,
} = request.body
const { rows } = await db.query(
`INSERT INTO rate_periods
(hotel_id, name, start_date, end_date, notes, category_prices, channel_markup,
extra_person_price, min_nights, days_of_week)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`,
[hotelId, name, start_date, end_date, notes,
JSON.stringify(category_prices), JSON.stringify(channel_markup),
extra_person_price, min_nights, days_of_week],
)
const r = rows[0]
return reply.code(201).send({
id: r.id, name: r.name, startDate: r.start_date, endDate: r.end_date,
notes: r.notes, categoryPrices: r.category_prices, channelMarkup: r.channel_markup,
extraPersonPrice: r.extra_person_price, minNights: r.min_nights, daysOfWeek: r.days_of_week,
})
},
)
// PATCH /api/hotels/:slug/rate-periods/:id
fastify.patch<SlugIdParam & { Body: {
name?: string; start_date?: string; end_date?: string; notes?: string
category_prices?: Record<string, number>; channel_markup?: Record<string, number>
extra_person_price?: number; min_nights?: number; days_of_week?: number[] | null
} }>(
'/api/hotels/:slug/rate-periods/:id',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, id } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug))
return reply.code(403).send({ error: 'Forbidden' })
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const b = request.body
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.start_date !== undefined) { sets.push(`start_date = $${i++}`); vals.push(b.start_date) }
if (b.end_date !== undefined) { sets.push(`end_date = $${i++}`); vals.push(b.end_date) }
if (b.notes !== undefined) { sets.push(`notes = $${i++}`); vals.push(b.notes) }
if (b.category_prices !== undefined) { sets.push(`category_prices = $${i++}`); vals.push(JSON.stringify(b.category_prices)) }
if (b.channel_markup !== undefined) { sets.push(`channel_markup = $${i++}`); vals.push(JSON.stringify(b.channel_markup)) }
if (b.extra_person_price !== undefined) { sets.push(`extra_person_price = $${i++}`); vals.push(b.extra_person_price) }
if (b.min_nights !== undefined) { sets.push(`min_nights = $${i++}`); vals.push(b.min_nights) }
if (b.days_of_week !== undefined) { sets.push(`days_of_week = $${i++}`); vals.push(b.days_of_week) }
const { rows } = await db.query(
`UPDATE rate_periods SET ${sets.join(', ')} WHERE hotel_id=$1 AND id=$2 RETURNING *`,
vals,
)
if (!rows[0]) return reply.code(404).send({ error: 'Not found' })
const r = rows[0]
return {
id: r.id, name: r.name, startDate: r.start_date, endDate: r.end_date,
notes: r.notes, categoryPrices: r.category_prices, channelMarkup: r.channel_markup,
extraPersonPrice: r.extra_person_price, minNights: r.min_nights, daysOfWeek: r.days_of_week,
}
},
)
// DELETE /api/hotels/:slug/rate-periods/:id
fastify.delete<SlugIdParam>(
'/api/hotels/:slug/rate-periods/:id',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, id } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug))
return reply.code(403).send({ error: 'Forbidden' })
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
await db.query('DELETE FROM rate_periods WHERE hotel_id=$1 AND id=$2', [hotelId, id])
return reply.code(204).send()
},
)
}
export default ratePeriods