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 roomserviceRoutes = require('./routes/roomservice.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)
|
||||
app.use('/api/auth', authRoutes);
|
||||
@@ -68,5 +70,7 @@ app.use('/api/guests', authMiddleware, guestsRoutes);
|
||||
app.use('/api/housekeeping', authMiddleware, housekeepingRoutes);
|
||||
app.use('/api/room-service', authMiddleware, roomserviceRoutes);
|
||||
app.use('/api/events', authMiddleware, eventsRoutes);
|
||||
app.use('/api/schedules', authMiddleware, schedulesRoutes);
|
||||
app.use('/api/reports', authMiddleware, operationalReportsRoutes);
|
||||
|
||||
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 NewOrder from "./pages/RoomService/NewOrder.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";
|
||||
//Submenú de Hotel
|
||||
@@ -151,6 +153,8 @@ export default function App() {
|
||||
<Route path="room-service/menu" element={<MenuManager />} />
|
||||
<Route path="room-service/new-order" element={<NewOrder />} />
|
||||
<Route path="events" element={<EventsVenues />} />
|
||||
<Route path="schedules" element={<Schedules />} />
|
||||
<Route path="operational-reports" element={<OperationalReports />} />
|
||||
</Route>
|
||||
</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