feat: add room dashboard with KPI bar and floor grid

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 01:33:08 +00:00
parent 74e2bed34d
commit 45a027694d
12 changed files with 505 additions and 0 deletions

View File

@@ -39,6 +39,7 @@ import InventoryReport from "./pages/Inventory/InventoryReport.jsx";
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 "./styles/global.css";
//Submenú de Hotel
@@ -131,6 +132,7 @@ export default function App() {
<Route path="alter-product/:id" element={<AlterProduct />} />
<Route path="inventory/outcomes" element={<Outcomes />} />
<Route path="housekeeper/outcomes" element={<HousekeeperOutcomes />} />
<Route path="room-dashboard" element={<RoomDashboard />} />
</Route>
</Routes>
</>

View File

@@ -1,6 +1,16 @@
// src/constants/menuConfig.js
export const menuConfig = {
operations: {
label: "Operations",
spanish_label: "Operaciones",
basePath: "/app/room-dashboard",
submenu: [
{ label: "Room Dashboard", spanish_label: "Panel de Habitaciones", route: "/app/room-dashboard" },
{ label: "Reservations", spanish_label: "Reservaciones", route: "/app/reservations" },
{ label: "Guests", spanish_label: "Huespedes", route: "/app/guests" },
],
},
dashboards: {
label: "Dashboards",
spanish_label: "Tableros",

View File

@@ -0,0 +1,58 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import KPIBar from "./components/KPIBar";
import RoomGrid from "./components/RoomGrid";
import RoomDetailModal from "./components/RoomDetailModal";
import { getRoomsWithStatus, getDashboardKPIs, updateRoomStatus } from "../../services/roomService";
export default function RoomDashboard() {
const { t } = useTranslation();
const [rooms, setRooms] = useState([]);
const [kpis, setKpis] = useState({});
const [selectedRoom, setSelectedRoom] = useState(null);
const [loading, setLoading] = useState(true);
const fetchData = async () => {
try {
const [roomsRes, kpisRes] = await Promise.all([
getRoomsWithStatus(),
getDashboardKPIs(),
]);
setRooms(roomsRes.data.rooms);
setKpis(kpisRes.data);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchData(); }, []);
const handleStatusChange = async (roomId, newStatus) => {
await updateRoomStatus(roomId, newStatus);
fetchData();
setSelectedRoom(null);
};
if (loading) return <div className="empty-state"><p>{t('common.loading')}</p></div>;
return (
<div>
<div className="page-header">
<h2>{t('rooms.title')}</h2>
</div>
<KPIBar kpis={kpis} />
<div style={{ marginTop: 'var(--space-xl)' }}>
<RoomGrid rooms={rooms} onRoomClick={setSelectedRoom} />
</div>
{selectedRoom && (
<RoomDetailModal
room={selectedRoom}
onClose={() => setSelectedRoom(null)}
onStatusChange={handleStatusChange}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,27 @@
import React from "react";
import { useTranslation } from "react-i18next";
export default function KPIBar({ kpis }) {
const { t } = useTranslation();
return (
<div className="grid-4">
<div className="kpi-card">
<span className="kpi-value">{kpis.occupancy ?? 0}%</span>
<span className="kpi-label">{t("rooms.occupancyRate")}</span>
</div>
<div className="kpi-card">
<span className="kpi-value">{kpis.availableRooms ?? 0}</span>
<span className="kpi-label">{t("rooms.availableRooms")}</span>
</div>
<div className="kpi-card">
<span className="kpi-value">{kpis.todayCheckIns ?? 0}</span>
<span className="kpi-label">{t("rooms.todayCheckIns")}</span>
</div>
<div className="kpi-card">
<span className="kpi-value">{kpis.todayCheckOuts ?? 0}</span>
<span className="kpi-label">{t("rooms.todayCheckOuts")}</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,180 @@
import React from "react";
import { useTranslation } from "react-i18next";
export default function RoomDetailModal({ room, onClose, onStatusChange }) {
const { t } = useTranslation();
if (!room) return null;
const amenities = Array.isArray(room.amenities) ? room.amenities : [];
return (
<div className="modal-overlay-dark" onClick={onClose}>
<div className="modal-content-dark" onClick={(e) => e.stopPropagation()}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "var(--space-lg)",
}}
>
<h2 style={{ margin: 0 }}>
{t("rooms.roomNumber")}: {room.name_room}
</h2>
<button
className="btn-outline btn-sm"
onClick={onClose}
style={{ minWidth: "auto" }}
>
X
</button>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)" }}>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "var(--space-md)",
}}
>
<div>
<span className="label-dark">{t("rooms.roomType")}</span>
<p style={{ margin: 0, color: "var(--text-primary)" }}>
{room.bed_type}
</p>
</div>
<div>
<span className="label-dark">{t("rooms.floor")}</span>
<p style={{ margin: 0, color: "var(--text-primary)" }}>
{room.floor}
</p>
</div>
</div>
<div>
<span className="label-dark">{t("common.status")}</span>
<div style={{ marginTop: "var(--space-xs)" }}>
<span className={`badge badge-${room.status || "available"}`}>
{t(`rooms.${room.status || "available"}`)}
</span>
</div>
</div>
{room.guest_name && (
<div
className="card"
style={{ padding: "var(--space-md)" }}
>
<span
className="label-dark"
style={{ marginBottom: "var(--space-sm)", display: "block" }}
>
{t("rooms.guestInfo")}
</span>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "var(--space-xs)",
}}
>
<p style={{ margin: 0, color: "var(--text-primary)" }}>
<strong>{t("common.name")}:</strong> {room.guest_name}
</p>
{room.guest_phone && (
<p style={{ margin: 0, color: "var(--text-primary)" }}>
<strong>{t("common.phone")}:</strong> {room.guest_phone}
</p>
)}
{room.check_in && (
<p style={{ margin: 0, color: "var(--text-primary)" }}>
<strong>{t("reservations.checkIn")}:</strong>{" "}
{new Date(room.check_in).toLocaleDateString()}
</p>
)}
{room.check_out && (
<p style={{ margin: 0, color: "var(--text-primary)" }}>
<strong>{t("reservations.checkOut")}:</strong>{" "}
{new Date(room.check_out).toLocaleDateString()}
</p>
)}
</div>
</div>
)}
{amenities.length > 0 && (
<div>
<span className="label-dark">{t("rooms.amenities")}</span>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "var(--space-xs)",
marginTop: "var(--space-xs)",
}}
>
{amenities.map((amenity, index) => (
<span
key={index}
className="badge badge-info"
>
{amenity}
</span>
))}
</div>
</div>
)}
<div>
<span className="label-dark">{t("rooms.pricePerNight")}</span>
<p
style={{
margin: 0,
color: "var(--accent-gold)",
fontSize: "1.25rem",
fontWeight: 700,
}}
>
${room.cost_per_nigth}
</p>
</div>
<div
style={{
display: "flex",
gap: "var(--space-sm)",
marginTop: "var(--space-md)",
}}
>
{room.status === "available" && (
<button
className="btn-danger"
onClick={() => onStatusChange(room.id_room, "maintenance")}
>
{t("rooms.maintenance")}
</button>
)}
{room.status === "cleaning" && (
<button
className="btn-success"
onClick={() => onStatusChange(room.id_room, "available")}
>
{t("rooms.available")}
</button>
)}
{room.status === "maintenance" && (
<button
className="btn-success"
onClick={() => onStatusChange(room.id_room, "available")}
>
{t("rooms.available")}
</button>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import React from "react";
import { useTranslation } from "react-i18next";
export default function RoomGrid({ rooms, onRoomClick }) {
const { t } = useTranslation();
const roomsByFloor = rooms.reduce((acc, room) => {
const floor = room.floor ?? 1;
if (!acc[floor]) acc[floor] = [];
acc[floor].push(room);
return acc;
}, {});
const sortedFloors = Object.keys(roomsByFloor)
.map(Number)
.sort((a, b) => a - b);
return (
<div>
{sortedFloors.map((floor) => (
<div key={floor} style={{ marginBottom: "var(--space-xl)" }}>
<h3
style={{
color: "var(--text-secondary)",
fontSize: "0.875rem",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: "var(--space-md)",
}}
>
{t("rooms.floor")} {floor}
</h3>
<div className="grid-5">
{roomsByFloor[floor].map((room) => (
<div
key={room.id_room}
className={`room-card ${room.status || "available"}`}
onClick={() => onRoomClick(room)}
>
<div className="room-number">{room.name_room}</div>
{room.guest_name && (
<div className="room-info">{room.guest_name}</div>
)}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: "var(--space-sm)",
}}
>
<span className={`badge badge-${room.status || "available"}`}>
{t(`rooms.${room.status || "available"}`)}
</span>
<span
style={{
color: "var(--accent-gold)",
fontSize: "0.8rem",
fontWeight: 600,
}}
>
${room.cost_per_nigth}
</span>
</div>
</div>
))}
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,7 @@
import api from "./api";
export const getRoomsWithStatus = () => api.get("/rooms/status");
export const updateRoomStatus = (id, status) => api.put(`/rooms/${id}/status`, { status });
export const getDashboardKPIs = () => api.get("/dashboard/kpis");
export const getWeeklyRevenue = () => api.get("/dashboard/weekly-revenue");
export const getTodayArrivals = () => api.get("/dashboard/today-arrivals");