feat: persist rate overrides to DB when applying prices in AvailabilityPage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
12
backend/migrations/022_rate_overrides.sql
Normal file
12
backend/migrations/022_rate_overrides.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE IF NOT EXISTS rate_overrides (
|
||||
hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE,
|
||||
category_id TEXT NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
price INTEGER NOT NULL,
|
||||
extra_person INTEGER NOT NULL DEFAULT 0,
|
||||
min_nights INTEGER NOT NULL DEFAULT 1,
|
||||
channel_prices JSONB NOT NULL DEFAULT '{}',
|
||||
closed BOOLEAN NOT NULL DEFAULT false,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (hotel_id, category_id, date)
|
||||
);
|
||||
@@ -26,6 +26,7 @@ import rentalRoutes from './routes/rental'
|
||||
import categoriesRoutes from './routes/categories'
|
||||
import tariffsRoutes from './routes/tariffs'
|
||||
import ratePeriodsRoutes from './routes/rate-periods'
|
||||
import rateOverridesRoutes from './routes/rate-overrides'
|
||||
import uploadRoutes from './routes/upload'
|
||||
|
||||
export async function buildApp() {
|
||||
@@ -97,6 +98,7 @@ export async function buildApp() {
|
||||
await fastify.register(categoriesRoutes)
|
||||
await fastify.register(tariffsRoutes)
|
||||
await fastify.register(ratePeriodsRoutes)
|
||||
await fastify.register(rateOverridesRoutes)
|
||||
await fastify.register(uploadRoutes)
|
||||
|
||||
return fastify
|
||||
|
||||
94
backend/src/routes/rate-overrides.ts
Normal file
94
backend/src/routes/rate-overrides.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { FastifyPluginAsync } from 'fastify'
|
||||
import { db } from '../db'
|
||||
|
||||
type SlugParam = { Params: { slug: string } }
|
||||
|
||||
const rateOverrides: 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-overrides
|
||||
fastify.get<SlugParam>(
|
||||
'/api/hotels/:slug/rate-overrides',
|
||||
{ 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 category_id, to_char(date, 'YYYY-MM-DD') AS date,
|
||||
price, extra_person, min_nights, channel_prices, closed
|
||||
FROM rate_overrides
|
||||
WHERE hotel_id = $1
|
||||
ORDER BY date, category_id`,
|
||||
[hotelId],
|
||||
)
|
||||
return rows
|
||||
},
|
||||
)
|
||||
|
||||
// POST /api/hotels/:slug/rate-overrides/bulk — upsert many cells at once
|
||||
fastify.post<SlugParam & { Body: {
|
||||
overrides: Array<{
|
||||
category_id: string; date: string; price: number
|
||||
extra_person?: number; min_nights?: number
|
||||
channel_prices?: Record<string, number>; closed?: boolean
|
||||
}>
|
||||
} }>(
|
||||
'/api/hotels/:slug/rate-overrides/bulk',
|
||||
{ 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 { 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})`)
|
||||
vals.push(
|
||||
o.category_id,
|
||||
o.date,
|
||||
o.price,
|
||||
o.extra_person ?? 0,
|
||||
o.min_nights ?? 1,
|
||||
JSON.stringify(o.channel_prices ?? {}),
|
||||
o.closed ?? false,
|
||||
)
|
||||
idx += 7
|
||||
}
|
||||
|
||||
await db.query(
|
||||
`INSERT INTO rate_overrides
|
||||
(hotel_id, category_id, date, price, extra_person, min_nights, channel_prices, closed)
|
||||
VALUES ${rowPlaceholders.join(', ')}
|
||||
ON CONFLICT (hotel_id, category_id, date) DO UPDATE SET
|
||||
price = EXCLUDED.price,
|
||||
extra_person = EXCLUDED.extra_person,
|
||||
min_nights = EXCLUDED.min_nights,
|
||||
channel_prices = EXCLUDED.channel_prices,
|
||||
closed = EXCLUDED.closed,
|
||||
updated_at = NOW()`,
|
||||
vals,
|
||||
)
|
||||
return { count: overrides.length }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export default rateOverrides
|
||||
@@ -395,6 +395,15 @@ export const api = {
|
||||
req<void>('DELETE', `/api/hotels/${slug}/tariffs/${id}`),
|
||||
},
|
||||
|
||||
// ── Rate Overrides (per-cell prices) ─────────────────────────────────────
|
||||
rateOverrides: {
|
||||
list: (slug: string) =>
|
||||
req<RateOverrideApi[]>('GET', `/api/hotels/${slug}/rate-overrides`),
|
||||
|
||||
bulkUpsert: (slug: string, overrides: RateOverridePayload[]) =>
|
||||
req<{ count: number }>('POST', `/api/hotels/${slug}/rate-overrides/bulk`, { overrides }),
|
||||
},
|
||||
|
||||
// ── Rate Periods ─────────────────────────────────────────────────────────
|
||||
ratePeriods: {
|
||||
list: (slug: string) =>
|
||||
@@ -733,6 +742,26 @@ export interface TariffPayload {
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface RateOverrideApi {
|
||||
category_id: string
|
||||
date: string
|
||||
price: number
|
||||
extra_person: number
|
||||
min_nights: number
|
||||
channel_prices: Record<string, number>
|
||||
closed: boolean
|
||||
}
|
||||
|
||||
export interface RateOverridePayload {
|
||||
category_id: string
|
||||
date: string
|
||||
price: number
|
||||
extra_person?: number
|
||||
min_nights?: number
|
||||
channel_prices?: Record<string, number>
|
||||
closed?: boolean
|
||||
}
|
||||
|
||||
export interface RatePeriodApi {
|
||||
id: string
|
||||
name: string
|
||||
|
||||
@@ -20,7 +20,7 @@ interface AvailabilityCat {
|
||||
|
||||
function buildPriceGrid(
|
||||
categories: AvailabilityCat[],
|
||||
days = 60,
|
||||
days = 90,
|
||||
): Record<string, Record<string, PriceCell>> {
|
||||
const result: Record<string, Record<string, PriceCell>> = {}
|
||||
const today = new Date()
|
||||
@@ -29,13 +29,11 @@ function buildPriceGrid(
|
||||
for (let i = 0; i < days; i++) {
|
||||
const d = addDays(today, i)
|
||||
const dateStr = format(d, 'yyyy-MM-dd')
|
||||
const isWeekend = [5, 6].includes(getDay(d))
|
||||
const basePrice = Math.round(cat.basePrice * (isWeekend ? 1.25 : 1.0))
|
||||
const channelPrices: Record<string, number> = {}
|
||||
for (const ch of RATE_CHANNELS) {
|
||||
channelPrices[ch.id] = Math.round(basePrice * (DEFAULT_CHANNEL_MARKUP[ch.id] ?? 1))
|
||||
channelPrices[ch.id] = Math.round(cat.basePrice * (DEFAULT_CHANNEL_MARKUP[ch.id] ?? 1))
|
||||
}
|
||||
result[cat.id][dateStr] = { price: basePrice, extraPerson: 0, minNights: 1, channelPrices, closed: false }
|
||||
result[cat.id][dateStr] = { price: cat.basePrice, extraPerson: 0, minNights: 1, channelPrices, closed: false }
|
||||
}
|
||||
}
|
||||
return result
|
||||
@@ -684,63 +682,65 @@ export function AvailabilityPage() {
|
||||
const [pickerDate, setPickerDate] = useState(format(new Date(), DATE_FMT))
|
||||
const navPickerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Load categories, rooms, and rate periods from API
|
||||
// Load categories, rooms, rate periods and overrides from API
|
||||
useEffect(() => {
|
||||
if (!slug) return
|
||||
setLoading(true)
|
||||
Promise.all([
|
||||
api.categories.list(slug),
|
||||
api.rooms.list(slug),
|
||||
api.ratePeriods.list(slug),
|
||||
]).then(([cats, rooms, apiPeriods]) => {
|
||||
api.categories.list(slug).catch(() => []),
|
||||
api.rooms.list(slug).catch(() => []),
|
||||
api.ratePeriods.list(slug).catch(() => []),
|
||||
api.rateOverrides.list(slug).catch(() => []),
|
||||
]).then(([cats, rooms, apiPeriods, apiOverrides]) => {
|
||||
// Build AvailabilityCat[] from real categories, derive basePrice from rooms
|
||||
const availCats: AvailabilityCat[] = cats.map(cat => {
|
||||
const catRooms = rooms.filter(r => r.categoryId === cat.id)
|
||||
const basePrice = catRooms.length > 0
|
||||
? Math.min(...catRooms.map(r => r.baseRate))
|
||||
: 3000
|
||||
return {
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
color: cat.color || '#6366f1',
|
||||
basePrice,
|
||||
}
|
||||
return { id: cat.id, name: cat.name, color: cat.color || '#6366f1', basePrice }
|
||||
})
|
||||
|
||||
// If no categories defined, fall back to room types as groups
|
||||
// If no categories, fall back to room types as groups
|
||||
const colors = ['#6366f1','#10b981','#f59e0b','#ef4444','#8b5cf6','#06b6d4']
|
||||
const finalCats = availCats.length > 0 ? availCats : (() => {
|
||||
const typeMap = new Map<string, number>()
|
||||
for (const r of rooms) {
|
||||
if (!r.type) continue
|
||||
if (!typeMap.has(r.type)) typeMap.set(r.type, r.baseRate)
|
||||
else typeMap.set(r.type, Math.min(typeMap.get(r.type)!, r.baseRate))
|
||||
}
|
||||
const colors = ['#6366f1','#10b981','#f59e0b','#ef4444','#8b5cf6','#06b6d4']
|
||||
return Array.from(typeMap.entries()).map(([name, price], i) => ({
|
||||
id: name.toLowerCase().replace(/\s+/g, '_'),
|
||||
name,
|
||||
color: colors[i % colors.length],
|
||||
basePrice: price,
|
||||
name, color: colors[i % colors.length], basePrice: price,
|
||||
}))
|
||||
})()
|
||||
|
||||
setRoomCategories(finalCats)
|
||||
setPrices(buildPriceGrid(finalCats))
|
||||
|
||||
// Map API rate periods to local RatePeriod format
|
||||
// Build initial grid then apply saved overrides on top
|
||||
const grid = buildPriceGrid(finalCats)
|
||||
for (const o of apiOverrides) {
|
||||
if (!grid[o.category_id]) continue
|
||||
grid[o.category_id][o.date] = {
|
||||
price: o.price,
|
||||
extraPerson: o.extra_person,
|
||||
minNights: o.min_nights,
|
||||
channelPrices: o.channel_prices,
|
||||
closed: o.closed,
|
||||
}
|
||||
}
|
||||
setPrices(grid)
|
||||
|
||||
// Map API rate periods
|
||||
const mappedPeriods: RatePeriod[] = apiPeriods.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
startDate: p.startDate,
|
||||
endDate: p.endDate,
|
||||
notes: p.notes ?? undefined,
|
||||
categoryPrices: p.categoryPrices,
|
||||
channelMarkup: p.channelMarkup,
|
||||
extraPersonPrice: p.extraPersonPrice,
|
||||
minNights: p.minNights,
|
||||
daysOfWeek: p.daysOfWeek ?? undefined,
|
||||
id: p.id, name: p.name, startDate: p.startDate, endDate: p.endDate,
|
||||
notes: p.notes ?? undefined, categoryPrices: p.categoryPrices,
|
||||
channelMarkup: p.channelMarkup, extraPersonPrice: p.extraPersonPrice,
|
||||
minNights: p.minNights, daysOfWeek: p.daysOfWeek ?? undefined,
|
||||
}))
|
||||
setPeriods(mappedPeriods)
|
||||
}).catch(console.error).finally(() => setLoading(false))
|
||||
}).finally(() => setLoading(false))
|
||||
}, [slug])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -797,6 +797,13 @@ export function AvailabilityPage() {
|
||||
const catsToUpdate = onlyCategoryId
|
||||
? roomCategories.filter(c => c.id === onlyCategoryId)
|
||||
: roomCategories
|
||||
|
||||
const overrides: Array<{
|
||||
category_id: string; date: string; price: number
|
||||
extra_person: number; min_nights: number
|
||||
channel_prices: Record<string, number>; closed: boolean
|
||||
}> = []
|
||||
|
||||
setPrices(prev => {
|
||||
const next = { ...prev }
|
||||
for (const cat of catsToUpdate) {
|
||||
@@ -810,10 +817,26 @@ export function AvailabilityPage() {
|
||||
next[cat.id][d] = {
|
||||
price: basePrice, extraPerson, minNights, channelPrices, closed,
|
||||
}
|
||||
overrides.push({
|
||||
category_id: cat.id,
|
||||
date: d,
|
||||
price: basePrice,
|
||||
extra_person: extraPerson,
|
||||
min_nights: minNights,
|
||||
channel_prices: channelPrices,
|
||||
closed,
|
||||
})
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
if (overrides.length > 0) {
|
||||
api.rateOverrides.bulkUpsert(slug, overrides).catch(err =>
|
||||
console.error('Failed to save rate overrides:', err)
|
||||
)
|
||||
}
|
||||
|
||||
setShowEditPanel(false)
|
||||
setSelection(null)
|
||||
if (onlyCategoryId) setSelectedCat(null)
|
||||
|
||||
Reference in New Issue
Block a user