feat: add reservations module with status state machine

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 01:37:11 +00:00
parent 45a027694d
commit baf52dc478
8 changed files with 739 additions and 0 deletions

View File

@@ -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>
</>

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 });