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

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

View File

@@ -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 };

View File

@@ -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 };

View File

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

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