FASE 5 COMPLETADA: Analytics y Administración

Implementados 5 módulos de analytics con agent swarm:

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

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

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

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

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

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

Dependencias:
- xlsx - Generación de archivos Excel

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

View File

@@ -0,0 +1,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<T extends Record<string, any>>(
data: T[],
dateField: string,
groupBy: GroupByPeriod
): Record<string, T[]> {
const grouped: Record<string, T[]> = {};
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<T extends { date: string }>(
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<T extends Record<string, any>>(
data: T[],
field: string
): Record<string, T[]> {
return data.reduce((acc, item) => {
const key = item[field]?.toString() || 'unknown';
if (!acc[key]) {
acc[key] = [];
}
acc[key].push(item);
return acc;
}, {} as Record<string, T[]>);
}
/**
* 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,
};