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:
2026-03-23 16:14:45 +03:00
parent 8312d2372e
commit e5daf91ed4
5 changed files with 194 additions and 34 deletions

View 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)
);

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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)