From baf52dc4781f7ba04df03827d0a611638d7f94cd Mon Sep 17 00:00:00 2001 From: ialcarazsalazar Date: Sun, 15 Feb 2026 01:37:11 +0000 Subject: [PATCH] feat: add reservations module with status state machine Co-Authored-By: Claude Opus 4.6 --- backend/hotel_hacienda/src/app.js | 2 + .../controllers/reservations.controller.js | 191 ++++++++++++ .../src/routes/reservations.routes.js | 11 + frontend/Frontend-Hotel/src/App.jsx | 4 + .../src/pages/Reservations/NewReservation.jsx | 272 ++++++++++++++++++ .../src/pages/Reservations/Reservations.jsx | 116 ++++++++ .../components/ReservationCard.jsx | 136 +++++++++ .../src/services/reservationService.js | 7 + 8 files changed, 739 insertions(+) create mode 100644 backend/hotel_hacienda/src/controllers/reservations.controller.js create mode 100644 backend/hotel_hacienda/src/routes/reservations.routes.js create mode 100644 frontend/Frontend-Hotel/src/pages/Reservations/NewReservation.jsx create mode 100644 frontend/Frontend-Hotel/src/pages/Reservations/Reservations.jsx create mode 100644 frontend/Frontend-Hotel/src/pages/Reservations/components/ReservationCard.jsx create mode 100644 frontend/Frontend-Hotel/src/services/reservationService.js diff --git a/backend/hotel_hacienda/src/app.js b/backend/hotel_hacienda/src/app.js index e140b56..84f0350 100644 --- a/backend/hotel_hacienda/src/app.js +++ b/backend/hotel_hacienda/src/app.js @@ -36,6 +36,7 @@ const restaurantRoutes = require('./routes/restaurantpl.routes'); const incomehrxRoutes = require('./routes/incomehrx.routes'); const roomsRoutes = require('./routes/rooms.routes'); const dashboardRoutes = require('./routes/dashboard.routes'); +const reservationsRoutes = require('./routes/reservations.routes'); //Prefijo - Auth routes are public (no middleware) app.use('/api/auth', authRoutes); @@ -58,5 +59,6 @@ app.use('/api/hotelpl', authMiddleware, hotelRoutes); app.use('/api/restaurantpl', authMiddleware, restaurantRoutes); app.use('/api/rooms', authMiddleware, roomsRoutes); app.use('/api/dashboard', authMiddleware, dashboardRoutes); +app.use('/api/reservations', authMiddleware, reservationsRoutes); module.exports = app; diff --git a/backend/hotel_hacienda/src/controllers/reservations.controller.js b/backend/hotel_hacienda/src/controllers/reservations.controller.js new file mode 100644 index 0000000..5b08b8a --- /dev/null +++ b/backend/hotel_hacienda/src/controllers/reservations.controller.js @@ -0,0 +1,191 @@ +const pool = require('../db/connection'); + +const getReservations = async (req, res) => { + try { + const { status, from_date, to_date, search } = req.query; + let query = ` + SELECT r.*, g.first_name, g.last_name, g.phone, g.email, + rm.name_room, rm.bed_type + FROM reservations r + JOIN guests g ON g.id = r.guest_id + LEFT JOIN rooms rm ON rm.id_room = r.room_id + WHERE 1=1 + `; + const params = []; + let paramIndex = 1; + + if (status && status !== 'all') { + query += ` AND r.status = $${paramIndex++}`; + params.push(status); + } + if (from_date) { + query += ` AND r.check_in >= $${paramIndex++}`; + params.push(from_date); + } + if (to_date) { + query += ` AND r.check_out <= $${paramIndex++}`; + params.push(to_date); + } + if (search) { + query += ` AND (g.first_name ILIKE $${paramIndex} OR g.last_name ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + query += ' ORDER BY r.created_at DESC LIMIT 100'; + + const result = await pool.query(query, params); + res.json({ reservations: result.rows }); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Error al obtener reservaciones' }); + } +}; + +const createReservation = async (req, res) => { + try { + const { room_id, guest_id, guest, check_in, check_out, channel, total_amount, adults, children, notes } = req.body; + const created_by = req.user?.user_id; + + // Create guest if new + let finalGuestId = guest_id; + if (!finalGuestId && guest) { + const guestResult = await pool.query( + 'INSERT INTO guests (first_name, last_name, email, phone, nationality) VALUES ($1, $2, $3, $4, $5) RETURNING id', + [guest.first_name, guest.last_name, guest.email || null, guest.phone || null, guest.nationality || null] + ); + finalGuestId = guestResult.rows[0].id; + } + + // Check availability + const overlap = await pool.query( + `SELECT id FROM reservations + WHERE room_id = $1 AND status IN ('confirmed', 'checked_in') + AND check_in < $3 AND check_out > $2`, + [room_id, check_in, check_out] + ); + if (overlap.rows.length > 0) { + return res.status(409).json({ message: 'La habitacion no esta disponible para esas fechas' }); + } + + const result = await pool.query( + `INSERT INTO reservations (room_id, guest_id, check_in, check_out, status, channel, total_amount, adults, children, notes, created_by) + VALUES ($1, $2, $3, $4, 'pending', $5, $6, $7, $8, $9, $10) RETURNING *`, + [room_id, finalGuestId, check_in, check_out, channel || 'direct', total_amount, adults || 1, children || 0, notes, created_by] + ); + + res.status(201).json({ reservation: result.rows[0], message: 'Reservacion creada correctamente' }); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Error al crear reservacion' }); + } +}; + +const updateReservation = async (req, res) => { + try { + const { id } = req.params; + const { room_id, check_in, check_out, channel, total_amount, adults, children, notes } = req.body; + + const result = await pool.query( + `UPDATE reservations SET room_id = COALESCE($1, room_id), check_in = COALESCE($2, check_in), + check_out = COALESCE($3, check_out), channel = COALESCE($4, channel), + total_amount = COALESCE($5, total_amount), adults = COALESCE($6, adults), + children = COALESCE($7, children), notes = COALESCE($8, notes), updated_at = NOW() + WHERE id = $9 RETURNING *`, + [room_id, check_in, check_out, channel, total_amount, adults, children, notes, id] + ); + + if (result.rows.length === 0) return res.status(404).json({ message: 'Reservacion no encontrada' }); + res.json({ reservation: result.rows[0] }); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Error al actualizar reservacion' }); + } +}; + +const updateReservationStatus = async (req, res) => { + try { + const { id } = req.params; + const { status } = req.body; + const userId = req.user?.user_id; + + const current = await pool.query('SELECT * FROM reservations WHERE id = $1', [id]); + if (current.rows.length === 0) return res.status(404).json({ message: 'Reservacion no encontrada' }); + + const reservation = current.rows[0]; + const validTransitions = { + pending: ['confirmed', 'cancelled'], + confirmed: ['checked_in', 'cancelled'], + checked_in: ['checked_out'], + }; + + const allowed = validTransitions[reservation.status]; + if (!allowed || !allowed.includes(status)) { + return res.status(400).json({ message: `No se puede cambiar de ${reservation.status} a ${status}` }); + } + + await pool.query('UPDATE reservations SET status = $1, updated_at = NOW() WHERE id = $2', [status, id]); + + // Cascading side effects + if (status === 'checked_in') { + // Set room to occupied + await pool.query("UPDATE rooms SET status = 'occupied' WHERE id_room = $1", [reservation.room_id]); + await pool.query( + 'INSERT INTO room_status_log (room_id, previous_status, new_status, changed_by) VALUES ($1, $2, $3, $4)', + [reservation.room_id, 'available', 'occupied', userId] + ); + // Create guest stay record + await pool.query( + 'INSERT INTO guest_stays (guest_id, reservation_id, room_id, check_in) VALUES ($1, $2, $3, NOW())', + [reservation.guest_id, reservation.id, reservation.room_id] + ); + } + + if (status === 'checked_out') { + // Set room to cleaning + await pool.query("UPDATE rooms SET status = 'cleaning' WHERE id_room = $1", [reservation.room_id]); + await pool.query( + 'INSERT INTO room_status_log (room_id, previous_status, new_status, changed_by) VALUES ($1, $2, $3, $4)', + [reservation.room_id, 'occupied', 'cleaning', userId] + ); + // Create housekeeping task + await pool.query( + `INSERT INTO housekeeping_tasks (room_id, priority, type, status) VALUES ($1, 'high', 'checkout', 'pending')`, + [reservation.room_id] + ); + // Close guest stay + await pool.query( + 'UPDATE guest_stays SET check_out = NOW(), total_charged = $1 WHERE reservation_id = $2 AND check_out IS NULL', + [reservation.total_amount, reservation.id] + ); + } + + if (status === 'cancelled') { + // Free room if it was occupied + if (reservation.status === 'checked_in') { + await pool.query("UPDATE rooms SET status = 'available' WHERE id_room = $1", [reservation.room_id]); + } + } + + res.json({ message: 'Estado de reservacion actualizado' }); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Error al actualizar estado' }); + } +}; + +const checkAvailability = async (req, res) => { + try { + const { room_id, check_in, check_out } = req.query; + const overlap = await pool.query( + `SELECT id FROM reservations WHERE room_id = $1 AND status IN ('confirmed', 'checked_in') + AND check_in < $3 AND check_out > $2`, + [room_id, check_in, check_out] + ); + res.json({ available: overlap.rows.length === 0 }); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Error al verificar disponibilidad' }); + } +}; + +module.exports = { getReservations, createReservation, updateReservation, updateReservationStatus, checkAvailability }; diff --git a/backend/hotel_hacienda/src/routes/reservations.routes.js b/backend/hotel_hacienda/src/routes/reservations.routes.js new file mode 100644 index 0000000..a4b6370 --- /dev/null +++ b/backend/hotel_hacienda/src/routes/reservations.routes.js @@ -0,0 +1,11 @@ +const express = require('express'); +const router = express.Router(); +const ctrl = require('../controllers/reservations.controller'); + +router.get('/', ctrl.getReservations); +router.get('/availability', ctrl.checkAvailability); +router.post('/', ctrl.createReservation); +router.put('/:id', ctrl.updateReservation); +router.put('/:id/status', ctrl.updateReservationStatus); + +module.exports = router; diff --git a/frontend/Frontend-Hotel/src/App.jsx b/frontend/Frontend-Hotel/src/App.jsx index e55cc78..f75cabb 100644 --- a/frontend/Frontend-Hotel/src/App.jsx +++ b/frontend/Frontend-Hotel/src/App.jsx @@ -40,6 +40,8 @@ import NewMonthlyPayment from "./pages/Expenses/NewMonthlyPayment.jsx"; import Outcomes from "./pages/Inventory/Outcomes.jsx"; import HousekeeperOutcomes from "./pages/Inventory/HousekeeperOutcomes.jsx"; import RoomDashboard from "./pages/RoomDashboard/RoomDashboard.jsx"; +import Reservations from "./pages/Reservations/Reservations.jsx"; +import NewReservation from "./pages/Reservations/NewReservation.jsx"; import "./styles/global.css"; //SubmenĂș de Hotel @@ -133,6 +135,8 @@ export default function App() { } /> } /> } /> + } /> + } /> diff --git a/frontend/Frontend-Hotel/src/pages/Reservations/NewReservation.jsx b/frontend/Frontend-Hotel/src/pages/Reservations/NewReservation.jsx new file mode 100644 index 0000000..eb9c0e2 --- /dev/null +++ b/frontend/Frontend-Hotel/src/pages/Reservations/NewReservation.jsx @@ -0,0 +1,272 @@ +import React, { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { useForm } from "react-hook-form"; +import { createReservation } from "../../services/reservationService"; +import { getRoomsWithStatus } from "../../services/roomService"; + +const CHANNELS = ["direct", "booking", "expedia", "airbnb", "other"]; + +export default function NewReservation() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { register, handleSubmit, watch, formState: { errors } } = useForm({ + defaultValues: { + adults: 1, + children: 0, + channel: "direct", + }, + }); + + const [rooms, setRooms] = useState([]); + const [guestMode, setGuestMode] = useState("new"); // "new" or "existing" + const [guests, setGuests] = useState([]); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + const fetchRooms = async () => { + try { + const res = await getRoomsWithStatus(); + setRooms(res.data.rooms || []); + } catch (error) { + console.error(error); + } + }; + fetchRooms(); + }, []); + + const onSubmit = async (data) => { + try { + setSubmitting(true); + const payload = { + room_id: Number(data.room_id), + check_in: data.check_in, + check_out: data.check_out, + channel: data.channel, + total_amount: Number(data.total_amount), + adults: Number(data.adults), + children: Number(data.children), + notes: data.notes || null, + }; + + if (guestMode === "new") { + payload.guest = { + first_name: data.guest_first_name, + last_name: data.guest_last_name, + email: data.guest_email || null, + phone: data.guest_phone || null, + nationality: data.guest_nationality || null, + }; + } else { + payload.guest_id = Number(data.guest_id); + } + + await createReservation(payload); + navigate("/app/reservations"); + } catch (error) { + console.error(error); + } finally { + setSubmitting(false); + } + }; + + const channelLabelKey = { + direct: "reservations.channels.direct", + booking: "reservations.channels.booking", + expedia: "reservations.channels.expedia", + airbnb: "reservations.channels.airbnb", + other: "reservations.channels.other", + }; + + return ( +
+
+

{t("reservations.newReservation")}

+
+ +
+
+ {/* Dates */} +
+
+ + +
+
+ + +
+
+ + {/* Room selector */} +
+ + +
+ + {/* Guest section toggle */} +
+ +
+ + +
+ + {guestMode === "new" ? ( +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ ) : ( +
+ + +
+ )} +
+ + {/* Channel */} +
+ + +
+ + {/* Adults, Children, Total */} +
+
+ + +
+
+ + +
+
+ + +
+
+ + {/* Notes */} +
+ +