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 categoriesRoutes from './routes/categories'
|
||||||
import tariffsRoutes from './routes/tariffs'
|
import tariffsRoutes from './routes/tariffs'
|
||||||
import ratePeriodsRoutes from './routes/rate-periods'
|
import ratePeriodsRoutes from './routes/rate-periods'
|
||||||
|
import rateOverridesRoutes from './routes/rate-overrides'
|
||||||
import uploadRoutes from './routes/upload'
|
import uploadRoutes from './routes/upload'
|
||||||
|
|
||||||
export async function buildApp() {
|
export async function buildApp() {
|
||||||
@@ -97,6 +98,7 @@ export async function buildApp() {
|
|||||||
await fastify.register(categoriesRoutes)
|
await fastify.register(categoriesRoutes)
|
||||||
await fastify.register(tariffsRoutes)
|
await fastify.register(tariffsRoutes)
|
||||||
await fastify.register(ratePeriodsRoutes)
|
await fastify.register(ratePeriodsRoutes)
|
||||||
|
await fastify.register(rateOverridesRoutes)
|
||||||
await fastify.register(uploadRoutes)
|
await fastify.register(uploadRoutes)
|
||||||
|
|
||||||
return fastify
|
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}`),
|
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 ─────────────────────────────────────────────────────────
|
// ── Rate Periods ─────────────────────────────────────────────────────────
|
||||||
ratePeriods: {
|
ratePeriods: {
|
||||||
list: (slug: string) =>
|
list: (slug: string) =>
|
||||||
@@ -733,6 +742,26 @@ export interface TariffPayload {
|
|||||||
is_active?: boolean
|
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 {
|
export interface RatePeriodApi {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ interface AvailabilityCat {
|
|||||||
|
|
||||||
function buildPriceGrid(
|
function buildPriceGrid(
|
||||||
categories: AvailabilityCat[],
|
categories: AvailabilityCat[],
|
||||||
days = 60,
|
days = 90,
|
||||||
): Record<string, Record<string, PriceCell>> {
|
): Record<string, Record<string, PriceCell>> {
|
||||||
const result: Record<string, Record<string, PriceCell>> = {}
|
const result: Record<string, Record<string, PriceCell>> = {}
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
@@ -29,13 +29,11 @@ function buildPriceGrid(
|
|||||||
for (let i = 0; i < days; i++) {
|
for (let i = 0; i < days; i++) {
|
||||||
const d = addDays(today, i)
|
const d = addDays(today, i)
|
||||||
const dateStr = format(d, 'yyyy-MM-dd')
|
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> = {}
|
const channelPrices: Record<string, number> = {}
|
||||||
for (const ch of RATE_CHANNELS) {
|
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
|
return result
|
||||||
@@ -684,63 +682,65 @@ export function AvailabilityPage() {
|
|||||||
const [pickerDate, setPickerDate] = useState(format(new Date(), DATE_FMT))
|
const [pickerDate, setPickerDate] = useState(format(new Date(), DATE_FMT))
|
||||||
const navPickerRef = useRef<HTMLDivElement>(null)
|
const navPickerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// Load categories, rooms, and rate periods from API
|
// Load categories, rooms, rate periods and overrides from API
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!slug) return
|
if (!slug) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
Promise.all([
|
Promise.all([
|
||||||
api.categories.list(slug),
|
api.categories.list(slug).catch(() => []),
|
||||||
api.rooms.list(slug),
|
api.rooms.list(slug).catch(() => []),
|
||||||
api.ratePeriods.list(slug),
|
api.ratePeriods.list(slug).catch(() => []),
|
||||||
]).then(([cats, rooms, apiPeriods]) => {
|
api.rateOverrides.list(slug).catch(() => []),
|
||||||
|
]).then(([cats, rooms, apiPeriods, apiOverrides]) => {
|
||||||
// Build AvailabilityCat[] from real categories, derive basePrice from rooms
|
// Build AvailabilityCat[] from real categories, derive basePrice from rooms
|
||||||
const availCats: AvailabilityCat[] = cats.map(cat => {
|
const availCats: AvailabilityCat[] = cats.map(cat => {
|
||||||
const catRooms = rooms.filter(r => r.categoryId === cat.id)
|
const catRooms = rooms.filter(r => r.categoryId === cat.id)
|
||||||
const basePrice = catRooms.length > 0
|
const basePrice = catRooms.length > 0
|
||||||
? Math.min(...catRooms.map(r => r.baseRate))
|
? Math.min(...catRooms.map(r => r.baseRate))
|
||||||
: 3000
|
: 3000
|
||||||
return {
|
return { id: cat.id, name: cat.name, color: cat.color || '#6366f1', basePrice }
|
||||||
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 finalCats = availCats.length > 0 ? availCats : (() => {
|
||||||
const typeMap = new Map<string, number>()
|
const typeMap = new Map<string, number>()
|
||||||
for (const r of rooms) {
|
for (const r of rooms) {
|
||||||
|
if (!r.type) continue
|
||||||
if (!typeMap.has(r.type)) typeMap.set(r.type, r.baseRate)
|
if (!typeMap.has(r.type)) typeMap.set(r.type, r.baseRate)
|
||||||
else typeMap.set(r.type, Math.min(typeMap.get(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) => ({
|
return Array.from(typeMap.entries()).map(([name, price], i) => ({
|
||||||
id: name.toLowerCase().replace(/\s+/g, '_'),
|
id: name.toLowerCase().replace(/\s+/g, '_'),
|
||||||
name,
|
name, color: colors[i % colors.length], basePrice: price,
|
||||||
color: colors[i % colors.length],
|
|
||||||
basePrice: price,
|
|
||||||
}))
|
}))
|
||||||
})()
|
})()
|
||||||
|
|
||||||
setRoomCategories(finalCats)
|
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 => ({
|
const mappedPeriods: RatePeriod[] = apiPeriods.map(p => ({
|
||||||
id: p.id,
|
id: p.id, name: p.name, startDate: p.startDate, endDate: p.endDate,
|
||||||
name: p.name,
|
notes: p.notes ?? undefined, categoryPrices: p.categoryPrices,
|
||||||
startDate: p.startDate,
|
channelMarkup: p.channelMarkup, extraPersonPrice: p.extraPersonPrice,
|
||||||
endDate: p.endDate,
|
minNights: p.minNights, daysOfWeek: p.daysOfWeek ?? undefined,
|
||||||
notes: p.notes ?? undefined,
|
|
||||||
categoryPrices: p.categoryPrices,
|
|
||||||
channelMarkup: p.channelMarkup,
|
|
||||||
extraPersonPrice: p.extraPersonPrice,
|
|
||||||
minNights: p.minNights,
|
|
||||||
daysOfWeek: p.daysOfWeek ?? undefined,
|
|
||||||
}))
|
}))
|
||||||
setPeriods(mappedPeriods)
|
setPeriods(mappedPeriods)
|
||||||
}).catch(console.error).finally(() => setLoading(false))
|
}).finally(() => setLoading(false))
|
||||||
}, [slug])
|
}, [slug])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -797,6 +797,13 @@ export function AvailabilityPage() {
|
|||||||
const catsToUpdate = onlyCategoryId
|
const catsToUpdate = onlyCategoryId
|
||||||
? roomCategories.filter(c => c.id === onlyCategoryId)
|
? roomCategories.filter(c => c.id === onlyCategoryId)
|
||||||
: roomCategories
|
: 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 => {
|
setPrices(prev => {
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
for (const cat of catsToUpdate) {
|
for (const cat of catsToUpdate) {
|
||||||
@@ -810,10 +817,26 @@ export function AvailabilityPage() {
|
|||||||
next[cat.id][d] = {
|
next[cat.id][d] = {
|
||||||
price: basePrice, extraPerson, minNights, channelPrices, closed,
|
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
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (overrides.length > 0) {
|
||||||
|
api.rateOverrides.bulkUpsert(slug, overrides).catch(err =>
|
||||||
|
console.error('Failed to save rate overrides:', err)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
setShowEditPanel(false)
|
setShowEditPanel(false)
|
||||||
setSelection(null)
|
setSelection(null)
|
||||||
if (onlyCategoryId) setSelectedCat(null)
|
if (onlyCategoryId) setSelectedCat(null)
|
||||||
|
|||||||
Reference in New Issue
Block a user