feat: add guest management with profiles and stay history
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,7 @@ const incomehrxRoutes = require('./routes/incomehrx.routes');
|
|||||||
const roomsRoutes = require('./routes/rooms.routes');
|
const roomsRoutes = require('./routes/rooms.routes');
|
||||||
const dashboardRoutes = require('./routes/dashboard.routes');
|
const dashboardRoutes = require('./routes/dashboard.routes');
|
||||||
const reservationsRoutes = require('./routes/reservations.routes');
|
const reservationsRoutes = require('./routes/reservations.routes');
|
||||||
|
const guestsRoutes = require('./routes/guests.routes');
|
||||||
|
|
||||||
//Prefijo - Auth routes are public (no middleware)
|
//Prefijo - Auth routes are public (no middleware)
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
@@ -60,5 +61,6 @@ app.use('/api/restaurantpl', authMiddleware, restaurantRoutes);
|
|||||||
app.use('/api/rooms', authMiddleware, roomsRoutes);
|
app.use('/api/rooms', authMiddleware, roomsRoutes);
|
||||||
app.use('/api/dashboard', authMiddleware, dashboardRoutes);
|
app.use('/api/dashboard', authMiddleware, dashboardRoutes);
|
||||||
app.use('/api/reservations', authMiddleware, reservationsRoutes);
|
app.use('/api/reservations', authMiddleware, reservationsRoutes);
|
||||||
|
app.use('/api/guests', authMiddleware, guestsRoutes);
|
||||||
|
|
||||||
module.exports = app;
|
module.exports = app;
|
||||||
|
|||||||
108
backend/hotel_hacienda/src/controllers/guests.controller.js
Normal file
108
backend/hotel_hacienda/src/controllers/guests.controller.js
Normal 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 };
|
||||||
11
backend/hotel_hacienda/src/routes/guests.routes.js
Normal file
11
backend/hotel_hacienda/src/routes/guests.routes.js
Normal 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;
|
||||||
@@ -42,6 +42,8 @@ import HousekeeperOutcomes from "./pages/Inventory/HousekeeperOutcomes.jsx";
|
|||||||
import RoomDashboard from "./pages/RoomDashboard/RoomDashboard.jsx";
|
import RoomDashboard from "./pages/RoomDashboard/RoomDashboard.jsx";
|
||||||
import Reservations from "./pages/Reservations/Reservations.jsx";
|
import Reservations from "./pages/Reservations/Reservations.jsx";
|
||||||
import NewReservation from "./pages/Reservations/NewReservation.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";
|
import "./styles/global.css";
|
||||||
//Submenú de Hotel
|
//Submenú de Hotel
|
||||||
@@ -137,6 +139,8 @@ export default function App() {
|
|||||||
<Route path="room-dashboard" element={<RoomDashboard />} />
|
<Route path="room-dashboard" element={<RoomDashboard />} />
|
||||||
<Route path="reservations" element={<Reservations />} />
|
<Route path="reservations" element={<Reservations />} />
|
||||||
<Route path="reservations/new" element={<NewReservation />} />
|
<Route path="reservations/new" element={<NewReservation />} />
|
||||||
|
<Route path="guests" element={<Guests />} />
|
||||||
|
<Route path="guests/:id" element={<GuestDetail />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</>
|
</>
|
||||||
|
|||||||
363
frontend/Frontend-Hotel/src/pages/Guests/GuestDetail.jsx
Normal file
363
frontend/Frontend-Hotel/src/pages/Guests/GuestDetail.jsx
Normal 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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</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")}>
|
||||||
|
← {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>
|
||||||
|
);
|
||||||
|
}
|
||||||
277
frontend/Frontend-Hotel/src/pages/Guests/Guests.jsx
Normal file
277
frontend/Frontend-Hotel/src/pages/Guests/Guests.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
frontend/Frontend-Hotel/src/services/guestService.js
Normal file
6
frontend/Frontend-Hotel/src/services/guestService.js
Normal 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`);
|
||||||
Reference in New Issue
Block a user