FASE 5 COMPLETADA: Analytics y Administración

Implementados 5 módulos de analytics con agent swarm:

1. DASHBOARD ADMINISTRATIVO
   - Resumen ejecutivo (reservas, ingresos, usuarios)
   - Vista del día con alertas
   - Calendario semanal de ocupación

2. MÉTRICAS DE OCUPACIÓN
   - Ocupación por fecha, cancha, franja horaria
   - Horas pico (top 5 demandados)
   - Comparativa entre períodos
   - Tendencias de uso

3. MÉTRICAS FINANCIERAS
   - Ingresos por período, cancha, tipo
   - Métodos de pago más usados
   - Estadísticas de reembolsos
   - Tendencias de crecimiento
   - Top días de ingresos

4. MÉTRICAS DE USUARIOS
   - Stats generales y actividad
   - Top jugadores (por partidos/victorias/puntos)
   - Detección de churn (riesgo de abandono)
   - Tasa de retención
   - Crecimiento mensual

5. EXPORTACIÓN DE DATOS
   - Exportar a CSV (separado por ;)
   - Exportar a JSON
   - Exportar a Excel (múltiples hojas)
   - Reportes completos descargables

Endpoints nuevos (solo admin):
- /analytics/dashboard/*
- /analytics/occupancy/*
- /analytics/revenue/*
- /analytics/reports/*
- /analytics/users/*
- /analytics/exports/*

Dependencias:
- xlsx - Generación de archivos Excel

Utilidades:
- Cálculo de crecimiento porcentual
- Formateo de moneda
- Agrupación por fechas
- Relleno de fechas faltantes
This commit is contained in:
2026-01-31 09:13:03 +00:00
parent b8a964dc2c
commit 5e50dd766f
31 changed files with 6068 additions and 3 deletions

View File

@@ -0,0 +1,89 @@
import { Request, Response, NextFunction } from 'express';
import { DashboardService } from '../../services/analytics/dashboard.service';
import { ApiError } from '../../middleware/errorHandler';
export class DashboardController {
/**
* GET /analytics/dashboard/summary
* Resumen rápido para el dashboard
*/
static async getDashboardSummary(req: Request, res: Response, next: NextFunction) {
try {
const summary = await DashboardService.getDashboardSummary();
res.status(200).json({
success: true,
data: summary,
timestamp: new Date().toISOString(),
});
} catch (error) {
next(error);
}
}
/**
* GET /analytics/dashboard/today
* Vista general del día actual
*/
static async getTodayOverview(req: Request, res: Response, next: NextFunction) {
try {
const overview = await DashboardService.getTodayOverview();
res.status(200).json({
success: true,
data: overview,
timestamp: new Date().toISOString(),
});
} catch (error) {
next(error);
}
}
/**
* GET /analytics/dashboard/calendar
* Calendario semanal de ocupación
*/
static async getWeeklyCalendar(req: Request, res: Response, next: NextFunction) {
try {
const { courtId } = req.query;
const calendar = await DashboardService.getWeeklyCalendar();
// Filtrar por cancha si se especifica
let filteredCalendar = calendar;
if (courtId) {
filteredCalendar = calendar.filter(item => item.courtId === courtId);
}
// Agrupar por cancha para mejor visualización
const groupedByCourt: Record<string, any> = {};
filteredCalendar.forEach(item => {
if (!groupedByCourt[item.courtId]) {
groupedByCourt[item.courtId] = {
courtId: item.courtId,
courtName: item.courtName,
days: [],
};
}
groupedByCourt[item.courtId].days.push({
dayOfWeek: item.dayOfWeek,
date: item.date,
bookings: item.bookings,
totalBookings: item.bookings.length,
});
});
res.status(200).json({
success: true,
data: {
weekStart: filteredCalendar[0]?.date || null,
courts: Object.values(groupedByCourt),
},
timestamp: new Date().toISOString(),
});
} catch (error) {
next(error);
}
}
}
export default DashboardController;

View File

