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:
9
backend/migrations/033_category_pricing.sql
Normal file
9
backend/migrations/033_category_pricing.sql
Normal 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;
|
||||||
@@ -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] },
|
||||||
@@ -71,12 +79,15 @@ const categories: FastifyPluginAsync = async (fastify) => {
|
|||||||
const sets: string[] = ['updated_at = NOW()']
|
const sets: string[] = ['updated_at = NOW()']
|
||||||
const vals: unknown[] = [hotelId, id]
|
const vals: unknown[] = [hotelId, id]
|
||||||
let i = 3
|
let i = 3
|
||||||
if (b.name !== undefined) { sets.push(`name = $${i++}`); vals.push(b.name) }
|
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.description !== undefined) { sets.push(`description = $${i++}`); vals.push(b.description) }
|
||||||
if (b.color !== undefined) { sets.push(`color = $${i++}`); vals.push(b.color) }
|
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.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,
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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?.price
|
? (cell?.hourlyPrice ?? cat.hourlyBasePrice ?? 0)
|
||||||
: cell?.channelPrices[activeChannel] ?? cell?.price
|
: (activeChannel === 'direct'
|
||||||
|
? 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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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('')
|
||||||
@@ -112,13 +118,16 @@ function CategoryForm({ category, onClose, onSave }: CategoryFormProps) {
|
|||||||
setSaveError('')
|
setSaveError('')
|
||||||
try {
|
try {
|
||||||
await onSave({
|
await onSave({
|
||||||
id: category?.id ?? `cat-${Date.now()}`,
|
id: category?.id ?? `cat-${Date.now()}`,
|
||||||
hotelId: category?.hotelId ?? '',
|
hotelId: category?.hotelId ?? '',
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
description,
|
description,
|
||||||
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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user