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:
15
backend/migrations/021_rate_periods.sql
Normal file
15
backend/migrations/021_rate_periods.sql
Normal 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()
|
||||
);
|
||||
@@ -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
|
||||
|
||||
139
backend/src/routes/rate-periods.ts
Normal file
139
backend/src/routes/rate-periods.ts
Normal 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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user