@@ -0,0 +1,260 @@
import { Request, Response, NextFunction } from 'express';
import { FinancialService, GroupByPeriod } from '../../services/analytics/financial.service';
import { ApiError } from '../../middleware/errorHandler';
// Validar fechas
const validateDates = (startDateStr: string, endDateStr: string): { startDate: Date; endDate: Date } => {
const startDate = new Date(startDateStr);
const endDate = new Date(endDateStr);
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
throw new ApiError('Fechas inválidas', 400);
}
if (startDate > endDate) {
throw new ApiError('La fecha de inicio debe ser anterior a la fecha de fin', 400);
}
// Ajustar endDate para incluir todo el día
endDate.setHours(23, 59, 59, 999);
return { startDate, endDate };
};
export class FinancialController {
/**
* GET /analytics/revenue
* Ingresos por período
*/
static async getRevenueByPeriod(req: Request, res: Response, next: NextFunction) {
try {
const { startDate: startDateStr, endDate: endDateStr, groupBy = 'day' } = req.query;
if (!startDateStr || !endDateStr) {
throw new ApiError('Se requieren fechas de inicio y fin', 400);
}
const { startDate, endDate } = validateDates(startDateStr as string, endDateStr as string);
// Validar groupBy
const validGroupBy: GroupByPeriod[] = ['day', 'week', 'month'];
const groupByValue = groupBy as GroupByPeriod;
if (!validGroupBy.includes(groupByValue)) {
throw new ApiError('Valor de groupBy inválido. Use: day, week, month', 400);
}
const revenue = await FinancialService.getRevenueByPeriod(startDate, endDate, groupByValue);
res.status(200).json({
success: true,
data: revenue,
meta: {
startDate: startDateStr,
endDate: endDateStr,
groupBy: groupByValue,
totalItems: revenue.length,
},
});
} catch (error) {
next(error);
}
}
/**
* GET /analytics/revenue/by-court
* Ingresos por cancha
*/
static async getRevenueByCourt(req: Request, res: Response, next: NextFunction) {
try {
const { startDate: startDateStr, endDate: endDateStr } = req.query;
if (!startDateStr || !endDateStr) {
throw new ApiError('Se requieren fechas de inicio y fin', 400);
}
const { startDate, endDate } = validateDates(startDateStr as string, endDateStr as string);
const revenue = await FinancialService.getRevenueByCourt(startDate, endDate);
res.status(200).json({
success: true,
data: revenue,
meta: {
startDate: startDateStr,
endDate: endDateStr,
totalItems: revenue.length,
},
});
} catch (error) {
next(error);
}
}
/**
* GET /analytics/revenue/by-type
* Ingresos por tipo
*/
static async getRevenueByType(req: Request, res: Response, next: NextFunction) {
try {
const { startDate: startDateStr, endDate: endDateStr } = req.query;
if (!startDateStr || !endDateStr) {
throw new ApiError('Se requieren fechas de inicio y fin', 400);
}
const { startDate, endDate } = validateDates(startDateStr as string, endDateStr as string);
const revenue = await FinancialService.getRevenueByType(startDate, endDate);
res.status(200).json({
success: true,
data: revenue,
meta: {
startDate: startDateStr,
endDate: endDateStr,
totalItems: revenue.length,
},
});
} catch (error) {
next(error);
}
}
/**
* GET /analytics/payment-methods
* Estadísticas por método de pago
*/
static async getPaymentMethods(req: Request, res: Response, next: NextFunction) {
try {
const { startDate: startDateStr, endDate: endDateStr } = req.query;
if (!startDateStr || !endDateStr) {
throw new ApiError('Se requieren fechas de inicio y fin', 400);
}
const { startDate, endDate } = validateDates(startDateStr as string, endDateStr as string);
const stats = await FinancialService.getPaymentMethodsStats(startDate, endDate);
res.status(200).json({
success: true,
data: stats,
meta: {
startDate: startDateStr,
endDate: endDateStr,
totalItems: stats.length,
},
});
} catch (error) {
next(error);
}
}
/**
* GET /analytics/outstanding-payments
* Pagos pendientes
*/
static async getOutstandingPayments(req: Request, res: Response, next: NextFunction) {
try {
const payments = await FinancialService.getOutstandingPayments();
res.status(200).json({
success: true,
data: payments,
meta: {
totalItems: payments.length,
totalAmount: payments.reduce((sum, p) => sum + p.amount, 0),
},
});
} catch (error) {
next(error);
}
}
/**
* GET /analytics/refunds
* Estadísticas de reembolsos
*/
static async getRefundStats(req: Request, res: Response, next: NextFunction) {
try {
const { startDate: startDateStr, endDate: endDateStr } = req.query;
if (!startDateStr || !endDateStr) {
throw new ApiError('Se requieren fechas de inicio y fin', 400);
}
const { startDate, endDate } = validateDates(startDateStr as string, endDateStr as string);
const stats = await FinancialService.getRefundStats(startDate, endDate);
res.status(200).json({
success: true,
data: stats,
meta: {
startDate: startDateStr,
endDate: endDateStr,
},
});
} catch (error) {
next(error);
}
}
/**
* GET /analytics/trends
* Tendencias financieras
*/
static async getFinancialTrends(req: Request, res: Response, next: NextFunction) {
try {
const { months = '6' } = req.query;
const monthsNum = parseInt(months as string, 10);
if (isNaN(monthsNum) || monthsNum < 1 || monthsNum > 24) {
throw new ApiError('El parámetro months debe estar entre 1 y 24', 400);
}
const trends = await FinancialService.getFinancialTrends(monthsNum);
res.status(200).json({
success: true,
data: trends,
meta: {
months: monthsNum,
totalItems: trends.length,
},
});
} catch (error) {
next(error);
}
}
/**
* GET /analytics/top-days
* Días con mayores ingresos
*/
static async getTopRevenueDays(req: Request, res: Response, next: NextFunction) {
try {
const { limit = '10' } = req.query;
const limitNum = parseInt(limit as string, 10);
if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) {
throw new ApiError('El parámetro limit debe estar entre 1 y 100', 400);
}
const days = await FinancialService.getTopRevenueDays(limitNum);
res.status(200).json({
success: true,
data: days,
meta: {
limit: limitNum,
totalItems: days.length,
},
});
} catch (error) {
next(error);
}
}
}
export default FinancialController;

