feat: add employee scheduling and operational reports
Add weekly shift scheduling grid with morning/afternoon/night/off cycles and bulk save. Add operational reports with occupancy, revenue by room type, booking sources, and guest satisfaction KPIs using CSS-based visualizations. Register both route sets in backend app.js and frontend App.jsx. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,8 @@ const guestsRoutes = require('./routes/guests.routes');
|
|||||||
const housekeepingRoutes = require('./routes/housekeeping.routes');
|
const housekeepingRoutes = require('./routes/housekeeping.routes');
|
||||||
const roomserviceRoutes = require('./routes/roomservice.routes');
|
const roomserviceRoutes = require('./routes/roomservice.routes');
|
||||||
const eventsRoutes = require('./routes/events.routes');
|
const eventsRoutes = require('./routes/events.routes');
|
||||||
|
const schedulesRoutes = require('./routes/schedules.routes');
|
||||||
|
const operationalReportsRoutes = require('./routes/operational-reports.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);
|
||||||
@@ -68,5 +70,7 @@ app.use('/api/guests', authMiddleware, guestsRoutes);
|
|||||||
app.use('/api/housekeeping', authMiddleware, housekeepingRoutes);
|
app.use('/api/housekeeping', authMiddleware, housekeepingRoutes);
|
||||||
app.use('/api/room-service', authMiddleware, roomserviceRoutes);
|
app.use('/api/room-service', authMiddleware, roomserviceRoutes);
|
||||||
app.use('/api/events', authMiddleware, eventsRoutes);
|
app.use('/api/events', authMiddleware, eventsRoutes);
|
||||||
|
app.use('/api/schedules', authMiddleware, schedulesRoutes);
|
||||||
|
app.use('/api/reports', authMiddleware, operationalReportsRoutes);
|
||||||
|
|
||||||
module.exports = app;
|
module.exports = app;
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
const pool = require('../db/connection');
|
||||||
|
|
||||||
|
const getOccupancyReport = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { period = 'month' } = req.query;
|
||||||
|
let interval;
|
||||||
|
switch (period) {
|
||||||
|
case 'week': interval = '7 days'; break;
|
||||||
|
case 'month': interval = '30 days'; break;
|
||||||
|
case 'quarter': interval = '90 days'; break;
|
||||||
|
case 'year': interval = '365 days'; break;
|
||||||
|
default: interval = '30 days';
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalRooms = await pool.query('SELECT COUNT(*) as count FROM rooms');
|
||||||
|
const total = parseInt(totalRooms.rows[0].count) || 1;
|
||||||
|
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT DATE(rsl.changed_at) as day,
|
||||||
|
COUNT(CASE WHEN rsl.new_status = 'occupied' THEN 1 END) as occupied_count
|
||||||
|
FROM room_status_log rsl
|
||||||
|
WHERE rsl.changed_at >= CURRENT_DATE - INTERVAL '${interval}'
|
||||||
|
GROUP BY DATE(rsl.changed_at)
|
||||||
|
ORDER BY day
|
||||||
|
`);
|
||||||
|
|
||||||
|
const occupancyData = result.rows.map(row => ({
|
||||||
|
day: row.day,
|
||||||
|
occupancy: Math.round((parseInt(row.occupied_count) / total) * 100)
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Current occupancy
|
||||||
|
const currentOccupied = await pool.query("SELECT COUNT(*) as count FROM rooms WHERE status = 'occupied'");
|
||||||
|
const currentRate = Math.round((parseInt(currentOccupied.rows[0].count) / total) * 100);
|
||||||
|
|
||||||
|
res.json({ occupancyData, currentRate, totalRooms: total });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({ message: 'Error al obtener reporte de ocupacion' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRevenueReport = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { period = 'month' } = req.query;
|
||||||
|
let interval;
|
||||||
|
switch (period) {
|
||||||
|
case 'week': interval = '7 days'; break;
|
||||||
|
case 'month': interval = '30 days'; break;
|
||||||
|
case 'quarter': interval = '90 days'; break;
|
||||||
|
case 'year': interval = '365 days'; break;
|
||||||
|
default: interval = '30 days';
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT rm.bed_type as room_type, COALESCE(SUM(r.total_amount), 0) as revenue
|
||||||
|
FROM reservations r
|
||||||
|
JOIN rooms rm ON rm.id_room = r.room_id
|
||||||
|
WHERE r.status = 'checked_out' AND r.check_out >= CURRENT_DATE - INTERVAL '${interval}'
|
||||||
|
GROUP BY rm.bed_type
|
||||||
|
ORDER BY revenue DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const totalRevenue = result.rows.reduce((sum, r) => sum + parseFloat(r.revenue), 0);
|
||||||
|
res.json({ revenueByType: result.rows, totalRevenue });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({ message: 'Error al obtener reporte de ingresos' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBookingSourcesReport = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { period = 'month' } = req.query;
|
||||||
|
let interval;
|
||||||
|
switch (period) {
|
||||||
|
case 'week': interval = '7 days'; break;
|
||||||
|
case 'month': interval = '30 days'; break;
|
||||||
|
case 'quarter': interval = '90 days'; break;
|
||||||
|
case 'year': interval = '365 days'; break;
|
||||||
|
default: interval = '30 days';
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT channel, COUNT(*) as count, COALESCE(SUM(total_amount), 0) as revenue
|
||||||
|
FROM reservations
|
||||||
|
WHERE created_at >= CURRENT_DATE - INTERVAL '${interval}'
|
||||||
|
GROUP BY channel
|
||||||
|
ORDER BY count DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const total = result.rows.reduce((sum, r) => sum + parseInt(r.count), 0);
|
||||||
|
const sources = result.rows.map(r => ({
|
||||||
|
channel: r.channel,
|
||||||
|
count: parseInt(r.count),
|
||||||
|
revenue: parseFloat(r.revenue),
|
||||||
|
percentage: total > 0 ? Math.round((parseInt(r.count) / total) * 100) : 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ sources, totalBookings: total });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({ message: 'Error al obtener fuentes de reservacion' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSatisfactionReport = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT AVG(rating) as avg_rating, COUNT(rating) as total_ratings,
|
||||||
|
COUNT(CASE WHEN rating = 5 THEN 1 END) as five_star,
|
||||||
|
COUNT(CASE WHEN rating = 4 THEN 1 END) as four_star,
|
||||||
|
COUNT(CASE WHEN rating = 3 THEN 1 END) as three_star,
|
||||||
|
COUNT(CASE WHEN rating <= 2 THEN 1 END) as low_star
|
||||||
|
FROM guest_stays
|
||||||
|
WHERE rating IS NOT NULL
|
||||||
|
`);
|
||||||
|
res.json({ satisfaction: result.rows[0] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({ message: 'Error al obtener satisfaccion' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { getOccupancyReport, getRevenueReport, getBookingSourcesReport, getSatisfactionReport };
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
const pool = require('../db/connection');
|
||||||
|
|
||||||
|
const getSchedules = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { week_start, department } = req.query;
|
||||||
|
if (!week_start) return res.status(400).json({ message: 'week_start es requerido' });
|
||||||
|
|
||||||
|
const weekEnd = new Date(week_start);
|
||||||
|
weekEnd.setDate(weekEnd.getDate() + 6);
|
||||||
|
|
||||||
|
let query = `
|
||||||
|
SELECT es.*, e.first_name, e.last_name
|
||||||
|
FROM employee_schedules es
|
||||||
|
JOIN employees e ON e.id_employee = es.employee_id
|
||||||
|
WHERE es.schedule_date BETWEEN $1 AND $2
|
||||||
|
`;
|
||||||
|
const params = [week_start, weekEnd.toISOString().split('T')[0]];
|
||||||
|
|
||||||
|
query += ' ORDER BY e.first_name, es.schedule_date';
|
||||||
|
const result = await pool.query(query, params);
|
||||||
|
res.json({ schedules: result.rows });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({ message: 'Error al obtener horarios' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSchedules = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { entries } = req.body; // Array of { employee_id, schedule_date, shift_type }
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const { employee_id, schedule_date, shift_type } = entry;
|
||||||
|
const startTime = shift_type === 'morning' ? '07:00' : shift_type === 'afternoon' ? '15:00' : shift_type === 'night' ? '23:00' : null;
|
||||||
|
const endTime = shift_type === 'morning' ? '15:00' : shift_type === 'afternoon' ? '23:00' : shift_type === 'night' ? '07:00' : null;
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO employee_schedules (employee_id, schedule_date, shift_type, start_time, end_time)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (employee_id, schedule_date) DO UPDATE SET shift_type = $3, start_time = $4, end_time = $5`,
|
||||||
|
[employee_id, schedule_date, shift_type, startTime, endTime]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: 'Horarios guardados correctamente' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({ message: 'Error al guardar horarios' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEmployeeSchedule = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { from_date, to_date } = req.query;
|
||||||
|
const result = await pool.query(
|
||||||
|
'SELECT * FROM employee_schedules WHERE employee_id = $1 AND schedule_date BETWEEN $2 AND $3 ORDER BY schedule_date',
|
||||||
|
[id, from_date, to_date]
|
||||||
|
);
|
||||||
|
res.json({ schedules: result.rows });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({ message: 'Error al obtener horario del empleado' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEmployeesForScheduling = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
'SELECT id_employee, first_name, last_name FROM employees WHERE status_employee = true ORDER BY first_name LIMIT 100'
|
||||||
|
);
|
||||||
|
res.json({ employees: result.rows });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.json({ employees: [] });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { getSchedules, saveSchedules, getEmployeeSchedule, getEmployeesForScheduling };
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const ctrl = require('../controllers/operational-reports.controller');
|
||||||
|
|
||||||
|
router.get('/occupancy', ctrl.getOccupancyReport);
|
||||||
|
router.get('/revenue', ctrl.getRevenueReport);
|
||||||
|
router.get('/booking-sources', ctrl.getBookingSourcesReport);
|
||||||
|
router.get('/satisfaction', ctrl.getSatisfactionReport);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
10
backend/hotel_hacienda/src/routes/schedules.routes.js
Normal file
10
backend/hotel_hacienda/src/routes/schedules.routes.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const ctrl = require('../controllers/schedules.controller');
|
||||||
|
|
||||||
|
router.get('/', ctrl.getSchedules);
|
||||||
|
router.get('/employees', ctrl.getEmployeesForScheduling);
|
||||||
|
router.get('/employee/:id', ctrl.getEmployeeSchedule);
|
||||||
|
router.post('/', ctrl.saveSchedules);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -49,6 +49,8 @@ import RoomServiceOrders from "./pages/RoomService/RoomServiceOrders.jsx";
|
|||||||
import MenuManager from "./pages/RoomService/MenuManager.jsx";
|
import MenuManager from "./pages/RoomService/MenuManager.jsx";
|
||||||
import NewOrder from "./pages/RoomService/NewOrder.jsx";
|
import NewOrder from "./pages/RoomService/NewOrder.jsx";
|
||||||
import EventsVenues from "./pages/Events/EventsVenues.jsx";
|
import EventsVenues from "./pages/Events/EventsVenues.jsx";
|
||||||
|
import Schedules from "./pages/Schedules/Schedules.jsx";
|
||||||
|
import OperationalReports from "./pages/Reports/OperationalReports.jsx";
|
||||||
|
|
||||||
import "./styles/global.css";
|
import "./styles/global.css";
|
||||||
//Submenú de Hotel
|
//Submenú de Hotel
|
||||||
@@ -151,6 +153,8 @@ export default function App() {
|
|||||||
<Route path="room-service/menu" element={<MenuManager />} />
|
<Route path="room-service/menu" element={<MenuManager />} />
|
||||||
<Route path="room-service/new-order" element={<NewOrder />} />
|
<Route path="room-service/new-order" element={<NewOrder />} />
|
||||||
<Route path="events" element={<EventsVenues />} />
|
<Route path="events" element={<EventsVenues />} />
|
||||||
|
<Route path="schedules" element={<Schedules />} />
|
||||||
|
<Route path="operational-reports" element={<OperationalReports />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</>
|
</>
|
||||||
|
|||||||
312
frontend/Frontend-Hotel/src/pages/Reports/OperationalReports.jsx
Normal file
312
frontend/Frontend-Hotel/src/pages/Reports/OperationalReports.jsx
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
getOccupancyReport,
|
||||||
|
getRevenueReport,
|
||||||
|
getBookingSourcesReport,
|
||||||
|
getSatisfactionReport,
|
||||||
|
} from "../../services/operationalReportService";
|
||||||
|
|
||||||
|
const PERIODS = ["week", "month", "quarter", "year"];
|
||||||
|
|
||||||
|
export default function OperationalReports() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [period, setPeriod] = useState("month");
|
||||||
|
const [occupancy, setOccupancy] = useState({ currentRate: 0, totalRooms: 0 });
|
||||||
|
const [revenue, setRevenue] = useState({ revenueByType: [], totalRevenue: 0 });
|
||||||
|
const [sources, setSources] = useState({ sources: [], totalBookings: 0 });
|
||||||
|
const [satisfaction, setSatisfaction] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [occRes, revRes, srcRes, satRes] = await Promise.all([
|
||||||
|
getOccupancyReport({ period }),
|
||||||
|
getRevenueReport({ period }),
|
||||||
|
getBookingSourcesReport({ period }),
|
||||||
|
getSatisfactionReport(),
|
||||||
|
]);
|
||||||
|
setOccupancy(occRes.data || { currentRate: 0, totalRooms: 0 });
|
||||||
|
setRevenue(revRes.data || { revenueByType: [], totalRevenue: 0 });
|
||||||
|
setSources(srcRes.data || { sources: [], totalBookings: 0 });
|
||||||
|
setSatisfaction(satRes.data?.satisfaction || null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [period]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const formatCurrency = (val) => {
|
||||||
|
const num = parseFloat(val) || 0;
|
||||||
|
return "$" + num.toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const avgRating = satisfaction?.avg_rating ? parseFloat(satisfaction.avg_rating).toFixed(1) : "N/A";
|
||||||
|
const totalRatings = satisfaction?.total_ratings ? parseInt(satisfaction.total_ratings) : 0;
|
||||||
|
|
||||||
|
const renderStars = (rating) => {
|
||||||
|
const numRating = parseFloat(rating) || 0;
|
||||||
|
const full = Math.floor(numRating);
|
||||||
|
const hasHalf = numRating - full >= 0.5;
|
||||||
|
const stars = [];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
if (i < full) {
|
||||||
|
stars.push(
|
||||||
|
<span key={i} style={{ color: "var(--accent-gold)", fontSize: "1.2rem" }}>
|
||||||
|
★
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} else if (i === full && hasHalf) {
|
||||||
|
stars.push(
|
||||||
|
<span key={i} style={{ color: "var(--accent-gold)", fontSize: "1.2rem", opacity: 0.6 }}>
|
||||||
|
★
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
stars.push(
|
||||||
|
<span key={i} style={{ color: "var(--text-muted)", fontSize: "1.2rem" }}>
|
||||||
|
☆
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stars;
|
||||||
|
};
|
||||||
|
|
||||||
|
const maxRevenue = Math.max(
|
||||||
|
...revenue.revenueByType.map((r) => parseFloat(r.revenue) || 0),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="empty-state">
|
||||||
|
<p>{t("common.loading")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-header">
|
||||||
|
<h2>{t("reports.title")}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Period Selector */}
|
||||||
|
<div className="filter-pills" style={{ marginBottom: "var(--space-xl)" }}>
|
||||||
|
{PERIODS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
className={`filter-pill ${period === p ? "active" : ""}`}
|
||||||
|
onClick={() => setPeriod(p)}
|
||||||
|
>
|
||||||
|
{t(`reports.period.${p}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI Row */}
|
||||||
|
<div className="grid-4" style={{ marginBottom: "var(--space-xl)" }}>
|
||||||
|
{/* Occupancy Rate */}
|
||||||
|
<div className="kpi-card">
|
||||||
|
<span className="kpi-label">{t("reports.occupancyRate")}</span>
|
||||||
|
<span className="kpi-value">{occupancy.currentRate}%</span>
|
||||||
|
<span style={{ fontSize: "0.75rem", color: "var(--text-muted)" }}>
|
||||||
|
{occupancy.totalRooms} {t("common.room")}s
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total Revenue */}
|
||||||
|
<div className="kpi-card">
|
||||||
|
<span className="kpi-label">{t("reports.revenue")}</span>
|
||||||
|
<span className="kpi-value">{formatCurrency(revenue.totalRevenue)}</span>
|
||||||
|
<span style={{ fontSize: "0.75rem", color: "var(--text-muted)" }}>
|
||||||
|
{t(`reports.period.${period}`)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Guest Satisfaction */}
|
||||||
|
<div className="kpi-card">
|
||||||
|
<span className="kpi-label">{t("reports.guestSatisfaction")}</span>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-sm)" }}>
|
||||||
|
<span className="kpi-value">{avgRating}</span>
|
||||||
|
<div>{renderStars(avgRating)}</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: "0.75rem", color: "var(--text-muted)" }}>
|
||||||
|
{totalRatings} ratings
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total Bookings */}
|
||||||
|
<div className="kpi-card">
|
||||||
|
<span className="kpi-label">{t("reports.bookingSources")}</span>
|
||||||
|
<span className="kpi-value">{sources.totalBookings}</span>
|
||||||
|
<span style={{ fontSize: "0.75rem", color: "var(--text-muted)" }}>
|
||||||
|
{t(`reports.period.${period}`)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts Section */}
|
||||||
|
<div className="grid-2">
|
||||||
|
{/* Revenue by Room Type */}
|
||||||
|
<div className="card">
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
marginTop: 0,
|
||||||
|
marginBottom: "var(--space-lg)",
|
||||||
|
fontSize: "1rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("reports.revenueByRoomType")}
|
||||||
|
</h3>
|
||||||
|
{revenue.revenueByType.length === 0 ? (
|
||||||
|
<p style={{ color: "var(--text-muted)", fontSize: "0.875rem" }}>
|
||||||
|
{t("common.noResults")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)" }}>
|
||||||
|
{revenue.revenueByType.map((item, idx) => {
|
||||||
|
const pct = Math.round((parseFloat(item.revenue) / maxRevenue) * 100);
|
||||||
|
return (
|
||||||
|
<div key={idx}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: "var(--space-xs)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: "capitalize",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.room_type || "Unknown"}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
color: "var(--accent-gold)",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatCurrency(item.revenue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "8px",
|
||||||
|
background: "var(--bg-elevated)",
|
||||||
|
borderRadius: "4px",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${pct}%`,
|
||||||
|
height: "100%",
|
||||||
|
background: "var(--accent-gold)",
|
||||||
|
borderRadius: "4px",
|
||||||
|
transition: "width 0.4s ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Booking Sources */}
|
||||||
|
<div className="card">
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
marginTop: 0,
|
||||||
|
marginBottom: "var(--space-lg)",
|
||||||
|
fontSize: "1rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("reports.bookingSourceDistribution")}
|
||||||
|
</h3>
|
||||||
|
{sources.sources.length === 0 ? (
|
||||||
|
<p style={{ color: "var(--text-muted)", fontSize: "0.875rem" }}>
|
||||||
|
{t("common.noResults")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)" }}>
|
||||||
|
{sources.sources.map((src, idx) => (
|
||||||
|
<div key={idx}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: "var(--space-xs)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: "capitalize",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{src.channel || "Unknown"}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-sm)" }}>
|
||||||
|
<span style={{ fontSize: "0.8rem", color: "var(--text-secondary)" }}>
|
||||||
|
{src.count}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="badge badge-info"
|
||||||
|
style={{ fontSize: "0.7rem" }}
|
||||||
|
>
|
||||||
|
{src.percentage}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "6px",
|
||||||
|
background: "var(--bg-elevated)",
|
||||||
|
borderRadius: "3px",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${src.percentage}%`,
|
||||||
|
height: "100%",
|
||||||
|
background: "var(--info)",
|
||||||
|
borderRadius: "3px",
|
||||||
|
transition: "width 0.4s ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
331
frontend/Frontend-Hotel/src/pages/Schedules/Schedules.jsx
Normal file
331
frontend/Frontend-Hotel/src/pages/Schedules/Schedules.jsx
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
getSchedules,
|
||||||
|
saveSchedules,
|
||||||
|
getEmployeesForScheduling,
|
||||||
|
} from "../../services/scheduleService";
|
||||||
|
|
||||||
|
const SHIFT_CYCLE = ["morning", "afternoon", "night", "off"];
|
||||||
|
|
||||||
|
function getMonday(d) {
|
||||||
|
const date = new Date(d);
|
||||||
|
const day = date.getDay();
|
||||||
|
const diff = date.getDate() - day + (day === 0 ? -6 : 1);
|
||||||
|
date.setDate(diff);
|
||||||
|
date.setHours(0, 0, 0, 0);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date) {
|
||||||
|
return date.toISOString().split("T")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(date, days) {
|
||||||
|
const result = new Date(date);
|
||||||
|
result.setDate(result.getDate() + days);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY_LABELS_EN = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||||
|
const DAY_LABELS_ES = ["Lun", "Mar", "Mie", "Jue", "Vie", "Sab", "Dom"];
|
||||||
|
|
||||||
|
export default function Schedules() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const [weekStart, setWeekStart] = useState(() => getMonday(new Date()));
|
||||||
|
const [employees, setEmployees] = useState([]);
|
||||||
|
const [grid, setGrid] = useState({});
|
||||||
|
const [changes, setChanges] = useState({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const weekEnd = addDays(weekStart, 6);
|
||||||
|
const dayLabels = i18n.language === "es" ? DAY_LABELS_ES : DAY_LABELS_EN;
|
||||||
|
|
||||||
|
const weekDates = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [empRes, schedRes] = await Promise.all([
|
||||||
|
getEmployeesForScheduling(),
|
||||||
|
getSchedules({ week_start: formatDate(weekStart) }),
|
||||||
|
]);
|
||||||
|
const emps = empRes.data.employees || [];
|
||||||
|
setEmployees(emps);
|
||||||
|
|
||||||
|
const schedules = schedRes.data.schedules || [];
|
||||||
|
const newGrid = {};
|
||||||
|
for (const emp of emps) {
|
||||||
|
newGrid[emp.id_employee] = {};
|
||||||
|
for (const wd of weekDates) {
|
||||||
|
newGrid[emp.id_employee][formatDate(wd)] = "off";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const s of schedules) {
|
||||||
|
const dateKey = s.schedule_date?.split("T")[0] || s.schedule_date;
|
||||||
|
if (newGrid[s.employee_id]) {
|
||||||
|
newGrid[s.employee_id][dateKey] = s.shift_type || "off";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setGrid(newGrid);
|
||||||
|
setChanges({});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [weekStart]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const handleCellClick = (employeeId, dateStr) => {
|
||||||
|
const current = grid[employeeId]?.[dateStr] || "off";
|
||||||
|
const idx = SHIFT_CYCLE.indexOf(current);
|
||||||
|
const next = SHIFT_CYCLE[(idx + 1) % SHIFT_CYCLE.length];
|
||||||
|
|
||||||
|
setGrid((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[employeeId]: {
|
||||||
|
...prev[employeeId],
|
||||||
|
[dateStr]: next,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const key = `${employeeId}_${dateStr}`;
|
||||||
|
setChanges((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: { employee_id: employeeId, schedule_date: dateStr, shift_type: next },
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const entries = Object.values(changes);
|
||||||
|
if (entries.length === 0) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await saveSchedules(entries);
|
||||||
|
setChanges({});
|
||||||
|
fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevWeek = () => setWeekStart((prev) => addDays(prev, -7));
|
||||||
|
const nextWeek = () => setWeekStart((prev) => addDays(prev, 7));
|
||||||
|
|
||||||
|
const getShiftDisplay = (shift) => {
|
||||||
|
switch (shift) {
|
||||||
|
case "morning":
|
||||||
|
return "M 7-15";
|
||||||
|
case "afternoon":
|
||||||
|
return "T 15-23";
|
||||||
|
case "night":
|
||||||
|
return "N 23-7";
|
||||||
|
default:
|
||||||
|
return "---";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getShiftClass = (shift) => {
|
||||||
|
switch (shift) {
|
||||||
|
case "morning":
|
||||||
|
return "shift-morning";
|
||||||
|
case "afternoon":
|
||||||
|
return "shift-afternoon";
|
||||||
|
case "night":
|
||||||
|
return "shift-night";
|
||||||
|
default:
|
||||||
|
return "shift-off";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasChanges = Object.keys(changes).length > 0;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="empty-state">
|
||||||
|
<p>{t("common.loading")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-header">
|
||||||
|
<h2>{t("schedules.title")}</h2>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-md)" }}>
|
||||||
|
{hasChanges && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "var(--accent-gold)",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Object.keys(changes).length} {i18n.language === "es" ? "cambios pendientes" : "pending changes"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="btn-gold"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!hasChanges || saving}
|
||||||
|
style={{ opacity: hasChanges ? 1 : 0.5 }}
|
||||||
|
>
|
||||||
|
{saving
|
||||||
|
? t("common.loading")
|
||||||
|
: t("schedules.saveSchedule")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Week Navigation */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "var(--space-lg)",
|
||||||
|
marginBottom: "var(--space-xl)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button className="btn-outline btn-sm" onClick={prevWeek}>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "1.1rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatDate(weekStart)} — {formatDate(weekEnd)}
|
||||||
|
</span>
|
||||||
|
<button className="btn-outline btn-sm" onClick={nextWeek}>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Schedule Grid */}
|
||||||
|
<div className="card" style={{ overflowX: "auto" }}>
|
||||||
|
<table className="table-dark">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ minWidth: "160px" }}>{t("schedules.employee")}</th>
|
||||||
|
{weekDates.map((wd, i) => (
|
||||||
|
<th key={i} style={{ textAlign: "center", minWidth: "90px" }}>
|
||||||
|
<div>{dayLabels[i]}</div>
|
||||||
|
<div style={{ fontSize: "0.7rem", fontWeight: 400, color: "var(--text-muted)" }}>
|
||||||
|
{formatDate(wd).slice(5)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{employees.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} style={{ textAlign: "center", color: "var(--text-muted)" }}>
|
||||||
|
{t("common.noResults")}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
employees.map((emp) => (
|
||||||
|
<tr key={emp.id_employee}>
|
||||||
|
<td style={{ fontWeight: 600 }}>
|
||||||
|
{emp.first_name} {emp.last_name}
|
||||||
|
</td>
|
||||||
|
{weekDates.map((wd) => {
|
||||||
|
const dateStr = formatDate(wd);
|
||||||
|
const shift = grid[emp.id_employee]?.[dateStr] || "off";
|
||||||
|
const changeKey = `${emp.id_employee}_${dateStr}`;
|
||||||
|
const isChanged = changes[changeKey] !== undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={dateStr}
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
cursor: "pointer",
|
||||||
|
userSelect: "none",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
onClick={() => handleCellClick(emp.id_employee, dateStr)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={getShiftClass(shift)}
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
padding: "4px 10px",
|
||||||
|
borderRadius: "var(--radius-sm)",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
minWidth: "60px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getShiftDisplay(shift)}
|
||||||
|
</span>
|
||||||
|
{isChanged && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "4px",
|
||||||
|
right: "4px",
|
||||||
|
width: "6px",
|
||||||
|
height: "6px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "var(--accent-gold)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "var(--space-lg)",
|
||||||
|
marginTop: "var(--space-lg)",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{SHIFT_CYCLE.map((shift) => (
|
||||||
|
<div
|
||||||
|
key={shift}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: "var(--space-sm)" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={getShiftClass(shift)}
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: "var(--radius-sm)",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getShiftDisplay(shift)}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: "0.8rem", color: "var(--text-secondary)" }}>
|
||||||
|
{t(`schedules.shifts.${shift}`)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import api from "./api";
|
||||||
|
export const getOccupancyReport = (params) => api.get("/reports/occupancy", { params });
|
||||||
|
export const getRevenueReport = (params) => api.get("/reports/revenue", { params });
|
||||||
|
export const getBookingSourcesReport = (params) => api.get("/reports/booking-sources", { params });
|
||||||
|
export const getSatisfactionReport = () => api.get("/reports/satisfaction");
|
||||||
4
frontend/Frontend-Hotel/src/services/scheduleService.js
Normal file
4
frontend/Frontend-Hotel/src/services/scheduleService.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import api from "./api";
|
||||||
|
export const getSchedules = (params) => api.get("/schedules", { params });
|
||||||
|
export const saveSchedules = (entries) => api.post("/schedules", { entries });
|
||||||
|
export const getEmployeesForScheduling = () => api.get("/schedules/employees");
|
||||||
Reference in New Issue
Block a user