✅ 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:
89
backend/src/controllers/analytics/dashboard.controller.ts
Normal file
89
backend/src/controllers/analytics/dashboard.controller.ts
Normal 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;
|
||||
260
backend/src/controllers/analytics/financial.controller.ts
Normal file
260
backend/src/controllers/analytics/financial.controller.ts
Normal 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;
|
||||
263
backend/src/controllers/analytics/occupancy.controller.ts
Normal file
263
backend/src/controllers/analytics/occupancy.controller.ts
Normal 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;
|
||||
142
backend/src/controllers/analytics/report.controller.ts
Normal file
142
backend/src/controllers/analytics/report.controller.ts
Normal 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;
|
||||
Reference in New Issue
Block a user