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:
@@ -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
|
||||
|
||||
139
backend/src/routes/rate-periods.ts
Normal file
139
backend/src/routes/rate-periods.ts
Normal 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
|
||||
Reference in New Issue
Block a user