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:
2026-03-21 15:46:01 +03:00
parent 3f4d0dac4c
commit b48e50e62b
6 changed files with 396 additions and 43 deletions

View File

@@ -0,0 +1,52 @@
-- Rental objects (tennis court, sauna, conference hall, etc.)
CREATE TABLE IF NOT EXISTS rental_objects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE,
name TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT '🏨',
color TEXT NOT NULL DEFAULT 'bg-slate-500',
text_color TEXT NOT NULL DEFAULT 'text-slate-700 dark:text-slate-400',
price_per_hour INTEGER NOT NULL DEFAULT 0,
price_per_day INTEGER NOT NULL DEFAULT 0,
open_hour INTEGER NOT NULL DEFAULT 8,
close_hour INTEGER NOT NULL DEFAULT 22,
max_hours_per_slot INTEGER,
buffer_minutes INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Rental bookings (hourly / full-day slots)
CREATE TABLE IF NOT EXISTS rental_bookings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE,
object_id UUID NOT NULL REFERENCES rental_objects(id) ON DELETE CASCADE,
date DATE NOT NULL,
is_full_day BOOLEAN NOT NULL DEFAULT false,
start_hour INTEGER NOT NULL DEFAULT 0,
end_hour INTEGER NOT NULL DEFAULT 0,
guest_name TEXT NOT NULL,
guest_phone TEXT NOT NULL DEFAULT '',
linked_room_id UUID REFERENCES rooms(id),
total_amount INTEGER NOT NULL DEFAULT 0,
paid_amount INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'confirmed',
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Seed demo rental objects for all existing hotels (idempotent)
INSERT INTO rental_objects (hotel_id, name, icon, color, text_color, price_per_hour, price_per_day, open_hour, close_hour, sort_order)
SELECT h.id, 'Теннисный корт', '🎾', 'bg-green-500', 'text-green-700 dark:text-green-400', 1500, 8000, 8, 22, 1
FROM hotels h
WHERE NOT EXISTS (SELECT 1 FROM rental_objects ro WHERE ro.hotel_id = h.id AND ro.name = 'Теннисный корт');
INSERT INTO rental_objects (hotel_id, name, icon, color, text_color, price_per_hour, price_per_day, open_hour, close_hour, max_hours_per_slot, sort_order)
SELECT h.id, 'Баня', '🛁', 'bg-orange-500', 'text-orange-700 dark:text-orange-400', 2500, 12000, 10, 23, 4, 2
FROM hotels h
WHERE NOT EXISTS (SELECT 1 FROM rental_objects ro WHERE ro.hotel_id = h.id AND ro.name = 'Баня');
INSERT INTO rental_objects (hotel_id, name, icon, color, text_color, price_per_hour, price_per_day, open_hour, close_hour, sort_order)
SELECT h.id, 'Конференц-зал', '🏛️', 'bg-violet-500', 'text-violet-700 dark:text-violet-400', 3000, 15000, 9, 20, 3
FROM hotels h
WHERE NOT EXISTS (SELECT 1 FROM rental_objects ro WHERE ro.hotel_id = h.id AND ro.name = 'Конференц-зал');

View File

