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 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
|
||||
}
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user