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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user