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:
@@ -34,6 +34,8 @@ const exchangeRoutes = require('./routes/exchange.routes');
|
|||||||
const hotelRoutes = require('./routes/hotelpl.routes');
|
const hotelRoutes = require('./routes/hotelpl.routes');
|
||||||
const restaurantRoutes = require('./routes/restaurantpl.routes');
|
const restaurantRoutes = require('./routes/restaurantpl.routes');
|
||||||
const incomehrxRoutes = require('./routes/incomehrx.routes');
|
const incomehrxRoutes = require('./routes/incomehrx.routes');
|
||||||
|
const roomsRoutes = require('./routes/rooms.routes');
|
||||||
|
const dashboardRoutes = require('./routes/dashboard.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);
|
||||||
@@ -54,5 +56,7 @@ app.use('/api/purchases', authMiddleware, purchaseRoutes);
|
|||||||
app.use('/api/exchange', authMiddleware, exchangeRoutes);
|
app.use('/api/exchange', authMiddleware, exchangeRoutes);
|
||||||
app.use('/api/hotelpl', authMiddleware, hotelRoutes);
|
app.use('/api/hotelpl', authMiddleware, hotelRoutes);
|
||||||
app.use('/api/restaurantpl', authMiddleware, restaurantRoutes);
|
app.use('/api/restaurantpl', authMiddleware, restaurantRoutes);
|
||||||
|
app.use('/api/rooms', authMiddleware, roomsRoutes);
|
||||||
|
app.use('/api/dashboard', authMiddleware, dashboardRoutes);
|
||||||
|
|
||||||
module.exports = app;
|
module.exports = app;
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
const pool = require('../db/connection');
|
||||||
|
|
||||||
|
const getKPIs = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const [totalRooms, availableRooms, checkIns, checkOuts] = await Promise.all([
|
||||||
|
pool.query('SELECT COUNT(*) as count FROM rooms'),
|
||||||
|
pool.query("SELECT COUNT(*) as count FROM rooms WHERE status = 'available'"),
|
||||||
|
pool.query("SELECT COUNT(*) as count FROM reservations WHERE check_in = $1 AND status IN ('confirmed', 'checked_in')", [today]),
|
||||||
|
pool.query("SELECT COUNT(*) as count FROM reservations WHERE check_out = $1 AND status = 'checked_in'", [today]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const total = parseInt(totalRooms.rows[0].count);
|
||||||
|
const available = parseInt(availableRooms.rows[0].count);
|
||||||
|
const occupancy = total > 0 ? Math.round(((total - available) / total) * 100) : 0;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
occupancy,
|
||||||
|
availableRooms: available,
|
||||||
|
totalRooms: total,
|
||||||
|
todayCheckIns: parseInt(checkIns.rows[0].count),
|
||||||
|
todayCheckOuts: parseInt(checkOuts.rows[0].count),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({ message: 'Error al obtener KPIs' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getWeeklyRevenue = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT DATE(check_out) as day, COALESCE(SUM(total_amount), 0) as revenue
|
||||||
|
FROM reservations
|
||||||
|
WHERE check_out >= CURRENT_DATE - INTERVAL '7 days' AND status = 'checked_out'
|
||||||
|
GROUP BY DATE(check_out) ORDER BY day
|
||||||
|
`);
|
||||||
|
res.json({ weeklyRevenue: result.rows });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({ message: 'Error al obtener ingresos semanales' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTodayArrivals = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT r.id, r.check_in, r.check_out, r.room_id, r.status,
|
||||||
|
g.first_name, g.last_name, g.phone
|
||||||
|
FROM reservations r
|
||||||
|
JOIN guests g ON g.id = r.guest_id
|
||||||
|
WHERE (r.check_in = $1 OR r.check_out = $1)
|
||||||
|
AND r.status IN ('confirmed', 'checked_in')
|
||||||
|
ORDER BY r.check_in
|
||||||
|
`, [today]);
|
||||||
|
res.json({ arrivals: result.rows });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({ message: 'Error al obtener llegadas' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { getKPIs, getWeeklyRevenue, getTodayArrivals };
|
||||||
62
backend/hotel_hacienda/src/controllers/rooms.controller.js
Normal file
62
backend/hotel_hacienda/src/controllers/rooms.controller.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
const pool = require('../db/connection');
|
||||||
|
|
||||||
|
const getRoomsWithStatus = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT r.*,
|
||||||
|
res.id as reservation_id,
|
||||||
|
g.first_name || ' ' || g.last_name as guest_name,
|
||||||
|
g.phone as guest_phone,
|
||||||
|
res.check_in,
|
||||||
|
res.check_out
|
||||||
|
FROM rooms r
|
||||||
|
LEFT JOIN reservations res ON res.room_id = r.id_room AND res.status = 'checked_in'
|
||||||
|
LEFT JOIN guests g ON g.id = res.guest_id
|
||||||
|
ORDER BY r.floor, r.name_room
|
||||||
|
`);
|
||||||
|
res.json({ rooms: result.rows });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({ message: 'Error al obtener estado de habitaciones' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateRoomStatus = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { status } = req.body;
|
||||||
|
const userId = req.user?.user_id;
|
||||||
|
|
||||||
|
const validStatuses = ['available', 'occupied', 'cleaning', 'maintenance'];
|
||||||
|
if (!validStatuses.includes(status)) {
|
||||||
|
return res.status(400).json({ message: 'Estado invalido' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = await pool.query('SELECT status FROM rooms WHERE id_room = $1', [id]);
|
||||||
|
if (current.rows.length === 0) {
|
||||||
|
return res.status(404).json({ message: 'Habitacion no encontrada' });
|
||||||
|
}
|
||||||
|
const previousStatus = current.rows[0].status;
|
||||||
|
|
||||||
|
await pool.query('UPDATE rooms SET status = $1 WHERE id_room = $2', [status, id]);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO room_status_log (room_id, previous_status, new_status, changed_by) VALUES ($1, $2, $3, $4)',
|
||||||
|
[id, previousStatus, status, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (status === 'cleaning') {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO housekeeping_tasks (room_id, priority, type, status) VALUES ($1, 'high', 'checkout', 'pending')`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: 'Estado actualizado correctamente' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({ message: 'Error al actualizar estado' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { getRoomsWithStatus, updateRoomStatus };
|
||||||
9
backend/hotel_hacienda/src/routes/dashboard.routes.js
Normal file
9
backend/hotel_hacienda/src/routes/dashboard.routes.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const dashboardController = require('../controllers/dashboard.controller');
|
||||||
|
|
||||||
|
router.get('/kpis', dashboardController.getKPIs);
|
||||||
|
router.get('/weekly-revenue', dashboardController.getWeeklyRevenue);
|
||||||
|
router.get('/today-arrivals', dashboardController.getTodayArrivals);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
8
backend/hotel_hacienda/src/routes/rooms.routes.js
Normal file
8
backend/hotel_hacienda/src/routes/rooms.routes.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const roomsController = require('../controllers/rooms.controller');
|
||||||
|
|
||||||
|
router.get('/status', roomsController.getRoomsWithStatus);
|
||||||
|
router.put('/:id/status', roomsController.updateRoomStatus);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -39,6 +39,7 @@ import InventoryReport from "./pages/Inventory/InventoryReport.jsx";
|
|||||||
import NewMonthlyPayment from "./pages/Expenses/NewMonthlyPayment.jsx";
|
import NewMonthlyPayment from "./pages/Expenses/NewMonthlyPayment.jsx";
|
||||||
import Outcomes from "./pages/Inventory/Outcomes.jsx";
|
import Outcomes from "./pages/Inventory/Outcomes.jsx";
|
||||||
import HousekeeperOutcomes from "./pages/Inventory/HousekeeperOutcomes.jsx";
|
import HousekeeperOutcomes from "./pages/Inventory/HousekeeperOutcomes.jsx";
|
||||||
|
import RoomDashboard from "./pages/RoomDashboard/RoomDashboard.jsx";
|
||||||
|
|
||||||
import "./styles/global.css";
|
import "./styles/global.css";
|
||||||
//Submenú de Hotel
|
//Submenú de Hotel
|
||||||
@@ -131,6 +132,7 @@ export default function App() {
|
|||||||
<Route path="alter-product/:id" element={<AlterProduct />} />
|
<Route path="alter-product/:id" element={<AlterProduct />} />
|
||||||
<Route path="inventory/outcomes" element={<Outcomes />} />
|
<Route path="inventory/outcomes" element={<Outcomes />} />
|
||||||
<Route path="housekeeper/outcomes" element={<HousekeeperOutcomes />} />
|
<Route path="housekeeper/outcomes" element={<HousekeeperOutcomes />} />
|
||||||
|
<Route path="room-dashboard" element={<RoomDashboard />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
// src/constants/menuConfig.js
|
// src/constants/menuConfig.js
|
||||||
|
|
||||||
export const menuConfig = {
|
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: {
|
dashboards: {
|
||||||
label: "Dashboards",
|
label: "Dashboards",
|
||||||
spanish_label: "Tableros",
|
spanish_label: "Tableros",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
frontend/Frontend-Hotel/src/services/roomService.js
Normal file
7
frontend/Frontend-Hotel/src/services/roomService.js
Normal 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");
|
||||||
Reference in New Issue
Block a user