diff --git a/backend/migrations/033_category_pricing.sql b/backend/migrations/033_category_pricing.sql new file mode 100644 index 0000000..b55b5f5 --- /dev/null +++ b/backend/migrations/033_category_pricing.sql @@ -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; diff --git a/backend/src/routes/categories.ts b/backend/src/routes/categories.ts index 9c400a8..7a6e01d 100644 --- a/backend/src/routes/categories.ts +++ b/backend/src/routes/categories.ts @@ -35,6 +35,7 @@ const categories: FastifyPluginAsync = async (fastify) => { fastify.post( '/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( '/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, diff --git a/backend/src/routes/rate-overrides.ts b/backend/src/routes/rate-overrides.ts index 8cb06dd..dfa7899 100644 --- a/backend/src/routes/rate-overrides.ts +++ b/backend/src/routes/rate-overrides.ts @@ -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; 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, ) diff --git a/src/components/bookings/BookingModal.tsx b/src/components/bookings/BookingModal.tsx index 2b34eb0..0c36e8f 100644 --- a/src/components/bookings/BookingModal.tsx +++ b/src/components/bookings/BookingModal.tsx @@ -33,6 +33,10 @@ interface BookingModalProps { draft: DraftBooking /** categoryId → date (YYYY-MM-DD) → price; used to price bookings by availability rates */ priceOverrides?: Record> + /** categoryId → date (YYYY-MM-DD) → hourlyPrice; per-date hourly rate overrides */ + hourlyPriceOverrides?: Record> + /** categoryId → { nightlyBase, hourlyBase } from category settings */ + categoryBasePrices?: Record 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' diff --git a/src/components/calendar/BookingCalendar.tsx b/src/components/calendar/BookingCalendar.tsx index c2f15ce..3b10eea 100644 --- a/src/components/calendar/BookingCalendar.tsx +++ b/src/components/calendar/BookingCalendar.tsx @@ -44,6 +44,10 @@ interface BookingCalendarProps { wsConnected?: boolean /** categoryId → date (YYYY-MM-DD) → price */ priceOverrides?: Record> + /** categoryId → date (YYYY-MM-DD) → hourly price */ + hourlyPriceOverrides?: Record> + /** categoryId → { nightlyBase, hourlyBase } */ + categoryBasePrices?: Record } const CATEGORY_ORDER: Record = { @@ -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} diff --git a/src/data/ratesData.ts b/src/data/ratesData.ts index ee63883..fbdcb3d 100644 --- a/src/data/ratesData.ts +++ b/src/data/ratesData.ts @@ -23,6 +23,7 @@ export interface PriceCell { closed: boolean // закрыто для продажи fromPeriod?: boolean // цена из ценового периода periodName?: string // название периода + hourlyPrice?: number // почасовая цена на эту дату } export interface RatePeriod { diff --git a/src/lib/api.ts b/src/lib/api.ts index 67f3503..ae15cab 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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 closed: boolean + hourlyPrice?: number | null } export interface RateOverridePayload { @@ -804,6 +811,7 @@ export interface RateOverridePayload { min_nights?: number channel_prices?: Record closed?: boolean + hourly_price?: number | null } export interface RatePeriodApi { diff --git a/src/pages/AvailabilityPage.tsx b/src/pages/AvailabilityPage.tsx index 7b4e369..59e0f66 100644 --- a/src/pages/AvailabilityPage.tsx +++ b/src/pages/AvailabilityPage.tsx @@ -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> @@ -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 (
{price ? price.toLocaleString('ru-RU') : '—'} - {cell?.fromPeriod && !isSel && ( + {hourlyMode && ( + + )} + {!hourlyMode && cell?.fromPeriod && !isSel && ( П @@ -247,6 +255,7 @@ function EditPanel({ onClose, onlyCategoryId, categories, + hourlyMode, }: { selection: Selection prices: Record> @@ -259,10 +268,12 @@ function EditPanel({ channelMarkup: Record 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(firstCell?.hourlyPrice ?? activeCat.hourlyBasePrice ?? 0) const [channelMarkup, setChannelMarkup] = useState>( { ...DEFAULT_CHANNEL_MARKUP }, ) @@ -394,6 +406,27 @@ function EditPanel({
)} + {/* Hourly price (when hourly mode active) */} + {hourlyMode && !closed && ( +
+ +
+ + setHourlyPrice(+e.target.value)} + placeholder={String(activeCat.hourlyBasePrice || 0)} + className="input text-sm py-1.5 pl-7" + min={0} + step={100} + /> +
+
+ )} + {/* Extra person */} {!closed && (
@@ -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>([]) const [editingHourlyRoom, setEditingHourlyRoom] = useState(null) const [hourlyRateInput, setHourlyRateInput] = useState('') + const [hourlyMode, setHourlyMode] = useState(false) const [loading, setLoading] = useState(true) const [selection, setSelection] = useState(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 @@ -846,17 +883,30 @@ export function AvailabilityPage() { channelMarkup: Record 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; 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 ? : } {cellEditMode ? 'По ячейке' : 'По диапазону'} + + {/* Hourly / nightly mode toggle */} +
)} @@ -1169,6 +1236,7 @@ export function AvailabilityPage() { cellEditMode={cellEditMode} selectedCat={selectedCat} categories={roomCategories} + hourlyMode={hourlyMode} /> )} @@ -1182,6 +1250,7 @@ export function AvailabilityPage() { onClose={() => { setShowEditPanel(false); setSelection(null); setSelectedCat(null) }} onlyCategoryId={cellEditMode && selectedCat ? selectedCat : undefined} categories={roomCategories} + hourlyMode={hourlyMode} /> )} diff --git a/src/pages/CalendarPage.tsx b/src/pages/CalendarPage.tsx index 6ec0e64..170243c 100644 --- a/src/pages/CalendarPage.tsx +++ b/src/pages/CalendarPage.tsx @@ -25,6 +25,8 @@ export function CalendarPage() { const [rentalBookings, setRentalBookings] = useState([]) const [locks, setLocks] = useState>(new Map()) const [priceOverrides, setPriceOverrides] = useState>>({}) + const [hourlyPriceOverrides, setHourlyPriceOverrides] = useState>>({}) + const [categoryBasePrices, setCategoryBasePrices] = useState>({}) // roomId → assigneeName for "убирается" tooltip const [cleaningAssignees, setCleaningAssignees] = useState>({}) // 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> = {} - for (const o of overrides as import('../lib/api').RateOverrideApi[]) { + const hourlyMap: Record> = {} + 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 = {} + 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} /> diff --git a/src/pages/RoomCategoriesPage.tsx b/src/pages/RoomCategoriesPage.tsx index 4b6a077..98ea0bf 100644 --- a/src/pages/RoomCategoriesPage.tsx +++ b/src/pages/RoomCategoriesPage.tsx @@ -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(category?.amenities ?? []) const [photos, setPhotos] = useState(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) { + {/* Pricing */} +
+
+ + setBasePrice(parseInt(e.target.value) || 0)} + /> +

0 = цена из номеров категории

+
+
+ + +
+
+ {allowHourly && ( +
+ + setHourlyBasePrice(parseInt(e.target.value) || 0)} + /> +
+ )} +

Отображается в виджете бронирования как описание категории

@@ -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 diff --git a/src/types/index.ts b/src/types/index.ts index 7d0801a..d008b01 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -75,6 +75,9 @@ export interface RoomCategory { photos: string[] color: string amenities: string[] + basePrice?: number + allowHourly?: boolean + hourlyBasePrice?: number } export interface Room {