Files
hotelsync/backend/src/routes/rental.ts
HotelSync b48e50e62b 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>
2026-03-21 15:46:01 +03:00

208 lines
9.1 KiB
TypeScript

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