FASE 5 COMPLETADA: Analytics y Administración

Implementados 5 módulos de analytics con agent swarm:

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

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

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

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

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

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

Dependencias:
- xlsx - Generación de archivos Excel

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

166
src/utils/export.ts Normal file
View File

@@ -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<string, unknown>[], headers: Record<string, string>): 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<string, unknown>[], {
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<string, unknown>[]);
} 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<string, unknown>[];
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;
}