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 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 15:05:12 +03:00
parent 63da3d21cc
commit c3a30be74e
6 changed files with 411 additions and 45 deletions

View File

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

View File

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

View File

@@ -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<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-periods
fastify.get<SlugParam>(
'/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<SlugParam & { Body: {
name: string; start_date: string; end_date: string; notes?: string
category_prices?: Record<string, number>; channel_markup?: Record<string, number>
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<SlugIdParam & { Body: {
name?: string; start_date?: string; end_date?: string; notes?: string
category_prices?: Record<string, number>; channel_markup?: Record<string, number>
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<SlugIdParam>(
'/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

View File

@@ -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<ReturnType<typeof setTimeout> | 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(
<div
className="fixed z-[9990] pointer-events-none px-3 py-2 rounded-xl bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 shadow-xl text-xs"
style={{ left: hoverTooltip.x + 16, top: hoverTooltip.y + 16 }}
>
<p className="font-semibold text-slate-700 dark:text-slate-200 mb-0.5">
{format(hoverTooltip.date, 'd MMMM yyyy', { locale: ru })}
</p>
<p className="text-slate-400 dark:text-slate-500 mb-1">Номер {hoverTooltip.room.number}{hoverTooltip.room.name ? ` · ${hoverTooltip.room.name}` : ''}</p>
<p className="font-bold text-slate-900 dark:text-slate-100">
{formatCurrency(hoverTooltip.room.baseRate)}<span className="font-normal text-slate-400"> / ночь</span>
</p>
{hoverTooltip.room.allowHourly && hoverTooltip.room.hourlyRate ? (
<p className="text-slate-500 dark:text-slate-400 mt-0.5">
{formatCurrency(hoverTooltip.room.hourlyRate)} / час
</p>
) : null}
</div>,
document.body,
)}
</div>
)
}

View File

@@ -395,6 +395,21 @@ export const api = {
req<void>('DELETE', `/api/hotels/${slug}/tariffs/${id}`),
},
// ── Rate Periods ─────────────────────────────────────────────────────────
ratePeriods: {
list: (slug: string) =>
req<RatePeriodApi[]>('GET', `/api/hotels/${slug}/rate-periods`),
create: (slug: string, data: RatePeriodPayload) =>
req<RatePeriodApi>('POST', `/api/hotels/${slug}/rate-periods`, data),
update: (slug: string, id: string, data: Partial<RatePeriodPayload>) =>
req<RatePeriodApi>('PATCH', `/api/hotels/${slug}/rate-periods/${id}`, data),
delete: (slug: string, id: string) =>
req<void>('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<string, number>
channelMarkup: Record<string, number>
extraPersonPrice: number
minNights: number
daysOfWeek: number[] | null
}
export interface RatePeriodPayload {
name: string
start_date: string
end_date: string
notes?: string
category_prices?: Record<string, number>
channel_markup?: Record<string, number>
extra_person_price?: number
min_nights?: number
days_of_week?: number[] | null
}
function toHotelPayload(h: HotelPayload): Record<string, unknown> {
const out: Record<string, unknown> = {}
if (h.name !== undefined) out.name = h.name

View File

@@ -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<string, Record<string, PriceCell>> {
const result: Record<string, Record<string, PriceCell>> = {}
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<string, number> = {}
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<string, Record<string, PriceCell>>
@@ -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({
</div>
{/* Category rows */}
{ROOM_CATEGORIES.map(cat => {
{categories.map(cat => {
const isActiveCat = cellEditMode && selectedCat === cat.id
return (
<div key={cat.id} className={cn(
@@ -138,7 +168,7 @@ function PriceGrid({
isActiveCat && 'bg-brand-50 dark:bg-brand-900/20 border-r-brand-300 dark:border-r-brand-700',
)}
>
<div className={cn('w-2.5 h-2.5 rounded-full shrink-0', cat.color)} />
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: cat.color }} />
<div className="min-w-0">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{cat.name}</p>
<p className="text-xs text-slate-400">
@@ -213,6 +243,7 @@ function EditPanel({
onApply,
onClose,
onlyCategoryId,
categories,
}: {
selection: Selection
prices: Record<string, Record<string, PriceCell>>
@@ -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<Record<string, number>>(() => {
const r: Record<string, number> = {}
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({
) : 'Редактировать цены'}
</p>
<p className="text-xs text-slate-400 mt-0.5">
{nightCount} {nightCount === 1 ? 'день' : 'дней'}
{nightCount} {nightCount === 1 ? 'день' : nightCount < 5 ? 'дня' : 'дней'}
{onlyCategoryId && ' • только эта категория'}
</p>
</div>
@@ -336,9 +368,9 @@ function EditPanel({
{onlyCategoryId ? 'Цена' : 'Цена по категориям'}
</label>
<div className="space-y-2">
{(onlyCategoryId ? [activeCat] : ROOM_CATEGORIES).map(cat => (
{(onlyCategoryId ? [activeCat] : categories).map(cat => (
<div key={cat.id} className="flex items-center gap-2">
<div className={cn('w-2 h-2 rounded-full shrink-0', cat.color)} />
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: cat.color }} />
<span className="text-xs text-slate-600 dark:text-slate-400 w-28 shrink-0 truncate">
{cat.name}
</span>
@@ -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<Record<string, number>>(
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<Record<string, number>>(
period?.channelMarkup ?? { ...DEFAULT_CHANNEL_MARKUP },
@@ -538,9 +572,9 @@ function PeriodModal({
Цены по категориям
</label>
<div className="grid grid-cols-2 gap-2">
{ROOM_CATEGORIES.map(cat => (
{categories.map(cat => (
<div key={cat.id} className="flex items-center gap-2">
<div className={cn('w-2 h-2 rounded-full shrink-0', cat.color)} />
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: cat.color }} />
<span className="text-xs text-slate-600 dark:text-slate-400 flex-1 truncate min-w-0">
{cat.name}
</span>
@@ -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<RatePeriod[]>(DEMO_PERIODS)
const [prices, setPrices] = useState<Record<string, Record<string, PriceCell>>>({})
const [periods, setPeriods] = useState<RatePeriod[]>([])
const [roomCategories, setRoomCategories] = useState<AvailabilityCat[]>([])
const [loading, setLoading] = useState(true)
const [selection, setSelection] = useState<Selection | null>(null)
const [dragging, setDragging] = useState(false)
const [dragStart, setDragStart] = useState<string | null>(null)
@@ -645,6 +684,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
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<string, number>()
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() {
</span>
</div>
<PriceGrid
dates={dates}
prices={prices}
selection={selection}
dragging={dragging}
onCellDown={handleCellDown}
onCellEnter={handleCellEnter}
activeChannel={activeChannel}
cellEditMode={cellEditMode}
selectedCat={selectedCat}
/>
{loading ? (
<div className="flex items-center justify-center h-48">
<Loader2 size={24} className="animate-spin text-brand-600" />
</div>
) : roomCategories.length === 0 ? (
<div className="flex items-center justify-center h-48 text-slate-400 dark:text-slate-500 text-sm">
Нет категорий номеров. Создайте их в разделе «Категории номеров».
</div>
) : (
<PriceGrid
dates={dates}
prices={prices}
selection={selection}
dragging={dragging}
onCellDown={handleCellDown}
onCellEnter={handleCellEnter}
activeChannel={activeChannel}
cellEditMode={cellEditMode}
selectedCat={selectedCat}
categories={roomCategories}
/>
)}
</div>
{/* 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 */}
<div className="flex gap-3 flex-wrap mt-2">
{ROOM_CATEGORIES.map(cat => (
{roomCategories.map(cat => (
p.categoryPrices[cat.id] ? (
<span key={cat.id} className="text-xs text-slate-600 dark:text-slate-400 flex items-center gap-1">
<span className={cn('w-1.5 h-1.5 rounded-full', cat.color)} />
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: cat.color }} />
{cat.name}: <strong>{fmtPrice(p.categoryPrices[cat.id])}</strong>
</span>
) : null
@@ -1053,6 +1182,7 @@ export function AvailabilityPage() {
period={periodModal === 'new' ? undefined : periodModal}
onSave={savePeriod}
onClose={() => setPeriodModal(null)}
categories={roomCategories}
/>
)}
</div>