feat: add guest management with profiles and stay history

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 01:41:24 +00:00
parent baf52dc478
commit 785b1f0925
7 changed files with 771 additions and 0 deletions

View File

@@ -37,6 +37,7 @@ const incomehrxRoutes = require('./routes/incomehrx.routes');
const roomsRoutes = require('./routes/rooms.routes');
const dashboardRoutes = require('./routes/dashboard.routes');
const reservationsRoutes = require('./routes/reservations.routes');
const guestsRoutes = require('./routes/guests.routes');
//Prefijo - Auth routes are public (no middleware)
app.use('/api/auth', authRoutes);
@@ -60,5 +61,6 @@ app.use('/api/restaurantpl', authMiddleware, restaurantRoutes);
app.use('/api/rooms', authMiddleware, roomsRoutes);
app.use('/api/dashboard', authMiddleware, dashboardRoutes);
app.use('/api/reservations', authMiddleware, reservationsRoutes);
app.use('/api/guests', authMiddleware, guestsRoutes);
module.exports = app;

View File

@@ -0,0 +1,108 @@
const pool = require('../db/connection');
const getGuests = async (req, res) => {
try {
const { search, limit = 50, offset = 0 } = req.query;
let query = `
SELECT g.*,
r.room_id, rm.name_room,
r.check_in as current_check_in
FROM guests g
LEFT JOIN reservations r ON r.guest_id = g.id AND r.status = 'checked_in'
LEFT JOIN rooms rm ON rm.id_room = r.room_id
`;
const params = [];
let paramIndex = 1;
if (search) {
query += ` WHERE g.first_name ILIKE $${paramIndex} OR g.last_name ILIKE $${paramIndex} OR g.email ILIKE $${paramIndex} OR g.phone ILIKE $${paramIndex}`;
params.push(`%${search}%`);
paramIndex++;
}
query += ` ORDER BY g.created_at DESC LIMIT $${paramIndex++} OFFSET $${paramIndex++}`;
params.push(parseInt(limit), parseInt(offset));
const result = await pool.query(query, params);
const countQuery = search
? await pool.query('SELECT COUNT(*) FROM guests WHERE first_name ILIKE $1 OR last_name ILIKE $1 OR email ILIKE $1 OR phone ILIKE $1', [`%${search}%`])
: await pool.query('SELECT COUNT(*) FROM guests');
res.json({ guests: result.rows, total: parseInt(countQuery.rows[0].count) });
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Error al obtener huespedes' });
}
};
const getGuestById = async (req, res) => {
try {
const { id } = req.params;
const guest = await pool.query(`
SELECT g.*, r.room_id, rm.name_room, r.check_in as current_check_in, r.check_out as current_check_out
FROM guests g
LEFT JOIN reservations r ON r.guest_id = g.id AND r.status = 'checked_in'
LEFT JOIN rooms rm ON rm.id_room = r.room_id
WHERE g.id = $1
`, [id]);
if (guest.rows.length === 0) return res.status(404).json({ message: 'Huesped no encontrado' });
res.json({ guest: guest.rows[0] });
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Error al obtener huesped' });
}
};
const createGuest = async (req, res) => {
try {
const { first_name, last_name, email, phone, id_type, id_number, nationality, address, notes } = req.body;
const result = await pool.query(
`INSERT INTO guests (first_name, last_name, email, phone, id_type, id_number, nationality, address, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`,
[first_name, last_name, email, phone, id_type, id_number, nationality, address, notes]
);
res.status(201).json({ guest: result.rows[0], message: 'Huesped creado correctamente' });
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Error al crear huesped' });
}
};
const updateGuest = async (req, res) => {
try {
const { id } = req.params;
const { first_name, last_name, email, phone, id_type, id_number, nationality, address, notes } = req.body;
const result = await pool.query(
`UPDATE guests SET first_name = COALESCE($1, first_name), last_name = COALESCE($2, last_name),
email = COALESCE($3, email), phone = COALESCE($4, phone), id_type = COALESCE($5, id_type),
id_number = COALESCE($6, id_number), nationality = COALESCE($7, nationality),
address = COALESCE($8, address), notes = COALESCE($9, notes), updated_at = NOW()
WHERE id = $10 RETURNING *`,
[first_name, last_name, email, phone, id_type, id_number, nationality, address, notes, id]
);
if (result.rows.length === 0) return res.status(404).json({ message: 'Huesped no encontrado' });
res.json({ guest: result.rows[0] });
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Error al actualizar huesped' });
}
};
const getGuestStays = async (req, res) => {
try {
const { id } = req.params;
const result = await pool.query(`
SELECT gs.*, rm.name_room, rm.bed_type
FROM guest_stays gs
LEFT JOIN rooms rm ON rm.id_room = gs.room_id
WHERE gs.guest_id = $1
ORDER BY gs.check_in DESC
`, [id]);
res.json({ stays: result.rows });
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Error al obtener estadias' });
}
};
module.exports = { getGuests, getGuestById, createGuest, updateGuest, getGuestStays };

