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:
52
backend/migrations/015_rental.sql
Normal file
52
backend/migrations/015_rental.sql
Normal 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 = 'Конференц-зал');
|
||||||
@@ -19,6 +19,7 @@ import netupRoutes from './routes/netup'
|
|||||||
import guestsRoutes from './routes/guests'
|
import guestsRoutes from './routes/guests'
|
||||||
import bookingGuestsRoutes from './routes/booking-guests'
|
import bookingGuestsRoutes from './routes/booking-guests'
|
||||||
import hotelSettingsRoutes from './routes/hotel-settings'
|
import hotelSettingsRoutes from './routes/hotel-settings'
|
||||||
|
import rentalRoutes from './routes/rental'
|
||||||
|
|
||||||
export async function buildApp() {
|
export async function buildApp() {
|
||||||
const fastify = Fastify({
|
const fastify = Fastify({
|
||||||
@@ -79,6 +80,7 @@ export async function buildApp() {
|
|||||||
await fastify.register(guestsRoutes)
|
await fastify.register(guestsRoutes)
|
||||||
await fastify.register(bookingGuestsRoutes)
|
await fastify.register(bookingGuestsRoutes)
|
||||||
await fastify.register(hotelSettingsRoutes)
|
await fastify.register(hotelSettingsRoutes)
|
||||||
|
await fastify.register(rentalRoutes)
|
||||||
|
|
||||||
return fastify
|
return fastify
|
||||||
}
|
}
|
||||||
|
|||||||
207
backend/src/routes/rental.ts
Normal file
207
backend/src/routes/rental.ts
Normal 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
|
||||||
@@ -443,11 +443,6 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!compact && !isMobile && (
|
|
||||||
<div className="ml-auto text-xs text-slate-400 dark:text-slate-500">
|
|
||||||
{room.baseRate.toLocaleString('ru-RU')} ₽
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Day cells + booking blocks */}
|
{/* Day cells + booking blocks */}
|
||||||
@@ -693,42 +688,34 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
|
|||||||
{hasFullDay ? (
|
{hasFullDay ? (
|
||||||
/* Full day booking */
|
/* Full day booking */
|
||||||
<div className={cn(
|
<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,
|
obj.color,
|
||||||
)}>
|
)}>
|
||||||
Весь день
|
Весь день
|
||||||
</div>
|
</div>
|
||||||
) : dayBookings.length > 0 ? (
|
) : dayBookings.length > 0 ? (
|
||||||
/* Hourly booking bars */
|
/* Hourly bookings — squares + times */
|
||||||
<div className="absolute inset-x-1 bottom-1" style={{ top: 6 }}>
|
<div className="absolute inset-x-1 top-1.5 flex flex-col gap-0.5">
|
||||||
{/* Time scale bar (background) */}
|
{/* One colored square per booking */}
|
||||||
<div className="w-full h-2.5 rounded-sm bg-slate-100 dark:bg-slate-600 relative overflow-hidden">
|
<div className="flex gap-0.5 flex-wrap">
|
||||||
{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">
|
|
||||||
{dayBookings.map(b => (
|
{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">
|
<div
|
||||||
{b.startHour}–{b.endHour}
|
key={b.id}
|
||||||
</span>
|
className={cn('w-3 h-3 rounded-[3px]', obj.color)}
|
||||||
|
title={`${b.guestName} · ${b.startHour}:00–${b.endHour}:00`}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Empty — show "+" hint on hover */
|
/* Empty — show "+" hint on hover */
|
||||||
|
|||||||
@@ -312,6 +312,29 @@ export const api = {
|
|||||||
req<void>('DELETE', `/api/hotels/${slug}/guests/${id}`),
|
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 IPTV ────────────────────────────────────────────────────────────
|
||||||
netup: {
|
netup: {
|
||||||
getSettings: (slug: string) =>
|
getSettings: (slug: string) =>
|
||||||
@@ -515,6 +538,56 @@ export interface GuestPayload {
|
|||||||
rating?: number
|
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> {
|
function toHotelPayload(h: HotelPayload): Record<string, unknown> {
|
||||||
const out: Record<string, unknown> = {}
|
const out: Record<string, unknown> = {}
|
||||||
if (h.name !== undefined) out.name = h.name
|
if (h.name !== undefined) out.name = h.name
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { BookingCalendar } from '../components/calendar/BookingCalendar'
|
import { BookingCalendar } from '../components/calendar/BookingCalendar'
|
||||||
import { RENTAL_OBJECTS, MOCK_RENTAL_BOOKINGS } from '../data/rentalData'
|
|
||||||
import { useModules } from '../contexts/ModulesContext'
|
import { useModules } from '../contexts/ModulesContext'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
|
import type { RentalObjectApi, RentalBookingApi } from '../lib/api'
|
||||||
import { useHotelSocket } from '../hooks/useHotelSocket'
|
import { useHotelSocket } from '../hooks/useHotelSocket'
|
||||||
import type { WsMessage } from '../hooks/useHotelSocket'
|
import type { WsMessage } from '../hooks/useHotelSocket'
|
||||||
import type { Room, Booking } from '../types'
|
import type { Room, Booking } from '../types'
|
||||||
@@ -20,19 +20,29 @@ export function CalendarPage() {
|
|||||||
const [rooms, setRooms] = useState<Room[]>([])
|
const [rooms, setRooms] = useState<Room[]>([])
|
||||||
const [bookings, setBookings] = useState<Booking[]>([])
|
const [bookings, setBookings] = useState<Booking[]>([])
|
||||||
const [fadingBookings, setFadingBookings] = useState<Set<string>>(new Set())
|
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())
|
const [locks, setLocks] = useState<Map<string, BookingLock>>(new Map())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!slug) return
|
if (!slug) return
|
||||||
Promise.all([
|
const fetches: Promise<unknown>[] = [
|
||||||
api.rooms.list(slug),
|
api.rooms.list(slug),
|
||||||
api.bookings.list(slug),
|
api.bookings.list(slug),
|
||||||
]).then(([r, b]) => {
|
]
|
||||||
setRooms(r)
|
if (isRentalActive) {
|
||||||
setBookings(b)
|
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)
|
}).catch(console.error)
|
||||||
}, [slug])
|
}, [slug, isRentalActive])
|
||||||
|
|
||||||
const handleWsMessage = useCallback((msg: WsMessage) => {
|
const handleWsMessage = useCallback((msg: WsMessage) => {
|
||||||
if (msg.type === 'lock') {
|
if (msg.type === 'lock') {
|
||||||
@@ -130,6 +140,28 @@ export function CalendarPage() {
|
|||||||
send({ type: 'unlock', roomId })
|
send({ type: 'unlock', roomId })
|
||||||
}, [send])
|
}, [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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
@@ -141,9 +173,9 @@ export function CalendarPage() {
|
|||||||
onBookingUpdate={handleUpdate}
|
onBookingUpdate={handleUpdate}
|
||||||
onBookingBulkUpdate={handleBulkUpdate}
|
onBookingBulkUpdate={handleBulkUpdate}
|
||||||
fadingBookingIds={fadingBookings}
|
fadingBookingIds={fadingBookings}
|
||||||
rentalObjects={isRentalActive ? RENTAL_OBJECTS : undefined}
|
rentalObjects={isRentalActive ? rentalObjects as unknown as import('../data/rentalData').RentalObject[] : undefined}
|
||||||
rentalBookings={isRentalActive ? rentalBookings : undefined}
|
rentalBookings={isRentalActive ? rentalBookings as unknown as RentalBooking[] : undefined}
|
||||||
onRentalBookingCreate={isRentalActive ? (b: RentalBooking) => setRentalBookings(prev => [...prev, b]) : undefined}
|
onRentalBookingCreate={isRentalActive ? handleRentalBookingCreate : undefined}
|
||||||
locks={locks}
|
locks={locks}
|
||||||
onDraftStart={handleDraftStart}
|
onDraftStart={handleDraftStart}
|
||||||
onDraftCancel={handleDraftCancel}
|
onDraftCancel={handleDraftCancel}
|
||||||
|
|||||||
Reference in New Issue
Block a user