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

View File

@@ -24,7 +24,7 @@ const rateOverrides: FastifyPluginAsync = async (fastify) => {
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { rows } = await db.query( const { rows } = await db.query(
`SELECT category_id, to_char(date, 'YYYY-MM-DD') AS date, `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 FROM rate_overrides
WHERE hotel_id = $1 WHERE hotel_id = $1
ORDER BY date, category_id`, ORDER BY date, category_id`,
@@ -40,6 +40,7 @@ const rateOverrides: FastifyPluginAsync = async (fastify) => {
category_id: string; date: string; price: number category_id: string; date: string; price: number
extra_person?: number; min_nights?: number extra_person?: number; min_nights?: number
channel_prices?: Record<string, number>; closed?: boolean channel_prices?: Record<string, number>; closed?: boolean
hourly_price?: number | null
}> }>
} }>( } }>(
'/api/hotels/:slug/rate-overrides/bulk', '/api/hotels/:slug/rate-overrides/bulk',
@@ -54,13 +55,11 @@ const rateOverrides: FastifyPluginAsync = async (fastify) => {
const { overrides } = request.body const { overrides } = request.body
if (!overrides?.length) return { count: 0 } 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 vals: unknown[] = [hotelId]
const rowPlaceholders: string[] = [] const rowPlaceholders: string[] = []
let idx = 2 let idx = 2
for (const o of overrides) { 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( vals.push(
o.category_id, o.category_id,
o.date, o.date,
@@ -69,13 +68,14 @@ const rateOverrides: FastifyPluginAsync = async (fastify) => {
o.min_nights ?? 1, o.min_nights ?? 1,
JSON.stringify(o.channel_prices ?? {}), JSON.stringify(o.channel_prices ?? {}),
o.closed ?? false, o.closed ?? false,
o.hourly_price ?? null,
) )
idx += 7 idx += 8
} }
await db.query( await db.query(
`INSERT INTO rate_overrides `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(', ')} VALUES ${rowPlaceholders.join(', ')}
ON CONFLICT (hotel_id, category_id, date) DO UPDATE SET ON CONFLICT (hotel_id, category_id, date) DO UPDATE SET
price = EXCLUDED.price, price = EXCLUDED.price,
@@ -83,6 +83,7 @@ const rateOverrides: FastifyPluginAsync = async (fastify) => {
min_nights = EXCLUDED.min_nights, min_nights = EXCLUDED.min_nights,
channel_prices = EXCLUDED.channel_prices, channel_prices = EXCLUDED.channel_prices,
closed = EXCLUDED.closed, closed = EXCLUDED.closed,
hourly_price = COALESCE(EXCLUDED.hourly_price, rate_overrides.hourly_price),
updated_at = NOW()`, updated_at = NOW()`,
vals, vals,
) )

View File

@@ -33,6 +33,10 @@ interface BookingModalProps {
draft: DraftBooking draft: DraftBooking
/** categoryId → date (YYYY-MM-DD) → price; used to price bookings by availability rates */ /** categoryId → date (YYYY-MM-DD) → price; used to price bookings by availability rates */
priceOverrides?: Record<string, Record<string, number>> 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[] rooms: Room[]
bookings?: Booking[] bookings?: Booking[]
onClose: () => void 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 { statuses } = useModules()
const { user } = useAuth() const { user } = useAuth()
const tvEnabled = statuses['tv-welcome'] === 'active' const tvEnabled = statuses['tv-welcome'] === 'active'
@@ -331,14 +335,15 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
: 0 : 0
const hourlyHours = Math.max(0, endHour - startHour) 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 nightCount = Math.floor(nights)
const roomNightlyTotal = (() => { const roomNightlyTotal = (() => {
if (!room || !form.checkIn || nightCount <= 0) return 0 if (!room || !form.checkIn || nightCount <= 0) return 0
const catId = room.categoryId const catId = room.categoryId
const catOverrides = catId ? priceOverrides?.[catId] : undefined 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 // 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 if (!catOverrides) return fallback * nightCount
let sum = 0 let sum = 0
for (let i = 0; i < nightCount; i++) { for (let i = 0; i < nightCount; i++) {
@@ -348,8 +353,17 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
} }
return sum 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 const roomBaseTotal = isHourly && room?.allowHourly
? (room.hourlyRate ?? 0) * hourlyHours ? hourlyRateForDate * hourlyHours
: roomNightlyTotal : roomNightlyTotal
const discountAmount = selectedDiscount const discountAmount = selectedDiscount
? selectedDiscount.valueType === 'percent' ? selectedDiscount.valueType === 'percent'

View File

@@ -44,6 +44,10 @@ interface BookingCalendarProps {
wsConnected?: boolean wsConnected?: boolean
/** categoryId → date (YYYY-MM-DD) → price */ /** categoryId → date (YYYY-MM-DD) → price */
priceOverrides?: Record<string, Record<string, number>> 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> = { 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' 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 [startDate, setStartDate] = useState(() => startOfDay(new Date()))
const [visibleDays, setVisibleDays] = useState(DAYS_VISIBLE) const [visibleDays, setVisibleDays] = useState(DAYS_VISIBLE)
@@ -913,6 +917,8 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
draft={bookingModalDraft} draft={bookingModalDraft}
rooms={rooms} rooms={rooms}
priceOverrides={priceOverrides} priceOverrides={priceOverrides}
hourlyPriceOverrides={hourlyPriceOverrides}
categoryBasePrices={categoryBasePrices}
bookings={bookings} bookings={bookings}
slug={slug} slug={slug}
rentalObjects={rentalObjects} rentalObjects={rentalObjects}

View File

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

View File

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

View File

@@ -16,6 +16,7 @@ interface AvailabilityCat {
name: string name: string
color: string // hex color color: string // hex color
basePrice: number basePrice: number
hourlyBasePrice: number
} }
function buildPriceGrid( function buildPriceGrid(
@@ -82,7 +83,7 @@ interface Selection { start: string; end: string }
function PriceGrid({ function PriceGrid({
dates, prices, selection, dragging, dates, prices, selection, dragging,
onCellDown, onCellEnter, activeChannel, onCellDown, onCellEnter, activeChannel,
cellEditMode, selectedCat, categories, cellEditMode, selectedCat, categories, hourlyMode,
}: { }: {
dates: string[] dates: string[]
prices: Record<string, Record<string, PriceCell>> prices: Record<string, Record<string, PriceCell>>
@@ -94,6 +95,7 @@ function PriceGrid({
cellEditMode: boolean cellEditMode: boolean
selectedCat: string | null selectedCat: string | null
categories: AvailabilityCat[] categories: AvailabilityCat[]
hourlyMode: boolean
}) { }) {
const today = format(new Date(), DATE_FMT) const today = format(new Date(), DATE_FMT)
@@ -183,9 +185,11 @@ function PriceGrid({
const isWe = dow === 0 || dow === 6 const isWe = dow === 0 || dow === 6
const isTd = d === today const isTd = d === today
const isSel = isSelected(d, cat.id) const isSel = isSelected(d, cat.id)
const price = activeChannel === 'direct' const price = hourlyMode
? (cell?.hourlyPrice ?? cat.hourlyBasePrice ?? 0)
: (activeChannel === 'direct'
? cell?.price ? cell?.price
: cell?.channelPrices[activeChannel] ?? cell?.price : cell?.channelPrices[activeChannel] ?? cell?.price)
return ( return (
<div <div
@@ -212,10 +216,14 @@ function PriceGrid({
<span className={cn( <span className={cn(
'text-sm font-semibold leading-tight', 'text-sm font-semibold leading-tight',
isSel ? 'text-brand-700 dark:text-brand-300' : 'text-slate-800 dark:text-slate-200', 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') : '—'} {price ? price.toLocaleString('ru-RU') : '—'}
</span> </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 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> </span>
@@ -247,6 +255,7 @@ function EditPanel({
onClose, onClose,
onlyCategoryId, onlyCategoryId,
categories, categories,
hourlyMode,
}: { }: {
selection: Selection selection: Selection
prices: Record<string, Record<string, PriceCell>> prices: Record<string, Record<string, PriceCell>>
@@ -259,10 +268,12 @@ function EditPanel({
channelMarkup: Record<string, number> channelMarkup: Record<string, number>
closed: boolean closed: boolean
onlyCategoryId?: string onlyCategoryId?: string
hourlyPrice?: number | null
}) => void }) => void
onClose: () => void onClose: () => void
onlyCategoryId?: string onlyCategoryId?: string
categories: AvailabilityCat[] categories: AvailabilityCat[]
hourlyMode: boolean
}) { }) {
const [s, e] = normRange(selection.start, selection.end) const [s, e] = normRange(selection.start, selection.end)
@@ -278,6 +289,7 @@ function EditPanel({
const [extraPerson, setExtraPerson] = useState(firstCell?.extraPerson ?? 0) const [extraPerson, setExtraPerson] = useState(firstCell?.extraPerson ?? 0)
const [minNights, setMinNights] = useState(firstCell?.minNights ?? 1) const [minNights, setMinNights] = useState(firstCell?.minNights ?? 1)
const [closed, setClosed] = useState(false) const [closed, setClosed] = useState(false)
const [hourlyPrice, setHourlyPrice] = useState<number>(firstCell?.hourlyPrice ?? activeCat.hourlyBasePrice ?? 0)
const [channelMarkup, setChannelMarkup] = useState<Record<string, number>>( const [channelMarkup, setChannelMarkup] = useState<Record<string, number>>(
{ ...DEFAULT_CHANNEL_MARKUP }, { ...DEFAULT_CHANNEL_MARKUP },
) )
@@ -394,6 +406,27 @@ function EditPanel({
</div> </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 */} {/* Extra person */}
{!closed && ( {!closed && (
<div> <div>
@@ -476,6 +509,7 @@ function EditPanel({
startDate, endDate, categoryPrices: catPrices, startDate, endDate, categoryPrices: catPrices,
extraPerson, minNights, channelMarkup, closed, extraPerson, minNights, channelMarkup, closed,
onlyCategoryId, onlyCategoryId,
hourlyPrice: hourlyMode ? (hourlyPrice > 0 ? hourlyPrice : null) : null,
})} })}
className="btn-primary flex-1 justify-center gap-2 text-sm py-2" 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 [allRooms, setAllRooms] = useState<Array<{ id: string; number: string; categoryId?: string; allowHourly?: boolean; hourlyRate?: number; baseRate: number }>>([])
const [editingHourlyRoom, setEditingHourlyRoom] = useState<string | null>(null) const [editingHourlyRoom, setEditingHourlyRoom] = useState<string | null>(null)
const [hourlyRateInput, setHourlyRateInput] = useState('') const [hourlyRateInput, setHourlyRateInput] = useState('')
const [hourlyMode, setHourlyMode] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [selection, setSelection] = useState<Selection | null>(null) const [selection, setSelection] = useState<Selection | null>(null)
const [dragging, setDragging] = useState(false) const [dragging, setDragging] = useState(false)
@@ -704,14 +739,14 @@ export function AvailabilityPage() {
id: r.id, number: r.number, categoryId: r.categoryId, id: r.id, number: r.number, categoryId: r.categoryId,
allowHourly: r.allowHourly, hourlyRate: r.hourlyRate, baseRate: r.baseRate, 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 colors = ['#6366f1','#10b981','#f59e0b','#ef4444','#8b5cf6','#06b6d4']
const availCats: AvailabilityCat[] = cats.map(cat => { const availCats: AvailabilityCat[] = cats.map(cat => {
const catRooms = rooms.filter(r => r.categoryId === cat.id) const catRooms = rooms.filter(r => r.categoryId === cat.id)
const basePrice = catRooms.length > 0 const roomsMinPrice = catRooms.length > 0 ? Math.min(...catRooms.map(r => r.baseRate)) : 3000
? Math.min(...catRooms.map(r => r.baseRate)) const basePrice = (cat.base_price && cat.base_price > 0) ? cat.base_price : roomsMinPrice
: 3000 const hourlyBasePrice = cat.hourly_base_price ?? 0
return { id: cat.id, name: cat.name, color: cat.color || '#6366f1', basePrice } return { id: cat.id, name: cat.name, color: cat.color || '#6366f1', basePrice, hourlyBasePrice }
}) })
// If no categories, fall back to room types as groups // 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) => ({ return Array.from(typeMap.entries()).map(([name, price], i) => ({
id: name.toLowerCase().replace(/\s+/g, '_'), 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: 'Без категории', name: 'Без категории',
color: '#94a3b8', color: '#94a3b8',
basePrice, basePrice,
hourlyBasePrice: 0,
}] }]
} }
@@ -791,6 +827,7 @@ export function AvailabilityPage() {
minNights: o.minNights, minNights: o.minNights,
channelPrices: o.channelPrices, channelPrices: o.channelPrices,
closed: o.closed, closed: o.closed,
hourlyPrice: o.hourlyPrice ?? undefined,
} }
} }
setPrices(grid) setPrices(grid)
@@ -838,7 +875,7 @@ export function AvailabilityPage() {
// ── Apply price changes ── // ── Apply price changes ──
const applyPrices = ({ const applyPrices = ({
startDate, endDate, categoryPrices, extraPerson, minNights, channelMarkup, closed, startDate, endDate, categoryPrices, extraPerson, minNights, channelMarkup, closed,
onlyCategoryId, onlyCategoryId, hourlyPrice,
}: { }: {
startDate: string; endDate: string startDate: string; endDate: string
categoryPrices: Record<string, number> categoryPrices: Record<string, number>
@@ -846,17 +883,30 @@ export function AvailabilityPage() {
channelMarkup: Record<string, number> channelMarkup: Record<string, number>
closed: boolean closed: boolean
onlyCategoryId?: string onlyCategoryId?: string
hourlyPrice?: number | null
}) => { }) => {
const days = datesInRange(startDate, endDate) const days = datesInRange(startDate, endDate)
const catsToUpdate = onlyCategoryId const catsToUpdate = onlyCategoryId
? roomCategories.filter(c => c.id === onlyCategoryId) ? roomCategories.filter(c => c.id === onlyCategoryId)
: roomCategories : 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) // Build overrides BEFORE setPrices (updater runs async, can't rely on side-effects inside it)
const overrides: Array<{ const overrides: Array<{
category_id: string; date: string; price: number category_id: string; date: string; price: number
extra_person: number; min_nights: number extra_person: number; min_nights: number
channel_prices: Record<string, number>; closed: boolean channel_prices: Record<string, number>; closed: boolean
hourly_price?: number | null
}> = [] }> = []
for (const cat of catsToUpdate) { for (const cat of catsToUpdate) {
@@ -870,6 +920,7 @@ export function AvailabilityPage() {
category_id: cat.id, date: d, price: basePrice, category_id: cat.id, date: d, price: basePrice,
extra_person: extraPerson, min_nights: minNights, extra_person: extraPerson, min_nights: minNights,
channel_prices: channelPrices, closed, channel_prices: channelPrices, closed,
hourly_price: hourlyPrice ?? null,
}) })
} }
} }
@@ -882,6 +933,7 @@ export function AvailabilityPage() {
next[o.category_id][o.date] = { next[o.category_id][o.date] = {
price: o.price, extraPerson: o.extra_person, minNights: o.min_nights, price: o.price, extraPerson: o.extra_person, minNights: o.min_nights,
channelPrices: o.channel_prices, closed: o.closed, channelPrices: o.channel_prices, closed: o.closed,
hourlyPrice: o.hourly_price ?? undefined,
} }
} }
return next return next
@@ -1080,6 +1132,21 @@ export function AvailabilityPage() {
{cellEditMode ? <MousePointer2 size={13} /> : <Rows3 size={13} />} {cellEditMode ? <MousePointer2 size={13} /> : <Rows3 size={13} />}
{cellEditMode ? 'По ячейке' : 'По диапазону'} {cellEditMode ? 'По ячейке' : 'По диапазону'}
</button> </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>
)} )}
</div> </div>
@@ -1169,6 +1236,7 @@ export function AvailabilityPage() {
cellEditMode={cellEditMode} cellEditMode={cellEditMode}
selectedCat={selectedCat} selectedCat={selectedCat}
categories={roomCategories} categories={roomCategories}
hourlyMode={hourlyMode}
/> />
)} )}
</div> </div>
@@ -1182,6 +1250,7 @@ export function AvailabilityPage() {
onClose={() => { setShowEditPanel(false); setSelection(null); setSelectedCat(null) }} onClose={() => { setShowEditPanel(false); setSelection(null); setSelectedCat(null) }}
onlyCategoryId={cellEditMode && selectedCat ? selectedCat : undefined} onlyCategoryId={cellEditMode && selectedCat ? selectedCat : undefined}
categories={roomCategories} categories={roomCategories}
hourlyMode={hourlyMode}
/> />
)} )}
</> </>

View File

@@ -25,6 +25,8 @@ export function CalendarPage() {
const [rentalBookings, setRentalBookings] = useState<RentalBookingApi[]>([]) const [rentalBookings, setRentalBookings] = useState<RentalBookingApi[]>([])
const [locks, setLocks] = useState<Map<string, BookingLock>>(new Map()) const [locks, setLocks] = useState<Map<string, BookingLock>>(new Map())
const [priceOverrides, setPriceOverrides] = useState<Record<string, Record<string, number>>>({}) 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 // roomId → assigneeName for "убирается" tooltip
const [cleaningAssignees, setCleaningAssignees] = useState<Record<string, string>>({}) const [cleaningAssignees, setCleaningAssignees] = useState<Record<string, string>>({})
// Set of room IDs with active maintenance tasks // Set of room IDs with active maintenance tasks
@@ -36,21 +38,36 @@ export function CalendarPage() {
api.rooms.list(slug), api.rooms.list(slug),
api.bookings.list(slug), api.bookings.list(slug),
api.rateOverrides.list(slug).catch(() => []), api.rateOverrides.list(slug).catch(() => []),
api.categories.list(slug).catch(() => []),
] ]
if (isRentalActive) { if (isRentalActive) {
fetches.push(api.rental.listObjects(slug)) fetches.push(api.rental.listObjects(slug))
fetches.push(api.rental.listBookings(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[]) setRooms(r as Room[])
setBookings(b as Booking[]) setBookings(b as Booking[])
// Build categoryId → date → price lookup // Build categoryId → date → price lookup
const overrides = overridesRaw as import('../lib/api').RateOverrideApi[]
const map: Record<string, Record<string, number>> = {} 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] = {} if (!map[o.categoryId]) map[o.categoryId] = {}
map[o.categoryId][o.date] = o.price 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) 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) { if (isRentalActive) {
setRentalObjects(ro as RentalObjectApi[]) setRentalObjects(ro as RentalObjectApi[])
setRentalBookings(rb as RentalBookingApi[]) setRentalBookings(rb as RentalBookingApi[])
@@ -296,6 +313,8 @@ export function CalendarPage() {
onDraftCancel={handleDraftCancel} onDraftCancel={handleDraftCancel}
wsConnected={connected} wsConnected={connected}
priceOverrides={priceOverrides} priceOverrides={priceOverrides}
hourlyPriceOverrides={hourlyPriceOverrides}
categoryBasePrices={categoryBasePrices}
/> />
</div> </div>
</div> </div>

View File

@@ -24,6 +24,9 @@ function fromApi(c: CategoryApi): RoomCategory {
color: c.color, color: c.color,
amenities: c.amenities, amenities: c.amenities,
photos: c.photos, 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 [color, setColor] = useState(category?.color ?? '#4F46E5')
const [amenities, setAmenities] = useState<string[]>(category?.amenities ?? []) const [amenities, setAmenities] = useState<string[]>(category?.amenities ?? [])
const [photos, setPhotos] = useState<string[]>(category?.photos ?? []) 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 [photoIdx, setPhotoIdx] = useState(0)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState('') const [saveError, setSaveError] = useState('')
@@ -119,6 +125,9 @@ function CategoryForm({ category, onClose, onSave }: CategoryFormProps) {
color, color,
amenities, amenities,
photos, photos,
basePrice,
allowHourly,
hourlyBasePrice,
}) })
} catch (err) { } catch (err) {
setSaveError(err instanceof Error ? err.message : 'Ошибка сохранения') setSaveError(err instanceof Error ? err.message : 'Ошибка сохранения')
@@ -187,6 +196,47 @@ function CategoryForm({ category, onClose, onSave }: CategoryFormProps) {
</div> </div>
</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> <div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Описание</label> <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> <p className="text-xs text-slate-500 mb-1">Отображается в виджете бронирования как описание категории</p>
@@ -396,6 +446,9 @@ export function RoomCategoriesPage() {
const payload = { const payload = {
name: cat.name, description: cat.description, name: cat.name, description: cat.description,
color: cat.color, amenities: cat.amenities, photos: cat.photos, 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 isNew = !categories.find(c => c.id === cat.id)
const saved = isNew const saved = isNew

View File

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