From c3a30be74ef2bcb8a93a8cc75766a75332cb3e7c Mon Sep 17 00:00:00 2001 From: HotelSync Date: Mon, 23 Mar 2026 15:05:12 +0300 Subject: [PATCH] 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 --- backend/migrations/021_rate_periods.sql | 15 ++ backend/src/app.ts | 2 + backend/src/routes/rate-periods.ts | 139 +++++++++++++ src/components/calendar/BookingCalendar.tsx | 46 ++++- src/lib/api.ts | 40 ++++ src/pages/AvailabilityPage.tsx | 214 ++++++++++++++++---- 6 files changed, 411 insertions(+), 45 deletions(-) create mode 100644 backend/migrations/021_rate_periods.sql create mode 100644 backend/src/routes/rate-periods.ts diff --git a/backend/migrations/021_rate_periods.sql b/backend/migrations/021_rate_periods.sql new file mode 100644 index 0000000..d20726f --- /dev/null +++ b/backend/migrations/021_rate_periods.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS rate_periods ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE, + name TEXT NOT NULL, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + notes TEXT, + category_prices JSONB NOT NULL DEFAULT '{}', + channel_markup JSONB NOT NULL DEFAULT '{}', + extra_person_price INTEGER NOT NULL DEFAULT 0, + min_nights INTEGER NOT NULL DEFAULT 1, + days_of_week INTEGER[], + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/backend/src/app.ts b/backend/src/app.ts index b4ca9c7..b7f3550 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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 diff --git a/backend/src/routes/rate-periods.ts b/backend/src/routes/rate-periods.ts new file mode 100644 index 0000000..fe98fd3 --- /dev/null +++ b/backend/src/routes/rate-periods.ts @@ -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 => { + 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( + '/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; channel_markup?: Record + 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; channel_markup?: Record + 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( + '/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 diff --git a/src/components/calendar/BookingCalendar.tsx b/src/components/calendar/BookingCalendar.tsx index d0c6531..8301110 100644 --- a/src/components/calendar/BookingCalendar.tsx +++ b/src/components/calendar/BookingCalendar.tsx @@ -1,9 +1,10 @@ import { useState, useRef, useCallback, useEffect } from 'react' +import { createPortal } from 'react-dom' import type { ReactNode } from 'react' import { addDays, format, startOfDay, differenceInDays, parseISO, isToday } from 'date-fns' import { ru } from 'date-fns/locale' import { ChevronLeft, ChevronRight, Plus, CalendarDays, ChevronDown, AlignJustify, Clock } from 'lucide-react' -import { cn, BOOKING_STATUS_COLORS, BOOKING_STATUS_LABELS, SOURCE_LABELS } from '../../lib/utils' +import { cn, BOOKING_STATUS_COLORS, BOOKING_STATUS_LABELS, SOURCE_LABELS, formatCurrency } from '../../lib/utils' import type { Room, Booking, DraftBooking, RoomStatus, HousekeepingStatus } from '../../types' import type { RentalObject, RentalBooking } from '../../data/rentalData' import { BookingModal } from '../bookings/BookingModal' @@ -83,6 +84,22 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook onRoomUpdate?.(roomId, { housekeepingStatus: status }) } + // Cell hover price tooltip + const [hoverTooltip, setHoverTooltip] = useState<{ room: Room; date: Date; x: number; y: number } | null>(null) + const hoverTimerRef = useRef | null>(null) + + const handleCellHoverEnter = useCallback((room: Room, date: Date, e: React.MouseEvent) => { + if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current) + const x = e.clientX + const y = e.clientY + hoverTimerRef.current = setTimeout(() => setHoverTooltip({ room, date, x, y }), 600) + }, []) + + const handleCellHoverLeave = useCallback(() => { + if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current) + setHoverTooltip(null) + }, []) + // Date picker state const [showNavPicker, setShowNavPicker] = useState(false) const [pickerDateInput, setPickerDateInput] = useState(format(new Date(), 'yyyy-MM-dd')) @@ -544,8 +561,9 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook isTod && !room.status.match(/maintenance|blocked/) && 'bg-brand-50/50 dark:bg-brand-900/10', )} style={{ width: CELL_WIDTH }} - onMouseDown={(e) => handleCellMouseDown(room.id, i, e)} - onMouseEnter={() => handleCellMouseEnter(i)} + onMouseDown={(e) => { handleCellHoverLeave(); handleCellMouseDown(room.id, i, e) }} + onMouseEnter={(e) => { handleCellMouseEnter(i); handleCellHoverEnter(room, date, e) }} + onMouseLeave={handleCellHoverLeave} /> ) })} @@ -936,6 +954,28 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook onHkStatusChange={handleCtxHkChange} /> )} + + {/* Cell hover price tooltip */} + {hoverTooltip && createPortal( +
+

+ {format(hoverTooltip.date, 'd MMMM yyyy', { locale: ru })} +

+

Номер {hoverTooltip.room.number}{hoverTooltip.room.name ? ` · ${hoverTooltip.room.name}` : ''}

+

+ {formatCurrency(hoverTooltip.room.baseRate)} / ночь +

+ {hoverTooltip.room.allowHourly && hoverTooltip.room.hourlyRate ? ( +

+ {formatCurrency(hoverTooltip.room.hourlyRate)} / час +

+ ) : null} +
, + document.body, + )} ) } diff --git a/src/lib/api.ts b/src/lib/api.ts index 4d0b414..8908d4d 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -395,6 +395,21 @@ export const api = { req('DELETE', `/api/hotels/${slug}/tariffs/${id}`), }, + // ── Rate Periods ───────────────────────────────────────────────────────── + ratePeriods: { + list: (slug: string) => + req('GET', `/api/hotels/${slug}/rate-periods`), + + create: (slug: string, data: RatePeriodPayload) => + req('POST', `/api/hotels/${slug}/rate-periods`, data), + + update: (slug: string, id: string, data: Partial) => + req('PATCH', `/api/hotels/${slug}/rate-periods/${id}`, data), + + delete: (slug: string, id: string) => + req('DELETE', `/api/hotels/${slug}/rate-periods/${id}`), + }, + // ── NetUP IPTV ──────────────────────────────────────────────────────────── netup: { getSettings: (slug: string) => @@ -718,6 +733,31 @@ export interface TariffPayload { is_active?: boolean } +export interface RatePeriodApi { + id: string + name: string + startDate: string + endDate: string + notes: string | null + categoryPrices: Record + channelMarkup: Record + extraPersonPrice: number + minNights: number + daysOfWeek: number[] | null +} + +export interface RatePeriodPayload { + name: string + start_date: string + end_date: string + notes?: string + category_prices?: Record + channel_markup?: Record + extra_person_price?: number + min_nights?: number + days_of_week?: number[] | null +} + function toHotelPayload(h: HotelPayload): Record { const out: Record = {} if (h.name !== undefined) out.name = h.name diff --git a/src/pages/AvailabilityPage.tsx b/src/pages/AvailabilityPage.tsx index d4b794c..20795cf 100644 --- a/src/pages/AvailabilityPage.tsx +++ b/src/pages/AvailabilityPage.tsx @@ -1,16 +1,45 @@ import { useState, useRef, useCallback, useMemo, useEffect } from 'react' -import { addDays, format, parseISO, isWithinInterval, startOfDay, getDay, isSameDay } from 'date-fns' +import { addDays, format, parseISO, getDay, startOfDay } from 'date-fns' import { ru } from 'date-fns/locale' import { ChevronLeft, ChevronRight, X, Check, CalendarDays, - Plus, Pencil, Trash2, AlertCircle, RefreshCw, MousePointer2, Rows3, ChevronDown, + Plus, Pencil, Trash2, AlertCircle, Loader2, MousePointer2, Rows3, ChevronDown, RefreshCw, } from 'lucide-react' import { cn } from '../lib/utils' -import { - ROOM_CATEGORIES, RATE_CHANNELS, DEMO_PERIODS, - buildInitialPriceGrid, DEFAULT_CHANNEL_MARKUP, -} from '../data/ratesData' +import { RATE_CHANNELS, DEFAULT_CHANNEL_MARKUP } from '../data/ratesData' import type { PriceCell, RatePeriod } from '../data/ratesData' +import { useAuth } from '../contexts/AuthContext' +import { api } from '../lib/api' + +interface AvailabilityCat { + id: string + name: string + color: string // hex color + basePrice: number +} + +function buildPriceGrid( + categories: AvailabilityCat[], + days = 60, +): Record> { + const result: Record> = {} + const today = new Date() + for (const cat of categories) { + result[cat.id] = {} + 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)) + } + result[cat.id][dateStr] = { price: basePrice, extraPerson: 0, minNights: 1, channelPrices, closed: false } + } + } + return result +} // ─── Constants ──────────────────────────────────────────────────────────────── @@ -55,7 +84,7 @@ interface Selection { start: string; end: string } function PriceGrid({ dates, prices, selection, dragging, onCellDown, onCellEnter, activeChannel, - cellEditMode, selectedCat, + cellEditMode, selectedCat, categories, }: { dates: string[] prices: Record> @@ -66,6 +95,7 @@ function PriceGrid({ activeChannel: string cellEditMode: boolean selectedCat: string | null + categories: AvailabilityCat[] }) { const today = format(new Date(), DATE_FMT) @@ -123,7 +153,7 @@ function PriceGrid({ {/* Category rows */} - {ROOM_CATEGORIES.map(cat => { + {categories.map(cat => { const isActiveCat = cellEditMode && selectedCat === cat.id return (
-
+