@@ -19,6 +19,7 @@ import netupRoutes from './routes/netup'
import guestsRoutes from './routes/guests'
import bookingGuestsRoutes from './routes/booking-guests'
import hotelSettingsRoutes from './routes/hotel-settings'
import rentalRoutes from './routes/rental'
export async function buildApp() {
const fastify = Fastify({
@@ -79,6 +80,7 @@ export async function buildApp() {
await fastify.register(guestsRoutes)
await fastify.register(bookingGuestsRoutes)
await fastify.register(hotelSettingsRoutes)
await fastify.register(rentalRoutes)
return fastify
}

View File

@@ -0,0 +1,207 @@
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
type SlugParam = { Params: { slug: string } }
type SlugIdParam = { Params: { slug: string; id: string } }
const rental: FastifyPluginAsync = async (fastify) => {
const getHotelId = async (slug: string): Promise<string | null> => {
const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
return rows[0]?.id ?? null
}
const canAccess = (userSlug: string | null, role: string, slug: string) =>
role === 'super_admin' || userSlug === slug
// ── GET rental objects ─────────────────────────────────────────────────────
fastify.get<SlugParam>(
'/api/hotels/:slug/rental-objects',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' })
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { rows } = await db.query(
'SELECT * FROM rental_objects WHERE hotel_id = $1 ORDER BY sort_order, created_at',
[hotelId],
)
return rows
},
)
// ── POST rental object ─────────────────────────────────────────────────────
fastify.post<SlugParam>(
'/api/hotels/:slug/rental-objects',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' })
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const b = request.body as Record<string, unknown>
const { rows: [obj] } = await db.query(
`INSERT INTO rental_objects
(hotel_id, name, icon, color, text_color, price_per_hour, price_per_day,
open_hour, close_hour, max_hours_per_slot, buffer_minutes, sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
RETURNING *`,
[
hotelId,
b.name ?? '',
b.icon ?? '🏨',
b.color ?? 'bg-slate-500',
b.text_color ?? 'text-slate-700 dark:text-slate-400',
b.price_per_hour ?? 0,
b.price_per_day ?? 0,
b.open_hour ?? 8,
b.close_hour ?? 22,
b.max_hours_per_slot ?? null,
b.buffer_minutes ?? 0,
b.sort_order ?? 0,
],
)
return reply.code(201).send(obj)
},
)
// ── PATCH rental object ────────────────────────────────────────────────────
fastify.patch<SlugIdParam>(
'/api/hotels/:slug/rental-objects/:id',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, id } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' })
const b = request.body as Record<string, unknown>
const allowed = ['name','icon','color','text_color','price_per_hour','price_per_day',
'open_hour','close_hour','max_hours_per_slot','buffer_minutes','sort_order']
const sets: string[] = []
const vals: unknown[] = []
for (const key of allowed) {
if (b[key] !== undefined) {
sets.push(`${key} = $${vals.length + 1}`)
vals.push(b[key])
}
}
if (sets.length === 0) return reply.code(400).send({ error: 'Nothing to update' })
vals.push(id)
const { rows: [obj] } = await db.query(
`UPDATE rental_objects SET ${sets.join(', ')} WHERE id = $${vals.length} RETURNING *`,
vals,
)
if (!obj) return reply.code(404).send({ error: 'Not found' })
return obj
},
)
// ── DELETE rental object ───────────────────────────────────────────────────
fastify.delete<SlugIdParam>(
'/api/hotels/:slug/rental-objects/:id',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, id } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' })
await db.query('DELETE FROM rental_objects WHERE id = $1', [id])
return reply.code(204).send()
},
)
// ── GET rental bookings ────────────────────────────────────────────────────
fastify.get<SlugParam>(
'/api/hotels/:slug/rental-bookings',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' })
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { from, to } = request.query as Record<string, string>
let query = 'SELECT * FROM rental_bookings WHERE hotel_id = $1'
const vals: unknown[] = [hotelId]
if (from) { vals.push(from); query += ` AND date >= $${vals.length}` }
if (to) { vals.push(to); query += ` AND date <= $${vals.length}` }
query += ' ORDER BY date, start_hour'
const { rows } = await db.query(query, vals)
return rows
},
)
// ── POST rental booking ────────────────────────────────────────────────────
fastify.post<SlugParam>(
'/api/hotels/:slug/rental-bookings',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' })
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const b = request.body as Record<string, unknown>
const { rows: [booking] } = await db.query(
`INSERT INTO rental_bookings
(hotel_id, object_id, date, is_full_day, start_hour, end_hour,
guest_name, guest_phone, linked_room_id, total_amount, paid_amount, status, notes)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
RETURNING *`,
[
hotelId,
b.object_id,
b.date,
b.is_full_day ?? false,
b.start_hour ?? 0,
b.end_hour ?? 0,
b.guest_name ?? '',
b.guest_phone ?? '',
b.linked_room_id ?? null,
b.total_amount ?? 0,
b.paid_amount ?? 0,
b.status ?? 'confirmed',
b.notes ?? null,
],
)
return reply.code(201).send(booking)
},
)
// ── PATCH rental booking ───────────────────────────────────────────────────
fastify.patch<SlugIdParam>(
'/api/hotels/:slug/rental-bookings/:id',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, id } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' })
const b = request.body as Record<string, unknown>
const allowed = ['date','is_full_day','start_hour','end_hour','guest_name','guest_phone',
'linked_room_id','total_amount','paid_amount','status','notes']
const sets: string[] = []
const vals: unknown[] = []
for (const key of allowed) {
if (b[key] !== undefined) {
sets.push(`${key} = $${vals.length + 1}`)
vals.push(b[key])
}
}
if (sets.length === 0) return reply.code(400).send({ error: 'Nothing to update' })
vals.push(id)
const { rows: [booking] } = await db.query(
`UPDATE rental_bookings SET ${sets.join(', ')} WHERE id = $${vals.length} RETURNING *`,
vals,
)
if (!booking) return reply.code(404).send({ error: 'Not found' })
return booking
},
)
// ── DELETE rental booking ──────────────────────────────────────────────────
fastify.delete<SlugIdParam>(
'/api/hotels/:slug/rental-bookings/:id',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, id } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' })
await db.query('DELETE FROM rental_bookings WHERE id = $1', [id])
return reply.code(204).send()
},
)
}
export default rental

View File

@@ -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 */

View File

@@ -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

View File

@@ -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}