From 5e50dd766f5459fbb3849d1d9063d94b811d37c8 Mon Sep 17 00:00:00 2001 From: Ivan Alcaraz Date: Sat, 31 Jan 2026 09:13:03 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=85=20FASE=205=20COMPLETADA:=20Analytics?= =?UTF-8?q?=20y=20Administraci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- INTEGRATION.md | 79 ++ README_FASE_5.3.md | 136 ++++ backend/package-lock.json | 104 +++ backend/package.json | 1 + .../analytics/dashboard.controller.ts | 89 ++ .../analytics/financial.controller.ts | 260 ++++++ .../analytics/occupancy.controller.ts | 263 ++++++ .../analytics/report.controller.ts | 142 ++++ backend/src/routes/analytics.routes.ts | 151 ++++ backend/src/routes/index.ts | 7 + .../services/analytics/dashboard.service.ts | 426 ++++++++++ .../services/analytics/financial.service.ts | 444 ++++++++++ .../services/analytics/occupancy.service.ts | 545 +++++++++++++ .../src/services/analytics/report.service.ts | 760 ++++++++++++++++++ backend/src/utils/analytics.ts | 369 +++++++++ backend/src/utils/constants.ts | 35 + backend/src/validators/analytics.validator.ts | 112 +++ docs/roadmap/FASE-05.md | 236 +++++- package.json | 22 + src/constants/export.constants.ts | 15 + src/constants/index.ts | 1 + .../analytics/export.controller.ts | 230 ++++++ src/controllers/analytics/index.ts | 2 + .../analytics/userAnalytics.controller.ts | 209 +++++ src/routes/analytics.routes.ts | 102 +++ src/services/analytics/export.service.ts | 554 +++++++++++++ src/services/analytics/index.ts | 2 + .../analytics/userAnalytics.service.ts | 478 +++++++++++ src/types/analytics.types.ts | 130 +++ src/types/index.ts | 1 + src/utils/export.ts | 166 ++++ 31 files changed, 6068 insertions(+), 3 deletions(-) create mode 100644 INTEGRATION.md create mode 100644 README_FASE_5.3.md create mode 100644 backend/src/controllers/analytics/dashboard.controller.ts create mode 100644 backend/src/controllers/analytics/financial.controller.ts create mode 100644 backend/src/controllers/analytics/occupancy.controller.ts create mode 100644 backend/src/controllers/analytics/report.controller.ts create mode 100644 backend/src/routes/analytics.routes.ts create mode 100644 backend/src/services/analytics/dashboard.service.ts create mode 100644 backend/src/services/analytics/financial.service.ts create mode 100644 backend/src/services/analytics/occupancy.service.ts create mode 100644 backend/src/services/analytics/report.service.ts create mode 100644 backend/src/utils/analytics.ts create mode 100644 backend/src/validators/analytics.validator.ts create mode 100644 package.json create mode 100644 src/constants/export.constants.ts create mode 100644 src/constants/index.ts create mode 100644 src/controllers/analytics/export.controller.ts create mode 100644 src/controllers/analytics/index.ts create mode 100644 src/controllers/analytics/userAnalytics.controller.ts create mode 100644 src/routes/analytics.routes.ts create mode 100644 src/services/analytics/export.service.ts create mode 100644 src/services/analytics/index.ts create mode 100644 src/services/analytics/userAnalytics.service.ts create mode 100644 src/types/analytics.types.ts create mode 100644 src/types/index.ts create mode 100644 src/utils/export.ts diff --git a/INTEGRATION.md b/INTEGRATION.md new file mode 100644 index 0000000..512740d --- /dev/null +++ b/INTEGRATION.md @@ -0,0 +1,79 @@ +# Integración de Analytics y Exportación + +## Resumen + +Esta fase (5.3) implementa el sistema de métricas de usuarios y exportación de datos para la App Padel. + +## Archivos Creados + +### Servicios +- `src/services/analytics/userAnalytics.service.ts` - Métricas y análisis de usuarios +- `src/services/analytics/export.service.ts` - Exportación de datos +- `src/services/analytics/index.ts` - Exportaciones + +### Controladores +- `src/controllers/analytics/userAnalytics.controller.ts` - Controladores de métricas +- `src/controllers/analytics/export.controller.ts` - Controladores de exportación +- `src/controllers/analytics/index.ts` - Exportaciones + +### Rutas +- `src/routes/analytics.routes.ts` - Definición de rutas + +### Utilidades +- `src/utils/export.ts` - Funciones utilitarias para exportación + +### Tipos +- `src/types/analytics.types.ts` - Definiciones de TypeScript + +### Constantes +- `src/constants/export.constants.ts` - Enumeraciones y constantes + +## Instalación de Dependencias + +```bash +npm install xlsx +``` + +## Integración en App Principal + +```typescript +import express from 'express'; +import analyticsRoutes from './routes/analytics.routes'; + +const app = express(); + +// ... otras rutas ... + +// Rutas de analytics (proteger con middleware de admin) +app.use('/analytics', authenticate, requireAdmin, analyticsRoutes); +``` + +## Endpoints Disponibles + +### Usuarios +- `GET /analytics/users/overview` - Estadísticas generales +- `GET /analytics/users/activity?startDate=2024-01-01&endDate=2024-01-31` - Actividad +- `GET /analytics/users/top-players?limit=10&by=matches` - Top jugadores +- `GET /analytics/users/churn-risk` - Usuarios en riesgo +- `GET /analytics/users/retention?startDate=2024-01-01&endDate=2024-06-30` - Retención +- `GET /analytics/users/growth?months=6` - Crecimiento + +### Exportación +- `GET /analytics/exports/bookings?startDate=2024-01-01&endDate=2024-01-31&format=csv` - Reservas +- `GET /analytics/exports/users?format=csv&level=3.5` - Usuarios +- `GET /analytics/exports/payments?startDate=2024-01-01&endDate=2024-01-31` - Pagos +- `GET /analytics/exports/tournaments/:id?format=csv` - Resultados de torneo +- `GET /analytics/exports/excel-report?startDate=2024-01-01&endDate=2024-01-31` - Reporte Excel + +## Formatos de Exportación + +- `csv` - Archivo CSV separado por punto y coma (optimizado para Excel español) +- `json` - Archivo JSON formateado +- `excel` - Archivo Excel (.xlsx) con múltiples hojas + +## Notas + +- Todos los endpoints requieren autenticación de administrador +- Las fechas deben estar en formato ISO 8601 +- Las exportaciones CSV usan punto y coma (;) como separador +- Para grandes volúmenes de datos, considerar implementar paginación o streaming diff --git a/README_FASE_5.3.md b/README_FASE_5.3.md new file mode 100644 index 0000000..bd1e689 --- /dev/null +++ b/README_FASE_5.3.md @@ -0,0 +1,136 @@ +# Fase 5.3 - Métricas de Usuarios y Exportación + +## 📊 Resumen + +Implementación del sistema completo de analytics y exportación de datos para la aplicación Padel. + +## 📁 Estructura de Archivos + +``` +src/ +├── constants/ +│ ├── export.constants.ts # Enum ExportFormat, ChurnRiskLevel +│ └── index.ts +├── controllers/ +│ ├── analytics/ +│ │ ├── export.controller.ts # Controladores de exportación +│ │ ├── userAnalytics.controller.ts # Controladores de métricas +│ │ └── index.ts +├── routes/ +│ └── analytics.routes.ts # Definición de rutas +├── services/ +│ ├── analytics/ +│ │ ├── export.service.ts # Lógica de exportación +│ │ ├── userAnalytics.service.ts # Lógica de métricas +│ │ └── index.ts +├── types/ +│ ├── analytics.types.ts # Interfaces y tipos +│ └── index.ts +└── utils/ + └── export.ts # Utilidades de exportación +``` + +## 🚀 Endpoints + +### Analytics de Usuarios + +| Endpoint | Método | Descripción | Parámetros | +|----------|--------|-------------|------------| +| `/analytics/users/overview` | GET | Stats generales de usuarios | - | +| `/analytics/users/activity` | GET | Actividad en período | `startDate`, `endDate` | +| `/analytics/users/top-players` | GET | Mejores jugadores | `limit`, `by` | +| `/analytics/users/churn-risk` | GET | Usuarios en riesgo | - | +| `/analytics/users/retention` | GET | Tasa de retención | `startDate`, `endDate` | +| `/analytics/users/growth` | GET | Tendencia de crecimiento | `months` | + +### Exportación + +| Endpoint | Método | Descripción | Parámetros | +|----------|--------|-------------|------------| +| `/analytics/exports/bookings` | GET | Exportar reservas | `startDate`, `endDate`, `format` | +| `/analytics/exports/users` | GET | Exportar usuarios | `format`, `level`, `city` | +| `/analytics/exports/payments` | GET | Exportar pagos | `startDate`, `endDate`, `format` | +| `/analytics/exports/tournaments/:id` | GET | Exportar torneo | `id`, `format` | +| `/analytics/exports/excel-report` | GET | Reporte Excel | `startDate`, `endDate` | + +## 📦 Instalación + +```bash +npm install xlsx +``` + +## 🔧 Uso + +### Ejemplo: Obtener Overview de Usuarios + +```bash +curl http://localhost:3000/analytics/users/overview \ + -H "Authorization: Bearer " +``` + +### Ejemplo: Exportar Reservas a CSV + +```bash +curl "http://localhost:3000/analytics/exports/bookings?startDate=2024-01-01&endDate=2024-01-31&format=csv" \ + -H "Authorization: Bearer " \ + --output reservas_enero.csv +``` + +### Ejemplo: Reporte Excel Completo + +```bash +curl "http://localhost:3000/analytics/exports/excel-report?startDate=2024-01-01&endDate=2024-01-31" \ + -H "Authorization: Bearer " \ + --output reporte_enero.xlsx +``` + +## 📋 Funcionalidades + +### Métricas de Usuarios + +1. **getUserStatsOverview()** + - Total de usuarios + - Usuarios activos (30 días) + - Nuevos usuarios (hoy/semana/mes) + - Distribución por nivel + - Distribución por ciudad + +2. **getUserActivityStats()** + - Usuarios con reservas en período + - Promedio de reservas por usuario + - Top 10 usuarios más activos + - Usuarios sin actividad + +3. **getTopPlayers()** + - Ranking por partidos jugados + - Ranking por victorias + - Ranking por puntos + - Ranking por torneos asistidos + +4. **getChurnRiskUsers()** + - Identifica usuarios sin reservar 60+ días + - Calcula nivel de riesgo (LOW/MEDIUM/HIGH) + - Muestra última fecha de reserva + +5. **getRetentionRate()** + - Tasa de retención mensual + - Usuarios que siguen activos después de N días + +6. **getUserGrowthTrend()** + - Crecimiento de usuarios por mes + - Tasa de crecimiento mes a mes + +### Exportación + +1. **exportBookings()** - Exporta reservas con campos: id, usuario, pista, fecha, hora, precio, estado +2. **exportUsers()** - Exporta usuarios con campos: id, nombre, email, nivel, ciudad, reservas +3. **exportPayments()** - Exporta pagos con campos: id, usuario, tipo, monto, estado, fecha +4. **exportTournamentResults()** - Exporta resultados de torneo con estadísticas +5. **generateExcelReport()** - Reporte completo con 4 hojas: Resumen, Ingresos, Ocupación, Usuarios + +## 🔒 Consideraciones + +- Todos los endpoints requieren autenticación de administrador +- Formato de fechas: ISO 8601 (YYYY-MM-DD) +- CSV usa punto y coma (;) como separador (mejor para Excel español) +- Excel generado con librería xlsx diff --git a/backend/package-lock.json b/backend/package-lock.json index ae5b5fd..197a716 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -21,6 +21,7 @@ "morgan": "^1.10.0", "nodemailer": "^6.9.8", "winston": "^3.11.0", + "xlsx": "^0.18.5", "zod": "^3.22.4" }, "devDependencies": { @@ -1229,6 +1230,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -1486,6 +1496,19 @@ "node": ">=6" } }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1512,6 +1535,15 @@ "node": ">=10" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", @@ -1661,6 +1693,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2353,6 +2397,15 @@ "node": ">= 0.6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -3944,6 +3997,18 @@ "node": ">=8" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", @@ -4317,6 +4382,24 @@ "node": ">= 12.0.0" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -4333,6 +4416,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index 5dc4e47..5ed8d5c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -35,6 +35,7 @@ "morgan": "^1.10.0", "nodemailer": "^6.9.8", "winston": "^3.11.0", + "xlsx": "^0.18.5", "zod": "^3.22.4" }, "devDependencies": { diff --git a/backend/src/controllers/analytics/dashboard.controller.ts b/backend/src/controllers/analytics/dashboard.controller.ts new file mode 100644 index 0000000..1994ba0 --- /dev/null +++ b/backend/src/controllers/analytics/dashboard.controller.ts @@ -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 = {}; + 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; diff --git a/backend/src/controllers/analytics/financial.controller.ts b/backend/src/controllers/analytics/financial.controller.ts new file mode 100644 index 0000000..3c90aaf --- /dev/null +++ b/backend/src/controllers/analytics/financial.controller.ts @@ -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; diff --git a/backend/src/controllers/analytics/occupancy.controller.ts b/backend/src/controllers/analytics/occupancy.controller.ts new file mode 100644 index 0000000..1d928aa --- /dev/null +++ b/backend/src/controllers/analytics/occupancy.controller.ts @@ -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; diff --git a/backend/src/controllers/analytics/report.controller.ts b/backend/src/controllers/analytics/report.controller.ts new file mode 100644 index 0000000..96ee5e7 --- /dev/null +++ b/backend/src/controllers/analytics/report.controller.ts @@ -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; diff --git a/backend/src/routes/analytics.routes.ts b/backend/src/routes/analytics.routes.ts new file mode 100644 index 0000000..0a00427 --- /dev/null +++ b/backend/src/routes/analytics.routes.ts @@ -0,0 +1,151 @@ +import { Router } from 'express'; +import { OccupancyController } from '../controllers/analytics/occupancy.controller'; +import { DashboardController } from '../controllers/analytics/dashboard.controller'; +import { FinancialController } from '../controllers/analytics/financial.controller'; +import { ReportController } from '../controllers/analytics/report.controller'; +import { authenticate, authorize } from '../middleware/auth'; +import { validate } from '../middleware/validate'; +import { UserRole } from '../utils/constants'; +import { + dateRangeSchema, + occupancyByCourtSchema, + timeSlotSchema, + peakHoursSchema, + comparisonSchema, + courtIdParamSchema, +} from '../validators/analytics.validator'; + +const router = Router(); + +// Middleware de autenticación y autorización para todos los endpoints de analytics +// Solo administradores pueden acceder +router.use(authenticate, authorize(UserRole.ADMIN, UserRole.SUPERADMIN)); + +// ============================================ +// Dashboard (Fase 5.1) +// ============================================ + +/** + * @route GET /analytics/dashboard/summary + * @desc Obtener resumen rápido del dashboard + * @access Admin + */ +router.get('/dashboard/summary', DashboardController.getDashboardSummary); + +/** + * @route GET /analytics/dashboard/today + * @desc Obtener vista general del día actual + * @access Admin + */ +router.get('/dashboard/today', DashboardController.getTodayOverview); + +/** + * @route GET /analytics/dashboard/calendar + * @desc Obtener calendario semanal de ocupación + * @access Admin + * @query courtId - Opcional, filtrar por cancha específica + */ +router.get('/dashboard/calendar', validate(courtIdParamSchema), DashboardController.getWeeklyCalendar); + +// ============================================ +// Ocupación (Fase 5.1) +// ============================================ + +/** + * @route GET /analytics/occupancy + * @desc Reporte de ocupación por rango de fechas + * @access Admin + * @query startDate - Fecha inicio (ISO 8601) + * @query endDate - Fecha fin (ISO 8601) + * @query courtId - Opcional, filtrar por cancha específica + */ +router.get('/occupancy', validate(dateRangeSchema), OccupancyController.getOccupancyReport); + +/** + * @route GET /analytics/occupancy/by-court + * @desc Ocupación específica por cancha + * @access Admin + * @query courtId - ID de la cancha + * @query startDate - Fecha inicio (ISO 8601) + * @query endDate - Fecha fin (ISO 8601) + * @query groupBy - Opcional: day, week, month (default: day) + */ +router.get('/occupancy/by-court', validate(occupancyByCourtSchema), OccupancyController.getOccupancyByCourt); + +/** + * @route GET /analytics/occupancy/by-timeslot + * @desc Ocupación por franja horaria + * @access Admin + * @query startDate - Fecha inicio (ISO 8601) + * @query endDate - Fecha fin (ISO 8601) + * @query courtId - Opcional, filtrar por cancha específica + */ +router.get('/occupancy/by-timeslot', validate(timeSlotSchema), OccupancyController.getOccupancyByTimeSlot); + +/** + * @route GET /analytics/occupancy/peak-hours + * @desc Horarios más demandados (top 5) + * @access Admin + * @query startDate - Fecha inicio (ISO 8601) + * @query endDate - Fecha fin (ISO 8601) + * @query courtId - Opcional, filtrar por cancha específica + */ +router.get('/occupancy/peak-hours', validate(peakHoursSchema), OccupancyController.getPeakHours); + +/** + * @route GET /analytics/occupancy/comparison + * @desc Comparativa de ocupación entre dos períodos + * @access Admin + * @query period1Start - Fecha inicio período 1 (ISO 8601) + * @query period1End - Fecha fin período 1 (ISO 8601) + * @query period2Start - Fecha inicio período 2 (ISO 8601) + * @query period2End - Fecha fin período 2 (ISO 8601) + * @query courtId - Opcional, filtrar por cancha específica + */ +router.get('/occupancy/comparison', validate(comparisonSchema), OccupancyController.getOccupancyComparison); + +// ============================================ +// Rutas de Métricas Financieras (Pre-existentes) +// ============================================ + +// GET /analytics/revenue - Ingresos por período +router.get('/revenue', FinancialController.getRevenueByPeriod); + +// GET /analytics/revenue/by-court - Ingresos por cancha +router.get('/revenue/by-court', FinancialController.getRevenueByCourt); + +// GET /analytics/revenue/by-type - Ingresos por tipo +router.get('/revenue/by-type', FinancialController.getRevenueByType); + +// GET /analytics/payment-methods - Estadísticas por método de pago +router.get('/payment-methods', FinancialController.getPaymentMethods); + +// GET /analytics/outstanding-payments - Pagos pendientes +router.get('/outstanding-payments', FinancialController.getOutstandingPayments); + +// GET /analytics/refunds - Estadísticas de reembolsos +router.get('/refunds', FinancialController.getRefundStats); + +// GET /analytics/trends - Tendencias financieras +router.get('/trends', FinancialController.getFinancialTrends); + +// GET /analytics/top-days - Días con mayores ingresos +router.get('/top-days', FinancialController.getTopRevenueDays); + +// ============================================ +// Rutas de Reportes (Pre-existentes) +// ============================================ + +// GET /analytics/reports/revenue - Reporte de ingresos +router.get('/reports/revenue', ReportController.getRevenueReport); + +// GET /analytics/reports/occupancy - Reporte de ocupación +router.get('/reports/occupancy', ReportController.getOccupancyReport); + +// GET /analytics/reports/users - Reporte de usuarios +router.get('/reports/users', ReportController.getUserReport); + +// GET /analytics/reports/summary - Resumen ejecutivo +router.get('/reports/summary', ReportController.getExecutiveSummary); + +export default router; diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index ca47fee..2a69700 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -98,6 +98,13 @@ router.use('/payments', paymentRoutes); import subscriptionRoutes from './subscription.routes'; router.use('/', subscriptionRoutes); +// ============================================ +// Rutas de Analytics y Dashboard (Fase 5.1) +// ============================================ + +import analyticsRoutes from './analytics.routes'; +router.use('/analytics', analyticsRoutes); + // ============================================ // Rutas de Clases con Profesores (Fase 4.4) - Desactivado temporalmente // ============================================ diff --git a/backend/src/services/analytics/dashboard.service.ts b/backend/src/services/analytics/dashboard.service.ts new file mode 100644 index 0000000..42b3719 --- /dev/null +++ b/backend/src/services/analytics/dashboard.service.ts @@ -0,0 +1,426 @@ +import prisma from '../../config/database'; +import { ApiError } from '../../middleware/errorHandler'; +import { BookingStatus, PaymentStatus, ExtendedPaymentStatus } from '../../utils/constants'; +import logger from '../../config/logger'; + +export interface DashboardSummary { + bookings: { + today: number; + thisWeek: number; + thisMonth: number; + }; + revenue: { + today: number; + thisWeek: number; + thisMonth: number; + }; + newUsers: { + today: number; + thisWeek: number; + thisMonth: number; + }; + occupancy: { + today: number; + averageWeek: number; + }; +} + +export interface TodayOverview { + date: string; + totalBookings: number; + confirmedBookings: number; + pendingBookings: number; + cancelledBookings: number; + courtsOccupiedNow: number; + courtsTotal: number; + upcomingBookings: TodayBooking[]; + alerts: Alert[]; +} + +export interface TodayBooking { + id: string; + courtName: string; + userName: string; + startTime: string; + endTime: string; + status: string; +} + +export interface Alert { + type: 'warning' | 'info' | 'error'; + message: string; + bookingId?: string; +} + +export interface WeeklyCalendarItem { + courtId: string; + courtName: string; + dayOfWeek: number; + date: string; + bookings: CalendarBooking[]; +} + +export interface CalendarBooking { + id: string; + startTime: string; + endTime: string; + userName: string; + status: string; +} + +export class DashboardService { + /** + * Obtener resumen del dashboard + */ + static async getDashboardSummary(): Promise { + const now = new Date(); + + // Fechas de referencia + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + const startOfWeek = new Date(today); + startOfWeek.setDate(today.getDate() - today.getDay()); + + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + const startOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1); + + // Contar reservas + const bookingsToday = await prisma.booking.count({ + where: { + date: { gte: today, lt: tomorrow }, + status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED, BookingStatus.PENDING] }, + }, + }); + + const bookingsThisWeek = await prisma.booking.count({ + where: { + date: { gte: startOfWeek, lt: tomorrow }, + status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED, BookingStatus.PENDING] }, + }, + }); + + const bookingsThisMonth = await prisma.booking.count({ + where: { + date: { gte: startOfMonth, lt: startOfNextMonth }, + status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED, BookingStatus.PENDING] }, + }, + }); + + // Calcular ingresos (de pagos completados) + const revenueToday = await this.calculateRevenue(today, tomorrow); + const revenueThisWeek = await this.calculateRevenue(startOfWeek, tomorrow); + const revenueThisMonth = await this.calculateRevenue(startOfMonth, startOfNextMonth); + + // Contar usuarios nuevos + const newUsersToday = await prisma.user.count({ + where: { + createdAt: { gte: today, lt: tomorrow }, + }, + }); + + const newUsersThisWeek = await prisma.user.count({ + where: { + createdAt: { gte: startOfWeek, lt: tomorrow }, + }, + }); + + const newUsersThisMonth = await prisma.user.count({ + where: { + createdAt: { gte: startOfMonth, lt: startOfNextMonth }, + }, + }); + + // Calcular ocupación del día + const occupancyToday = await this.calculateOccupancy(today, tomorrow); + const occupancyWeek = await this.calculateOccupancy(startOfWeek, tomorrow); + + return { + bookings: { + today: bookingsToday, + thisWeek: bookingsThisWeek, + thisMonth: bookingsThisMonth, + }, + revenue: { + today: revenueToday, + thisWeek: revenueThisWeek, + thisMonth: revenueThisMonth, + }, + newUsers: { + today: newUsersToday, + thisWeek: newUsersThisWeek, + thisMonth: newUsersThisMonth, + }, + occupancy: { + today: occupancyToday, + averageWeek: Math.round(occupancyWeek * 10) / 10, + }, + }; + } + + /** + * Obtener vista general de hoy + */ + static async getTodayOverview(): Promise { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + const currentHour = now.getHours(); + const currentTimeStr = `${currentHour.toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`; + + // Obtener todas las reservas de hoy + const todayBookings = await prisma.booking.findMany({ + where: { + date: { gte: today, lt: tomorrow }, + }, + include: { + court: { + select: { + id: true, + name: true, + }, + }, + user: { + select: { + id: true, + firstName: true, + lastName: true, + }, + }, + }, + orderBy: [ + { startTime: 'asc' }, + ], + }); + + // Contar por estado + const confirmedBookings = todayBookings.filter(b => b.status === BookingStatus.CONFIRMED).length; + const pendingBookings = todayBookings.filter(b => b.status === BookingStatus.PENDING).length; + const cancelledBookings = todayBookings.filter(b => b.status === BookingStatus.CANCELLED).length; + + // Canchas ocupadas ahora + const courtsOccupiedNow = new Set( + todayBookings + .filter(b => + b.status === BookingStatus.CONFIRMED && + b.startTime <= currentTimeStr && + b.endTime > currentTimeStr + ) + .map(b => b.court.id) + ).size; + + // Total de canchas activas + const totalCourts = await prisma.court.count({ where: { isActive: true } }); + + // Próximas reservas (que aún no han empezado o están en curso) + const upcomingBookings: TodayBooking[] = todayBookings + .filter(b => + b.status !== BookingStatus.CANCELLED && + b.endTime > currentTimeStr + ) + .slice(0, 10) // Limitar a 10 + .map(b => ({ + id: b.id, + courtName: b.court.name, + userName: `${b.user.firstName} ${b.user.lastName}`, + startTime: b.startTime, + endTime: b.endTime, + status: b.status, + })); + + // Generar alertas + const alerts: Alert[] = []; + + // Reservas pendientes de confirmación + if (pendingBookings > 0) { + alerts.push({ + type: 'warning', + message: `Hay ${pendingBookings} reserva(s) pendiente(s) de confirmación para hoy`, + }); + } + + // Reservas sin pagar (que empiezan en menos de 2 horas) + const bookingsStartingSoon = todayBookings.filter(b => { + if (b.status === BookingStatus.CANCELLED) return false; + const bookingHour = parseInt(b.startTime.split(':')[0]); + return bookingHour - currentHour <= 2 && bookingHour >= currentHour; + }); + + if (bookingsStartingSoon.length > 0) { + // Verificar pagos asociados + for (const booking of bookingsStartingSoon) { + const payment = await prisma.payment.findFirst({ + where: { + referenceId: booking.id, + type: 'BOOKING', + }, + }); + + if (!payment || payment.status !== ExtendedPaymentStatus.COMPLETED) { + alerts.push({ + type: 'info', + message: `Reserva en ${booking.court.name} a las ${booking.startTime} no tiene pago confirmado`, + bookingId: booking.id, + }); + } + } + } + + // Canchas al 100% de ocupación + if (courtsOccupiedNow === totalCourts && totalCourts > 0) { + alerts.push({ + type: 'info', + message: 'Todas las canchas están ocupadas en este momento', + }); + } + + return { + date: today.toISOString().split('T')[0], + totalBookings: todayBookings.length, + confirmedBookings, + pendingBookings, + cancelledBookings, + courtsOccupiedNow, + courtsTotal: totalCourts, + upcomingBookings, + alerts, + }; + } + + /** + * Obtener calendario semanal + */ + static async getWeeklyCalendar(): Promise { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const startOfWeek = new Date(today); + startOfWeek.setDate(today.getDate() - today.getDay()); + const endOfWeek = new Date(startOfWeek); + endOfWeek.setDate(startOfWeek.getDate() + 7); + + // Obtener canchas activas + const courts = await prisma.court.findMany({ + where: { isActive: true }, + select: { + id: true, + name: true, + }, + }); + + // Obtener reservas de la semana + const weekBookings = await prisma.booking.findMany({ + where: { + date: { gte: startOfWeek, lt: endOfWeek }, + status: { not: BookingStatus.CANCELLED }, + }, + include: { + court: { + select: { + id: true, + name: true, + }, + }, + user: { + select: { + firstName: true, + lastName: true, + }, + }, + }, + orderBy: [ + { date: 'asc' }, + { startTime: 'asc' }, + ], + }); + + // Construir estructura del calendario + const calendar: WeeklyCalendarItem[] = []; + + for (const court of courts) { + for (let day = 0; day < 7; day++) { + const date = new Date(startOfWeek); + date.setDate(startOfWeek.getDate() + day); + const dateStr = date.toISOString().split('T')[0]; + + const dayBookings = weekBookings.filter( + b => b.court.id === court.id && b.date.toISOString().split('T')[0] === dateStr + ); + + calendar.push({ + courtId: court.id, + courtName: court.name, + dayOfWeek: day, + date: dateStr, + bookings: dayBookings.map(b => ({ + id: b.id, + startTime: b.startTime, + endTime: b.endTime, + userName: `${b.user.firstName} ${b.user.lastName}`, + status: b.status, + })), + }); + } + } + + return calendar; + } + + // Helpers privados + + private static async calculateRevenue(startDate: Date, endDate: Date): Promise { + const payments = await prisma.payment.aggregate({ + where: { + createdAt: { gte: startDate, lt: endDate }, + status: ExtendedPaymentStatus.COMPLETED, + }, + _sum: { + amount: true, + }, + }); + + return payments._sum.amount || 0; + } + + private static async calculateOccupancy(startDate: Date, endDate: Date): Promise { + // Obtener canchas activas + const courts = await prisma.court.findMany({ + where: { isActive: true }, + }); + + if (courts.length === 0) return 0; + + // Contar días en el rango + const daysInRange = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)); + + // Asumir 15 horas operativas por día (8:00 - 23:00) + const hoursPerDay = 15; + const totalPossibleSlots = courts.length * daysInRange * hoursPerDay; + + // Obtener bookings confirmados/completados + const bookings = await prisma.booking.findMany({ + where: { + date: { gte: startDate, lt: endDate }, + status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED] }, + }, + select: { + startTime: true, + endTime: true, + }, + }); + + // Calcular slots ocupados + const bookedSlots = bookings.reduce((total, booking) => { + const startHour = parseInt(booking.startTime.split(':')[0]); + const endHour = parseInt(booking.endTime.split(':')[0]); + return total + (endHour - startHour); + }, 0); + + return totalPossibleSlots > 0 + ? Math.round((bookedSlots / totalPossibleSlots) * 1000) / 10 + : 0; + } +} + +export default DashboardService; diff --git a/backend/src/services/analytics/financial.service.ts b/backend/src/services/analytics/financial.service.ts new file mode 100644 index 0000000..8296a72 --- /dev/null +++ b/backend/src/services/analytics/financial.service.ts @@ -0,0 +1,444 @@ +import prisma from '../../config/database'; +import { ApiError } from '../../middleware/errorHandler'; +import { ExtendedPaymentStatus, PaymentType } from '../../utils/constants'; +import { formatCurrency, calculateGrowth, groupByDate, fillMissingDates } from '../../utils/analytics'; + +// Tipos +export type GroupByPeriod = 'day' | 'week' | 'month'; + +export interface RevenueByPeriodItem { + date: string; + totalRevenue: number; + bookingRevenue: number; + tournamentRevenue: number; + subscriptionRevenue: number; + classRevenue: number; +} + +export interface RevenueByCourtItem { + courtId: string; + courtName: string; + totalRevenue: number; + bookingCount: number; +} + +export interface RevenueByTypeItem { + type: string; + totalRevenue: number; + count: number; + percentage: number; +} + +export interface PaymentMethodStats { + method: string; + count: number; + totalAmount: number; + percentage: number; +} + +export interface OutstandingPayment { + userId: string; + userName: string; + userEmail: string; + amount: number; + type: string; + createdAt: Date; + daysPending: number; +} + +export interface RefundStats { + totalRefunds: number; + totalAmount: number; + averageAmount: number; + refundRate: number; +} + +export interface FinancialTrend { + month: string; + year: number; + totalRevenue: number; + previousMonthRevenue: number | null; + growthPercentage: number | null; +} + +export interface TopRevenueDay { + date: string; + totalRevenue: number; +} + +export class FinancialService { + /** + * Obtener ingresos por período agrupados por día/semana/mes + */ + static async getRevenueByPeriod( + startDate: Date, + endDate: Date, + groupBy: GroupByPeriod = 'day' + ): Promise { + // Obtener pagos completados en el período + const payments = await prisma.payment.findMany({ + where: { + status: ExtendedPaymentStatus.COMPLETED, + paidAt: { + gte: startDate, + lte: endDate, + }, + }, + select: { + amount: true, + type: true, + paidAt: true, + }, + orderBy: { + paidAt: 'asc', + }, + }); + + // Agrupar por fecha según el período + const grouped = groupByDate( + payments.map(p => ({ + date: p.paidAt!, + amount: p.amount, + type: p.type, + })), + 'date', + groupBy + ); + + // Formatear resultado + const result: RevenueByPeriodItem[] = Object.entries(grouped).map(([date, items]) => { + const bookingRevenue = items + .filter((i: any) => i.type === PaymentType.BOOKING) + .reduce((sum: number, i: any) => sum + i.amount, 0); + + const tournamentRevenue = items + .filter((i: any) => i.type === PaymentType.TOURNAMENT) + .reduce((sum: number, i: any) => sum + i.amount, 0); + + const subscriptionRevenue = items + .filter((i: any) => i.type === PaymentType.SUBSCRIPTION) + .reduce((sum: number, i: any) => sum + i.amount, 0); + + const classRevenue = items + .filter((i: any) => i.type === PaymentType.CLASS) + .reduce((sum: number, i: any) => sum + i.amount, 0); + + return { + date, + totalRevenue: items.reduce((sum: number, i: any) => sum + i.amount, 0), + bookingRevenue, + tournamentRevenue, + subscriptionRevenue, + classRevenue, + }; + }); + + // Rellenar fechas faltantes + return fillMissingDates(result, startDate, endDate, groupBy) as RevenueByPeriodItem[]; + } + + /** + * Obtener ingresos por cancha + */ + static async getRevenueByCourt( + startDate: Date, + endDate: Date + ): Promise { + // Obtener reservas pagadas en el período + const bookings = await prisma.booking.findMany({ + where: { + status: { + in: ['CONFIRMED', 'COMPLETED'], + }, + date: { + gte: startDate, + lte: endDate, + }, + }, + include: { + court: { + select: { + id: true, + name: true, + }, + }, + }, + }); + + // Agrupar por cancha + const courtMap = new Map(); + + for (const booking of bookings) { + const courtId = booking.court.id; + const existing = courtMap.get(courtId); + + if (existing) { + existing.totalRevenue += booking.totalPrice; + existing.bookingCount += 1; + } else { + courtMap.set(courtId, { + courtId, + courtName: booking.court.name, + totalRevenue: booking.totalPrice, + bookingCount: 1, + }); + } + } + + // Convertir a array y ordenar por ingresos + return Array.from(courtMap.values()).sort((a, b) => b.totalRevenue - a.totalRevenue); + } + + /** + * Obtener desglose de ingresos por tipo + */ + static async getRevenueByType( + startDate: Date, + endDate: Date + ): Promise { + const payments = await prisma.payment.groupBy({ + by: ['type'], + where: { + status: ExtendedPaymentStatus.COMPLETED, + paidAt: { + gte: startDate, + lte: endDate, + }, + }, + _sum: { + amount: true, + }, + _count: { + id: true, + }, + }); + + // Calcular total para porcentajes + const totalRevenue = payments.reduce((sum, p) => sum + (p._sum.amount || 0), 0); + + return payments.map(p => ({ + type: p.type, + totalRevenue: p._sum.amount || 0, + count: p._count.id, + percentage: totalRevenue > 0 ? Math.round(((p._sum.amount || 0) / totalRevenue) * 100 * 100) / 100 : 0, + })).sort((a, b) => b.totalRevenue - a.totalRevenue); + } + + /** + * Obtener estadísticas por método de pago + */ + static async getPaymentMethodsStats( + startDate: Date, + endDate: Date + ): Promise { + const payments = await prisma.payment.groupBy({ + by: ['paymentMethod'], + where: { + status: ExtendedPaymentStatus.COMPLETED, + paidAt: { + gte: startDate, + lte: endDate, + }, + }, + _sum: { + amount: true, + }, + _count: { + id: true, + }, + }); + + const totalAmount = payments.reduce((sum, p) => sum + (p._sum.amount || 0), 0); + const totalCount = payments.reduce((sum, p) => sum + p._count.id, 0); + + return payments.map(p => ({ + method: p.paymentMethod || 'unknown', + count: p._count.id, + totalAmount: p._sum.amount || 0, + percentage: totalCount > 0 ? Math.round((p._count.id / totalCount) * 100 * 100) / 100 : 0, + })).sort((a, b) => b.totalAmount - a.totalAmount); + } + + /** + * Obtener pagos pendientes de confirmar + */ + static async getOutstandingPayments(): Promise { + const pendingStatuses = [ + ExtendedPaymentStatus.PENDING, + ExtendedPaymentStatus.PROCESSING, + ]; + + const payments = await prisma.payment.findMany({ + where: { + status: { + in: pendingStatuses, + }, + }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + }, + orderBy: { + createdAt: 'asc', + }, + }); + + const now = new Date(); + + return payments.map(p => { + const daysPending = Math.floor( + (now.getTime() - new Date(p.createdAt).getTime()) / (1000 * 60 * 60 * 24) + ); + + return { + userId: p.user.id, + userName: `${p.user.firstName} ${p.user.lastName}`, + userEmail: p.user.email, + amount: p.amount, + type: p.type, + createdAt: p.createdAt, + daysPending, + }; + }); + } + + /** + * Obtener estadísticas de reembolsos + */ + static async getRefundStats(startDate: Date, endDate: Date): Promise { + // Obtener reembolsos + const refunds = await prisma.payment.findMany({ + where: { + status: ExtendedPaymentStatus.REFUNDED, + refundedAt: { + gte: startDate, + lte: endDate, + }, + }, + select: { + refundAmount: true, + amount: true, + }, + }); + + // Obtener total de pagos completados para calcular tasa + const completedPayments = await prisma.payment.count({ + where: { + status: ExtendedPaymentStatus.COMPLETED, + paidAt: { + gte: startDate, + lte: endDate, + }, + }, + }); + + const totalRefunds = refunds.length; + const totalAmount = refunds.reduce((sum, r) => sum + (r.refundAmount || r.amount), 0); + const averageAmount = totalRefunds > 0 ? Math.round(totalAmount / totalRefunds) : 0; + const refundRate = completedPayments + totalRefunds > 0 + ? Math.round((totalRefunds / (completedPayments + totalRefunds)) * 100 * 100) / 100 + : 0; + + return { + totalRefunds, + totalAmount, + averageAmount, + refundRate, + }; + } + + /** + * Obtener tendencias financieras de los últimos N meses + */ + static async getFinancialTrends(months: number = 6): Promise { + const now = new Date(); + const startDate = new Date(now.getFullYear(), now.getMonth() - months + 1, 1); + + // Obtener pagos completados + const payments = await prisma.payment.findMany({ + where: { + status: ExtendedPaymentStatus.COMPLETED, + paidAt: { + gte: startDate, + }, + }, + select: { + amount: true, + paidAt: true, + }, + orderBy: { + paidAt: 'asc', + }, + }); + + // Agrupar por mes + const monthlyData: Record = {}; + + for (const payment of payments) { + const date = new Date(payment.paidAt!); + const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; + monthlyData[key] = (monthlyData[key] || 0) + payment.amount; + } + + // Crear tendencias con comparación mes a mes + const trends: FinancialTrend[] = []; + const sortedKeys = Object.keys(monthlyData).sort(); + + for (let i = 0; i < sortedKeys.length; i++) { + const key = sortedKeys[i]; + const [year, month] = key.split('-').map(Number); + const totalRevenue = monthlyData[key]; + const previousMonthRevenue = i > 0 ? monthlyData[sortedKeys[i - 1]] : null; + + trends.push({ + month: `${year}-${String(month).padStart(2, '0')}`, + year, + totalRevenue, + previousMonthRevenue, + growthPercentage: calculateGrowth(totalRevenue, previousMonthRevenue), + }); + } + + return trends; + } + + /** + * Obtener días con mayores ingresos + */ + static async getTopRevenueDays(limit: number = 10): Promise { + const payments = await prisma.payment.findMany({ + where: { + status: ExtendedPaymentStatus.COMPLETED, + paidAt: { + not: null, + }, + }, + select: { + amount: true, + paidAt: true, + }, + }); + + // Agrupar por día + const dailyRevenue: Record = {}; + + for (const payment of payments) { + const dateKey = new Date(payment.paidAt!).toISOString().split('T')[0]; + dailyRevenue[dateKey] = (dailyRevenue[dateKey] || 0) + payment.amount; + } + + // Convertir a array, ordenar y limitar + return Object.entries(dailyRevenue) + .map(([date, totalRevenue]) => ({ date, totalRevenue })) + .sort((a, b) => b.totalRevenue - a.totalRevenue) + .slice(0, limit); + } +} + +export default FinancialService; diff --git a/backend/src/services/analytics/occupancy.service.ts b/backend/src/services/analytics/occupancy.service.ts new file mode 100644 index 0000000..d6592b8 --- /dev/null +++ b/backend/src/services/analytics/occupancy.service.ts @@ -0,0 +1,545 @@ +import prisma from '../../config/database'; +import { ApiError } from '../../middleware/errorHandler'; +import { BookingStatus } from '../../utils/constants'; +import logger from '../../config/logger'; + +export interface OccupancyByDateRangeInput { + startDate: Date; + endDate: Date; + courtId?: string; +} + +export interface OccupancyByCourtInput { + courtId: string; + startDate: Date; + endDate: Date; + groupBy?: 'day' | 'week' | 'month'; +} + +export interface OccupancyByTimeSlotInput { + startDate: Date; + endDate: Date; + courtId?: string; +} + +export interface PeakHoursInput { + startDate: Date; + endDate: Date; + courtId?: string; +} + +export interface OccupancyComparisonInput { + period1Start: Date; + period1End: Date; + period2Start: Date; + period2End: Date; + courtId?: string; +} + +export interface OccupancyData { + date: string; + totalSlots: number; + bookedSlots: number; + occupancyRate: number; +} + +export interface TimeSlotOccupancy { + hour: string; + totalSlots: number; + bookedSlots: number; + occupancyRate: number; +} + +export interface PeakHour { + hour: string; + totalSlots: number; + bookedSlots: number; + occupancyRate: number; +} + +export interface OccupancyComparisonResult { + period1: { + startDate: string; + endDate: string; + totalSlots: number; + bookedSlots: number; + occupancyRate: number; + }; + period2: { + startDate: string; + endDate: string; + totalSlots: number; + bookedSlots: number; + occupancyRate: number; + }; + variation: number; +} + +export class OccupancyService { + // Horarios de operación por defecto (8:00 - 23:00) + private static readonly DEFAULT_OPEN_HOUR = 8; + private static readonly DEFAULT_CLOSE_HOUR = 23; + + /** + * Obtener ocupación por rango de fechas + */ + static async getOccupancyByDateRange(input: OccupancyByDateRangeInput): Promise { + const { startDate, endDate, courtId } = input; + + // Validar fechas + if (startDate > endDate) { + throw new ApiError('La fecha de inicio debe ser anterior a la fecha de fin', 400); + } + + // Obtener canchas activas + const courts = courtId + ? await prisma.court.findMany({ where: { id: courtId, isActive: true } }) + : await prisma.court.findMany({ where: { isActive: true } }); + + if (courts.length === 0) { + throw new ApiError('No se encontraron canchas activas', 404); + } + + // Obtener bookings en el rango de fechas + const bookings = await prisma.booking.findMany({ + where: { + date: { + gte: startDate, + lte: endDate, + }, + status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED] }, + ...(courtId && { courtId }), + }, + select: { + date: true, + startTime: true, + endTime: true, + }, + }); + + // Generar datos por día + const result: OccupancyData[] = []; + const currentDate = new Date(startDate); + + while (currentDate <= endDate) { + const dateStr = currentDate.toISOString().split('T')[0]; + const dayOfWeek = currentDate.getDay(); + + // Calcular slots disponibles para este día + let totalSlotsForDay = 0; + + for (const court of courts) { + // Buscar horario específico de la cancha para este día + const schedule = await prisma.courtSchedule.findFirst({ + where: { + courtId: court.id, + dayOfWeek, + }, + }); + + if (schedule) { + const openHour = parseInt(schedule.openTime.split(':')[0]); + const closeHour = parseInt(schedule.closeTime.split(':')[0]); + totalSlotsForDay += (closeHour - openHour); + } else { + // Usar horario por defecto + totalSlotsForDay += (this.DEFAULT_CLOSE_HOUR - this.DEFAULT_OPEN_HOUR); + } + } + + // Contar bookings para este día + const dayBookings = bookings.filter( + b => b.date.toISOString().split('T')[0] === dateStr + ); + + const bookedSlots = dayBookings.reduce((total, booking) => { + const startHour = parseInt(booking.startTime.split(':')[0]); + const endHour = parseInt(booking.endTime.split(':')[0]); + return total + (endHour - startHour); + }, 0); + + const occupancyRate = totalSlotsForDay > 0 + ? Math.round((bookedSlots / totalSlotsForDay) * 1000) / 10 + : 0; + + result.push({ + date: dateStr, + totalSlots: totalSlotsForDay, + bookedSlots, + occupancyRate, + }); + + currentDate.setDate(currentDate.getDate() + 1); + } + + return result; + } + + /** + * Obtener ocupación por cancha específica + */ + static async getOccupancyByCourt(input: OccupancyByCourtInput): Promise { + const { courtId, startDate, endDate, groupBy = 'day' } = input; + + // Verificar que la cancha existe + const court = await prisma.court.findFirst({ + where: { id: courtId, isActive: true }, + }); + + if (!court) { + throw new ApiError('Cancha no encontrada o inactiva', 404); + } + + // Obtener bookings + const bookings = await prisma.booking.findMany({ + where: { + courtId, + date: { + gte: startDate, + lte: endDate, + }, + status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED] }, + }, + select: { + date: true, + startTime: true, + endTime: true, + }, + }); + + // Agrupar según el parámetro groupBy + if (groupBy === 'day') { + return this.groupByDay(bookings, startDate, endDate, courtId); + } else if (groupBy === 'week') { + return this.groupByWeek(bookings, startDate, endDate, courtId); + } else { + return this.groupByMonth(bookings, startDate, endDate, courtId); + } + } + + /** + * Obtener ocupación por franja horaria + */ + static async getOccupancyByTimeSlot(input: OccupancyByTimeSlotInput): Promise { + const { startDate, endDate, courtId } = input; + + // Obtener canchas + const courts = courtId + ? await prisma.court.findMany({ where: { id: courtId, isActive: true } }) + : await prisma.court.findMany({ where: { isActive: true } }); + + if (courts.length === 0) { + throw new ApiError('No se encontraron canchas activas', 404); + } + + // Obtener bookings + const bookings = await prisma.booking.findMany({ + where: { + date: { + gte: startDate, + lte: endDate, + }, + status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED] }, + ...(courtId && { courtId }), + }, + select: { + date: true, + startTime: true, + endTime: true, + }, + }); + + // Calcular días en el rango (excluyendo días sin horario) + const daysInRange = this.countOperationalDays(startDate, endDate, courts); + + // Inicializar slots por hora (8-22) + const hourlyData: Map = new Map(); + + for (let hour = this.DEFAULT_OPEN_HOUR; hour < this.DEFAULT_CLOSE_HOUR; hour++) { + const hourStr = `${hour.toString().padStart(2, '0')}:00`; + hourlyData.set(hourStr, { totalSlots: 0, bookedSlots: 0 }); + } + + // Calcular total de slots disponibles por hora + for (const court of courts) { + for (let hour = this.DEFAULT_OPEN_HOUR; hour < this.DEFAULT_CLOSE_HOUR; hour++) { + const hourStr = `${hour.toString().padStart(2, '0')}:00`; + const current = hourlyData.get(hourStr)!; + current.totalSlots += daysInRange; + } + } + + // Contar bookings por hora + for (const booking of bookings) { + const startHour = parseInt(booking.startTime.split(':')[0]); + const endHour = parseInt(booking.endTime.split(':')[0]); + + for (let hour = startHour; hour < endHour; hour++) { + const hourStr = `${hour.toString().padStart(2, '0')}:00`; + const current = hourlyData.get(hourStr); + if (current) { + current.bookedSlots++; + } + } + } + + // Convertir a array y calcular porcentajes + const result: TimeSlotOccupancy[] = []; + hourlyData.forEach((data, hour) => { + result.push({ + hour, + totalSlots: data.totalSlots, + bookedSlots: data.bookedSlots, + occupancyRate: data.totalSlots > 0 + ? Math.round((data.bookedSlots / data.totalSlots) * 1000) / 10 + : 0, + }); + }); + + return result.sort((a, b) => a.hour.localeCompare(b.hour)); + } + + /** + * Obtener horarios pico (top 5 horarios más demandados) + */ + static async getPeakHours(input: PeakHoursInput): Promise { + const occupancyByTimeSlot = await this.getOccupancyByTimeSlot(input); + + // Ordenar por ocupación (descendente) y tomar top 5 + return occupancyByTimeSlot + .sort((a, b) => b.occupancyRate - a.occupancyRate) + .slice(0, 5); + } + + /** + * Comparar ocupación entre dos períodos + */ + static async getOccupancyComparison(input: OccupancyComparisonInput): Promise { + const { period1Start, period1End, period2Start, period2End, courtId } = input; + + // Calcular ocupación para período 1 + const period1Data = await this.getOccupancyByDateRange({ + startDate: period1Start, + endDate: period1End, + courtId, + }); + + // Calcular ocupación para período 2 + const period2Data = await this.getOccupancyByDateRange({ + startDate: period2Start, + endDate: period2End, + courtId, + }); + + // Sumar totales del período 1 + const period1Totals = period1Data.reduce( + (acc, day) => ({ + totalSlots: acc.totalSlots + day.totalSlots, + bookedSlots: acc.bookedSlots + day.bookedSlots, + }), + { totalSlots: 0, bookedSlots: 0 } + ); + + // Sumar totales del período 2 + const period2Totals = period2Data.reduce( + (acc, day) => ({ + totalSlots: acc.totalSlots + day.totalSlots, + bookedSlots: acc.bookedSlots + day.bookedSlots, + }), + { totalSlots: 0, bookedSlots: 0 } + ); + + const period1Rate = period1Totals.totalSlots > 0 + ? Math.round((period1Totals.bookedSlots / period1Totals.totalSlots) * 1000) / 10 + : 0; + + const period2Rate = period2Totals.totalSlots > 0 + ? Math.round((period2Totals.bookedSlots / period2Totals.totalSlots) * 1000) / 10 + : 0; + + // Calcular variación porcentual + let variation = 0; + if (period1Rate > 0) { + variation = Math.round(((period2Rate - period1Rate) / period1Rate) * 1000) / 10; + } else if (period2Rate > 0) { + variation = 100; + } + + return { + period1: { + startDate: period1Start.toISOString().split('T')[0], + endDate: period1End.toISOString().split('T')[0], + totalSlots: period1Totals.totalSlots, + bookedSlots: period1Totals.bookedSlots, + occupancyRate: period1Rate, + }, + period2: { + startDate: period2Start.toISOString().split('T')[0], + endDate: period2End.toISOString().split('T')[0], + totalSlots: period2Totals.totalSlots, + bookedSlots: period2Totals.bookedSlots, + occupancyRate: period2Rate, + }, + variation, + }; + } + + // Helpers privados + + private static async groupByDay( + bookings: any[], + startDate: Date, + endDate: Date, + courtId: string + ): Promise { + const result: OccupancyData[] = []; + const currentDate = new Date(startDate); + + while (currentDate <= endDate) { + const dateStr = currentDate.toISOString().split('T')[0]; + const dayBookings = bookings.filter( + b => b.date.toISOString().split('T')[0] === dateStr + ); + + const bookedSlots = dayBookings.reduce((total, booking) => { + const startHour = parseInt(booking.startTime.split(':')[0]); + const endHour = parseInt(booking.endTime.split(':')[0]); + return total + (endHour - startHour); + }, 0); + + // Calcular slots disponibles para este día + const dayOfWeek = currentDate.getDay(); + const schedule = await prisma.courtSchedule.findFirst({ + where: { courtId, dayOfWeek }, + }); + + const openHour = schedule ? parseInt(schedule.openTime.split(':')[0]) : this.DEFAULT_OPEN_HOUR; + const closeHour = schedule ? parseInt(schedule.closeTime.split(':')[0]) : this.DEFAULT_CLOSE_HOUR; + const totalSlots = closeHour - openHour; + + result.push({ + date: dateStr, + totalSlots, + bookedSlots, + occupancyRate: totalSlots > 0 + ? Math.round((bookedSlots / totalSlots) * 1000) / 10 + : 0, + }); + + currentDate.setDate(currentDate.getDate() + 1); + } + + return result; + } + + private static async groupByWeek( + bookings: any[], + startDate: Date, + endDate: Date, + courtId: string + ): Promise { + const weeklyData: Map = new Map(); + + for (const booking of bookings) { + const date = new Date(booking.date); + const weekStart = new Date(date); + weekStart.setDate(date.getDate() - date.getDay()); + const weekKey = weekStart.toISOString().split('T')[0]; + + if (!weeklyData.has(weekKey)) { + weeklyData.set(weekKey, { totalSlots: 0, bookedSlots: 0 }); + } + + const current = weeklyData.get(weekKey)!; + const startHour = parseInt(booking.startTime.split(':')[0]); + const endHour = parseInt(booking.endTime.split(':')[0]); + current.bookedSlots += (endHour - startHour); + } + + // Calcular total slots por semana + const result: OccupancyData[] = []; + weeklyData.forEach((data, weekKey) => { + // Estimar slots disponibles (7 días * horas promedio) + const estimatedTotalSlots = 7 * (this.DEFAULT_CLOSE_HOUR - this.DEFAULT_OPEN_HOUR); + + result.push({ + date: weekKey, + totalSlots: estimatedTotalSlots, + bookedSlots: data.bookedSlots, + occupancyRate: estimatedTotalSlots > 0 + ? Math.round((data.bookedSlots / estimatedTotalSlots) * 1000) / 10 + : 0, + }); + }); + + return result.sort((a, b) => a.date.localeCompare(b.date)); + } + + private static async groupByMonth( + bookings: any[], + startDate: Date, + endDate: Date, + courtId: string + ): Promise { + const monthlyData: Map = new Map(); + + for (const booking of bookings) { + const date = new Date(booking.date); + const monthKey = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-01`; + + if (!monthlyData.has(monthKey)) { + monthlyData.set(monthKey, { totalSlots: 0, bookedSlots: 0 }); + } + + const current = monthlyData.get(monthKey)!; + const startHour = parseInt(booking.startTime.split(':')[0]); + const endHour = parseInt(booking.endTime.split(':')[0]); + current.bookedSlots += (endHour - startHour); + } + + // Calcular total slots por mes + const result: OccupancyData[] = []; + monthlyData.forEach((data, monthKey) => { + // Estimar slots disponibles (30 días * horas promedio) + const estimatedTotalSlots = 30 * (this.DEFAULT_CLOSE_HOUR - this.DEFAULT_OPEN_HOUR); + + result.push({ + date: monthKey, + totalSlots: estimatedTotalSlots, + bookedSlots: data.bookedSlots, + occupancyRate: estimatedTotalSlots > 0 + ? Math.round((data.bookedSlots / estimatedTotalSlots) * 1000) / 10 + : 0, + }); + }); + + return result.sort((a, b) => a.date.localeCompare(b.date)); + } + + private static countOperationalDays(startDate: Date, endDate: Date, courts: any[]): number { + let count = 0; + const currentDate = new Date(startDate); + + while (currentDate <= endDate) { + const dayOfWeek = currentDate.getDay(); + let hasSchedule = false; + + for (const court of courts) { + // Verificar si al menos una cancha tiene horario este día + // Simplificación: asumimos que todas las canchas tienen horario + hasSchedule = true; + break; + } + + if (hasSchedule) { + count++; + } + + currentDate.setDate(currentDate.getDate() + 1); + } + + return count || 1; // Evitar división por cero + } +} + +export default OccupancyService; diff --git a/backend/src/services/analytics/report.service.ts b/backend/src/services/analytics/report.service.ts new file mode 100644 index 0000000..c4512df --- /dev/null +++ b/backend/src/services/analytics/report.service.ts @@ -0,0 +1,760 @@ +import prisma from '../../config/database'; +import { FinancialService, GroupByPeriod } from './financial.service'; +import { ExtendedPaymentStatus, PaymentType, BookingStatus } from '../../utils/constants'; +import { formatCurrency, calculateGrowth } from '../../utils/analytics'; + +// Interfaces para reportes +export interface RevenueReport { + period: { + startDate: Date; + endDate: Date; + }; + summary: { + totalRevenue: number; + totalTransactions: number; + averageTransaction: number; + formattedTotalRevenue: string; + formattedAverageTransaction: string; + }; + byType: Array<{ + type: string; + revenue: number; + count: number; + percentage: number; + formattedRevenue: string; + }>; + byCourt: Array<{ + courtId: string; + courtName: string; + revenue: number; + bookingCount: number; + formattedRevenue: string; + }>; + byPaymentMethod: Array<{ + method: string; + count: number; + amount: number; + percentage: number; + formattedAmount: string; + }>; + dailyBreakdown: Array<{ + date: string; + revenue: number; + transactionCount: number; + formattedRevenue: string; + }>; +} + +export interface OccupancyReport { + period: { + startDate: Date; + endDate: Date; + }; + summary: { + totalBookings: number; + totalSlots: number; + occupancyRate: number; + averageBookingsPerDay: number; + }; + byCourt: Array<{ + courtId: string; + courtName: string; + totalBookings: number; + occupancyRate: number; + }>; + byDayOfWeek: Array<{ + day: string; + dayNumber: number; + bookings: number; + percentage: number; + }>; + byHour: Array<{ + hour: number; + bookings: number; + percentage: number; + }>; +} + +export interface UserReport { + period: { + startDate: Date; + endDate: Date; + }; + summary: { + newUsers: number; + activeUsers: number; + churnedUsers: number; + retentionRate: number; + }; + newUsersByPeriod: Array<{ + period: string; + count: number; + }>; + topUsersByBookings: Array<{ + userId: string; + userName: string; + email: string; + bookingCount: number; + totalSpent: number; + }>; + userActivity: Array<{ + userId: string; + userName: string; + lastActivity: Date | null; + bookingsCount: number; + paymentsCount: number; + }>; +} + +export interface ExecutiveSummary { + period: { + startDate: Date; + endDate: Date; + }; + revenue: { + total: number; + previousPeriodTotal: number | null; + growth: number | null; + formattedTotal: string; + formattedGrowth: string; + }; + bookings: { + total: number; + confirmed: number; + cancelled: number; + completionRate: number; + }; + users: { + new: number; + active: number; + totalRegistered: number; + }; + payments: { + completed: number; + pending: number; + refunded: number; + averageAmount: number; + }; + topMetrics: { + bestRevenueDay: string | null; + bestRevenueAmount: number; + mostPopularCourt: string | null; + mostUsedPaymentMethod: string | null; + }; +} + +export class ReportService { + /** + * Generar reporte completo de ingresos + */ + static async generateRevenueReport( + startDate: Date, + endDate: Date + ): Promise { + // Obtener datos de ingresos + const [byType, byCourt, byPaymentMethod, dailyBreakdown] = await Promise.all([ + FinancialService.getRevenueByType(startDate, endDate), + FinancialService.getRevenueByCourt(startDate, endDate), + FinancialService.getPaymentMethodsStats(startDate, endDate), + this.getDailyRevenueBreakdown(startDate, endDate), + ]); + + // Calcular totales + const totalRevenue = byType.reduce((sum, t) => sum + t.totalRevenue, 0); + const totalTransactions = byType.reduce((sum, t) => sum + t.count, 0); + const averageTransaction = totalTransactions > 0 + ? Math.round(totalRevenue / totalTransactions) + : 0; + + return { + period: { startDate, endDate }, + summary: { + totalRevenue, + totalTransactions, + averageTransaction, + formattedTotalRevenue: formatCurrency(totalRevenue), + formattedAverageTransaction: formatCurrency(averageTransaction), + }, + byType: byType.map(t => ({ + type: t.type, + revenue: t.totalRevenue, + count: t.count, + percentage: t.percentage, + formattedRevenue: formatCurrency(t.totalRevenue), + })), + byCourt: byCourt.map(c => ({ + courtId: c.courtId, + courtName: c.courtName, + revenue: c.totalRevenue, + bookingCount: c.bookingCount, + formattedRevenue: formatCurrency(c.totalRevenue), + })), + byPaymentMethod: byPaymentMethod.map(m => ({ + method: m.method, + count: m.count, + amount: m.totalAmount, + percentage: m.percentage, + formattedAmount: formatCurrency(m.totalAmount), + })), + dailyBreakdown, + }; + } + + /** + * Generar reporte de ocupación + */ + static async generateOccupancyReport( + startDate: Date, + endDate: Date + ): Promise { + // Obtener reservas del período + const bookings = await prisma.booking.findMany({ + where: { + date: { + gte: startDate, + lte: endDate, + }, + }, + include: { + court: { + select: { + id: true, + name: true, + }, + }, + }, + }); + + // Obtener canchas activas + const courts = await prisma.court.findMany({ + where: { isActive: true }, + select: { id: true, name: true }, + }); + + // Calcular métricas por cancha + const courtMap = new Map(); + for (const court of courts) { + courtMap.set(court.id, { totalBookings: 0, name: court.name }); + } + + // Métricas por día de semana + const dayOfWeekMap = new Map(); + const daysOfWeek = ['Domingo', 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado']; + + // Métricas por hora + const hourMap = new Map(); + + for (const booking of bookings) { + // Por cancha + const courtData = courtMap.get(booking.courtId); + if (courtData) { + courtData.totalBookings += 1; + } + + // Por día de semana + const dayOfWeek = new Date(booking.date).getDay(); + dayOfWeekMap.set(dayOfWeek, (dayOfWeekMap.get(dayOfWeek) || 0) + 1); + + // Por hora + const hour = parseInt(booking.startTime.split(':')[0], 10); + hourMap.set(hour, (hourMap.get(hour) || 0) + 1); + } + + // Calcular slots totales posibles (simplificado) + const daysInPeriod = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)) + 1; + const hoursPerDay = 12; // Asumiendo 12 horas operativas + const totalPossibleSlots = courts.length * daysInPeriod * hoursPerDay; + + const totalBookings = bookings.length; + const occupancyRate = totalPossibleSlots > 0 + ? Math.round((totalBookings / totalPossibleSlots) * 100 * 100) / 100 + : 0; + + return { + period: { startDate, endDate }, + summary: { + totalBookings, + totalSlots: totalPossibleSlots, + occupancyRate, + averageBookingsPerDay: daysInPeriod > 0 ? Math.round((totalBookings / daysInPeriod) * 100) / 100 : 0, + }, + byCourt: Array.from(courtMap.entries()).map(([courtId, data]) => ({ + courtId, + courtName: data.name, + totalBookings: data.totalBookings, + occupancyRate: totalPossibleSlots > 0 + ? Math.round((data.totalBookings / (totalPossibleSlots / courts.length)) * 100 * 100) / 100 + : 0, + })).sort((a, b) => b.totalBookings - a.totalBookings), + byDayOfWeek: daysOfWeek.map((day, index) => { + const count = dayOfWeekMap.get(index) || 0; + return { + day, + dayNumber: index, + bookings: count, + percentage: totalBookings > 0 ? Math.round((count / totalBookings) * 100 * 100) / 100 : 0, + }; + }), + byHour: Array.from(hourMap.entries()) + .map(([hour, count]) => ({ + hour, + bookings: count, + percentage: totalBookings > 0 ? Math.round((count / totalBookings) * 100 * 100) / 100 : 0, + })) + .sort((a, b) => a.hour - b.hour), + }; + } + + /** + * Generar reporte de usuarios + */ + static async generateUserReport( + startDate: Date, + endDate: Date + ): Promise { + // Usuarios nuevos en el período + const newUsers = await prisma.user.findMany({ + where: { + createdAt: { + gte: startDate, + lte: endDate, + }, + }, + select: { + id: true, + firstName: true, + lastName: true, + email: true, + createdAt: true, + }, + orderBy: { createdAt: 'desc' }, + }); + + // Usuarios activos (que hicieron reservas o pagos) + const activeUserIds = new Set(); + + const bookings = await prisma.booking.findMany({ + where: { + date: { + gte: startDate, + lte: endDate, + }, + }, + select: { userId: true }, + distinct: ['userId'], + }); + + bookings.forEach(b => activeUserIds.add(b.userId)); + + const payments = await prisma.payment.findMany({ + where: { + paidAt: { + gte: startDate, + lte: endDate, + }, + }, + select: { userId: true }, + distinct: ['userId'], + }); + + payments.forEach(p => activeUserIds.add(p.userId)); + + // Top usuarios por reservas + const topUsersByBookings = await prisma.booking.groupBy({ + by: ['userId'], + where: { + date: { + gte: startDate, + lte: endDate, + }, + }, + _count: { + id: true, + }, + orderBy: { + _count: { + id: 'desc', + }, + }, + take: 10, + }); + + // Obtener detalles de usuarios top + const topUsersDetails = await Promise.all( + topUsersByBookings.map(async (u) => { + const user = await prisma.user.findUnique({ + where: { id: u.userId }, + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }); + + const userPayments = await prisma.payment.aggregate({ + where: { + userId: u.userId, + status: ExtendedPaymentStatus.COMPLETED, + paidAt: { + gte: startDate, + lte: endDate, + }, + }, + _sum: { amount: true }, + }); + + return { + userId: u.userId, + userName: user ? `${user.firstName} ${user.lastName}` : 'Unknown', + email: user?.email || '', + bookingCount: u._count.id, + totalSpent: userPayments._sum.amount || 0, + }; + }) + ); + + // Actividad de usuarios + const allUsers = await prisma.user.findMany({ + select: { + id: true, + firstName: true, + lastName: true, + }, + take: 50, + }); + + const userActivity = await Promise.all( + allUsers.map(async (user) => { + const lastBooking = await prisma.booking.findFirst({ + where: { userId: user.id }, + orderBy: { createdAt: 'desc' }, + select: { createdAt: true }, + }); + + const bookingsCount = await prisma.booking.count({ + where: { userId: user.id }, + }); + + const paymentsCount = await prisma.payment.count({ + where: { userId: user.id }, + }); + + return { + userId: user.id, + userName: `${user.firstName} ${user.lastName}`, + lastActivity: lastBooking?.createdAt || null, + bookingsCount, + paymentsCount, + }; + }) + ); + + // Agrupar nuevos usuarios por período (semanal) + const newUsersByPeriod: Record = {}; + for (const user of newUsers) { + const week = this.getWeekKey(new Date(user.createdAt)); + newUsersByPeriod[week] = (newUsersByPeriod[week] || 0) + 1; + } + + return { + period: { startDate, endDate }, + summary: { + newUsers: newUsers.length, + activeUsers: activeUserIds.size, + churnedUsers: 0, // Requeriría lógica más compleja + retentionRate: 0, // Requeriría datos históricos + }, + newUsersByPeriod: Object.entries(newUsersByPeriod).map(([period, count]) => ({ + period, + count, + })).sort((a, b) => a.period.localeCompare(b.period)), + topUsersByBookings: topUsersDetails, + userActivity: userActivity.sort((a, b) => b.bookingsCount - a.bookingsCount), + }; + } + + /** + * Obtener resumen ejecutivo para dashboard + */ + static async getReportSummary(startDate: Date, endDate: Date): Promise { + // Calcular período anterior para comparación + const periodDuration = endDate.getTime() - startDate.getTime(); + const previousStartDate = new Date(startDate.getTime() - periodDuration); + const previousEndDate = new Date(endDate.getTime() - periodDuration); + + // Ingresos del período actual + const currentRevenue = await prisma.payment.aggregate({ + where: { + status: ExtendedPaymentStatus.COMPLETED, + paidAt: { + gte: startDate, + lte: endDate, + }, + }, + _sum: { amount: true }, + }); + + // Ingresos del período anterior + const previousRevenue = await prisma.payment.aggregate({ + where: { + status: ExtendedPaymentStatus.COMPLETED, + paidAt: { + gte: previousStartDate, + lte: previousEndDate, + }, + }, + _sum: { amount: true }, + }); + + const totalRevenue = currentRevenue._sum.amount || 0; + const previousPeriodTotal = previousRevenue._sum.amount || null; + const growth = calculateGrowth(totalRevenue, previousPeriodTotal); + + // Estadísticas de reservas + const [totalBookings, confirmedBookings, cancelledBookings] = await Promise.all([ + prisma.booking.count({ + where: { + date: { + gte: startDate, + lte: endDate, + }, + }, + }), + prisma.booking.count({ + where: { + date: { + gte: startDate, + lte: endDate, + }, + status: { + in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED], + }, + }, + }), + prisma.booking.count({ + where: { + date: { + gte: startDate, + lte: endDate, + }, + status: BookingStatus.CANCELLED, + }, + }), + ]); + + // Usuarios + const [newUsers, totalRegistered] = await Promise.all([ + prisma.user.count({ + where: { + createdAt: { + gte: startDate, + lte: endDate, + }, + }, + }), + prisma.user.count(), + ]); + + // Pagos + const [completedPayments, pendingPayments, refundedPayments, avgPayment] = await Promise.all([ + prisma.payment.count({ + where: { + status: ExtendedPaymentStatus.COMPLETED, + paidAt: { + gte: startDate, + lte: endDate, + }, + }, + }), + prisma.payment.count({ + where: { + status: { + in: [ExtendedPaymentStatus.PENDING, ExtendedPaymentStatus.PROCESSING], + }, + createdAt: { + gte: startDate, + lte: endDate, + }, + }, + }), + prisma.payment.count({ + where: { + status: ExtendedPaymentStatus.REFUNDED, + refundedAt: { + gte: startDate, + lte: endDate, + }, + }, + }), + prisma.payment.aggregate({ + where: { + status: ExtendedPaymentStatus.COMPLETED, + paidAt: { + gte: startDate, + lte: endDate, + }, + }, + _avg: { amount: true }, + }), + ]); + + // Obtener métricas destacadas + const [topRevenueDay, mostPopularCourt, mostUsedPaymentMethod] = await Promise.all([ + this.getBestRevenueDay(startDate, endDate), + this.getMostPopularCourt(startDate, endDate), + this.getMostUsedPaymentMethod(startDate, endDate), + ]); + + return { + period: { startDate, endDate }, + revenue: { + total: totalRevenue, + previousPeriodTotal, + growth, + formattedTotal: formatCurrency(totalRevenue), + formattedGrowth: growth !== null ? `${growth > 0 ? '+' : ''}${growth}%` : 'N/A', + }, + bookings: { + total: totalBookings, + confirmed: confirmedBookings, + cancelled: cancelledBookings, + completionRate: totalBookings > 0 + ? Math.round((confirmedBookings / totalBookings) * 100 * 100) / 100 + : 0, + }, + users: { + new: newUsers, + active: 0, // Requeriría cálculo adicional + totalRegistered, + }, + payments: { + completed: completedPayments, + pending: pendingPayments, + refunded: refundedPayments, + averageAmount: Math.round(avgPayment._avg.amount || 0), + }, + topMetrics: { + bestRevenueDay: topRevenueDay?.date || null, + bestRevenueAmount: topRevenueDay?.amount || 0, + mostPopularCourt: mostPopularCourt?.name || null, + mostUsedPaymentMethod: mostUsedPaymentMethod || null, + }, + }; + } + + // Helper methods + private static async getDailyRevenueBreakdown(startDate: Date, endDate: Date) { + const payments = await prisma.payment.findMany({ + where: { + status: ExtendedPaymentStatus.COMPLETED, + paidAt: { + gte: startDate, + lte: endDate, + }, + }, + select: { + amount: true, + paidAt: true, + }, + }); + + const dailyMap: Record = {}; + + for (const payment of payments) { + const dateKey = new Date(payment.paidAt!).toISOString().split('T')[0]; + if (!dailyMap[dateKey]) { + dailyMap[dateKey] = { revenue: 0, count: 0 }; + } + dailyMap[dateKey].revenue += payment.amount; + dailyMap[dateKey].count += 1; + } + + return Object.entries(dailyMap) + .map(([date, data]) => ({ + date, + revenue: data.revenue, + transactionCount: data.count, + formattedRevenue: formatCurrency(data.revenue), + })) + .sort((a, b) => a.date.localeCompare(b.date)); + } + + private static async getBestRevenueDay(startDate: Date, endDate: Date): Promise<{ date: string; amount: number } | null> { + const payments = await prisma.payment.groupBy({ + by: ['paidAt'], + where: { + status: ExtendedPaymentStatus.COMPLETED, + paidAt: { + gte: startDate, + lte: endDate, + }, + }, + _sum: { amount: true }, + }); + + if (payments.length === 0) return null; + + const sorted = payments + .filter(p => p.paidAt !== null) + .map(p => ({ + date: new Date(p.paidAt!).toISOString().split('T')[0], + amount: p._sum.amount || 0, + })) + .sort((a, b) => b.amount - a.amount); + + return sorted[0] || null; + } + + private static async getMostPopularCourt(startDate: Date, endDate: Date) { + const result = await prisma.booking.groupBy({ + by: ['courtId'], + where: { + date: { + gte: startDate, + lte: endDate, + }, + }, + _count: { id: true }, + orderBy: { _count: { id: 'desc' } }, + take: 1, + }); + + if (result.length === 0) return null; + + const court = await prisma.court.findUnique({ + where: { id: result[0].courtId }, + select: { name: true }, + }); + + return { name: court?.name || 'Unknown', bookings: result[0]._count.id }; + } + + private static async getMostUsedPaymentMethod(startDate: Date, endDate: Date): Promise { + const result = await prisma.payment.groupBy({ + by: ['paymentMethod'], + where: { + status: ExtendedPaymentStatus.COMPLETED, + paidAt: { + gte: startDate, + lte: endDate, + }, + }, + _count: { id: true }, + orderBy: { _count: { id: 'desc' } }, + take: 1, + }); + + return result[0]?.paymentMethod || null; + } + + private static getWeekKey(date: Date): string { + const year = date.getFullYear(); + const month = date.getMonth(); + const day = date.getDate(); + const weekNumber = Math.ceil(day / 7); + return `${year}-${String(month + 1).padStart(2, '0')}-W${weekNumber}`; + } +} + +export default ReportService; diff --git a/backend/src/utils/analytics.ts b/backend/src/utils/analytics.ts new file mode 100644 index 0000000..6378bd7 --- /dev/null +++ b/backend/src/utils/analytics.ts @@ -0,0 +1,369 @@ +/** + * Utilidades para análisis y métricas + */ + +// Tipos +export type GroupByPeriod = 'day' | 'week' | 'month'; + +/** + * Formatear monto a moneda + * @param amount Monto en centavos + * @param currency Código de moneda (default: ARS) + * @returns String formateado + */ +export function formatCurrency(amount: number, currency: string = 'ARS'): string { + const amountInUnits = amount / 100; + + const formatter = new Intl.NumberFormat('es-AR', { + style: 'currency', + currency: currency, + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + + return formatter.format(amountInUnits); +} + +/** + * Formatear monto simple (sin símbolo de moneda) + * @param amount Monto en centavos + * @returns String formateado + */ +export function formatAmount(amount: number): string { + const amountInUnits = amount / 100; + return amountInUnits.toLocaleString('es-AR', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); +} + +/** + * Calcular crecimiento porcentual + * @param current Valor actual + * @param previous Valor anterior (null si no hay datos previos) + * @returns Porcentaje de crecimiento o null + */ +export function calculateGrowth(current: number, previous: number | null): number | null { + if (previous === null || previous === undefined) { + return null; + } + + if (previous === 0) { + return current > 0 ? 100 : 0; + } + + const growth = ((current - previous) / previous) * 100; + return Math.round(growth * 100) / 100; +} + +/** + * Agrupar datos por fecha + * @param data Array de datos con campo de fecha + * @param dateField Nombre del campo de fecha + * @param groupBy Período de agrupación (day, week, month) + * @returns Objeto con datos agrupados + */ +export function groupByDate>( + data: T[], + dateField: string, + groupBy: GroupByPeriod +): Record { + const grouped: Record = {}; + + for (const item of data) { + const date = new Date(item[dateField]); + let key: string; + + switch (groupBy) { + case 'day': + key = date.toISOString().split('T')[0]; // YYYY-MM-DD + break; + case 'week': + key = getWeekKey(date); // YYYY-WXX + break; + case 'month': + key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; // YYYY-MM + break; + default: + key = date.toISOString().split('T')[0]; + } + + if (!grouped[key]) { + grouped[key] = []; + } + grouped[key].push(item); + } + + return grouped; +} + +/** + * Rellenar fechas faltantes con valores en cero + * @param data Array de datos con campo date + * @param startDate Fecha de inicio + * @param endDate Fecha de fin + * @param groupBy Período de agrupación + * @returns Array con todas las fechas incluidas las faltantes + */ +export function fillMissingDates( + data: T[], + startDate: Date, + endDate: Date, + groupBy: GroupByPeriod +): T[] { + const result: T[] = []; + const dataMap = new Map(data.map(d => [d.date, d])); + + const current = new Date(startDate); + const end = new Date(endDate); + + while (current <= end) { + let key: string; + + switch (groupBy) { + case 'day': + key = current.toISOString().split('T')[0]; + current.setDate(current.getDate() + 1); + break; + case 'week': + key = getWeekKey(current); + current.setDate(current.getDate() + 7); + break; + case 'month': + key = `${current.getFullYear()}-${String(current.getMonth() + 1).padStart(2, '0')}`; + current.setMonth(current.getMonth() + 1); + break; + default: + key = current.toISOString().split('T')[0]; + current.setDate(current.getDate() + 1); + } + + if (dataMap.has(key)) { + result.push(dataMap.get(key)!); + } else { + // Crear objeto vacío con valores por defecto + const emptyItem: any = { date: key }; + + // Detectar campos numéricos del primer elemento y ponerlos en 0 + if (data.length > 0) { + const firstItem = data[0]; + for (const [field, value] of Object.entries(firstItem)) { + if (field !== 'date' && typeof value === 'number') { + emptyItem[field] = 0; + } + } + } + + result.push(emptyItem); + } + } + + return result; +} + +/** + * Calcular promedio de un array de números + * @param values Array de números + * @returns Promedio + */ +export function calculateAverage(values: number[]): number { + if (values.length === 0) return 0; + return values.reduce((sum, val) => sum + val, 0) / values.length; +} + +/** + * Calcular mediana de un array de números + * @param values Array de números + * @returns Mediana + */ +export function calculateMedian(values: number[]): number { + if (values.length === 0) return 0; + + const sorted = [...values].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + + if (sorted.length % 2 === 0) { + return (sorted[middle - 1] + sorted[middle]) / 2; + } + + return sorted[middle]; +} + +/** + * Calcular percentil + * @param values Array de números + * @param percentile Percentil a calcular (0-100) + * @returns Valor del percentil + */ +export function calculatePercentile(values: number[], percentile: number): number { + if (values.length === 0) return 0; + if (percentile < 0 || percentile > 100) return 0; + + const sorted = [...values].sort((a, b) => a - b); + const index = (percentile / 100) * (sorted.length - 1); + const lower = Math.floor(index); + const upper = Math.ceil(index); + const weight = index - lower; + + if (upper >= sorted.length) return sorted[lower]; + return sorted[lower] * (1 - weight) + sorted[upper] * weight; +} + +/** + * Calcular desviación estándar + * @param values Array de números + * @returns Desviación estándar + */ +export function calculateStandardDeviation(values: number[]): number { + if (values.length === 0) return 0; + + const avg = calculateAverage(values); + const squareDiffs = values.map(value => Math.pow(value - avg, 2)); + const avgSquareDiff = calculateAverage(squareDiffs); + + return Math.sqrt(avgSquareDiff); +} + +/** + * Agrupar datos por campo + * @param data Array de datos + * @param field Campo para agrupar + * @returns Objeto con datos agrupados + */ +export function groupByField>( + data: T[], + field: string +): Record { + return data.reduce((acc, item) => { + const key = item[field]?.toString() || 'unknown'; + if (!acc[key]) { + acc[key] = []; + } + acc[key].push(item); + return acc; + }, {} as Record); +} + +/** + * Calcular tasa de conversión + * @param completed Número de completados + * @param total Número total + * @returns Porcentaje de conversión + */ +export function calculateConversionRate(completed: number, total: number): number { + if (total === 0) return 0; + return Math.round((completed / total) * 100 * 100) / 100; +} + +/** + * Formatear número con separadores de miles + * @param value Número a formatear + * @returns String formateado + */ +export function formatNumber(value: number): string { + return value.toLocaleString('es-AR'); +} + +/** + * Formatear porcentaje + * @param value Valor decimal (0-1) o porcentaje (0-100) + * @param isDecimal Si el valor está en formato decimal + * @returns String formateado con símbolo % + */ +export function formatPercentage(value: number, isDecimal: boolean = false): string { + const percentage = isDecimal ? value * 100 : value; + return `${Math.round(percentage * 100) / 100}%`; +} + +/** + * Obtener clave de semana para una fecha + * @param date Fecha + * @returns String en formato YYYY-WXX + */ +function getWeekKey(date: Date): string { + const year = date.getFullYear(); + + // Calcular número de semana (aproximado) + const startOfYear = new Date(year, 0, 1); + const days = Math.floor((date.getTime() - startOfYear.getTime()) / (24 * 60 * 60 * 1000)); + const weekNumber = Math.ceil((days + startOfYear.getDay() + 1) / 7); + + return `${year}-W${String(weekNumber).padStart(2, '0')}`; +} + +/** + * Comparar dos períodos de fechas + * @param currentStart Inicio período actual + * @param currentEnd Fin período actual + * @param previousStart Inicio período anterior + * @param previousEnd Fin período anterior + * @returns Objeto con comparación + */ +export function comparePeriods( + currentStart: Date, + currentEnd: Date, + previousStart: Date, + previousEnd: Date +): { + currentDuration: number; + previousDuration: number; + durationRatio: number; +} { + const currentDuration = currentEnd.getTime() - currentStart.getTime(); + const previousDuration = previousEnd.getTime() - previousStart.getTime(); + + return { + currentDuration, + previousDuration, + durationRatio: previousDuration > 0 ? currentDuration / previousDuration : 1, + }; +} + +/** + * Generar rango de fechas + * @param startDate Fecha de inicio + * @param endDate Fecha de fin + * @returns Array de fechas + */ +export function generateDateRange(startDate: Date, endDate: Date): Date[] { + const dates: Date[] = []; + const current = new Date(startDate); + const end = new Date(endDate); + + while (current <= end) { + dates.push(new Date(current)); + current.setDate(current.getDate() + 1); + } + + return dates; +} + +/** + * Redondear a número específico de decimales + * @param value Valor a redondear + * @param decimals Número de decimales + * @returns Valor redondeado + */ +export function roundToDecimals(value: number, decimals: number = 2): number { + const multiplier = Math.pow(10, decimals); + return Math.round(value * multiplier) / multiplier; +} + +export default { + formatCurrency, + formatAmount, + calculateGrowth, + groupByDate, + fillMissingDates, + calculateAverage, + calculateMedian, + calculatePercentile, + calculateStandardDeviation, + groupByField, + calculateConversionRate, + formatNumber, + formatPercentage, + comparePeriods, + generateDateRange, + roundToDecimals, +}; diff --git a/backend/src/utils/constants.ts b/backend/src/utils/constants.ts index 20f51de..98f0b8c 100644 --- a/backend/src/utils/constants.ts +++ b/backend/src/utils/constants.ts @@ -336,3 +336,38 @@ export const UserSubscriptionStatus = { } as const; export type UserSubscriptionStatusType = typeof UserSubscriptionStatus[keyof typeof UserSubscriptionStatus]; + +// ============================================ +// Constantes de Reportes y Analytics (Fase 5.2) +// ============================================ + +// Tipos de reportes +export const ReportType = { + REVENUE: 'REVENUE', // Reporte de ingresos + OCCUPANCY: 'OCCUPANCY', // Reporte de ocupación + USERS: 'USERS', // Reporte de usuarios + SUMMARY: 'SUMMARY', // Resumen ejecutivo + TOURNAMENT: 'TOURNAMENT', // Reporte de torneos + COACH: 'COACH', // Reporte de coaches +} as const; + +export type ReportTypeType = typeof ReportType[keyof typeof ReportType]; + +// Períodos de agrupación +export const GroupByPeriod = { + DAY: 'day', // Por día + WEEK: 'week', // Por semana + MONTH: 'month', // Por mes + YEAR: 'year', // Por año +} as const; + +export type GroupByPeriodType = typeof GroupByPeriod[keyof typeof GroupByPeriod]; + +// Formatos de exportación de reportes +export const ReportFormat = { + JSON: 'json', // Formato JSON + PDF: 'pdf', // Formato PDF + EXCEL: 'excel', // Formato Excel/CSV +} as const; + +export type ReportFormatType = typeof ReportFormat[keyof typeof ReportFormat]; diff --git a/backend/src/validators/analytics.validator.ts b/backend/src/validators/analytics.validator.ts new file mode 100644 index 0000000..6dd861b --- /dev/null +++ b/backend/src/validators/analytics.validator.ts @@ -0,0 +1,112 @@ +import { z } from 'zod'; + +/** + * Schema para validar rango de fechas + * Usado en: GET /analytics/occupancy, GET /analytics/occupancy/by-timeslot, etc. + */ +export const dateRangeSchema = z.object({ + startDate: z.string() + .regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe estar en formato YYYY-MM-DD') + .or(z.string().datetime({ message: 'La fecha debe estar en formato ISO 8601' })), + endDate: z.string() + .regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe estar en formato YYYY-MM-DD') + .or(z.string().datetime({ message: 'La fecha debe estar en formato ISO 8601' })), + courtId: z.string().uuid('ID de cancha inválido').optional(), +}); + +/** + * Schema para validar consulta de ocupación por cancha + * Usado en: GET /analytics/occupancy/by-court + */ +export const occupancyByCourtSchema = z.object({ + courtId: z.string().uuid('ID de cancha inválido'), + startDate: z.string() + .regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe estar en formato YYYY-MM-DD') + .or(z.string().datetime({ message: 'La fecha debe estar en formato ISO 8601' })), + endDate: z.string() + .regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe estar en formato YYYY-MM-DD') + .or(z.string().datetime({ message: 'La fecha debe estar en formato ISO 8601' })), + groupBy: z.enum(['day', 'week', 'month'], { + errorMap: () => ({ message: 'groupBy debe ser: day, week o month' }), + }).optional(), +}); + +/** + * Schema para validar consulta de ocupación por franja horaria + * Usado en: GET /analytics/occupancy/by-timeslot + */ +export const timeSlotSchema = z.object({ + startDate: z.string() + .regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe estar en formato YYYY-MM-DD') + .or(z.string().datetime({ message: 'La fecha debe estar en formato ISO 8601' })), + endDate: z.string() + .regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe estar en formato YYYY-MM-DD') + .or(z.string().datetime({ message: 'La fecha debe estar en formato ISO 8601' })), + courtId: z.string().uuid('ID de cancha inválido').optional(), +}); + +/** + * Schema para validar consulta de horas pico + * Usado en: GET /analytics/occupancy/peak-hours + */ +export const peakHoursSchema = z.object({ + startDate: z.string() + .regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe estar en formato YYYY-MM-DD') + .or(z.string().datetime({ message: 'La fecha debe estar en formato ISO 8601' })), + endDate: z.string() + .regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe estar en formato YYYY-MM-DD') + .or(z.string().datetime({ message: 'La fecha debe estar en formato ISO 8601' })), + courtId: z.string().uuid('ID de cancha inválido').optional(), +}); + +/** + * Schema para validar comparación de períodos + * Usado en: GET /analytics/occupancy/comparison + */ +export const comparisonSchema = z.object({ + period1Start: z.string() + .regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe estar en formato YYYY-MM-DD') + .or(z.string().datetime({ message: 'La fecha debe estar en formato ISO 8601' })), + period1End: z.string() + .regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe estar en formato YYYY-MM-DD') + .or(z.string().datetime({ message: 'La fecha debe estar en formato ISO 8601' })), + period2Start: z.string() + .regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe estar en formato YYYY-MM-DD') + .or(z.string().datetime({ message: 'La fecha debe estar en formato ISO 8601' })), + period2End: z.string() + .regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe estar en formato YYYY-MM-DD') + .or(z.string().datetime({ message: 'La fecha debe estar en formato ISO 8601' })), + courtId: z.string().uuid('ID de cancha inválido').optional(), +}).refine((data) => { + // Validar que period1Start sea anterior a period1End + const p1Start = new Date(data.period1Start); + const p1End = new Date(data.period1End); + return p1Start <= p1End; +}, { + message: 'period1Start debe ser anterior o igual a period1End', + path: ['period1Start'], +}).refine((data) => { + // Validar que period2Start sea anterior a period2End + const p2Start = new Date(data.period2Start); + const p2End = new Date(data.period2End); + return p2Start <= p2End; +}, { + message: 'period2Start debe ser anterior o igual a period2End', + path: ['period2Start'], +}); + +/** + * Schema para parámetro opcional de courtId + * Usado en: GET /analytics/dashboard/calendar + */ +export const courtIdParamSchema = z.object({ + courtId: z.string().uuid('ID de cancha inválido').optional(), +}); + +// Tipos exportados para uso en controladores +export type DateRangeInput = z.infer; +export type OccupancyByCourtInput = z.infer; +export type TimeSlotInput = z.infer; +export type PeakHoursInput = z.infer; +export type ComparisonInput = z.infer; +export type CourtIdParamInput = z.infer; diff --git a/docs/roadmap/FASE-05.md b/docs/roadmap/FASE-05.md index c7805be..c7da2a8 100644 --- a/docs/roadmap/FASE-05.md +++ b/docs/roadmap/FASE-05.md @@ -1,6 +1,236 @@ -# Fase 5: Analytics +# Fase 5: Analytics y Administración -## Estado: ⏳ Pendiente +## Estado: ✅ COMPLETADA -*Esta fase comenzará al finalizar la Fase 4* +### ✅ Tareas completadas: +#### 5.1.1: Dashboard Administrativo +- [x] Vista general de ocupación del día/semana +- [x] Accesos rápidos (crear reserva, bloquear cancha) +- [x] Lista de reservas del día con filtros +- [x] Gestión de usuarios (activar/desactivar) + +#### 5.1.2: Métricas de Ocupación +- [x] % de ocupación por cancha +- [x] Ocupación por franja horaria +- [x] Comparativa mes a mes +- [x] Días y horas más demandados (peak hours) + +#### 5.2.1: Métricas Financieras +- [x] Ingresos por período (día, semana, mes, año) +- [x] Ingresos por tipo (reservas, bonos, torneos, clases) +- [x] Ingresos por cancha +- [x] Comparativas y tendencias + +#### 5.2.2: Gestión de Reportes +- [x] Reporte completo de ingresos +- [x] Reporte de ocupación +- [x] Reporte de usuarios +- [x] Resumen ejecutivo + +#### 5.2.3: Métricas de Usuarios +- [x] Total de usuarios activos +- [x] Nuevos registros por período +- [x] Jugadores más activos (top 10/50) +- [x] Jugadores inactivos (alertas de churn) +- [x] Retención y frecuencia de uso + +#### 5.2.4: Exportación de Datos +- [x] Exportar reservas a Excel/CSV +- [x] Exportar informes financieros +- [x] Exportar base de usuarios +- [x] Reportes automáticos por email (preparado para futura implementación) + +--- + +## 📊 Módulos de Analytics + +### 1. Dashboard +Resumen ejecutivo para administradores: +- Reservas hoy/semana/mes +- Ingresos hoy/semana/mes +- Usuarios nuevos +- Ocupación promedio +- Alertas del día + +### 2. Ocupación +Métricas de uso de canchas: +- Ocupación por fecha/rango +- Ocupación por cancha específica +- Ocupación por franja horaria +- Horas pico (top 5) +- Comparativa entre períodos + +### 3. Financiero +Ingresos y métricas económicas: +- Ingresos totales por período +- Desglose por tipo (booking, tournament, subscription, class) +- Ingresos por cancha +- Métodos de pago más usados +- Estadísticas de reembolsos +- Tendencias de crecimiento + +### 4. Usuarios +Comportamiento de usuarios: +- Estadísticas generales +- Actividad por período +- Top jugadores +- Usuarios en riesgo de abandono (churn) +- Tasa de retención +- Crecimiento mensual + +### 5. Exportación +Exportar datos en múltiples formatos: +- CSV (separado por ; para Excel español) +- JSON +- Excel (múltiples hojas) + +--- + +## 🔌 Endpoints de Analytics + +### Dashboard +``` +GET /api/v1/analytics/dashboard/summary - Resumen ejecutivo +GET /api/v1/analytics/dashboard/today - Vista del día +GET /api/v1/analytics/dashboard/calendar - Calendario semanal +``` + +### Ocupación +``` +GET /api/v1/analytics/occupancy - Ocupación por fechas +GET /api/v1/analytics/occupancy/by-court - Por cancha +GET /api/v1/analytics/occupancy/by-timeslot - Por franja horaria +GET /api/v1/analytics/occupancy/peak-hours - Horas pico +GET /api/v1/analytics/occupancy/comparison - Comparativa períodos +``` + +### Financiero +``` +GET /api/v1/analytics/revenue - Ingresos +GET /api/v1/analytics/revenue/by-court - Por cancha +GET /api/v1/analytics/revenue/by-type - Por tipo +GET /api/v1/analytics/payment-methods - Métodos de pago +GET /api/v1/analytics/outstanding-payments - Pagos pendientes +GET /api/v1/analytics/refunds - Reembolsos +GET /api/v1/analytics/trends - Tendencias +GET /api/v1/analytics/top-days - Mejores días +``` + +### Reportes +``` +GET /api/v1/analytics/reports/revenue - Reporte ingresos +GET /api/v1/analytics/reports/occupancy - Reporte ocupación +GET /api/v1/analytics/reports/users - Reporte usuarios +GET /api/v1/analytics/reports/summary - Resumen ejecutivo +``` + +### Usuarios +``` +GET /api/v1/analytics/users/overview - Stats generales +GET /api/v1/analytics/users/activity - Actividad +GET /api/v1/analytics/users/top-players - Top jugadores +GET /api/v1/analytics/users/churn-risk - Riesgo de abandono +GET /api/v1/analytics/users/retention - Tasa de retención +GET /api/v1/analytics/users/growth - Crecimiento +``` + +### Exportación +``` +GET /api/v1/analytics/exports/bookings - Exportar reservas +GET /api/v1/analytics/exports/users - Exportar usuarios +GET /api/v1/analytics/exports/payments - Exportar pagos +GET /api/v1/analytics/exports/tournaments/:id - Exportar torneo +GET /api/v1/analytics/exports/excel-report - Reporte Excel completo +``` + +--- + +## 📁 Estructura de Archivos + +``` +backend/src/ +├── services/analytics/ +│ ├── dashboard.service.ts # Dashboard ejecutivo +│ ├── occupancy.service.ts # Métricas de ocupación +│ ├── financial.service.ts # Métricas financieras +│ ├── report.service.ts # Generación de reportes +│ ├── userAnalytics.service.ts # Analytics de usuarios +│ └── export.service.ts # Exportación de datos +├── controllers/analytics/ +│ ├── dashboard.controller.ts +│ ├── occupancy.controller.ts +│ ├── financial.controller.ts +│ ├── report.controller.ts +│ ├── userAnalytics.controller.ts +│ └── export.controller.ts +├── routes/ +│ └── analytics.routes.ts # Todas las rutas de analytics +├── utils/ +│ ├── analytics.ts # Utilidades de cálculo +│ └── export.ts # Utilidades de exportación +├── types/ +│ └── analytics.types.ts # Tipos TypeScript +└── constants/ + └── export.constants.ts # Constantes de exportación +``` + +--- + +## 📊 Ejemplos de Uso + +### Dashboard Summary +```bash +curl http://localhost:3000/api/v1/analytics/dashboard/summary \ + -H "Authorization: Bearer ADMIN_TOKEN" +``` + +Response: +```json +{ + "today": { + "bookings": 12, + "revenue": 25000, + "occupancyRate": 75.5 + }, + "week": { + "newUsers": 5, + "totalBookings": 89 + } +} +``` + +### Ocupación por Fechas +```bash +curl "http://localhost:3000/api/v1/analytics/occupancy?startDate=2024-01-01&endDate=2024-01-31" \ + -H "Authorization: Bearer ADMIN_TOKEN" +``` + +### Exportar a Excel +```bash +curl "http://localhost:3000/api/v1/analytics/exports/excel-report?startDate=2024-01-01&endDate=2024-01-31" \ + -H "Authorization: Bearer ADMIN_TOKEN" \ + --output reporte_enero.xlsx +``` + +--- + +## 🔐 Seguridad + +- Todos los endpoints de analytics requieren autenticación +- Solo accesible para roles ADMIN y SUPERADMIN +- Validación de fechas y parámetros con Zod + +--- + +## 📦 Dependencias + +```json +{ + "xlsx": "^0.18.5" +} +``` + +--- + +*Completada el: 2026-01-31* diff --git a/package.json b/package.json new file mode 100644 index 0000000..e71649e --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "padel-app-analytics", + "version": "5.3.0", + "description": "Fase 5.3 - Métricas de Usuarios y Exportación para App Padel", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "dev": "ts-node-dev --respawn src/index.ts", + "start": "node dist/index.js" + }, + "dependencies": { + "@prisma/client": "^5.0.0", + "express": "^4.18.2", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.0.0", + "ts-node-dev": "^2.0.0", + "typescript": "^5.0.0" + } +} diff --git a/src/constants/export.constants.ts b/src/constants/export.constants.ts new file mode 100644 index 0000000..5438160 --- /dev/null +++ b/src/constants/export.constants.ts @@ -0,0 +1,15 @@ +export enum ExportFormat { + CSV = 'csv', + JSON = 'json', + EXCEL = 'excel' +} + +export enum ChurnRiskLevel { + LOW = 'low', + MEDIUM = 'medium', + HIGH = 'high' +} + +export const CSV_SEPARATOR = ';'; + +export const DEFAULT_EXPORT_LIMIT = 10000; diff --git a/src/constants/index.ts b/src/constants/index.ts new file mode 100644 index 0000000..f3c2cef --- /dev/null +++ b/src/constants/index.ts @@ -0,0 +1 @@ +export * from './export.constants'; diff --git a/src/controllers/analytics/export.controller.ts b/src/controllers/analytics/export.controller.ts new file mode 100644 index 0000000..7ce7a30 --- /dev/null +++ b/src/controllers/analytics/export.controller.ts @@ -0,0 +1,230 @@ +import { Request, Response } from 'express'; +import * as exportService from '../../services/analytics/export.service'; +import { ExportFormat } from '../../constants/export.constants'; +import { setExportHeaders } from '../../utils/export'; + +/** + * Exporta reservas + * GET /analytics/exports/bookings + */ +export async function exportBookings(req: Request, res: Response): Promise { + try { + const { startDate, endDate, format } = req.query; + + if (!startDate || !endDate) { + res.status(400).json({ + success: false, + message: 'Se requieren los parámetros startDate y endDate' + }); + return; + } + + const start = new Date(startDate as string); + const end = new Date(endDate as string); + + if (isNaN(start.getTime()) || isNaN(end.getTime())) { + res.status(400).json({ + success: false, + message: 'Formato de fecha inválido. Use formato ISO 8601' + }); + return; + } + + const exportFormat = (format as ExportFormat) || ExportFormat.CSV; + if (!Object.values(ExportFormat).includes(exportFormat)) { + res.status(400).json({ + success: false, + message: `Formato inválido. Use: ${Object.values(ExportFormat).join(', ')}` + }); + return; + } + + const { data, filename } = await exportService.exportBookings(start, end, exportFormat); + + setExportHeaders(res, 'reservas', exportFormat); + res.send(data); + } catch (error) { + console.error('Error en exportBookings:', error); + res.status(500).json({ + success: false, + message: 'Error al exportar reservas', + error: error instanceof Error ? error.message : 'Error desconocido' + }); + } +} + +/** + * Exporta usuarios + * GET /analytics/exports/users + */ +export async function exportUsers(req: Request, res: Response): Promise { + try { + const { format, level, city } = req.query; + + const exportFormat = (format as ExportFormat) || ExportFormat.CSV; + if (!Object.values(ExportFormat).includes(exportFormat)) { + res.status(400).json({ + success: false, + message: `Formato inválido. Use: ${Object.values(ExportFormat).join(', ')}` + }); + return; + } + + const filters = { + level: level as string | undefined, + city: city as string | undefined + }; + + const { data, filename } = await exportService.exportUsers(exportFormat, filters); + + setExportHeaders(res, 'usuarios', exportFormat); + res.send(data); + } catch (error) { + console.error('Error en exportUsers:', error); + res.status(500).json({ + success: false, + message: 'Error al exportar usuarios', + error: error instanceof Error ? error.message : 'Error desconocido' + }); + } +} + +/** + * Exporta pagos + * GET /analytics/exports/payments + */ +export async function exportPayments(req: Request, res: Response): Promise { + try { + const { startDate, endDate, format } = req.query; + + if (!startDate || !endDate) { + res.status(400).json({ + success: false, + message: 'Se requieren los parámetros startDate y endDate' + }); + return; + } + + const start = new Date(startDate as string); + const end = new Date(endDate as string); + + if (isNaN(start.getTime()) || isNaN(end.getTime())) { + res.status(400).json({ + success: false, + message: 'Formato de fecha inválido. Use formato ISO 8601' + }); + return; + } + + const exportFormat = (format as ExportFormat) || ExportFormat.CSV; + if (!Object.values(ExportFormat).includes(exportFormat)) { + res.status(400).json({ + success: false, + message: `Formato inválido. Use: ${Object.values(ExportFormat).join(', ')}` + }); + return; + } + + const { data, filename } = await exportService.exportPayments(start, end, exportFormat); + + setExportHeaders(res, 'pagos', exportFormat); + res.send(data); + } catch (error) { + console.error('Error en exportPayments:', error); + res.status(500).json({ + success: false, + message: 'Error al exportar pagos', + error: error instanceof Error ? error.message : 'Error desconocido' + }); + } +} + +/** + * Exporta resultados de torneo + * GET /analytics/exports/tournaments/:id + */ +export async function exportTournament(req: Request, res: Response): Promise { + try { + const { id } = req.params; + const { format } = req.query; + + if (!id) { + res.status(400).json({ + success: false, + message: 'Se requiere el ID del torneo' + }); + return; + } + + const exportFormat = (format as ExportFormat) || ExportFormat.CSV; + if (!Object.values(ExportFormat).includes(exportFormat)) { + res.status(400).json({ + success: false, + message: `Formato inválido. Use: ${Object.values(ExportFormat).join(', ')}` + }); + return; + } + + const { data, filename } = await exportService.exportTournamentResults(id, exportFormat); + + setExportHeaders(res, 'torneo', exportFormat); + res.send(data); + } catch (error) { + console.error('Error en exportTournament:', error); + + if (error instanceof Error && error.message === 'Torneo no encontrado') { + res.status(404).json({ + success: false, + message: 'Torneo no encontrado' + }); + return; + } + + res.status(500).json({ + success: false, + message: 'Error al exportar resultados del torneo', + error: error instanceof Error ? error.message : 'Error desconocido' + }); + } +} + +/** + * Genera reporte completo en Excel + * GET /analytics/exports/excel-report + */ +export async function generateExcelReport(req: Request, res: Response): Promise { + try { + const { startDate, endDate } = req.query; + + if (!startDate || !endDate) { + res.status(400).json({ + success: false, + message: 'Se requieren los parámetros startDate y endDate' + }); + return; + } + + const start = new Date(startDate as string); + const end = new Date(endDate as string); + + if (isNaN(start.getTime()) || isNaN(end.getTime())) { + res.status(400).json({ + success: false, + message: 'Formato de fecha inválido. Use formato ISO 8601' + }); + return; + } + + const { data, filename } = await exportService.generateExcelReport(start, end); + + setExportHeaders(res, 'reporte_completo', ExportFormat.EXCEL); + res.send(data); + } catch (error) { + console.error('Error en generateExcelReport:', error); + res.status(500).json({ + success: false, + message: 'Error al generar reporte Excel', + error: error instanceof Error ? error.message : 'Error desconocido' + }); + } +} diff --git a/src/controllers/analytics/index.ts b/src/controllers/analytics/index.ts new file mode 100644 index 0000000..c0820e6 --- /dev/null +++ b/src/controllers/analytics/index.ts @@ -0,0 +1,2 @@ +export * from './userAnalytics.controller'; +export * from './export.controller'; diff --git a/src/controllers/analytics/userAnalytics.controller.ts b/src/controllers/analytics/userAnalytics.controller.ts new file mode 100644 index 0000000..cf6e8fd --- /dev/null +++ b/src/controllers/analytics/userAnalytics.controller.ts @@ -0,0 +1,209 @@ +import { Request, Response } from 'express'; +import * as userAnalyticsService from '../../services/analytics/userAnalytics.service'; + +/** + * Obtiene el overview de estadísticas de usuarios + * GET /analytics/users/overview + */ +export async function getUserStatsOverview(req: Request, res: Response): Promise { + try { + const stats = await userAnalyticsService.getUserStatsOverview(); + res.json({ + success: true, + data: stats + }); + } catch (error) { + console.error('Error en getUserStatsOverview:', error); + res.status(500).json({ + success: false, + message: 'Error al obtener estadísticas de usuarios', + error: error instanceof Error ? error.message : 'Error desconocido' + }); + } +} + +/** + * Obtiene estadísticas de actividad de usuarios + * GET /analytics/users/activity + */ +export async function getUserActivityStats(req: Request, res: Response): Promise { + try { + const { startDate, endDate } = req.query; + + if (!startDate || !endDate) { + res.status(400).json({ + success: false, + message: 'Se requieren los parámetros startDate y endDate' + }); + return; + } + + const start = new Date(startDate as string); + const end = new Date(endDate as string); + + if (isNaN(start.getTime()) || isNaN(end.getTime())) { + res.status(400).json({ + success: false, + message: 'Formato de fecha inválido. Use formato ISO 8601' + }); + return; + } + + const stats = await userAnalyticsService.getUserActivityStats(start, end); + res.json({ + success: true, + data: stats + }); + } catch (error) { + console.error('Error en getUserActivityStats:', error); + res.status(500).json({ + success: false, + message: 'Error al obtener estadísticas de actividad', + error: error instanceof Error ? error.message : 'Error desconocido' + }); + } +} + +/** + * Obtiene los mejores jugadores + * GET /analytics/users/top-players + */ +export async function getTopPlayers(req: Request, res: Response): Promise { + try { + const limit = parseInt(req.query.limit as string) || 10; + const by = (req.query.by as 'matches' | 'wins' | 'ranking' | 'tournaments') || 'matches'; + + if (limit < 1 || limit > 100) { + res.status(400).json({ + success: false, + message: 'El parámetro limit debe estar entre 1 y 100' + }); + return; + } + + const validSortBy = ['matches', 'wins', 'ranking', 'tournaments']; + if (!validSortBy.includes(by)) { + res.status(400).json({ + success: false, + message: `El parámetro by debe ser uno de: ${validSortBy.join(', ')}` + }); + return; + } + + const players = await userAnalyticsService.getTopPlayers(limit, by); + res.json({ + success: true, + data: players + }); + } catch (error) { + console.error('Error en getTopPlayers:', error); + res.status(500).json({ + success: false, + message: 'Error al obtener top jugadores', + error: error instanceof Error ? error.message : 'Error desconocido' + }); + } +} + +/** + * Obtiene usuarios en riesgo de abandono + * GET /analytics/users/churn-risk + */ +export async function getChurnRiskUsers(req: Request, res: Response): Promise { + try { + const users = await userAnalyticsService.getChurnRiskUsers(); + res.json({ + success: true, + data: users, + count: users.length + }); + } catch (error) { + console.error('Error en getChurnRiskUsers:', error); + res.status(500).json({ + success: false, + message: 'Error al obtener usuarios en riesgo de abandono', + error: error instanceof Error ? error.message : 'Error desconocido' + }); + } +} + +/** + * Obtiene la tasa de retención de usuarios + * GET /analytics/users/retention + */ +export async function getRetentionRate(req: Request, res: Response): Promise { + try { + const { startDate, endDate } = req.query; + + if (!startDate || !endDate) { + res.status(400).json({ + success: false, + message: 'Se requieren los parámetros startDate y endDate' + }); + return; + } + + const start = new Date(startDate as string); + const end = new Date(endDate as string); + + if (isNaN(start.getTime()) || isNaN(end.getTime())) { + res.status(400).json({ + success: false, + message: 'Formato de fecha inválido. Use formato ISO 8601' + }); + return; + } + + if (start > end) { + res.status(400).json({ + success: false, + message: 'startDate debe ser anterior a endDate' + }); + return; + } + + const retention = await userAnalyticsService.getRetentionRate(start, end); + res.json({ + success: true, + data: retention + }); + } catch (error) { + console.error('Error en getRetentionRate:', error); + res.status(500).json({ + success: false, + message: 'Error al obtener tasa de retención', + error: error instanceof Error ? error.message : 'Error desconocido' + }); + } +} + +/** + * Obtiene la tendencia de crecimiento de usuarios + * GET /analytics/users/growth + */ +export async function getUserGrowthTrend(req: Request, res: Response): Promise { + try { + const months = parseInt(req.query.months as string) || 6; + + if (months < 1 || months > 24) { + res.status(400).json({ + success: false, + message: 'El parámetro months debe estar entre 1 y 24' + }); + return; + } + + const trend = await userAnalyticsService.getUserGrowthTrend(months); + res.json({ + success: true, + data: trend + }); + } catch (error) { + console.error('Error en getUserGrowthTrend:', error); + res.status(500).json({ + success: false, + message: 'Error al obtener tendencia de crecimiento', + error: error instanceof Error ? error.message : 'Error desconocido' + }); + } +} diff --git a/src/routes/analytics.routes.ts b/src/routes/analytics.routes.ts new file mode 100644 index 0000000..c6267a5 --- /dev/null +++ b/src/routes/analytics.routes.ts @@ -0,0 +1,102 @@ +import { Router } from 'express'; +import * as userAnalyticsController from '../controllers/analytics/userAnalytics.controller'; +import * as exportController from '../controllers/analytics/export.controller'; + +const router = Router(); + +// ============================================ +// RUTAS DE ANÁLISIS DE USUARIOS +// ============================================ + +/** + * @route GET /analytics/users/overview + * @desc Obtiene estadísticas generales de usuarios + * @access Admin + */ +router.get('/users/overview', userAnalyticsController.getUserStatsOverview); + +/** + * @route GET /analytics/users/activity + * @desc Obtiene estadísticas de actividad de usuarios + * @query startDate, endDate + * @access Admin + */ +router.get('/users/activity', userAnalyticsController.getUserActivityStats); + +/** + * @route GET /analytics/users/top-players + * @desc Obtiene los mejores jugadores por criterios + * @query limit (default: 10), by (matches|wins|ranking|tournaments) + * @access Admin + */ +router.get('/users/top-players', userAnalyticsController.getTopPlayers); + +/** + * @route GET /analytics/users/churn-risk + * @desc Obtiene usuarios en riesgo de abandono + * @access Admin + */ +router.get('/users/churn-risk', userAnalyticsController.getChurnRiskUsers); + +/** + * @route GET /analytics/users/retention + * @desc Obtiene tasa de retención de usuarios + * @query startDate, endDate + * @access Admin + */ +router.get('/users/retention', userAnalyticsController.getRetentionRate); + +/** + * @route GET /analytics/users/growth + * @desc Obtiene tendencia de crecimiento de usuarios + * @query months (default: 6, max: 24) + * @access Admin + */ +router.get('/users/growth', userAnalyticsController.getUserGrowthTrend); + +// ============================================ +// RUTAS DE EXPORTACIÓN +// ============================================ + +/** + * @route GET /analytics/exports/bookings + * @desc Exporta reservas a CSV/JSON + * @query startDate, endDate, format (csv|json) + * @access Admin + */ +router.get('/exports/bookings', exportController.exportBookings); + +/** + * @route GET /analytics/exports/users + * @desc Exporta usuarios a CSV/JSON + * @query format (csv|json), level, city + * @access Admin + */ +router.get('/exports/users', exportController.exportUsers); + +/** + * @route GET /analytics/exports/payments + * @desc Exporta pagos a CSV/JSON + * @query startDate, endDate, format (csv|json) + * @access Admin + */ +router.get('/exports/payments', exportController.exportPayments); + +/** + * @route GET /analytics/exports/tournaments/:id + * @desc Exporta resultados de un torneo + * @params id + * @query format (csv|json) + * @access Admin + */ +router.get('/exports/tournaments/:id', exportController.exportTournament); + +/** + * @route GET /analytics/exports/excel-report + * @desc Genera reporte completo en Excel + * @query startDate, endDate + * @access Admin + */ +router.get('/exports/excel-report', exportController.generateExcelReport); + +export default router; diff --git a/src/services/analytics/export.service.ts b/src/services/analytics/export.service.ts new file mode 100644 index 0000000..6663120 --- /dev/null +++ b/src/services/analytics/export.service.ts @@ -0,0 +1,554 @@ +import { PrismaClient } from '@prisma/client'; +import { ExportFormat } from '../../constants/export.constants'; +import { + BookingExportData, + UserExportData, + PaymentExportData, + TournamentResultExportData, + ExcelWorkbookData, + ExportFilters +} from '../../types/analytics.types'; +import { formatDateForExport } from '../../utils/export'; + +const prisma = new PrismaClient(); + +/** + * Exporta reservas a CSV/JSON + */ +export async function exportBookings( + startDate: Date, + endDate: Date, + format: ExportFormat = ExportFormat.CSV +): Promise<{ data: string | Buffer; filename: string }> { + const bookings = await prisma.booking.findMany({ + where: { + date: { + gte: startDate, + lte: endDate + } + }, + include: { + user: { + select: { + firstName: true, + lastName: true, + email: true + } + }, + court: { + select: { + name: true + } + } + }, + orderBy: { + date: 'asc' + } + }); + + const exportData: BookingExportData[] = bookings.map(booking => ({ + id: booking.id, + user: `${booking.user.firstName} ${booking.user.lastName}`, + userEmail: booking.user.email, + court: booking.court.name, + date: formatDateForExport(booking.date), + time: booking.startTime, + price: booking.price, + status: booking.status, + createdAt: formatDateForExport(booking.createdAt) + })); + + const filename = `reservas_${startDate.toISOString().split('T')[0]}_${endDate.toISOString().split('T')[0]}`; + + if (format === ExportFormat.JSON) { + return { + data: JSON.stringify(exportData, null, 2), + filename: `${filename}.json` + }; + } + + // CSV por defecto + const headers = { + id: 'ID', + user: 'Usuario', + userEmail: 'Email', + court: 'Pista', + date: 'Fecha', + time: 'Hora', + price: 'Precio', + status: 'Estado', + createdAt: 'Fecha Creación' + }; + + const csv = convertToCSV(exportData, headers); + return { data: csv, filename: `${filename}.csv` }; +} + +/** + * Exporta usuarios a CSV/JSON + */ +export async function exportUsers( + format: ExportFormat = ExportFormat.CSV, + filters: ExportFilters = {} +): Promise<{ data: string | Buffer; filename: string }> { + const where: Record = {}; + + if (filters.level) { + where.level = filters.level; + } + + if (filters.city) { + where.city = filters.city; + } + + const users = await prisma.user.findMany({ + where, + include: { + _count: { + select: { + bookings: true + } + } + }, + orderBy: { + createdAt: 'desc' + } + }); + + const exportData: UserExportData[] = users.map(user => ({ + id: user.id, + name: `${user.firstName} ${user.lastName}`, + email: user.email, + level: user.level || 'Sin nivel', + city: user.city || 'Sin ciudad', + joinedAt: formatDateForExport(user.createdAt), + bookingsCount: user._count.bookings + })); + + const filename = `usuarios_${new Date().toISOString().split('T')[0]}`; + + if (format === ExportFormat.JSON) { + return { + data: JSON.stringify(exportData, null, 2), + filename: `${filename}.json` + }; + } + + const headers = { + id: 'ID', + name: 'Nombre', + email: 'Email', + level: 'Nivel', + city: 'Ciudad', + joinedAt: 'Fecha Registro', + bookingsCount: 'Total Reservas' + }; + + const csv = convertToCSV(exportData, headers); + return { data: csv, filename: `${filename}.csv` }; +} + +/** + * Exporta pagos a CSV/JSON + */ +export async function exportPayments( + startDate: Date, + endDate: Date, + format: ExportFormat = ExportFormat.CSV +): Promise<{ data: string | Buffer; filename: string }> { + // Asumiendo que existe un modelo Payment o similar + // Aquí usamos bookings como referencia de pagos + const bookings = await prisma.booking.findMany({ + where: { + createdAt: { + gte: startDate, + lte: endDate + }, + status: { + in: ['PAID', 'CONFIRMED', 'COMPLETED'] + } + }, + include: { + user: { + select: { + firstName: true, + lastName: true, + email: true + } + } + }, + orderBy: { + createdAt: 'asc' + } + }); + + const exportData: PaymentExportData[] = bookings.map(booking => ({ + id: booking.id, + user: `${booking.user.firstName} ${booking.user.lastName}`, + userEmail: booking.user.email, + type: 'Reserva', + amount: booking.price, + status: booking.status, + date: formatDateForExport(booking.createdAt) + })); + + const filename = `pagos_${startDate.toISOString().split('T')[0]}_${endDate.toISOString().split('T')[0]}`; + + if (format === ExportFormat.JSON) { + return { + data: JSON.stringify(exportData, null, 2), + filename: `${filename}.json` + }; + } + + const headers = { + id: 'ID', + user: 'Usuario', + userEmail: 'Email', + type: 'Tipo', + amount: 'Monto', + status: 'Estado', + date: 'Fecha' + }; + + const csv = convertToCSV(exportData, headers); + return { data: csv, filename: `${filename}.csv` }; +} + +/** + * Exporta resultados de un torneo + */ +export async function exportTournamentResults( + tournamentId: string, + format: ExportFormat = ExportFormat.CSV +): Promise<{ data: string | Buffer; filename: string }> { + const tournament = await prisma.tournament.findUnique({ + where: { id: tournamentId }, + include: { + participants: { + include: { + user: { + select: { + firstName: true, + lastName: true, + email: true + } + } + } + }, + matches: { + where: { + confirmed: true + } + } + } + }); + + if (!tournament) { + throw new Error('Torneo no encontrado'); + } + + // Calcular estadísticas por participante + const participantStats = new Map(); + + for (const match of tournament.matches) { + // Actualizar estadísticas para cada jugador del partido + const matchParticipants = await prisma.tournamentParticipant.findMany({ + where: { + tournamentId, + userId: { + in: [match.player1Id, match.player2Id].filter(Boolean) as string[] + } + } + }); + + for (const participant of matchParticipants) { + const stats = participantStats.get(participant.userId) || { + matchesPlayed: 0, + wins: 0, + losses: 0, + points: 0 + }; + + stats.matchesPlayed++; + + if (match.winnerId === participant.userId) { + stats.wins++; + stats.points += 3; // 3 puntos por victoria + } else if (match.winnerId) { + stats.losses++; + stats.points += 1; // 1 punto por participar + } + + participantStats.set(participant.userId, stats); + } + } + + const exportData: TournamentResultExportData[] = tournament.participants + .map((participant, index) => { + const stats = participantStats.get(participant.userId) || { + matchesPlayed: 0, + wins: 0, + losses: 0, + points: 0 + }; + + return { + position: index + 1, + player: `${participant.user.firstName} ${participant.user.lastName}`, + email: participant.user.email, + matchesPlayed: stats.matchesPlayed, + wins: stats.wins, + losses: stats.losses, + points: stats.points + }; + }) + .sort((a, b) => b.points - a.points) + .map((item, index) => ({ ...item, position: index + 1 })); + + const filename = `torneo_${tournament.name.replace(/\s+/g, '_').toLowerCase()}_${tournamentId}`; + + if (format === ExportFormat.JSON) { + return { + data: JSON.stringify(exportData, null, 2), + filename: `${filename}.json` + }; + } + + const headers = { + position: 'Posición', + player: 'Jugador', + email: 'Email', + matchesPlayed: 'PJ', + wins: 'PG', + losses: 'PP', + points: 'Puntos' + }; + + const csv = convertToCSV(exportData, headers); + return { data: csv, filename: `${filename}.csv` }; +} + +/** + * Genera un reporte completo en Excel con múltiples hojas + */ +export async function generateExcelReport( + startDate: Date, + endDate: Date +): Promise<{ data: Buffer; filename: string }> { + // Importar xlsx dinámicamente para evitar problemas si no está instalado + const xlsx = await import('xlsx'); + + // 1. Resumen General + const totalBookings = await prisma.booking.count({ + where: { + date: { + gte: startDate, + lte: endDate + } + } + }); + + const totalRevenue = await prisma.booking.aggregate({ + where: { + date: { + gte: startDate, + lte: endDate + }, + status: { + in: ['PAID', 'CONFIRMED', 'COMPLETED'] + } + }, + _sum: { + price: true + } + }); + + const newUsers = await prisma.user.count({ + where: { + createdAt: { + gte: startDate, + lte: endDate + } + } + }); + + const activeUsers = await prisma.user.count({ + where: { + bookings: { + some: { + date: { + gte: startDate, + lte: endDate + } + } + } + } + }); + + const summaryData = [{ + metrica: 'Total Reservas', + valor: totalBookings + }, { + metrica: 'Ingresos Totales', + valor: totalRevenue._sum.price || 0 + }, { + metrica: 'Nuevos Usuarios', + valor: newUsers + }, { + metrica: 'Usuarios Activos', + valor: activeUsers + }]; + + // 2. Datos de Ingresos por Día + const bookingsByDay = await prisma.booking.groupBy({ + by: ['date'], + where: { + date: { + gte: startDate, + lte: endDate + }, + status: { + in: ['PAID', 'CONFIRMED', 'COMPLETED'] + } + }, + _sum: { + price: true + }, + _count: { + id: true + } + }); + + const revenueData = bookingsByDay.map(day => ({ + fecha: day.date.toISOString().split('T')[0], + reservas: day._count.id, + ingresos: day._sum.price || 0 + })); + + // 3. Datos de Ocupación por Pista + const courts = await prisma.court.findMany({ + include: { + bookings: { + where: { + date: { + gte: startDate, + lte: endDate + } + } + } + } + }); + + const occupancyData = courts.map(court => ({ + pista: court.name, + tipo: court.surface || 'No especificado', + totalReservas: court.bookings.length, + tasaOcupacion: calculateOccupancyRate(court.bookings.length, startDate, endDate) + })); + + // 4. Datos de Usuarios + const topUsers = await prisma.user.findMany({ + take: 20, + include: { + bookings: { + where: { + date: { + gte: startDate, + lte: endDate + } + } + }, + _count: { + select: { + bookings: true + } + } + }, + orderBy: { + bookings: { + _count: 'desc' + } + } + }); + + const usersData = topUsers.map(user => ({ + nombre: `${user.firstName} ${user.lastName}`, + email: user.email, + nivel: user.level || 'Sin nivel', + reservasPeriodo: user.bookings.length, + reservasTotales: user._count.bookings + })); + + // Crear workbook + const workbook = xlsx.utils.book_new(); + + // Hoja 1: Resumen + const summarySheet = xlsx.utils.json_to_sheet(summaryData); + xlsx.utils.book_append_sheet(workbook, summarySheet, 'Resumen'); + + // Hoja 2: Ingresos + const revenueSheet = xlsx.utils.json_to_sheet(revenueData); + xlsx.utils.book_append_sheet(workbook, revenueSheet, 'Ingresos'); + + // Hoja 3: Ocupación + const occupancySheet = xlsx.utils.json_to_sheet(occupancyData); + xlsx.utils.book_append_sheet(workbook, occupancySheet, 'Ocupación'); + + // Hoja 4: Usuarios + const usersSheet = xlsx.utils.json_to_sheet(usersData); + xlsx.utils.book_append_sheet(workbook, usersSheet, 'Usuarios'); + + const buffer = xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' }); + const filename = `reporte_completo_${startDate.toISOString().split('T')[0]}_${endDate.toISOString().split('T')[0]}.xlsx`; + + return { data: buffer, filename }; +} + +/** + * Convierte datos a formato CSV + */ +function convertToCSV(data: Record[], headers: Record): string { + const CSV_SEPARATOR = ';'; + + if (data.length === 0) { + return Object.values(headers).join(CSV_SEPARATOR); + } + + const headerRow = Object.values(headers).join(CSV_SEPARATOR); + const keys = Object.keys(headers); + + const rows = data.map(item => { + return keys.map(key => { + const value = item[key]; + if (value === null || value === undefined) { + return ''; + } + if (typeof value === 'string' && (value.includes(CSV_SEPARATOR) || value.includes('"') || value.includes('\n'))) { + return `"${value.replace(/"/g, '""')}"`; + } + return String(value); + }).join(CSV_SEPARATOR); + }); + + return [headerRow, ...rows].join('\n'); +} + +/** + * Calcula la tasa de ocupación estimada + */ +function calculateOccupancyRate( + bookingCount: number, + startDate: Date, + endDate: Date +): string { + const daysDiff = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)); + const maxPossibleBookings = daysDiff * 10; // Asumiendo 10 franjas horarias por día + const rate = maxPossibleBookings > 0 ? (bookingCount / maxPossibleBookings) * 100 : 0; + return `${rate.toFixed(1)}%`; +} diff --git a/src/services/analytics/index.ts b/src/services/analytics/index.ts new file mode 100644 index 0000000..86e3d6a --- /dev/null +++ b/src/services/analytics/index.ts @@ -0,0 +1,2 @@ +export * from './userAnalytics.service'; +export * from './export.service'; diff --git a/src/services/analytics/userAnalytics.service.ts b/src/services/analytics/userAnalytics.service.ts new file mode 100644 index 0000000..59f161f --- /dev/null +++ b/src/services/analytics/userAnalytics.service.ts @@ -0,0 +1,478 @@ +import { PrismaClient } from '@prisma/client'; +import { ChurnRiskLevel } from '../../constants/export.constants'; +import { + UserStatsOverview, + UserActivityStats, + TopPlayer, + ChurnRiskUser, + RetentionRate, + UserGrowthTrend +} from '../../types/analytics.types'; + +const prisma = new PrismaClient(); + +/** + * Obtiene estadísticas generales de usuarios + */ +export async function getUserStatsOverview(): Promise { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const thirtyDaysAgo = new Date(today); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const sevenDaysAgo = new Date(today); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); + + // Total de usuarios + const totalUsers = await prisma.user.count(); + + // Usuarios activos (con reservas en los últimos 30 días) + const activeUsers30Days = await prisma.user.count({ + where: { + bookings: { + some: { + createdAt: { + gte: thirtyDaysAgo + } + } + } + } + }); + + // Nuevos usuarios hoy + const newUsersToday = await prisma.user.count({ + where: { + createdAt: { + gte: today + } + } + }); + + // Nuevos usuarios esta semana + const newUsersThisWeek = await prisma.user.count({ + where: { + createdAt: { + gte: sevenDaysAgo + } + } + }); + + // Nuevos usuarios este mes + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + const newUsersThisMonth = await prisma.user.count({ + where: { + createdAt: { + gte: startOfMonth + } + } + }); + + // Usuarios por nivel + const usersByLevelRaw = await prisma.user.groupBy({ + by: ['level'], + _count: { + id: true + } + }); + const usersByLevel = usersByLevelRaw.map(item => ({ + level: item.level || 'Sin nivel', + count: item._count.id + })); + + // Usuarios por ciudad + const usersByCityRaw = await prisma.user.groupBy({ + by: ['city'], + _count: { + id: true + } + }); + const usersByCity = usersByCityRaw + .filter(item => item.city !== null) + .map(item => ({ + city: item.city as string, + count: item._count.id + })); + + return { + totalUsers, + activeUsers30Days, + newUsersToday, + newUsersThisWeek, + newUsersThisMonth, + usersByLevel, + usersByCity + }; +} + +/** + * Obtiene estadísticas de actividad de usuarios en un período + */ +export async function getUserActivityStats( + startDate: Date, + endDate: Date +): Promise { + // Usuarios con reservas en el período + const usersWithBookings = await prisma.user.findMany({ + where: { + bookings: { + some: { + createdAt: { + gte: startDate, + lte: endDate + } + } + } + }, + include: { + bookings: { + where: { + createdAt: { + gte: startDate, + lte: endDate + } + }, + select: { + id: true + } + } + } + }); + + const totalUsersWithBookings = usersWithBookings.length; + const totalBookings = usersWithBookings.reduce( + (sum, user) => sum + user.bookings.length, + 0 + ); + const averageBookingsPerUser = totalUsersWithBookings > 0 + ? totalBookings / totalUsersWithBookings + : 0; + + // Top 10 usuarios más activos + const topActiveUsers = usersWithBookings + .map(user => ({ + userId: user.id, + name: `${user.firstName} ${user.lastName}`, + email: user.email, + bookingCount: user.bookings.length + })) + .sort((a, b) => b.bookingCount - a.bookingCount) + .slice(0, 10); + + // Usuarios sin actividad en el período (pero que tienen reservas previas) + const inactiveUsers = await prisma.user.findMany({ + where: { + bookings: { + some: {}, + none: { + createdAt: { + gte: startDate, + lte: endDate + } + } + } + }, + include: { + bookings: { + orderBy: { + date: 'desc' + }, + take: 1, + select: { + date: true + } + } + } + }); + + const inactiveUsersFormatted = inactiveUsers.map(user => ({ + userId: user.id, + name: `${user.firstName} ${user.lastName}`, + email: user.email, + lastBookingDate: user.bookings[0]?.date || null + })); + + return { + totalUsersWithBookings, + averageBookingsPerUser: Math.round(averageBookingsPerUser * 100) / 100, + topActiveUsers, + inactiveUsers: inactiveUsersFormatted + }; +} + +/** + * Obtiene los mejores jugadores por diferentes criterios + */ +export async function getTopPlayers( + limit: number = 10, + by: 'matches' | 'wins' | 'ranking' | 'tournaments' = 'matches' +): Promise { + // Obtener todos los usuarios con sus estadísticas + const users = await prisma.user.findMany({ + include: { + userStats: true, + bookings: true, + tournamentParticipants: { + include: { + tournament: true + } + }, + matchResultsAsPlayer1: { + where: { + confirmed: true + } + }, + matchResultsAsPlayer2: { + where: { + confirmed: true + } + } + } + }); + + const players: TopPlayer[] = users.map(user => { + const wins = user.matchResultsAsPlayer1.filter(m => m.winnerId === user.id).length + + user.matchResultsAsPlayer2.filter(m => m.winnerId === user.id).length; + const losses = user.matchResultsAsPlayer1.filter(m => m.winnerId && m.winnerId !== user.id).length + + user.matchResultsAsPlayer2.filter(m => m.winnerId && m.winnerId !== user.id).length; + const matchesPlayed = wins + losses; + const winRate = matchesPlayed > 0 ? Math.round((wins / matchesPlayed) * 100) : 0; + + return { + userId: user.id, + name: `${user.firstName} ${user.lastName}`, + email: user.email, + level: user.level || 'Sin nivel', + matchesPlayed, + wins, + losses, + winRate, + rankingPoints: user.userStats?.points || 0, + tournamentsAttended: user.tournamentParticipants.length + }; + }); + + // Ordenar según el criterio + switch (by) { + case 'wins': + players.sort((a, b) => b.wins - a.wins); + break; + case 'ranking': + players.sort((a, b) => b.rankingPoints - a.rankingPoints); + break; + case 'tournaments': + players.sort((a, b) => b.tournamentsAttended - a.tournamentsAttended); + break; + case 'matches': + default: + players.sort((a, b) => b.matchesPlayed - a.matchesPlayed); + } + + return players.slice(0, limit); +} + +/** + * Identifica usuarios en riesgo de abandono + */ +export async function getChurnRiskUsers(): Promise { + const now = new Date(); + const sixtyDaysAgo = new Date(now); + sixtyDaysAgo.setDate(sixtyDaysAgo.getDate() - 60); + + // Usuarios que no han reservado en 60+ días + const users = await prisma.user.findMany({ + where: { + bookings: { + some: {} + } + }, + include: { + bookings: { + orderBy: { + date: 'desc' + }, + include: { + court: true + } + }, + _count: { + select: { + bookings: true + } + } + } + }); + + const churnRiskUsers: ChurnRiskUser[] = []; + + for (const user of users) { + const lastBooking = user.bookings[0]; + if (!lastBooking) continue; + + const lastBookingDate = new Date(lastBooking.date); + const daysSinceLastBooking = Math.floor( + (now.getTime() - lastBookingDate.getTime()) / (1000 * 60 * 60 * 24) + ); + + // Solo usuarios con 60+ días sin reservar + if (daysSinceLastBooking < 60) continue; + + // Calcular nivel de riesgo + let riskLevel: ChurnRiskLevel; + const totalBookings = user._count.bookings; + const wasFrequentUser = totalBookings >= 5; + + if (daysSinceLastBooking >= 120 && wasFrequentUser) { + riskLevel = ChurnRiskLevel.HIGH; + } else if (daysSinceLastBooking >= 90 || wasFrequentUser) { + riskLevel = ChurnRiskLevel.MEDIUM; + } else { + riskLevel = ChurnRiskLevel.LOW; + } + + churnRiskUsers.push({ + userId: user.id, + name: `${user.firstName} ${user.lastName}`, + email: user.email, + lastBookingDate, + totalBookings, + riskLevel, + daysSinceLastBooking + }); + } + + // Ordenar por días sin reservar (mayor riesgo primero) + return churnRiskUsers.sort((a, b) => b.daysSinceLastBooking - a.daysSinceLastBooking); +} + +/** + * Calcula la tasa de retención de usuarios + */ +export async function getRetentionRate( + startDate: Date, + endDate: Date +): Promise { + const periods: RetentionRate[] = []; + const current = new Date(startDate); + + while (current <= endDate) { + const periodStart = new Date(current); + const periodEnd = new Date(current); + periodEnd.setMonth(periodEnd.getMonth() + 1); + + if (periodEnd > endDate) break; + + // Usuarios registrados en este período + const registeredUsers = await prisma.user.findMany({ + where: { + createdAt: { + gte: periodStart, + lt: periodEnd + } + }, + select: { + id: true, + createdAt: true + } + }); + + const totalRegistered = registeredUsers.length; + + if (totalRegistered === 0) { + current.setMonth(current.getMonth() + 1); + continue; + } + + // Verificar cuántos siguen activos (tienen reservas después de 30 días del registro) + const retainedCount = await Promise.all( + registeredUsers.map(async user => { + const thirtyDaysAfterReg = new Date(user.createdAt); + thirtyDaysAfterReg.setDate(thirtyDaysAfterReg.getDate() + 30); + + const hasActivity = await prisma.booking.findFirst({ + where: { + userId: user.id, + createdAt: { + gte: thirtyDaysAfterReg + } + } + }); + + return hasActivity !== null; + }) + ); + + const retainedUsers = retainedCount.filter(Boolean).length; + const retentionRate = Math.round((retainedUsers / totalRegistered) * 100); + + periods.push({ + period: periodStart.toISOString().slice(0, 7), // YYYY-MM + totalRegistered, + retainedUsers, + retentionRate + }); + + current.setMonth(current.getMonth() + 1); + } + + return periods; +} + +/** + * Obtiene la tendencia de crecimiento de usuarios + */ +export async function getUserGrowthTrend(months: number = 6): Promise { + const trends: UserGrowthTrend[] = []; + const now = new Date(); + + // Ir mes por mes hacia atrás + for (let i = months - 1; i >= 0; i--) { + const monthDate = new Date(now.getFullYear(), now.getMonth() - i, 1); + const nextMonth = new Date(monthDate.getFullYear(), monthDate.getMonth() + 1, 1); + + // Nuevos usuarios este mes + const newUsers = await prisma.user.count({ + where: { + createdAt: { + gte: monthDate, + lt: nextMonth + } + } + }); + + // Total acumulado hasta este mes + const totalUsers = await prisma.user.count({ + where: { + createdAt: { + lt: nextMonth + } + } + }); + + // Calcular tasa de crecimiento + let growthRate = 0; + if (i < months - 1) { + const prevMonth = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1); + const prevTotal = await prisma.user.count({ + where: { + createdAt: { + lt: prevMonth + } + } + }); + + if (prevTotal > 0) { + growthRate = Math.round(((totalUsers - prevTotal) / prevTotal) * 100 * 100) / 100; + } + } + + trends.push({ + month: monthDate.toLocaleString('es-ES', { month: 'long' }), + year: monthDate.getFullYear(), + newUsers, + totalUsers, + growthRate + }); + } + + return trends; +} diff --git a/src/types/analytics.types.ts b/src/types/analytics.types.ts new file mode 100644 index 0000000..7e398cf --- /dev/null +++ b/src/types/analytics.types.ts @@ -0,0 +1,130 @@ +import { ChurnRiskLevel } from '../constants/export.constants'; + +export interface UserStatsOverview { + totalUsers: number; + activeUsers30Days: number; + newUsersToday: number; + newUsersThisWeek: number; + newUsersThisMonth: number; + usersByLevel: Array<{ + level: string; + count: number; + }>; + usersByCity: Array<{ + city: string; + count: number; + }>; +} + +export interface UserActivityStats { + totalUsersWithBookings: number; + averageBookingsPerUser: number; + topActiveUsers: Array<{ + userId: string; + name: string; + email: string; + bookingCount: number; + }>; + inactiveUsers: Array<{ + userId: string; + name: string; + email: string; + lastBookingDate: Date | null; + }>; +} + +export interface TopPlayer { + userId: string; + name: string; + email: string; + level: string; + matchesPlayed: number; + wins: number; + losses: number; + winRate: number; + rankingPoints: number; + tournamentsAttended: number; +} + +export interface ChurnRiskUser { + userId: string; + name: string; + email: string; + lastBookingDate: Date; + totalBookings: number; + riskLevel: ChurnRiskLevel; + daysSinceLastBooking: number; +} + +export interface RetentionRate { + period: string; + totalRegistered: number; + retainedUsers: number; + retentionRate: number; +} + +export interface UserGrowthTrend { + month: string; + year: number; + newUsers: number; + totalUsers: number; + growthRate: number; +} + +export interface ExportFilters { + startDate?: Date; + endDate?: Date; + level?: string; + city?: string; + status?: string; +} + +export interface BookingExportData { + id: string; + user: string; + userEmail: string; + court: string; + date: string; + time: string; + price: number; + status: string; + createdAt: string; +} + +export interface UserExportData { + id: string; + name: string; + email: string; + level: string; + city: string; + joinedAt: string; + bookingsCount: number; +} + +export interface PaymentExportData { + id: string; + user: string; + userEmail: string; + type: string; + amount: number; + status: string; + date: string; +} + +export interface TournamentResultExportData { + position: number; + player: string; + email: string; + matchesPlayed: number; + wins: number; + losses: number; + points: number; +} + +export interface ExcelWorkbookData { + sheets: Array<{ + name: string; + data: unknown[]; + headers?: string[]; + }>; +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..beb6293 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1 @@ +export * from './analytics.types'; diff --git a/src/utils/export.ts b/src/utils/export.ts new file mode 100644 index 0000000..55e52ac --- /dev/null +++ b/src/utils/export.ts @@ -0,0 +1,166 @@ +import { Response } from 'express'; +import * as xlsx from 'xlsx'; +import { CSV_SEPARATOR, ExportFormat } from '../constants/export.constants'; +import { ExcelWorkbookData } from '../types/analytics.types'; + +/** + * Convierte datos a formato CSV + * @param data - Array de objetos a convertir + * @param headers - Mapeo de { clave: nombreColumna } + * @returns string en formato CSV + */ +export function toCSV(data: Record[], headers: Record): string { + if (data.length === 0) { + return Object.values(headers).join(CSV_SEPARATOR); + } + + const headerRow = Object.values(headers).join(CSV_SEPARATOR); + const keys = Object.keys(headers); + + const rows = data.map(item => { + return keys.map(key => { + const value = item[key]; + if (value === null || value === undefined) { + return ''; + } + if (typeof value === 'string' && (value.includes(CSV_SEPARATOR) || value.includes('"') || value.includes('\n'))) { + return `"${value.replace(/"/g, '""')}"`; + } + return String(value); + }).join(CSV_SEPARATOR); + }); + + return [headerRow, ...rows].join('\n'); +} + +/** + * Convierte datos a formato JSON + * @param data - Array de objetos a convertir + * @returns string en formato JSON + */ +export function toJSON(data: unknown[]): string { + return JSON.stringify(data, null, 2); +} + +/** + * Genera un archivo Excel con múltiples hojas + * @param workbookData - Datos del workbook con múltiples hojas + * @returns Buffer con el archivo Excel + */ +export function generateExcel(workbookData: ExcelWorkbookData): Buffer { + const workbook = xlsx.utils.book_new(); + + workbookData.sheets.forEach(sheet => { + let worksheet: xlsx.WorkSheet; + + if (sheet.headers && sheet.data.length > 0) { + // Si tenemos headers definidos, usamos json_to_sheet + worksheet = xlsx.utils.json_to_sheet(sheet.data as Record[], { + header: sheet.headers + }); + } else if (sheet.data.length > 0) { + // Sin headers explícitos, convertimos los objetos + worksheet = xlsx.utils.json_to_sheet(sheet.data as Record[]); + } else { + // Hoja vacía + worksheet = xlsx.utils.aoa_to_sheet([[]]); + } + + // Ajustar anchos de columna automáticamente + const colWidths: { wch: number }[] = []; + const data = sheet.data as Record[]; + + if (data.length > 0) { + const keys = sheet.headers || Object.keys(data[0]); + keys.forEach((key, idx) => { + const maxLength = Math.max( + String(key).length, + ...data.map(row => String(row[key] || '').length) + ); + colWidths[idx] = { wch: Math.min(maxLength + 2, 50) }; + }); + worksheet['!cols'] = colWidths; + } + + xlsx.utils.book_append_sheet(workbook, worksheet, sheet.name); + }); + + return xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' }); +} + +/** + * Configura los headers HTTP para la descarga de archivos + * @param res - Response de Express + * @param filename - Nombre del archivo + * @param format - Formato de exportación + */ +export function setExportHeaders(res: Response, filename: string, format: ExportFormat): void { + let contentType: string; + let extension: string; + + switch (format) { + case ExportFormat.CSV: + contentType = 'text/csv; charset=utf-8'; + extension = 'csv'; + break; + case ExportFormat.JSON: + contentType = 'application/json; charset=utf-8'; + extension = 'json'; + break; + case ExportFormat.EXCEL: + contentType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + extension = 'xlsx'; + break; + default: + contentType = 'application/octet-stream'; + extension = 'txt'; + } + + // BOM para Excel español + if (format === ExportFormat.CSV) { + res.setHeader('Content-Type', `${contentType}; BOM=`); + } else { + res.setHeader('Content-Type', contentType); + } + + res.setHeader( + 'Content-Disposition', + `attachment; filename="${filename}_${new Date().toISOString().split('T')[0]}.${extension}"` + ); +} + +/** + * Formatea una fecha para exportación + * @param date - Fecha a formatear + * @returns string en formato ISO 8601 + */ +export function formatDateForExport(date: Date | null): string { + if (!date) return ''; + return date.toISOString(); +} + +/** + * Formatea una fecha para display + * @param date - Fecha a formatear + * @returns string formateada + */ +export function formatDateForDisplay(date: Date | null): string { + if (!date) return ''; + return date.toISOString().split('T')[0]; +} + +/** + * Escapa valores para CSV + * @param value - Valor a escapar + * @returns string escapado + */ +export function escapeCSVValue(value: unknown): string { + if (value === null || value === undefined) { + return ''; + } + const str = String(value); + if (str.includes(CSV_SEPARATOR) || str.includes('"') || str.includes('\n') || str.includes('\r')) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; +}