View File

@@ -0,0 +1,11 @@
const express = require('express');
const router = express.Router();
const ctrl = require('../controllers/guests.controller');
router.get('/', ctrl.getGuests);
router.get('/:id', ctrl.getGuestById);
router.get('/:id/stays', ctrl.getGuestStays);
router.post('/', ctrl.createGuest);
router.put('/:id', ctrl.updateGuest);
module.exports = router;

View File

@@ -42,6 +42,8 @@ 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 Guests from "./pages/Guests/Guests.jsx";
import GuestDetail from "./pages/Guests/GuestDetail.jsx";
import "./styles/global.css";
//Submenú de Hotel
@@ -137,6 +139,8 @@ export default function App() {
<Route path="room-dashboard" element={<RoomDashboard />} />
<Route path="reservations" element={<Reservations />} />
<Route path="reservations/new" element={<NewReservation />} />
<Route path="guests" element={<Guests />} />
<Route path="guests/:id" element={<GuestDetail />} />
</Route>
</Routes>
</>

View File

@@ -0,0 +1,363 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useParams, useNavigate } from "react-router-dom";
import { getGuestById, updateGuest, getGuestStays } from "../../services/guestService";
export default function GuestDetail() {
const { t } = useTranslation();
const { id } = useParams();
const navigate = useNavigate();
const [guest, setGuest] = useState(null);
const [stays, setStays] = useState([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [form, setForm] = useState({});
const fetchGuest = async () => {
try {
setLoading(true);
const [guestRes, staysRes] = await Promise.all([
getGuestById(id),
getGuestStays(id),
]);
setGuest(guestRes.data.guest);
setStays(staysRes.data.stays || []);
setForm(guestRes.data.guest);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchGuest();
}, [id]);
const handleUpdate = async (e) => {
e.preventDefault();
try {
await updateGuest(id, form);
setEditing(false);
fetchGuest();
} catch (error) {
console.error(error);
}
};
const getInitials = (firstName, lastName) => {
const f = firstName ? firstName.charAt(0).toUpperCase() : "";
const l = lastName ? lastName.charAt(0).toUpperCase() : "";
return f + l;
};
const formatDate = (dateStr) => {
if (!dateStr) return "-";
return new Date(dateStr).toLocaleDateString();
};
const renderStars = (rating) => {
if (!rating) return "-";
const stars = [];
for (let i = 1; i <= 5; i++) {
stars.push(
<span
key={i}
style={{
color: i <= rating ? "var(--accent-gold)" : "var(--text-muted)",
fontSize: "0.9rem",
}}
>
&#9733;
</span>
);
}
return stars;
};
if (loading) {
return (
<div className="empty-state">
<p>{t("common.loading")}</p>
</div>
);
}
if (!guest) {
return (
<div className="empty-state">
<p>{t("common.noResults")}</p>
</div>
);
}
return (
<div>
<div style={{ marginBottom: "var(--space-lg)" }}>
<button className="btn-outline btn-sm" onClick={() => navigate("/app/guests")}>
&larr; {t("common.back")}
</button>
</div>
{/* Current Room Banner */}
{guest.name_room && (
<div
className="card"
style={{
marginBottom: "var(--space-lg)",
borderLeft: "4px solid var(--status-occupied)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<span style={{ color: "var(--text-muted)", fontSize: "0.75rem", textTransform: "uppercase", fontWeight: 600 }}>
{t("guests.currentRoom")}
</span>
<h3 style={{ margin: "4px 0 0", color: "var(--text-primary)" }}>{guest.name_room}</h3>
</div>
<div style={{ textAlign: "right" }}>
<span style={{ fontSize: "0.8rem", color: "var(--text-secondary)" }}>
{t("reservations.checkIn")}: {formatDate(guest.current_check_in)}
</span>
{guest.current_check_out && (
<span style={{ display: "block", fontSize: "0.8rem", color: "var(--text-secondary)" }}>
{t("reservations.checkOut")}: {formatDate(guest.current_check_out)}
</span>
)}
</div>
</div>
)}
<div className="grid-2">
{/* Profile Section */}
<div className="card">
{!editing ? (
<div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: "var(--space-lg)" }}>
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-md)" }}>
<div
style={{
width: "64px",
height: "64px",
borderRadius: "50%",
background: "var(--accent-gold-muted)",
color: "var(--accent-gold)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontWeight: 700,
fontSize: "1.25rem",
flexShrink: 0,
}}
>
{getInitials(guest.first_name, guest.last_name)}
</div>
<div>
<h2 style={{ margin: 0, color: "var(--text-primary)" }}>
{guest.first_name} {guest.last_name}
</h2>
{guest.nationality && (
<span style={{ fontSize: "0.85rem", color: "var(--text-secondary)" }}>
{guest.nationality}
</span>
)}
</div>
</div>
<button className="btn-outline btn-sm" onClick={() => setEditing(true)}>
{t("common.edit")}
</button>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)" }}>
{guest.email && (
<div>
<span className="label-dark">{t("guests.email")}</span>
<p style={{ margin: "4px 0 0", color: "var(--text-primary)", fontSize: "0.9rem" }}>{guest.email}</p>
</div>
)}
{guest.phone && (
<div>
<span className="label-dark">{t("guests.phone")}</span>
<p style={{ margin: "4px 0 0", color: "var(--text-primary)", fontSize: "0.9rem" }}>{guest.phone}</p>
</div>
)}
{guest.id_type && (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-md)" }}>
<div>
<span className="label-dark">{t("guests.idType")}</span>
<p style={{ margin: "4px 0 0", color: "var(--text-primary)", fontSize: "0.9rem" }}>{guest.id_type}</p>
</div>
<div>
<span className="label-dark">{t("guests.idNumber")}</span>
<p style={{ margin: "4px 0 0", color: "var(--text-primary)", fontSize: "0.9rem" }}>{guest.id_number}</p>
</div>
</div>
)}
{guest.address && (
<div>
<span className="label-dark">{t("guests.address")}</span>
<p style={{ margin: "4px 0 0", color: "var(--text-primary)", fontSize: "0.9rem" }}>{guest.address}</p>
</div>
)}
{guest.notes && (
<div>
<span className="label-dark">{t("common.notes")}</span>
<p style={{ margin: "4px 0 0", color: "var(--text-secondary)", fontSize: "0.9rem" }}>{guest.notes}</p>
</div>
)}
</div>
</div>
) : (
<form onSubmit={handleUpdate} style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)" }}>
<h3 style={{ margin: 0, color: "var(--text-primary)" }}>{t("common.edit")}</h3>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-md)" }}>
<div>
<label className="label-dark">{t("guests.firstName")}</label>
<input
className="input-dark"
type="text"
value={form.first_name || ""}
onChange={(e) => setForm({ ...form, first_name: e.target.value })}
/>
</div>
<div>
<label className="label-dark">{t("guests.lastName")}</label>
<input
className="input-dark"
type="text"
value={form.last_name || ""}
onChange={(e) => setForm({ ...form, last_name: e.target.value })}
/>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-md)" }}>
<div>
<label className="label-dark">{t("guests.email")}</label>
<input
className="input-dark"
type="email"
value={form.email || ""}
onChange={(e) => setForm({ ...form, email: e.target.value })}
/>
</div>
<div>
<label className="label-dark">{t("guests.phone")}</label>
<input
className="input-dark"
type="text"
value={form.phone || ""}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
/>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-md)" }}>
<div>
<label className="label-dark">{t("guests.idType")}</label>
<input
className="input-dark"
type="text"
value={form.id_type || ""}
onChange={(e) => setForm({ ...form, id_type: e.target.value })}
/>
</div>
<div>
<label className="label-dark">{t("guests.idNumber")}</label>
<input
className="input-dark"
type="text"
value={form.id_number || ""}
onChange={(e) => setForm({ ...form, id_number: e.target.value })}
/>
</div>
</div>
<div>
<label className="label-dark">{t("guests.nationality")}</label>
<input
className="input-dark"
type="text"
value={form.nationality || ""}
onChange={(e) => setForm({ ...form, nationality: e.target.value })}
/>
</div>
<div>
<label className="label-dark">{t("guests.address")}</label>
<input
className="input-dark"
type="text"
value={form.address || ""}
onChange={(e) => setForm({ ...form, address: e.target.value })}
/>
</div>
<div>
<label className="label-dark">{t("common.notes")}</label>
<textarea
className="input-dark"
rows={3}
value={form.notes || ""}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
style={{ resize: "vertical" }}
/>
</div>
<div style={{ display: "flex", gap: "var(--space-md)", justifyContent: "flex-end" }}>
<button type="button" className="btn-outline" onClick={() => { setEditing(false); setForm(guest); }}>
{t("common.cancel")}
</button>
<button type="submit" className="btn-gold">
{t("common.save")}
</button>
</div>
</form>
)}
</div>
{/* Stay History Section */}
<div className="card">
<h3 style={{ margin: "0 0 var(--space-lg)", color: "var(--text-primary)" }}>
{t("guests.stayHistory")}
</h3>
{stays.length === 0 ? (
<div className="empty-state">
<p>{t("common.noResults")}</p>
</div>
) : (
<div style={{ overflowX: "auto" }}>
<table className="table-dark">
<thead>
<tr>
<th>{t("common.room")}</th>
<th>{t("reservations.checkIn")}</th>
<th>{t("reservations.checkOut")}</th>
<th>{t("common.total")}</th>
<th>Rating</th>
</tr>
</thead>
<tbody>
{stays.map((stay, idx) => (
<tr key={stay.id || idx}>
<td>
<span style={{ fontWeight: 600 }}>{stay.name_room || "-"}</span>
{stay.bed_type && (
<span style={{ display: "block", fontSize: "0.75rem", color: "var(--text-muted)" }}>
{stay.bed_type}
</span>
)}
</td>
<td>{formatDate(stay.check_in)}</td>
<td>{formatDate(stay.check_out)}</td>
<td style={{ color: "var(--accent-gold)", fontWeight: 600 }}>
${Number(stay.total_charged || 0).toLocaleString()}
</td>
<td>{renderStars(stay.rating)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,277 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { getGuests, createGuest } from "../../services/guestService";
export default function Guests() {
const { t } = useTranslation();
const navigate = useNavigate();
const [guests, setGuests] = useState([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [showModal, setShowModal] = useState(false);
const [form, setForm] = useState({
first_name: "",
last_name: "",
email: "",
phone: "",
id_type: "",
id_number: "",
nationality: "",
address: "",
notes: "",
});
const fetchGuests = async () => {
try {
setLoading(true);
const params = {};
if (search.trim()) params.search = search.trim();
const res = await getGuests(params);
setGuests(res.data.guests || []);
setTotal(res.data.total || 0);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchGuests();
}, []);
useEffect(() => {
const timer = setTimeout(() => {
fetchGuests();
}, 400);
return () => clearTimeout(timer);
}, [search]);
const handleCreate = async (e) => {
e.preventDefault();
try {
await createGuest(form);
setShowModal(false);
setForm({
first_name: "",
last_name: "",
email: "",
phone: "",
id_type: "",
id_number: "",
nationality: "",
address: "",
notes: "",
});
fetchGuests();
} catch (error) {
console.error(error);
}
};
const getInitials = (firstName, lastName) => {
const f = firstName ? firstName.charAt(0).toUpperCase() : "";
const l = lastName ? lastName.charAt(0).toUpperCase() : "";
return f + l;
};
if (loading && guests.length === 0) {
return (
<div className="empty-state">
<p>{t("common.loading")}</p>
</div>
);
}
return (
<div>
<div className="page-header">
<h2>{t("guests.title")}</h2>
<button className="btn-gold" onClick={() => setShowModal(true)}>
{t("guests.newGuest")}
</button>
</div>
<div style={{ marginBottom: "var(--space-lg)" }}>
<div className="search-bar" style={{ maxWidth: "400px" }}>
<input
type="text"
placeholder={`${t("common.search")} ${t("common.name")}, ${t("common.email")}, ${t("common.phone")}...`}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
{guests.length === 0 ? (
<div className="empty-state">
<p>{t("common.noResults")}</p>
</div>
) : (
<div className="grid-3">
{guests.map((guest) => (
<div
key={guest.id}
className="card"
style={{ cursor: "pointer", transition: "all 0.2s" }}
onClick={() => navigate(`/app/guests/${guest.id}`)}
>
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-md)" }}>
<div
style={{
width: "48px",
height: "48px",
borderRadius: "50%",
background: "var(--accent-gold-muted)",
color: "var(--accent-gold)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontWeight: 700,
fontSize: "1rem",
flexShrink: 0,
}}
>
{getInitials(guest.first_name, guest.last_name)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<h3 style={{ margin: 0, fontSize: "1rem", color: "var(--text-primary)" }}>
{guest.first_name} {guest.last_name}
</h3>
{guest.email && (
<p style={{ margin: "2px 0 0", fontSize: "0.8rem", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{guest.email}
</p>
)}
{guest.phone && (
<p style={{ margin: "2px 0 0", fontSize: "0.8rem", color: "var(--text-muted)" }}>
{guest.phone}
</p>
)}
</div>
</div>
{guest.name_room && (
<div style={{ marginTop: "var(--space-sm)" }}>
<span className="badge badge-info" style={{ fontSize: "0.7rem" }}>
{guest.name_room}
</span>
</div>
)}
</div>
))}
</div>
)}
{/* New Guest Modal */}
{showModal && (
<div className="modal-overlay-dark" onClick={() => setShowModal(false)}>
<div className="modal-content-dark" onClick={(e) => e.stopPropagation()} style={{ maxWidth: "550px" }}>
<h2>{t("guests.newGuest")}</h2>
<form onSubmit={handleCreate} style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)" }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-md)" }}>
<div>
<label className="label-dark">{t("guests.firstName")}</label>
<input
className="input-dark"
type="text"
required
value={form.first_name}
onChange={(e) => setForm({ ...form, first_name: e.target.value })}
/>
</div>
<div>
<label className="label-dark">{t("guests.lastName")}</label>
<input
className="input-dark"
type="text"
required
value={form.last_name}
onChange={(e) => setForm({ ...form, last_name: e.target.value })}
/>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-md)" }}>
<div>
<label className="label-dark">{t("guests.email")}</label>
<input
className="input-dark"
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
/>
</div>
<div>
<label className="label-dark">{t("guests.phone")}</label>
<input
className="input-dark"
type="text"
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
/>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-md)" }}>
<div>
<label className="label-dark">{t("guests.idType")}</label>
<input
className="input-dark"
type="text"
value={form.id_type}
onChange={(e) => setForm({ ...form, id_type: e.target.value })}
/>
</div>
<div>
<label className="label-dark">{t("guests.idNumber")}</label>
<input
className="input-dark"
type="text"
value={form.id_number}
onChange={(e) => setForm({ ...form, id_number: e.target.value })}
/>
</div>
</div>
<div>
<label className="label-dark">{t("guests.nationality")}</label>
<input
className="input-dark"
type="text"
value={form.nationality}
onChange={(e) => setForm({ ...form, nationality: e.target.value })}
/>
</div>
<div>
<label className="label-dark">{t("guests.address")}</label>
<input
className="input-dark"
type="text"
value={form.address}
onChange={(e) => setForm({ ...form, address: e.target.value })}
/>
</div>
<div>
<label className="label-dark">{t("common.notes")}</label>
<textarea
className="input-dark"
rows={3}
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
style={{ resize: "vertical" }}
/>
</div>
<div style={{ display: "flex", gap: "var(--space-md)", justifyContent: "flex-end" }}>
<button type="button" className="btn-outline" onClick={() => setShowModal(false)}>
{t("common.cancel")}
</button>
<button type="submit" className="btn-gold">
{t("common.save")}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,6 @@
import api from "./api";
export const getGuests = (params) => api.get("/guests", { params });
export const getGuestById = (id) => api.get(`/guests/${id}`);
export const createGuest = (data) => api.post("/guests", data);
export const updateGuest = (id, data) => api.put(`/guests/${id}`, data);
export const getGuestStays = (id) => api.get(`/guests/${id}/stays`);