View File

@@ -0,0 +1,263 @@
import { Request, Response, NextFunction } from 'express';
import { OccupancyService } from '../../services/analytics/occupancy.service';
import { ApiError } from '../../middleware/errorHandler';
export class OccupancyController {
/**
* GET /analytics/occupancy
* Reporte de ocupación por rango de fechas
*/
static async getOccupancyReport(req: Request, res: Response, next: NextFunction) {
try {
const { startDate, endDate, courtId } = req.query;
if (!startDate || !endDate) {
throw new ApiError('Se requieren los parámetros startDate y endDate', 400);
}
const start = new Date(startDate as string);
const end = new Date(endDate as string);
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
throw new ApiError('Formato de fecha inválido. Use formato ISO 8601 (YYYY-MM-DD)', 400);
}
const data = await OccupancyService.getOccupancyByDateRange({
startDate: start,
endDate: end,
courtId: courtId as string | undefined,
});
res.status(200).json({
success: true,
data: {
period: {
startDate: start.toISOString().split('T')[0],
endDate: end.toISOString().split('T')[0],
},
courtId: courtId || null,
dailyData: data,
summary: {
averageOccupancy: data.length > 0
? Math.round((data.reduce((sum, d) => sum + d.occupancyRate, 0) / data.length) * 10) / 10
: 0,
totalSlots: data.reduce((sum, d) => sum + d.totalSlots, 0),
totalBooked: data.reduce((sum, d) => sum + d.bookedSlots, 0),
},
},
});
} catch (error) {
next(error);
}
}
/**
* GET /analytics/occupancy/by-court
* Ocupación específica por cancha
*/
static async getOccupancyByCourt(req: Request, res: Response, next: NextFunction) {
try {
const { courtId, startDate, endDate, groupBy } = req.query;
if (!courtId || !startDate || !endDate) {
throw new ApiError('Se requieren los parámetros courtId, startDate y endDate', 400);
}
const start = new Date(startDate as string);
const end = new Date(endDate as string);
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
throw new ApiError('Formato de fecha inválido. Use formato ISO 8601 (YYYY-MM-DD)', 400);
}
const validGroupBy = ['day', 'week', 'month'];
const groupByValue = groupBy as string;
if (groupBy && !validGroupBy.includes(groupByValue)) {
throw new ApiError('El parámetro groupBy debe ser: day, week o month', 400);
}
const data = await OccupancyService.getOccupancyByCourt({
courtId: courtId as string,
startDate: start,
endDate: end,
groupBy: (groupBy as 'day' | 'week' | 'month') || 'day',
});
res.status(200).json({
success: true,
data: {
courtId,
period: {
startDate: start.toISOString().split('T')[0],
endDate: end.toISOString().split('T')[0],
},
groupBy: groupBy || 'day',
occupancyData: data,
summary: {
averageOccupancy: data.length > 0
? Math.round((data.reduce((sum, d) => sum + d.occupancyRate, 0) / data.length) * 10) / 10
: 0,
peakDay: data.length > 0
? data.reduce((max, d) => d.occupancyRate > max.occupancyRate ? d : max, data[0])
: null,
},
},
});
} catch (error) {
next(error);
}
}
/**
* GET /analytics/occupancy/by-timeslot
* Ocupación por franja horaria
*/
static async getOccupancyByTimeSlot(req: Request, res: Response, next: NextFunction) {
try {
const { startDate, endDate, courtId } = req.query;
if (!startDate || !endDate) {
throw new ApiError('Se requieren los parámetros startDate y endDate', 400);
}
const start = new Date(startDate as string);
const end = new Date(endDate as string);
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
throw new ApiError('Formato de fecha inválido. Use formato ISO 8601 (YYYY-MM-DD)', 400);
}
const data = await OccupancyService.getOccupancyByTimeSlot({
startDate: start,
endDate: end,
courtId: courtId as string | undefined,
});
// Identificar horas pico y valle
const sortedByOccupancy = [...data].sort((a, b) => b.occupancyRate - a.occupancyRate);
const peakHours = sortedByOccupancy.slice(0, 3);
const valleyHours = sortedByOccupancy.slice(-3);
res.status(200).json({
success: true,
data: {
period: {
startDate: start.toISOString().split('T')[0],
endDate: end.toISOString().split('T')[0],
},
courtId: courtId || null,
hourlyData: data,
peakHours,
valleyHours,
},
});
} catch (error) {
next(error);
}
}
/**
* GET /analytics/occupancy/peak-hours
* Horarios más demandados (top 5)
*/
static async getPeakHours(req: Request, res: Response, next: NextFunction) {
try {
const { startDate, endDate, courtId } = req.query;
if (!startDate || !endDate) {
throw new ApiError('Se requieren los parámetros startDate y endDate', 400);
}
const start = new Date(startDate as string);
const end = new Date(endDate as string);
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
throw new ApiError('Formato de fecha inválido. Use formato ISO 8601 (YYYY-MM-DD)', 400);
}
const data = await OccupancyService.getPeakHours({
startDate: start,
endDate: end,
courtId: courtId as string | undefined,
});
res.status(200).json({
success: true,
data: {
period: {
startDate: start.toISOString().split('T')[0],
endDate: end.toISOString().split('T')[0],
},
courtId: courtId || null,
peakHours: data,
},
});
} catch (error) {
next(error);
}
}
/**
* GET /analytics/occupancy/comparison
* Comparativa de ocupación entre dos períodos
*/
static async getOccupancyComparison(req: Request, res: Response, next: NextFunction) {
try {
const {
period1Start,
period1End,
period2Start,
period2End,
courtId
} = req.query;
if (!period1Start || !period1End || !period2Start || !period2End) {
throw new ApiError(
'Se requieren los parámetros: period1Start, period1End, period2Start, period2End',
400
);
}
const p1Start = new Date(period1Start as string);
const p1End = new Date(period1End as string);
const p2Start = new Date(period2Start as string);
const p2End = new Date(period2End as string);
if (
isNaN(p1Start.getTime()) ||
isNaN(p1End.getTime()) ||
isNaN(p2Start.getTime()) ||
isNaN(p2End.getTime())
) {
throw new ApiError('Formato de fecha inválido. Use formato ISO 8601 (YYYY-MM-DD)', 400);
}
const data = await OccupancyService.getOccupancyComparison({
period1Start: p1Start,
period1End: p1End,
period2Start: p2Start,
period2End: p2End,
courtId: courtId as string | undefined,
});
res.status(200).json({
success: true,
data: {
comparison: data,
interpretation: {
trend: data.variation > 0 ? 'up' : data.variation < 0 ? 'down' : 'stable',
message: data.variation > 0
? `La ocupación aumentó un ${data.variation}% respecto al período anterior`
: data.variation < 0
? `La ocupación disminuyó un ${Math.abs(data.variation)}% respecto al período anterior`
: 'La ocupación se mantuvo estable respecto al período anterior',
},
},
});
} catch (error) {
next(error);
}
}
}
export default OccupancyController;

