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;
|
||||
Reference in New Issue
Block a user