diff --git a/backend/migrations/015_rental.sql b/backend/migrations/015_rental.sql new file mode 100644 index 0000000..6f84341 --- /dev/null +++ b/backend/migrations/015_rental.sql @@ -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 = 'ΠšΠΎΠ½Ρ„Π΅Ρ€Π΅Π½Ρ†-Π·Π°Π»'); diff --git a/backend/src/app.ts b/backend/src/app.ts index a98fa70..8c1424b 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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 } diff --git a/backend/src/routes/rental.ts b/backend/src/routes/rental.ts new file mode 100644 index 0000000..49cc181 --- /dev/null +++ b/backend/src/routes/rental.ts @@ -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 => { + 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( + '/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( + '/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 + 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( + '/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 + 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( + '/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( + '/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 + 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( + '/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 + 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( + '/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 + 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( + '/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 diff --git a/src/components/calendar/BookingCalendar.tsx b/src/components/calendar/BookingCalendar.tsx index 1c8b9e4..d460093 100644 --- a/src/components/calendar/BookingCalendar.tsx +++ b/src/components/calendar/BookingCalendar.tsx @@ -443,11 +443,6 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook )} - {!compact && !isMobile && ( -
- {room.baseRate.toLocaleString('ru-RU')} β‚½ -
- )} {/* Day cells + booking blocks */} @@ -693,42 +688,34 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook {hasFullDay ? ( /* Full day booking */
Π’Π΅ΡΡŒ дСнь
) : dayBookings.length > 0 ? ( - /* Hourly booking bars */ -
- {/* Time scale bar (background) */} -
- {dayBookings.map(b => { - const leftPct = ((b.startHour - obj.openHour) / totalSpan) * 100 - const widthPct = ((b.endHour - b.startHour) / totalSpan) * 100 - return ( -
- ) - })} -
- {/* Hour labels */} -
- {obj.openHour}:00 - {obj.closeHour}:00 -
- {/* Booking count */} -
+ /* Hourly bookings β€” squares + times */ +
+ {/* One colored square per booking */} +
{dayBookings.map(b => ( - - {b.startHour}–{b.endHour} - +
))}
+ {/* Working hours range */} + + {obj.openHour}:00–{obj.closeHour}:00 + + {/* Booked time slots */} + {dayBookings.map(b => ( + + {b.startHour}–{b.endHour} + + ))}
) : ( /* Empty β€” show "+" hint on hover */ diff --git a/src/lib/api.ts b/src/lib/api.ts index fe439c9..4ad2b1a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -312,6 +312,29 @@ export const api = { req('DELETE', `/api/hotels/${slug}/guests/${id}`), }, + // ── Rental ──────────────────────────────────────────────────────────────── + rental: { + listObjects: (slug: string) => + req('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('GET', `/api/hotels/${slug}/rental-bookings${q ? `?${q}` : ''}`) + }, + + createBooking: (slug: string, data: RentalBookingPayload) => + req('POST', `/api/hotels/${slug}/rental-bookings`, data), + + updateBooking: (slug: string, id: string, data: Partial) => + req('PATCH', `/api/hotels/${slug}/rental-bookings/${id}`, data), + + deleteBooking: (slug: string, id: string) => + req('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 { const out: Record = {} if (h.name !== undefined) out.name = h.name diff --git a/src/pages/CalendarPage.tsx b/src/pages/CalendarPage.tsx index f349c1c..d48d1eb 100644 --- a/src/pages/CalendarPage.tsx +++ b/src/pages/CalendarPage.tsx @@ -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([]) const [bookings, setBookings] = useState([]) const [fadingBookings, setFadingBookings] = useState>(new Set()) - const [rentalBookings, setRentalBookings] = useState(MOCK_RENTAL_BOOKINGS) + const [rentalObjects, setRentalObjects] = useState([]) + const [rentalBookings, setRentalBookings] = useState([]) const [locks, setLocks] = useState>(new Map()) useEffect(() => { if (!slug) return - Promise.all([ + const fetches: Promise[] = [ 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 (
@@ -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}