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:
2026-02-15 01:54:44 +00:00
parent b0887286c1
commit f2a0460c88
10 changed files with 884 additions and 0 deletions

View File

@@ -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>
</>

View 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" }}>
&#9733;
</span>
);
} else if (i === full && hasHalf) {
stars.push(
<span key={i} style={{ color: "var(--accent-gold)", fontSize: "1.2rem", opacity: 0.6 }}>
&#9733;
</span>
);
} else {
stars.push(
<span key={i} style={{ color: "var(--text-muted)", fontSize: "1.2rem" }}>
&#9734;
</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>
);
}

View 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}>
&larr;
</button>
<span
style={{
fontSize: "1.1rem",
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{formatDate(weekStart)} &mdash; {formatDate(weekEnd)}
</span>
<button className="btn-outline btn-sm" onClick={nextWeek}>
&rarr;
</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>
);
}

View File

@@ -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");

View 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");