{cat.name}

@@ -213,6 +243,7 @@ function EditPanel({ onApply, onClose, onlyCategoryId, + categories, }: { selection: Selection prices: Record> @@ -228,12 +259,13 @@ function EditPanel({ }) => void onClose: () => void onlyCategoryId?: string + categories: AvailabilityCat[] }) { const [s, e] = normRange(selection.start, selection.end) const activeCat = onlyCategoryId - ? ROOM_CATEGORIES.find(c => c.id === onlyCategoryId) ?? ROOM_CATEGORIES[0] - : ROOM_CATEGORIES[0] + ? categories.find(c => c.id === onlyCategoryId) ?? categories[0] + : categories[0] // Initial values from first cell of selection const firstCell = prices[activeCat.id]?.[s] @@ -250,8 +282,8 @@ function EditPanel({ const [catPrices, setCatPrices] = useState>(() => { const r: Record = {} const catsToInit = onlyCategoryId - ? ROOM_CATEGORIES.filter(c => c.id === onlyCategoryId) - : ROOM_CATEGORIES + ? categories.filter(c => c.id === onlyCategoryId) + : categories for (const cat of catsToInit) { r[cat.id] = prices[cat.id]?.[s]?.price ?? cat.basePrice } @@ -278,7 +310,7 @@ function EditPanel({ ) : 'Редактировать цены'}

- {nightCount} {nightCount === 1 ? 'день' : 'дней'} + {nightCount} {nightCount === 1 ? 'день' : nightCount < 5 ? 'дня' : 'дней'} {onlyCategoryId && ' • только эта категория'}

@@ -336,9 +368,9 @@ function EditPanel({ {onlyCategoryId ? 'Цена' : 'Цена по категориям'}
- {(onlyCategoryId ? [activeCat] : ROOM_CATEGORIES).map(cat => ( + {(onlyCategoryId ? [activeCat] : categories).map(cat => (
-
+
{cat.name} @@ -460,10 +492,12 @@ function PeriodModal({ period, onSave, onClose, + categories, }: { period?: RatePeriod onSave: (p: RatePeriod) => void onClose: () => void + categories: AvailabilityCat[] }) { const [name, setName] = useState(period?.name ?? '') const [startDate, setStartDate] = useState(period?.startDate ?? format(new Date(), DATE_FMT)) @@ -472,7 +506,7 @@ function PeriodModal({ const [minNights, setMinNights] = useState(period?.minNights ?? 1) const [extraPerson, setExtraPerson] = useState(period?.extraPersonPrice ?? 0) const [catPrices, setCatPrices] = useState>( - period?.categoryPrices ?? Object.fromEntries(ROOM_CATEGORIES.map(c => [c.id, c.basePrice])), + period?.categoryPrices ?? Object.fromEntries(categories.map(c => [c.id, c.basePrice])), ) const [markup, setMarkup] = useState>( period?.channelMarkup ?? { ...DEFAULT_CHANNEL_MARKUP }, @@ -538,9 +572,9 @@ function PeriodModal({ Цены по категориям
- {ROOM_CATEGORIES.map(cat => ( + {categories.map(cat => (
-
+
{cat.name} @@ -627,12 +661,17 @@ function PeriodModal({ // ─── Main Page ──────────────────────────────────────────────────────────────── export function AvailabilityPage() { + const { user } = useAuth() + const slug = user?.hotelSlug ?? '' + const today = useMemo(() => new Date(), []) const [offset, setOffset] = useState(0) const [tab, setTab] = useState<'grid' | 'periods'>('grid') const [activeChannel, setActiveChannel] = useState('direct') - const [prices, setPrices] = useState(() => buildInitialPriceGrid()) - const [periods, setPeriods] = useState(DEMO_PERIODS) + const [prices, setPrices] = useState>>({}) + const [periods, setPeriods] = useState([]) + const [roomCategories, setRoomCategories] = useState([]) + const [loading, setLoading] = useState(true) const [selection, setSelection] = useState(null) const [dragging, setDragging] = useState(false) const [dragStart, setDragStart] = useState(null) @@ -645,6 +684,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 + useEffect(() => { + if (!slug) return + setLoading(true) + Promise.all([ + api.categories.list(slug), + api.rooms.list(slug), + api.ratePeriods.list(slug), + ]).then(([cats, rooms, apiPeriods]) => { + // 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, + } + }) + + // If no categories defined, fall back to room types as groups + const finalCats = availCats.length > 0 ? availCats : (() => { + const typeMap = new Map() + for (const r of rooms) { + 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, + })) + })() + + setRoomCategories(finalCats) + setPrices(buildPriceGrid(finalCats)) + + // Map API rate periods to local RatePeriod format + 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, + })) + setPeriods(mappedPeriods) + }).catch(console.error).finally(() => setLoading(false)) + }, [slug]) + useEffect(() => { if (!showNavPicker) return const handler = (e: MouseEvent) => { @@ -697,8 +795,8 @@ export function AvailabilityPage() { }) => { const days = datesInRange(startDate, endDate) const catsToUpdate = onlyCategoryId - ? ROOM_CATEGORIES.filter(c => c.id === onlyCategoryId) - : ROOM_CATEGORIES + ? roomCategories.filter(c => c.id === onlyCategoryId) + : roomCategories setPrices(prev => { const next = { ...prev } for (const cat of catsToUpdate) { @@ -735,15 +833,34 @@ export function AvailabilityPage() { }) } - const savePeriod = (p: RatePeriod) => { - setPeriods(prev => { - const idx = prev.findIndex(x => x.id === p.id) - return idx >= 0 ? prev.map(x => x.id === p.id ? p : x) : [...prev, p] - }) + const savePeriod = async (p: RatePeriod) => { + if (!slug) return + const payload = { + name: p.name, + start_date: p.startDate, + end_date: p.endDate, + notes: p.notes, + category_prices: p.categoryPrices, + channel_markup: p.channelMarkup, + extra_person_price: p.extraPersonPrice, + min_nights: p.minNights, + days_of_week: p.daysOfWeek ?? null, + } + // Check if this is a new (temp) ID (starts with 'p-') or a real UUID + const isNew = !p.id || p.id.startsWith('p-') || p.id.length < 32 + if (isNew) { + const created = await api.ratePeriods.create(slug, payload) + setPeriods(prev => [...prev, { ...p, id: created.id }]) + } else { + await api.ratePeriods.update(slug, p.id, payload) + setPeriods(prev => prev.map(x => x.id === p.id ? p : x)) + } setPeriodModal(null) } - const deletePeriod = (id: string) => { + const deletePeriod = async (id: string) => { + if (!slug) return + await api.ratePeriods.delete(slug, id).catch(console.error) setPeriods(prev => prev.filter(p => p.id !== id)) } @@ -949,17 +1066,28 @@ export function AvailabilityPage() {
- + {loading ? ( +
+ +
+ ) : roomCategories.length === 0 ? ( +
+ Нет категорий номеров. Создайте их в разделе «Категории номеров». +
+ ) : ( + + )}
{/* Edit panel */} @@ -970,6 +1098,7 @@ export function AvailabilityPage() { onApply={applyPrices} onClose={() => { setShowEditPanel(false); setSelection(null); setSelectedCat(null) }} onlyCategoryId={cellEditMode && selectedCat ? selectedCat : undefined} + categories={roomCategories} /> )} @@ -1007,10 +1136,10 @@ export function AvailabilityPage() { )} {/* Category prices */}
- {ROOM_CATEGORIES.map(cat => ( + {roomCategories.map(cat => ( p.categoryPrices[cat.id] ? ( - + {cat.name}: {fmtPrice(p.categoryPrices[cat.id])} ) : null @@ -1053,6 +1182,7 @@ export function AvailabilityPage() { period={periodModal === 'new' ? undefined : periodModal} onSave={savePeriod} onClose={() => setPeriodModal(null)} + categories={roomCategories} /> )}