From e5daf91ed48eb26100876cbc18c93c6677de3339 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Mon, 23 Mar 2026 16:14:45 +0300 Subject: [PATCH] feat: persist rate overrides to DB when applying prices in AvailabilityPage Co-Authored-By: Claude Sonnet 4.6 --- backend/migrations/022_rate_overrides.sql | 12 +++ backend/src/app.ts | 2 + backend/src/routes/rate-overrides.ts | 94 +++++++++++++++++++++++ src/lib/api.ts | 29 +++++++ src/pages/AvailabilityPage.tsx | 91 ++++++++++++++-------- 5 files changed, 194 insertions(+), 34 deletions(-) create mode 100644 backend/migrations/022_rate_overrides.sql create mode 100644 backend/src/routes/rate-overrides.ts diff --git a/backend/migrations/022_rate_overrides.sql b/backend/migrations/022_rate_overrides.sql new file mode 100644 index 0000000..5a0e2c3 --- /dev/null +++ b/backend/migrations/022_rate_overrides.sql @@ -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) +); diff --git a/backend/src/app.ts b/backend/src/app.ts index b7f3550..941a6db 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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 diff --git a/backend/src/routes/rate-overrides.ts b/backend/src/routes/rate-overrides.ts new file mode 100644 index 0000000..8cb06dd --- /dev/null +++ b/backend/src/routes/rate-overrides.ts @@ -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 => { + 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( + '/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; 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 diff --git a/src/lib/api.ts b/src/lib/api.ts index 8908d4d..a4c50cf 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -395,6 +395,15 @@ export const api = { req('DELETE', `/api/hotels/${slug}/tariffs/${id}`), }, + // ── Rate Overrides (per-cell prices) ───────────────────────────────────── + rateOverrides: { + list: (slug: string) => + req('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 + closed: boolean +} + +export interface RateOverridePayload { + category_id: string + date: string + price: number + extra_person?: number + min_nights?: number + channel_prices?: Record + closed?: boolean +} + export interface RatePeriodApi { id: string name: string diff --git a/src/pages/AvailabilityPage.tsx b/src/pages/AvailabilityPage.tsx index 20795cf..b81906b 100644 --- a/src/pages/AvailabilityPage.tsx +++ b/src/pages/AvailabilityPage.tsx @@ -20,7 +20,7 @@ interface AvailabilityCat { function buildPriceGrid( categories: AvailabilityCat[], - days = 60, + days = 90, ): Record> { const result: Record> = {} 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 = {} 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(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() 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; 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)