Rental: connect to API, improve cell UI; remove price from room labels
Backend: - Migration 015_rental.sql: rental_objects + rental_bookings tables, seed demo data - New routes: GET/POST/PATCH/DELETE rental-objects and rental-bookings per hotel - Register rentalRoutes in app.ts Frontend: - api.ts: add rental.listObjects, listBookings, createBooking, updateBooking, deleteBooking - CalendarPage: fetch rental objects + bookings from API instead of mock data - BookingCalendar: rental cell UI redesigned — colored squares + working hours + time slots (matches design spec) - BookingCalendar: remove base rate price from desktop room label column (irrelevant with dynamic pricing) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -443,11 +443,6 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!compact && !isMobile && (
|
||||
<div className="ml-auto text-xs text-slate-400 dark:text-slate-500">
|
||||
{room.baseRate.toLocaleString('ru-RU')} ₽
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Day cells + booking blocks */}
|
||||
@@ -693,42 +688,34 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
|
||||
{hasFullDay ? (
|
||||
/* Full day booking */
|
||||
<div className={cn(
|
||||
'absolute inset-1.5 rounded flex items-center justify-center text-white text-[10px] font-medium',
|
||||
'absolute inset-1.5 rounded flex items-center justify-center text-white text-[10px] font-semibold',
|
||||
obj.color,
|
||||
)}>
|
||||
Весь день
|
||||
</div>
|
||||
) : dayBookings.length > 0 ? (
|
||||
/* Hourly booking bars */
|
||||
<div className="absolute inset-x-1 bottom-1" style={{ top: 6 }}>
|
||||
{/* Time scale bar (background) */}
|
||||
<div className="w-full h-2.5 rounded-sm bg-slate-100 dark:bg-slate-600 relative overflow-hidden">
|
||||
{dayBookings.map(b => {
|
||||
const leftPct = ((b.startHour - obj.openHour) / totalSpan) * 100
|
||||
const widthPct = ((b.endHour - b.startHour) / totalSpan) * 100
|
||||
return (
|
||||
<div
|
||||
key={b.id}
|
||||
className={cn('absolute h-full rounded-sm', obj.color)}
|
||||
style={{ left: `${leftPct}%`, width: `${widthPct}%` }}
|
||||
title={`${b.guestName} · ${b.startHour}:00–${b.endHour}:00`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Hour labels */}
|
||||
<div className="flex justify-between mt-0.5 px-0.5">
|
||||
<span className="text-[9px] text-slate-400">{obj.openHour}:00</span>
|
||||
<span className="text-[9px] text-slate-400">{obj.closeHour}:00</span>
|
||||
</div>
|
||||
{/* Booking count */}
|
||||
<div className="flex flex-wrap gap-0.5 mt-1">
|
||||
/* Hourly bookings — squares + times */
|
||||
<div className="absolute inset-x-1 top-1.5 flex flex-col gap-0.5">
|
||||
{/* One colored square per booking */}
|
||||
<div className="flex gap-0.5 flex-wrap">
|
||||
{dayBookings.map(b => (
|
||||
<span key={b.id} className="text-[9px] text-slate-500 dark:text-slate-400 bg-slate-100 dark:bg-slate-700 px-1 rounded truncate max-w-full">
|
||||
{b.startHour}–{b.endHour}
|
||||
</span>
|
||||
<div
|
||||
key={b.id}
|
||||
className={cn('w-3 h-3 rounded-[3px]', obj.color)}
|
||||
title={`${b.guestName} · ${b.startHour}:00–${b.endHour}:00`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Working hours range */}
|
||||
<span className="text-[9px] text-slate-400 dark:text-slate-500 leading-none">
|
||||
{obj.openHour}:00–{obj.closeHour}:00
|
||||
</span>
|
||||
{/* Booked time slots */}
|
||||
{dayBookings.map(b => (
|
||||
<span key={b.id} className="text-[9px] text-slate-600 dark:text-slate-300 font-medium leading-none">
|
||||
{b.startHour}–{b.endHour}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* Empty — show "+" hint on hover */
|
||||
|
||||
@@ -312,6 +312,29 @@ export const api = {
|
||||
req<void>('DELETE', `/api/hotels/${slug}/guests/${id}`),
|
||||
},
|
||||
|
||||
// ── Rental ────────────────────────────────────────────────────────────────
|
||||
rental: {
|
||||
listObjects: (slug: string) =>
|
||||
req<RentalObjectApi[]>('GET', `/api/hotels/${slug}/rental-objects`),
|
||||
|
||||
listBookings: (slug: string, from?: string, to?: string) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (from) qs.set('from', from)
|
||||
if (to) qs.set('to', to)
|
||||
const q = qs.toString()
|
||||
return req<RentalBookingApi[]>('GET', `/api/hotels/${slug}/rental-bookings${q ? `?${q}` : ''}`)
|
||||
},
|
||||
|
||||
createBooking: (slug: string, data: RentalBookingPayload) =>
|
||||
req<RentalBookingApi>('POST', `/api/hotels/${slug}/rental-bookings`, data),
|
||||
|
||||
updateBooking: (slug: string, id: string, data: Partial<RentalBookingPayload>) =>
|
||||
req<RentalBookingApi>('PATCH', `/api/hotels/${slug}/rental-bookings/${id}`, data),
|
||||
|
||||
deleteBooking: (slug: string, id: string) =>
|
||||
req<void>('DELETE', `/api/hotels/${slug}/rental-bookings/${id}`),
|
||||
},
|
||||
|
||||
// ── NetUP IPTV ────────────────────────────────────────────────────────────
|
||||
netup: {
|
||||
getSettings: (slug: string) =>
|
||||
@@ -515,6 +538,56 @@ export interface GuestPayload {
|
||||
rating?: number
|
||||
}
|
||||
|
||||
export interface RentalObjectApi {
|
||||
id: string
|
||||
hotelId: string
|
||||
name: string
|
||||
icon: string
|
||||
color: string
|
||||
textColor: string
|
||||
pricePerHour: number
|
||||
pricePerDay: number
|
||||
openHour: number
|
||||
closeHour: number
|
||||
maxHoursPerSlot: number | null
|
||||
bufferMinutes: number
|
||||
sortOrder: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface RentalBookingApi {
|
||||
id: string
|
||||
hotelId: string
|
||||
objectId: string
|
||||
date: string
|
||||
isFullDay: boolean
|
||||
startHour: number
|
||||
endHour: number
|
||||
guestName: string
|
||||
guestPhone: string
|
||||
linkedRoomId: string | null
|
||||
totalAmount: number
|
||||
paidAmount: number
|
||||
status: 'confirmed' | 'cancelled'
|
||||
notes: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface RentalBookingPayload {
|
||||
object_id: string
|
||||
date: string
|
||||
is_full_day?: boolean
|
||||
start_hour?: number
|
||||
end_hour?: number
|
||||
guest_name: string
|
||||
guest_phone?: string
|
||||
linked_room_id?: string
|
||||
total_amount?: number
|
||||
paid_amount?: number
|
||||
status?: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
function toHotelPayload(h: HotelPayload): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {}
|
||||
if (h.name !== undefined) out.name = h.name
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { BookingCalendar } from '../components/calendar/BookingCalendar'
|
||||
import { RENTAL_OBJECTS, MOCK_RENTAL_BOOKINGS } from '../data/rentalData'
|
||||
import { useModules } from '../contexts/ModulesContext'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { api } from '../lib/api'
|
||||
import type { RentalObjectApi, RentalBookingApi } from '../lib/api'
|
||||
import { useHotelSocket } from '../hooks/useHotelSocket'
|
||||
import type { WsMessage } from '../hooks/useHotelSocket'
|
||||
import type { Room, Booking } from '../types'
|
||||
@@ -20,19 +20,29 @@ export function CalendarPage() {
|
||||
const [rooms, setRooms] = useState<Room[]>([])
|
||||
const [bookings, setBookings] = useState<Booking[]>([])
|
||||
const [fadingBookings, setFadingBookings] = useState<Set<string>>(new Set())
|
||||
const [rentalBookings, setRentalBookings] = useState<RentalBooking[]>(MOCK_RENTAL_BOOKINGS)
|
||||
const [rentalObjects, setRentalObjects] = useState<RentalObjectApi[]>([])
|
||||
const [rentalBookings, setRentalBookings] = useState<RentalBookingApi[]>([])
|
||||
const [locks, setLocks] = useState<Map<string, BookingLock>>(new Map())
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return
|
||||
Promise.all([
|
||||
const fetches: Promise<unknown>[] = [
|
||||
api.rooms.list(slug),
|
||||
api.bookings.list(slug),
|
||||
]).then(([r, b]) => {
|
||||
setRooms(r)
|
||||
setBookings(b)
|
||||
]
|
||||
if (isRentalActive) {
|
||||
fetches.push(api.rental.listObjects(slug))
|
||||
fetches.push(api.rental.listBookings(slug))
|
||||
}
|
||||
Promise.all(fetches).then(([r, b, ro, rb]) => {
|
||||
setRooms(r as Room[])
|
||||
setBookings(b as Booking[])
|
||||
if (isRentalActive) {
|
||||
setRentalObjects(ro as RentalObjectApi[])
|
||||
setRentalBookings(rb as RentalBookingApi[])
|
||||
}
|
||||
}).catch(console.error)
|
||||
}, [slug])
|
||||
}, [slug, isRentalActive])
|
||||
|
||||
const handleWsMessage = useCallback((msg: WsMessage) => {
|
||||
if (msg.type === 'lock') {
|
||||
@@ -130,6 +140,28 @@ export function CalendarPage() {
|
||||
send({ type: 'unlock', roomId })
|
||||
}, [send])
|
||||
|
||||
const handleRentalBookingCreate = useCallback(async (b: RentalBooking) => {
|
||||
try {
|
||||
const created = await api.rental.createBooking(slug, {
|
||||
object_id: b.objectId,
|
||||
date: b.date,
|
||||
is_full_day: b.isFullDay,
|
||||
start_hour: b.startHour,
|
||||
end_hour: b.endHour,
|
||||
guest_name: b.guestName,
|
||||
guest_phone: b.guestPhone ?? '',
|
||||
linked_room_id: b.linkedRoomId,
|
||||
total_amount: b.totalAmount,
|
||||
paid_amount: b.paidAmount ?? 0,
|
||||
status: b.status,
|
||||
notes: b.notes,
|
||||
})
|
||||
setRentalBookings(prev => [...prev, created])
|
||||
} catch (err) {
|
||||
console.error('Failed to create rental booking:', err)
|
||||
}
|
||||
}, [slug])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
@@ -141,9 +173,9 @@ export function CalendarPage() {
|
||||
onBookingUpdate={handleUpdate}
|
||||
onBookingBulkUpdate={handleBulkUpdate}
|
||||
fadingBookingIds={fadingBookings}
|
||||
rentalObjects={isRentalActive ? RENTAL_OBJECTS : undefined}
|
||||
rentalBookings={isRentalActive ? rentalBookings : undefined}
|
||||
onRentalBookingCreate={isRentalActive ? (b: RentalBooking) => setRentalBookings(prev => [...prev, b]) : undefined}
|
||||
rentalObjects={isRentalActive ? rentalObjects as unknown as import('../data/rentalData').RentalObject[] : undefined}
|
||||
rentalBookings={isRentalActive ? rentalBookings as unknown as RentalBooking[] : undefined}
|
||||
onRentalBookingCreate={isRentalActive ? handleRentalBookingCreate : undefined}
|
||||
locks={locks}
|
||||
onDraftStart={handleDraftStart}
|
||||
onDraftCancel={handleDraftCancel}
|
||||
|
||||
Reference in New Issue
Block a user