feat: hourly pricing + category base prices in booking modal + availability grid

- Add hourly_price column to rate_overrides, base_price/allow_hourly/hourly_base_price to room_categories (migration 033)
- Update categories and rate-overrides backend routes to handle new fields
- AvailabilityPage: hourly/nightly mode toggle, period prices auto-applied to grid with 'П' indicator, confirmation dialog when overriding period prices
- BookingModal: nightly/hourly toggle at top, pricing hierarchy (override → category base → room rate)
- RoomCategoriesPage: base_price/allow_hourly/hourly_base_price fields in category form; inline rooms panel
- CalendarPage + BookingCalendar: pass hourlyPriceOverrides and categoryBasePrices down to BookingModal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 00:05:14 +03:00
parent b3d143f1dd
commit 588ecb0f2a
11 changed files with 232 additions and 38 deletions

View File

@@ -0,0 +1,9 @@
-- Category pricing fields
ALTER TABLE room_categories
ADD COLUMN IF NOT EXISTS base_price INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS allow_hourly BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS hourly_base_price INTEGER NOT NULL DEFAULT 0;
-- Hourly price override per date in availability grid
ALTER TABLE rate_overrides
ADD COLUMN IF NOT EXISTS hourly_price INTEGER;

View File

@@ -35,6 +35,7 @@ const categories: FastifyPluginAsync = async (fastify) => {
fastify.post<SlugParam & { Body: {
name: string; description?: string; color?: string
amenities?: string[]; photos?: string[]; sort_order?: number
base_price?: number; allow_hourly?: boolean; hourly_base_price?: number
} }>(
'/api/hotels/:slug/categories',
{ onRequest: [fastify.authenticate] },
@@ -44,11 +45,17 @@ const categories: FastifyPluginAsync = async (fastify) => {
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, description = '', color = '#4F46E5', amenities = [], photos = [], sort_order = 0 } = request.body
const {
name, description = '', color = '#4F46E5', amenities = [], photos = [], sort_order = 0,
base_price = 0, allow_hourly = false, hourly_base_price = 0,
} = request.body
const { rows } = await db.query(
`INSERT INTO room_categories (hotel_id, name, description, color, amenities, photos, sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
[hotelId, name, description, color, amenities, photos, sort_order],
`INSERT INTO room_categories
(hotel_id, name, description, color, amenities, photos, sort_order,
base_price, allow_hourly, hourly_base_price)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`,
[hotelId, name, description, color, amenities, photos, sort_order,
base_price, allow_hourly, hourly_base_price],
)
return reply.code(201).send(rows[0])
},
@@ -58,6 +65,7 @@ const categories: FastifyPluginAsync = async (fastify) => {
fastify.patch<SlugIdParam & { Body: {
name?: string; description?: string; color?: string
amenities?: string[]; photos?: string[]; sort_order?: number
base_price?: number; allow_hourly?: boolean; hourly_base_price?: number
} }>(
'/api/hotels/:slug/categories/:id',
{ onRequest: [fastify.authenticate] },
@@ -71,12 +79,15 @@ const categories: FastifyPluginAsync = async (fastify) => {
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.description !== undefined) { sets.push(`description = $${i++}`); vals.push(b.description) }
if (b.color !== undefined) { sets.push(`color = $${i++}`); vals.push(b.color) }
if (b.amenities !== undefined) { sets.push(`amenities = $${i++}`); vals.push(b.amenities) }
if (b.photos !== undefined) { sets.push(`photos = $${i++}`); vals.push(b.photos) }
if (b.sort_order !== undefined) { sets.push(`sort_order = $${i++}`); vals.push(b.sort_order) }
if (b.name !== undefined) { sets.push(`name = $${i++}`); vals.push(b.name) }
if (b.description !== undefined) { sets.push(`description = $${i++}`); vals.push(b.description) }
if (b.color !== undefined) { sets.push(`color = $${i++}`); vals.push(b.color) }
if (b.amenities !== undefined) { sets.push(`amenities = $${i++}`); vals.push(b.amenities) }
if (b.photos !== undefined) { sets.push(`photos = $${i++}`); vals.push(b.photos) }
if (b.sort_order !== undefined) { sets.push(`sort_order = $${i++}`); vals.push(b.sort_order) }
if (b.base_price !== undefined) { sets.push(`base_price = $${i++}`); vals.push(b.base_price) }
if (b.allow_hourly !== undefined) { sets.push(`allow_hourly = $${i++}`); vals.push(b.allow_hourly) }
if (b.hourly_base_price !== undefined) { sets.push(`hourly_base_price = $${i++}`); vals.push(b.hourly_base_price) }
const { rows } = await db.query(
`UPDATE room_categories SET ${sets.join(', ')} WHERE hotel_id=$1 AND id=$2 RETURNING *`,
vals,

View File

@@ -24,7 +24,7 @@ const rateOverrides: FastifyPluginAsync = async (fastify) => {
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
price, extra_person, min_nights, channel_prices, closed, hourly_price
FROM rate_overrides
WHERE hotel_id = $1
ORDER BY date, category_id`,
@@ -40,6 +40,7 @@ const rateOverrides: FastifyPluginAsync = async (fastify) => {
category_id: string; date: string; price: number
extra_person?: number; min_nights?: number
channel_prices?: Record<string, number>; closed?: boolean
hourly_price?: number | null
}>
} }>(
'/api/hotels/:slug/rate-overrides/bulk',
@@ -54,13 +55,11 @@ const rateOverrides: FastifyPluginAsync = async (fastify) => {
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})`)
rowPlaceholders.push(`($1, $${idx}, $${idx+1}, $${idx+2}, $${idx+3}, $${idx+4}, $${idx+5}, $${idx+6}, $${idx+7})`)
vals.push(
o.category_id,
o.date,
@@ -69,13 +68,14 @@ const rateOverrides: FastifyPluginAsync = async (fastify) => {
o.min_nights ?? 1,
JSON.stringify(o.channel_prices ?? {}),
o.closed ?? false,
o.hourly_price ?? null,
)
idx += 7
idx += 8
}
await db.query(
`INSERT INTO rate_overrides
(hotel_id, category_id, date, price, extra_person, min_nights, channel_prices, closed)
(hotel_id, category_id, date, price, extra_person, min_nights, channel_prices, closed, hourly_price)
VALUES ${rowPlaceholders.join(', ')}
ON CONFLICT (hotel_id, category_id, date) DO UPDATE SET
price = EXCLUDED.price,
@@ -83,6 +83,7 @@ const rateOverrides: FastifyPluginAsync = async (fastify) => {
min_nights = EXCLUDED.min_nights,
channel_prices = EXCLUDED.channel_prices,
closed = EXCLUDED.closed,
hourly_price = COALESCE(EXCLUDED.hourly_price, rate_overrides.hourly_price),
updated_at = NOW()`,
vals,
)

View File

@@ -33,6 +33,10 @@ interface BookingModalProps {
draft: DraftBooking
/** categoryId → date (YYYY-MM-DD) → price; used to price bookings by availability rates */
priceOverrides?: Record<string, Record<string, number>>
/** categoryId → date (YYYY-MM-DD) → hourlyPrice; per-date hourly rate overrides */
hourlyPriceOverrides?: Record<string, Record<string, number>>
/** categoryId → { nightlyBase, hourlyBase } from category settings */
categoryBasePrices?: Record<string, { nightlyBase: number; hourlyBase: number }>
rooms: Room[]
bookings?: Booking[]
onClose: () => void
@@ -96,7 +100,7 @@ function getRoomCategories(rooms: Room[], bookings: Booking[], checkIn: string,
})
}
export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSave, existing, rentalObjects, rentalBookings = [], onRentalSave, slug, priceOverrides }: BookingModalProps) {
export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSave, existing, rentalObjects, rentalBookings = [], onRentalSave, slug, priceOverrides, hourlyPriceOverrides, categoryBasePrices }: BookingModalProps) {
const { statuses } = useModules()
const { user } = useAuth()
const tvEnabled = statuses['tv-welcome'] === 'active'
@@ -331,14 +335,15 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
: 0
const hourlyHours = Math.max(0, endHour - startHour)
// Calculate room cost: sum per-night prices from availability overrides, fall back to baseRate
// Calculate room cost: priority = availability override → category base price → room base rate
const nightCount = Math.floor(nights)
const roomNightlyTotal = (() => {
if (!room || !form.checkIn || nightCount <= 0) return 0
const catId = room.categoryId
const catOverrides = catId ? priceOverrides?.[catId] : undefined
const catBase = catId ? categoryBasePrices?.[catId]?.nightlyBase : undefined
// room.baseRate is NUMERIC in DB — pg returns it as a string; always coerce
const fallback = Number(room.baseRate) || 0
const fallback = (catBase && catBase > 0) ? catBase : (Number(room.baseRate) || 0)
if (!catOverrides) return fallback * nightCount
let sum = 0
for (let i = 0; i < nightCount; i++) {
@@ -348,8 +353,17 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
}
return sum
})()
const hourlyRateForDate = (() => {
if (!room?.allowHourly) return 0
const catId = room.categoryId
const hourlyOverride = catId ? hourlyPriceOverrides?.[catId]?.[hourlyDate] : undefined
if (hourlyOverride && hourlyOverride > 0) return hourlyOverride
const catHourlyBase = catId ? categoryBasePrices?.[catId]?.hourlyBase : undefined
if (catHourlyBase && catHourlyBase > 0) return catHourlyBase
return room.hourlyRate ?? 0
})()
const roomBaseTotal = isHourly && room?.allowHourly
? (room.hourlyRate ?? 0) * hourlyHours
? hourlyRateForDate * hourlyHours
: roomNightlyTotal
const discountAmount = selectedDiscount
? selectedDiscount.valueType === 'percent'

View File

@@ -44,6 +44,10 @@ interface BookingCalendarProps {
wsConnected?: boolean
/** categoryId → date (YYYY-MM-DD) → price */
priceOverrides?: Record<string, Record<string, number>>
/** categoryId → date (YYYY-MM-DD) → hourly price */
hourlyPriceOverrides?: Record<string, Record<string, number>>
/** categoryId → { nightlyBase, hourlyBase } */
categoryBasePrices?: Record<string, { nightlyBase: number; hourlyBase: number }>
}
const CATEGORY_ORDER: Record<string, number> = {
@@ -67,7 +71,7 @@ function getRoomTypeColor(type: string): string {
return map[type] ?? 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300'
}
export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBookingUpdate, onBookingBulkUpdate, onRoomUpdate, onMaintenanceTaskCreate, cleaningAssignees = {}, activeMaintenance = new Set(), fadingBookingIds, rentalObjects, rentalBookings, onRentalBookingCreate, locks = new Map(), onDraftStart, onDraftCancel, wsConnected, priceOverrides }: BookingCalendarProps) {
export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBookingUpdate, onBookingBulkUpdate, onRoomUpdate, onMaintenanceTaskCreate, cleaningAssignees = {}, activeMaintenance = new Set(), fadingBookingIds, rentalObjects, rentalBookings, onRentalBookingCreate, locks = new Map(), onDraftStart, onDraftCancel, wsConnected, priceOverrides, hourlyPriceOverrides, categoryBasePrices }: BookingCalendarProps) {
const [startDate, setStartDate] = useState(() => startOfDay(new Date()))
const [visibleDays, setVisibleDays] = useState(DAYS_VISIBLE)
@@ -913,6 +917,8 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
draft={bookingModalDraft}
rooms={rooms}
priceOverrides={priceOverrides}
hourlyPriceOverrides={hourlyPriceOverrides}
categoryBasePrices={categoryBasePrices}
bookings={bookings}
slug={slug}
rentalObjects={rentalObjects}

View File

@@ -23,6 +23,7 @@ export interface PriceCell {
closed: boolean // закрыто для продажи
fromPeriod?: boolean // цена из ценового периода
periodName?: string // название периода
hourlyPrice?: number // почасовая цена на эту дату
}
export interface RatePeriod {

View File

@@ -745,6 +745,9 @@ export interface CategoryApi {
amenities: string[]
photos: string[]
sort_order: number
base_price: number
allow_hourly: boolean
hourly_base_price: number
created_at: string
}
@@ -755,6 +758,9 @@ export interface CategoryPayload {
amenities?: string[]
photos?: string[]
sort_order?: number
base_price?: number
allow_hourly?: boolean
hourly_base_price?: number
}
export interface TariffApi {
@@ -794,6 +800,7 @@ export interface RateOverrideApi {
minNights: number
channelPrices: Record<string, number>
closed: boolean
hourlyPrice?: number | null
}
export interface RateOverridePayload {
@@ -804,6 +811,7 @@ export interface RateOverridePayload {
min_nights?: number
channel_prices?: Record<string, number>
closed?: boolean
hourly_price?: number | null
}
export interface RatePeriodApi {

View File

@@ -16,6 +16,7 @@ interface AvailabilityCat {
name: string
color: string // hex color
basePrice: number
hourlyBasePrice: number
}
function buildPriceGrid(
@@ -82,7 +83,7 @@ interface Selection { start: string; end: string }
function PriceGrid({
dates, prices, selection, dragging,
onCellDown, onCellEnter, activeChannel,
cellEditMode, selectedCat, categories,
cellEditMode, selectedCat, categories, hourlyMode,
}: {
dates: string[]
prices: Record<string, Record<string, PriceCell>>
@@ -94,6 +95,7 @@ function PriceGrid({
cellEditMode: boolean
selectedCat: string | null
categories: AvailabilityCat[]
hourlyMode: boolean
}) {
const today = format(new Date(), DATE_FMT)
@@ -183,9 +185,11 @@ function PriceGrid({
const isWe = dow === 0 || dow === 6
const isTd = d === today
const isSel = isSelected(d, cat.id)
const price = activeChannel === 'direct'
? cell?.price
: cell?.channelPrices[activeChannel] ?? cell?.price
const price = hourlyMode
? (cell?.hourlyPrice ?? cat.hourlyBasePrice ?? 0)
: (activeChannel === 'direct'
? cell?.price
: cell?.channelPrices[activeChannel] ?? cell?.price)
return (
<div
@@ -212,10 +216,14 @@ function PriceGrid({
<span className={cn(
'text-sm font-semibold leading-tight',
isSel ? 'text-brand-700 dark:text-brand-300' : 'text-slate-800 dark:text-slate-200',
hourlyMode && cell?.hourlyPrice && 'text-violet-600 dark:text-violet-400',
)}>
{price ? price.toLocaleString('ru-RU') : '—'}
</span>
{cell?.fromPeriod && !isSel && (
{hourlyMode && (
<span className="text-[9px] text-slate-400 leading-none">/ч</span>
)}
{!hourlyMode && cell?.fromPeriod && !isSel && (
<span className="text-[9px] font-semibold text-violet-500 dark:text-violet-400 leading-none bg-violet-50 dark:bg-violet-900/20 px-1 rounded">
П
</span>
@@ -247,6 +255,7 @@ function EditPanel({
onClose,
onlyCategoryId,
categories,
hourlyMode,
}: {
selection: Selection
prices: Record<string, Record<string, PriceCell>>
@@ -259,10 +268,12 @@ function EditPanel({
channelMarkup: Record<string, number>
closed: boolean
onlyCategoryId?: string
hourlyPrice?: number | null
}) => void
onClose: () => void
onlyCategoryId?: string
categories: AvailabilityCat[]
hourlyMode: boolean
}) {
const [s, e] = normRange(selection.start, selection.end)
@@ -278,6 +289,7 @@ function EditPanel({
const [extraPerson, setExtraPerson] = useState(firstCell?.extraPerson ?? 0)
const [minNights, setMinNights] = useState(firstCell?.minNights ?? 1)
const [closed, setClosed] = useState(false)
const [hourlyPrice, setHourlyPrice] = useState<number>(firstCell?.hourlyPrice ?? activeCat.hourlyBasePrice ?? 0)
const [channelMarkup, setChannelMarkup] = useState<Record<string, number>>(
{ ...DEFAULT_CHANNEL_MARKUP },
)
@@ -394,6 +406,27 @@ function EditPanel({
</div>
)}
{/* Hourly price (when hourly mode active) */}
{hourlyMode && !closed && (
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
Почасовая цена (/ч)
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm text-slate-400"></span>
<input
type="number"
value={hourlyPrice || ''}
onChange={e => setHourlyPrice(+e.target.value)}
placeholder={String(activeCat.hourlyBasePrice || 0)}
className="input text-sm py-1.5 pl-7"
min={0}
step={100}
/>
</div>
</div>
)}
{/* Extra person */}
{!closed && (
<div>
@@ -476,6 +509,7 @@ function EditPanel({
startDate, endDate, categoryPrices: catPrices,
extraPerson, minNights, channelMarkup, closed,
onlyCategoryId,
hourlyPrice: hourlyMode ? (hourlyPrice > 0 ? hourlyPrice : null) : null,
})}
className="btn-primary flex-1 justify-center gap-2 text-sm py-2"
>
@@ -677,6 +711,7 @@ export function AvailabilityPage() {
const [allRooms, setAllRooms] = useState<Array<{ id: string; number: string; categoryId?: string; allowHourly?: boolean; hourlyRate?: number; baseRate: number }>>([])
const [editingHourlyRoom, setEditingHourlyRoom] = useState<string | null>(null)
const [hourlyRateInput, setHourlyRateInput] = useState('')
const [hourlyMode, setHourlyMode] = useState(false)
const [loading, setLoading] = useState(true)
const [selection, setSelection] = useState<Selection | null>(null)
const [dragging, setDragging] = useState(false)
@@ -704,14 +739,14 @@ export function AvailabilityPage() {
id: r.id, number: r.number, categoryId: r.categoryId,
allowHourly: r.allowHourly, hourlyRate: r.hourlyRate, baseRate: r.baseRate,
})))
// Build AvailabilityCat[] from real categories, derive basePrice from rooms
// Build AvailabilityCat[] from real categories; use category base_price if set, else derive from rooms
const colors = ['#6366f1','#10b981','#f59e0b','#ef4444','#8b5cf6','#06b6d4']
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 }
const roomsMinPrice = catRooms.length > 0 ? Math.min(...catRooms.map(r => r.baseRate)) : 3000
const basePrice = (cat.base_price && cat.base_price > 0) ? cat.base_price : roomsMinPrice
const hourlyBasePrice = cat.hourly_base_price ?? 0
return { id: cat.id, name: cat.name, color: cat.color || '#6366f1', basePrice, hourlyBasePrice }
})
// If no categories, fall back to room types as groups
@@ -724,7 +759,7 @@ export function AvailabilityPage() {
}
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, hourlyBasePrice: 0,
}))
})()
@@ -738,6 +773,7 @@ export function AvailabilityPage() {
name: 'Без категории',
color: '#94a3b8',
basePrice,
hourlyBasePrice: 0,
}]
}
@@ -791,6 +827,7 @@ export function AvailabilityPage() {
minNights: o.minNights,
channelPrices: o.channelPrices,
closed: o.closed,
hourlyPrice: o.hourlyPrice ?? undefined,
}
}
setPrices(grid)
@@ -838,7 +875,7 @@ export function AvailabilityPage() {
// ── Apply price changes ──
const applyPrices = ({
startDate, endDate, categoryPrices, extraPerson, minNights, channelMarkup, closed,
onlyCategoryId,
onlyCategoryId, hourlyPrice,
}: {
startDate: string; endDate: string
categoryPrices: Record<string, number>
@@ -846,17 +883,30 @@ export function AvailabilityPage() {
channelMarkup: Record<string, number>
closed: boolean
onlyCategoryId?: string
hourlyPrice?: number | null
}) => {
const days = datesInRange(startDate, endDate)
const catsToUpdate = onlyCategoryId
? roomCategories.filter(c => c.id === onlyCategoryId)
: roomCategories
// Check if any selected cells have period prices — require confirmation to override
const hasPeriodCells = catsToUpdate.some(cat =>
days.some(d => prices[cat.id]?.[d]?.fromPeriod)
)
if (hasPeriodCells) {
const ok = window.confirm(
'Некоторые выбранные даты покрыты тарифным периодом.\nПерезаписать цену периода ручной ценой?'
)
if (!ok) return
}
// Build overrides BEFORE setPrices (updater runs async, can't rely on side-effects inside it)
const overrides: Array<{
category_id: string; date: string; price: number
extra_person: number; min_nights: number
channel_prices: Record<string, number>; closed: boolean
hourly_price?: number | null
}> = []
for (const cat of catsToUpdate) {
@@ -870,6 +920,7 @@ export function AvailabilityPage() {
category_id: cat.id, date: d, price: basePrice,
extra_person: extraPerson, min_nights: minNights,
channel_prices: channelPrices, closed,
hourly_price: hourlyPrice ?? null,
})
}
}
@@ -882,6 +933,7 @@ export function AvailabilityPage() {
next[o.category_id][o.date] = {
price: o.price, extraPerson: o.extra_person, minNights: o.min_nights,
channelPrices: o.channel_prices, closed: o.closed,
hourlyPrice: o.hourly_price ?? undefined,
}
}
return next
@@ -1080,6 +1132,21 @@ export function AvailabilityPage() {
{cellEditMode ? <MousePointer2 size={13} /> : <Rows3 size={13} />}
{cellEditMode ? 'По ячейке' : 'По диапазону'}
</button>
{/* Hourly / nightly mode toggle */}
<button
onClick={() => setHourlyMode(v => !v)}
title={hourlyMode ? 'Показать ночные цены' : 'Показать почасовые цены'}
className={cn(
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-colors border',
hourlyMode
? 'bg-violet-50 dark:bg-violet-900/30 border-violet-300 dark:border-violet-700 text-violet-700 dark:text-violet-300'
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-500 hover:text-slate-700 dark:hover:text-slate-300',
)}
>
{hourlyMode ? '₽/ч' : '₽/н'}
{hourlyMode ? ' Почасово' : ' По ночам'}
</button>
</div>
)}
</div>
@@ -1169,6 +1236,7 @@ export function AvailabilityPage() {
cellEditMode={cellEditMode}
selectedCat={selectedCat}
categories={roomCategories}
hourlyMode={hourlyMode}
/>
)}
</div>
@@ -1182,6 +1250,7 @@ export function AvailabilityPage() {
onClose={() => { setShowEditPanel(false); setSelection(null); setSelectedCat(null) }}
onlyCategoryId={cellEditMode && selectedCat ? selectedCat : undefined}
categories={roomCategories}
hourlyMode={hourlyMode}
/>
)}
</>

View File

@@ -25,6 +25,8 @@ export function CalendarPage() {
const [rentalBookings, setRentalBookings] = useState<RentalBookingApi[]>([])
const [locks, setLocks] = useState<Map<string, BookingLock>>(new Map())
const [priceOverrides, setPriceOverrides] = useState<Record<string, Record<string, number>>>({})
const [hourlyPriceOverrides, setHourlyPriceOverrides] = useState<Record<string, Record<string, number>>>({})
const [categoryBasePrices, setCategoryBasePrices] = useState<Record<string, { nightlyBase: number; hourlyBase: number }>>({})
// roomId → assigneeName for "убирается" tooltip
const [cleaningAssignees, setCleaningAssignees] = useState<Record<string, string>>({})
// Set of room IDs with active maintenance tasks
@@ -36,21 +38,36 @@ export function CalendarPage() {
api.rooms.list(slug),
api.bookings.list(slug),
api.rateOverrides.list(slug).catch(() => []),
api.categories.list(slug).catch(() => []),
]
if (isRentalActive) {
fetches.push(api.rental.listObjects(slug))
fetches.push(api.rental.listBookings(slug))
}
Promise.all(fetches).then(([r, b, overrides, ro, rb]) => {
Promise.all(fetches).then(([r, b, overridesRaw, catsRaw, ro, rb]) => {
setRooms(r as Room[])
setBookings(b as Booking[])
// Build categoryId → date → price lookup
const overrides = overridesRaw as import('../lib/api').RateOverrideApi[]
const map: Record<string, Record<string, number>> = {}
for (const o of overrides as import('../lib/api').RateOverrideApi[]) {
const hourlyMap: Record<string, Record<string, number>> = {}
for (const o of overrides) {
if (!map[o.categoryId]) map[o.categoryId] = {}
map[o.categoryId][o.date] = o.price
if (o.hourlyPrice != null && o.hourlyPrice > 0) {
if (!hourlyMap[o.categoryId]) hourlyMap[o.categoryId] = {}
hourlyMap[o.categoryId][o.date] = o.hourlyPrice
}
}
setPriceOverrides(map)
setHourlyPriceOverrides(hourlyMap)
// Build category base prices
const cats = catsRaw as import('../lib/api').CategoryApi[]
const catBases: Record<string, { nightlyBase: number; hourlyBase: number }> = {}
for (const cat of cats) {
catBases[cat.id] = { nightlyBase: cat.base_price ?? 0, hourlyBase: cat.hourly_base_price ?? 0 }
}
setCategoryBasePrices(catBases)
if (isRentalActive) {
setRentalObjects(ro as RentalObjectApi[])
setRentalBookings(rb as RentalBookingApi[])
@@ -296,6 +313,8 @@ export function CalendarPage() {
onDraftCancel={handleDraftCancel}
wsConnected={connected}
priceOverrides={priceOverrides}
hourlyPriceOverrides={hourlyPriceOverrides}
categoryBasePrices={categoryBasePrices}
/>
</div>
</div>

View File

@@ -24,6 +24,9 @@ function fromApi(c: CategoryApi): RoomCategory {
color: c.color,
amenities: c.amenities,
photos: c.photos,
basePrice: c.base_price,
allowHourly: c.allow_hourly,
hourlyBasePrice: c.hourly_base_price,
}
}
@@ -44,6 +47,9 @@ function CategoryForm({ category, onClose, onSave }: CategoryFormProps) {
const [color, setColor] = useState(category?.color ?? '#4F46E5')
const [amenities, setAmenities] = useState<string[]>(category?.amenities ?? [])
const [photos, setPhotos] = useState<string[]>(category?.photos ?? [])
const [basePrice, setBasePrice] = useState(category?.basePrice ?? 0)
const [allowHourly, setAllowHourly] = useState(category?.allowHourly ?? false)
const [hourlyBasePrice, setHourlyBasePrice] = useState(category?.hourlyBasePrice ?? 0)
const [photoIdx, setPhotoIdx] = useState(0)
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState('')
@@ -112,13 +118,16 @@ function CategoryForm({ category, onClose, onSave }: CategoryFormProps) {
setSaveError('')
try {
await onSave({
id: category?.id ?? `cat-${Date.now()}`,
hotelId: category?.hotelId ?? '',
name: name.trim(),
id: category?.id ?? `cat-${Date.now()}`,
hotelId: category?.hotelId ?? '',
name: name.trim(),
description,
color,
amenities,
photos,
basePrice,
allowHourly,
hourlyBasePrice,
})
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Ошибка сохранения')
@@ -187,6 +196,47 @@ function CategoryForm({ category, onClose, onSave }: CategoryFormProps) {
</div>
</div>
{/* Pricing */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Базовая цена за ночь ()</label>
<input
type="number" min={0} className="input"
placeholder="0 — брать из номеров"
value={basePrice || ''}
onChange={e => setBasePrice(parseInt(e.target.value) || 0)}
/>
<p className="text-xs text-slate-400 mt-1">0 = цена из номеров категории</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Разрешить почасовую бронь</label>
<button
type="button"
onClick={() => setAllowHourly(v => !v)}
className={cn(
'relative w-11 h-6 rounded-full transition-colors mt-1',
allowHourly ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600',
)}
>
<span className={cn(
'absolute top-1 left-1 w-4 h-4 rounded-full bg-white shadow transition-transform',
allowHourly && 'translate-x-5',
)} />
</button>
</div>
</div>
{allowHourly && (
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Базовая почасовая ставка (/ч)</label>
<input
type="number" min={0} className="input"
placeholder="0 — брать из настроек номеров"
value={hourlyBasePrice || ''}
onChange={e => setHourlyBasePrice(parseInt(e.target.value) || 0)}
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Описание</label>
<p className="text-xs text-slate-500 mb-1">Отображается в виджете бронирования как описание категории</p>
@@ -396,6 +446,9 @@ export function RoomCategoriesPage() {
const payload = {
name: cat.name, description: cat.description,
color: cat.color, amenities: cat.amenities, photos: cat.photos,
base_price: cat.basePrice ?? 0,
allow_hourly: cat.allowHourly ?? false,
hourly_base_price: cat.hourlyBasePrice ?? 0,
}
const isNew = !categories.find(c => c.id === cat.id)
const saved = isNew

View File

@@ -75,6 +75,9 @@ export interface RoomCategory {
photos: string[]
color: string
amenities: string[]
basePrice?: number
allowHourly?: boolean
hourlyBasePrice?: number
}
export interface Room {