feat: add reservations module with status state machine
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
11
backend/hotel_hacienda/src/routes/reservations.routes.js
Normal file
11
backend/hotel_hacienda/src/routes/reservations.routes.js
Normal file
@@ -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;
|
||||
@@ -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() {
|
||||
<Route path="inventory/outcomes" element={<Outcomes />} />
|
||||
<Route path="housekeeper/outcomes" element={<HousekeeperOutcomes />} />
|
||||
<Route path="room-dashboard" element={<RoomDashboard />} />
|
||||
<Route path="reservations" element={<Reservations />} />
|
||||
<Route path="reservations/new" element={<NewReservation />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</>
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h2>{t("reservations.newReservation")}</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="card" style={{ maxWidth: "800px" }}>
|
||||
{/* Dates */}
|
||||
<div className="grid-2" style={{ marginBottom: "var(--space-lg)" }}>
|
||||
<div>
|
||||
<label className="label-dark">{t("reservations.checkIn")}</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input-dark"
|
||||
{...register("check_in", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label-dark">{t("reservations.checkOut")}</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input-dark"
|
||||
{...register("check_out", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Room selector */}
|
||||
<div style={{ marginBottom: "var(--space-lg)" }}>
|
||||
<label className="label-dark">{t("common.room")}</label>
|
||||
<select className="select-dark" {...register("room_id", { required: true })}>
|
||||
<option value="">{t("common.room")}...</option>
|
||||
{rooms
|
||||
.filter((rm) => rm.status === "available")
|
||||
.map((rm) => (
|
||||
<option key={rm.id_room} value={rm.id_room}>
|
||||
{rm.name_room} - {rm.bed_type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Guest section toggle */}
|
||||
<div style={{ marginBottom: "var(--space-lg)" }}>
|
||||
<label className="label-dark">{t("reservations.guestName")}</label>
|
||||
<div className="filter-pills" style={{ marginBottom: "var(--space-md)" }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`filter-pill${guestMode === "new" ? " active" : ""}`}
|
||||
onClick={() => setGuestMode("new")}
|
||||
>
|
||||
{t("guests.newGuest")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`filter-pill${guestMode === "existing" ? " active" : ""}`}
|
||||
onClick={() => setGuestMode("existing")}
|
||||
>
|
||||
{t("common.search")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{guestMode === "new" ? (
|
||||
<div className="grid-2" style={{ gap: "var(--space-md)" }}>
|
||||
<div>
|
||||
<label className="label-dark">{t("guests.firstName")}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-dark"
|
||||
{...register("guest_first_name", { required: guestMode === "new" })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label-dark">{t("guests.lastName")}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-dark"
|
||||
{...register("guest_last_name", { required: guestMode === "new" })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label-dark">{t("common.email")}</label>
|
||||
<input
|
||||
type="email"
|
||||
className="input-dark"
|
||||
{...register("guest_email")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label-dark">{t("common.phone")}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-dark"
|
||||
{...register("guest_phone")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label-dark">{t("guests.nationality")}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-dark"
|
||||
{...register("guest_nationality")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="label-dark">{t("reservations.guestName")}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-dark"
|
||||
placeholder="Guest ID"
|
||||
{...register("guest_id", { required: guestMode === "existing" })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Channel */}
|
||||
<div style={{ marginBottom: "var(--space-lg)" }}>
|
||||
<label className="label-dark">{t("reservations.channel")}</label>
|
||||
<select className="select-dark" {...register("channel")}>
|
||||
{CHANNELS.map((ch) => (
|
||||
<option key={ch} value={ch}>
|
||||
{t(channelLabelKey[ch])}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Adults, Children, Total */}
|
||||
<div className="grid-3" style={{ marginBottom: "var(--space-lg)" }}>
|
||||
<div>
|
||||
<label className="label-dark">{t("reservations.adults")}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-dark"
|
||||
min="1"
|
||||
{...register("adults", { required: true, min: 1 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label-dark">{t("reservations.children")}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-dark"
|
||||
min="0"
|
||||
{...register("children", { min: 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label-dark">{t("reservations.totalAmount")}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-dark"
|
||||
step="0.01"
|
||||
min="0"
|
||||
{...register("total_amount", { required: true, min: 0 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div style={{ marginBottom: "var(--space-lg)" }}>
|
||||
<label className="label-dark">{t("common.notes")}</label>
|
||||
<textarea
|
||||
className="input-dark"
|
||||
rows={3}
|
||||
style={{ resize: "vertical" }}
|
||||
{...register("notes")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: "flex", gap: "var(--space-md)", justifyContent: "flex-end" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline"
|
||||
onClick={() => navigate("/app/reservations")}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button type="submit" className="btn-gold" disabled={submitting}>
|
||||
{submitting ? t("common.loading") : t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
frontend/Frontend-Hotel/src/pages/Reservations/Reservations.jsx
Normal file
116
frontend/Frontend-Hotel/src/pages/Reservations/Reservations.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { getReservations, updateReservationStatus } from "../../services/reservationService";
|
||||
import ReservationCard from "./components/ReservationCard";
|
||||
|
||||
const STATUS_FILTERS = ["all", "pending", "confirmed", "checked_in", "checked_out", "cancelled"];
|
||||
|
||||
const statusLabelKey = {
|
||||
all: "common.all",
|
||||
pending: "reservations.status.pending",
|
||||
confirmed: "reservations.status.confirmed",
|
||||
checked_in: "reservations.status.checkedIn",
|
||||
checked_out: "reservations.status.checkedOut",
|
||||
cancelled: "reservations.status.cancelled",
|
||||
};
|
||||
|
||||
export default function Reservations() {
|
||||
const { t } = useTranslation();
|
||||
const [reservations, setReservations] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeStatus, setActiveStatus] = useState("all");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const fetchReservations = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = {};
|
||||
if (activeStatus !== "all") params.status = activeStatus;
|
||||
if (search.trim()) params.search = search.trim();
|
||||
const res = await getReservations(params);
|
||||
setReservations(res.data.reservations || []);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchReservations();
|
||||
}, [activeStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
fetchReservations();
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
const handleStatusChange = async (id, newStatus) => {
|
||||
try {
|
||||
await updateReservationStatus(id, newStatus);
|
||||
fetchReservations();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<p>{t("common.loading")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h2>{t("reservations.title")}</h2>
|
||||
<Link to="/app/reservations/new">
|
||||
<button className="btn-gold">{t("reservations.newReservation")}</button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "var(--space-md)", alignItems: "center", marginBottom: "var(--space-lg)", flexWrap: "wrap" }}>
|
||||
<div className="filter-pills">
|
||||
{STATUS_FILTERS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
className={`filter-pill${activeStatus === s ? " active" : ""}`}
|
||||
onClick={() => setActiveStatus(s)}
|
||||
>
|
||||
{t(statusLabelKey[s])}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="search-bar" style={{ minWidth: "220px" }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("reservations.guestName") + "..."}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{reservations.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>{t("common.noResults")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid-3">
|
||||
{reservations.map((r) => (
|
||||
<ReservationCard
|
||||
key={r.id}
|
||||
reservation={r}
|
||||
onStatusChange={handleStatusChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const statusBadgeClass = {
|
||||
pending: "badge badge-pending",
|
||||
confirmed: "badge badge-confirmed",
|
||||
checked_in: "badge badge-info",
|
||||
checked_out: "badge badge-success",
|
||||
cancelled: "badge badge-error",
|
||||
};
|
||||
|
||||
const statusKey = {
|
||||
pending: "reservations.status.pending",
|
||||
confirmed: "reservations.status.confirmed",
|
||||
checked_in: "reservations.status.checkedIn",
|
||||
checked_out: "reservations.status.checkedOut",
|
||||
cancelled: "reservations.status.cancelled",
|
||||
};
|
||||
|
||||
const channelKey = {
|
||||
direct: "reservations.channels.direct",
|
||||
booking: "reservations.channels.booking",
|
||||
expedia: "reservations.channels.expedia",
|
||||
airbnb: "reservations.channels.airbnb",
|
||||
other: "reservations.channels.other",
|
||||
};
|
||||
|
||||
function calculateNights(checkIn, checkOut) {
|
||||
const start = new Date(checkIn);
|
||||
const end = new Date(checkOut);
|
||||
const diff = Math.ceil((end - start) / (1000 * 60 * 60 * 24));
|
||||
return diff > 0 ? diff : 0;
|
||||
}
|
||||
|
||||
export default function ReservationCard({ reservation, onStatusChange }) {
|
||||
const { t } = useTranslation();
|
||||
const r = reservation;
|
||||
const nights = calculateNights(r.check_in, r.check_out);
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return "-";
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card" style={{ display: "flex", flexDirection: "column", gap: "var(--space-sm)" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: "1.1rem", color: "var(--text-primary)" }}>
|
||||
{r.first_name} {r.last_name}
|
||||
</h3>
|
||||
{r.phone && (
|
||||
<span style={{ fontSize: "0.8rem", color: "var(--text-secondary)" }}>
|
||||
{r.phone}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={statusBadgeClass[r.status] || "badge"}>
|
||||
{t(statusKey[r.status] || r.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{r.name_room && (
|
||||
<div>
|
||||
<span className="badge badge-info" style={{ fontSize: "0.7rem" }}>
|
||||
{r.name_room}
|
||||
</span>
|
||||
{r.bed_type && (
|
||||
<span style={{ fontSize: "0.75rem", color: "var(--text-muted)", marginLeft: "var(--space-sm)" }}>
|
||||
{r.bed_type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", fontSize: "0.8rem", color: "var(--text-secondary)" }}>
|
||||
<div>
|
||||
<span style={{ display: "block", color: "var(--text-muted)", fontSize: "0.7rem", textTransform: "uppercase" }}>
|
||||
{t("reservations.checkIn")}
|
||||
</span>
|
||||
{formatDate(r.check_in)}
|
||||
</div>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<span style={{ display: "block", color: "var(--text-muted)", fontSize: "0.7rem", textTransform: "uppercase" }}>
|
||||
{t("reservations.duration")}
|
||||
</span>
|
||||
{nights} {t("reservations.nights")}
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<span style={{ display: "block", color: "var(--text-muted)", fontSize: "0.7rem", textTransform: "uppercase" }}>
|
||||
{t("reservations.checkOut")}
|
||||
</span>
|
||||
{formatDate(r.check_out)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: "0.8rem" }}>
|
||||
<span className="badge" style={{ background: "var(--bg-elevated)", color: "var(--text-secondary)", fontSize: "0.7rem" }}>
|
||||
{t(channelKey[r.channel] || r.channel)}
|
||||
</span>
|
||||
<span style={{ fontWeight: 700, color: "var(--accent-gold)", fontSize: "1rem" }}>
|
||||
${Number(r.total_amount || 0).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Contextual action buttons based on status */}
|
||||
<div style={{ display: "flex", gap: "var(--space-sm)", marginTop: "var(--space-xs)" }}>
|
||||
{r.status === "pending" && (
|
||||
<>
|
||||
<button className="btn-gold btn-sm" style={{ flex: 1 }} onClick={() => onStatusChange(r.id, "confirmed")}>
|
||||
{t("reservations.actions.confirm")}
|
||||
</button>
|
||||
<button className="btn-danger btn-sm" style={{ flex: 1 }} onClick={() => onStatusChange(r.id, "cancelled")}>
|
||||
{t("reservations.actions.cancel")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{r.status === "confirmed" && (
|
||||
<>
|
||||
<button className="btn-success btn-sm" style={{ flex: 1 }} onClick={() => onStatusChange(r.id, "checked_in")}>
|
||||
{t("reservations.actions.checkIn")}
|
||||
</button>
|
||||
<button className="btn-danger btn-sm" style={{ flex: 1 }} onClick={() => onStatusChange(r.id, "cancelled")}>
|
||||
{t("reservations.actions.cancel")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{r.status === "checked_in" && (
|
||||
<button className="btn-gold btn-sm" style={{ flex: 1 }} onClick={() => onStatusChange(r.id, "checked_out")}>
|
||||
{t("reservations.actions.checkOut")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import api from "./api";
|
||||
|
||||
export const getReservations = (params) => api.get("/reservations", { params });
|
||||
export const createReservation = (data) => api.post("/reservations", data);
|
||||
export const updateReservation = (id, data) => api.put(`/reservations/${id}`, data);
|
||||
export const updateReservationStatus = (id, status) => api.put(`/reservations/${id}/status`, { status });
|
||||
export const checkAvailability = (params) => api.get("/reservations/availability", { params });
|
||||
Reference in New Issue
Block a user