View File

@@ -0,0 +1,142 @@
import { Request, Response, NextFunction } from 'express';
import { ReportService } from '../../services/analytics/report.service';
import { ApiError } from '../../middleware/errorHandler';
// Validar fechas
const validateDates = (startDateStr: string, endDateStr: string): { startDate: Date; endDate: Date } => {
const startDate = new Date(startDateStr);
const endDate = new Date(endDateStr);
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
throw new ApiError('Fechas inválidas', 400);
}
if (startDate > endDate) {
throw new ApiError('La fecha de inicio debe ser anterior a la fecha de fin', 400);
}
// Ajustar endDate para incluir todo el día
endDate.setHours(23, 59, 59, 999);
return { startDate, endDate };
};
export class ReportController {
/**
* GET /analytics/reports/revenue
* Reporte completo de ingresos
*/
static async getRevenueReport(req: Request, res: Response, next: NextFunction) {
try {
const { startDate: startDateStr, endDate: endDateStr } = req.query;
if (!startDateStr || !endDateStr) {
throw new ApiError('Se requieren fechas de inicio y fin', 400);
}
const { startDate, endDate } = validateDates(startDateStr as string, endDateStr as string);
const report = await ReportService.generateRevenueReport(startDate, endDate);
res.status(200).json({
success: true,
data: report,
meta: {
reportType: 'revenue',
generatedAt: new Date().toISOString(),
},
});
} catch (error) {
next(error);
}
}
/**
* GET /analytics/reports/occupancy
* Reporte de ocupación
*/
static async getOccupancyReport(req: Request, res: Response, next: NextFunction) {
try {
const { startDate: startDateStr, endDate: endDateStr } = req.query;
if (!startDateStr || !endDateStr) {
throw new ApiError('Se requieren fechas de inicio y fin', 400);
}
const { startDate, endDate } = validateDates(startDateStr as string, endDateStr as string);
const report = await ReportService.generateOccupancyReport(startDate, endDate);
res.status(200).json({
success: true,
data: report,
meta: {
reportType: 'occupancy',
generatedAt: new Date().toISOString(),
},
});
} catch (error) {
next(error);
}
}
/**
* GET /analytics/reports/users
* Reporte de usuarios
*/
static async getUserReport(req: Request, res: Response, next: NextFunction) {
try {
const { startDate: startDateStr, endDate: endDateStr } = req.query;
if (!startDateStr || !endDateStr) {
throw new ApiError('Se requieren fechas de inicio y fin', 400);
}
const { startDate, endDate } = validateDates(startDateStr as string, endDateStr as string);
const report = await ReportService.generateUserReport(startDate, endDate);
res.status(200).json({
success: true,
data: report,
meta: {
reportType: 'users',
generatedAt: new Date().toISOString(),
},
});
} catch (error) {
next(error);
}
}
/**
* GET /analytics/reports/summary
* Resumen ejecutivo
*/
static async getExecutiveSummary(req: Request, res: Response, next: NextFunction) {
try {
const { startDate: startDateStr, endDate: endDateStr } = req.query;
if (!startDateStr || !endDateStr) {
throw new ApiError('Se requieren fechas de inicio y fin', 400);
}
const { startDate, endDate } = validateDates(startDateStr as string, endDateStr as string);
const summary = await ReportService.getReportSummary(startDate, endDate);
res.status(200).json({
success: true,
data: summary,
meta: {
reportType: 'executive-summary',
generatedAt: new Date().toISOString(),
},
});
} catch (error) {
next(error);
}
}
}
export default ReportController;