/** * Tipos del endpoint GET /api/reportes/resumen y transformaciones puras para * las gráficas del tablero. Toda la lógica de forma de datos vive aquí para * probarla sin renderizar componentes. */ export interface ResumenReportes { generadoEn: string leads: { total: number nuevosHoy: number nuevosSemana: number porEstado: Record porOrigen: Record /** Perfilamiento de la afluencia: solo valores capturados (sin '(sin dato)') */ perfil: { porDesarrollo: Record porModelo: Record porEstadoCivil: Record porTipoEmpleo: Record porTipoEconomia: Record porCalifica: Record porCredito: Record porRangoSalarial: Record porMedioContacto: Record porLoQuierePara: Record porEdad: Record // buckets '15–24' | '25–34' | '35–44' | '45–54' | '55+' } } oportunidades: { total: number activas: number porEtapa: Record tasaAparto: number tasaGanado: number } unidades: { total: number disponibles: number porDesarrollo: { desarrollo: string; total: number; porStatus: Record }[] /** Conteo por etapa del pipeline de venta, en el orden oficial de las 10 etapas */ porEtapa: { etapa: string; total: number }[] } cobranza: { planes: { activos: number; liquidados: number; cancelados: number } planesAlCorriente: number planesConVencidos: number pagosVencidos: number montoVencido: number } actividadesAbiertas: number } // Status de inventario que se grafican por separado; el resto se agrupa en OTROS export const STATUS_UNIDAD = ['DISPONIBLE', 'APARTADO', 'VENDIDO', 'BLOQUEADO'] as const export interface RenglonUnidades { desarrollo: string DISPONIBLE: number APARTADO: number VENDIDO: number BLOQUEADO: number OTROS: number } /** Filas para la barras apiladas: unidades por desarrollo × status */ export function unidadesApiladas(resumen: ResumenReportes): RenglonUnidades[] { return resumen.unidades.porDesarrollo.map((d) => { const renglon: RenglonUnidades = { desarrollo: d.desarrollo, DISPONIBLE: 0, APARTADO: 0, VENDIDO: 0, BLOQUEADO: 0, OTROS: 0, } for (const [status, n] of Object.entries(d.porStatus)) { if ((STATUS_UNIDAD as readonly string[]).includes(status)) { renglon[status as (typeof STATUS_UNIDAD)[number]] = n } else { renglon.OTROS += n } } return renglon }) } export interface EtapaFunnel { etapa: string valor: number } /** * Datos de la gráfica "Pipeline de ventas por etapa": las 10 etapas del * pipeline de unidad en su orden oficial (el API ya las manda ordenadas). */ export function datosPipeline(resumen: ResumenReportes): EtapaFunnel[] { return resumen.unidades.porEtapa.map((e) => ({ etapa: e.etapa, valor: e.total })) } /** Unidades actualmente en una etapa dada del pipeline (p. ej. 'Notaría'); 0 si no hay */ export function unidadesEnEtapa(resumen: ResumenReportes, etapa: string): number { return resumen.unidades.porEtapa.find((e) => e.etapa === etapa)?.total ?? 0 } /** * Datos del funnel comercial: Leads (total del tablero Leads, captura ligera) * → Afluencia → Agendo Cita → Cotizador → Aparto o más → Ganado (las etapas, * del tablero Oportunidades vía porEtapa). "Aparto o más" es acumulado * (Aparto + Ganado) porque Aparto es subconjunto del camino a Ganado. */ export function datosFunnel(resumen: ResumenReportes): EtapaFunnel[] { const porEtapa = resumen.oportunidades.porEtapa const conteo = (etapa: string): number => Object.entries(porEtapa) .filter(([k]) => k.trim().toUpperCase() === etapa) .reduce((acc, [, v]) => acc + v, 0) const apartoOMas = conteo('APARTO') + conteo('GANADO') return [ { etapa: 'Leads', valor: resumen.leads.total }, { etapa: 'Afluencia', valor: conteo('AFLUENCIA') }, { etapa: 'Agendo Cita', valor: conteo('AGENDO CITA') }, { etapa: 'Cotizador', valor: conteo('COTIZADOR') }, { etapa: 'Aparto o más', valor: apartoOMas }, { etapa: 'Ganado', valor: conteo('GANADO') }, ] } export interface Rebanada { nombre: string valor: number } /** Rebanadas de la gráfica de cobranza: planes activos al corriente vs con vencidos */ export function datosCobranza(resumen: ResumenReportes): Rebanada[] { return [ { nombre: 'Al corriente', valor: resumen.cobranza.planesAlCorriente }, { nombre: 'Con vencidos', valor: resumen.cobranza.planesConVencidos }, ] } /** * Rebanadas de una dimensión de perfilamiento (barras horizontales): sin * valores en 0, ordenadas de mayor a menor. */ export function rebanadasDe(registro: Record): Rebanada[] { return Object.entries(registro) .filter(([, valor]) => valor > 0) .map(([nombre, valor]) => ({ nombre, valor })) .sort((a, b) => b.valor - a.valor) } const formatoMXN = new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }) /** Formato monetario MXN sin centavos: $1,250,000 */ export function formatoMoneda(valor: number): string { return formatoMXN.format(valor) } /** Fracción (0.4) a texto porcentaje (40%) */ export function formatoPorcentaje(fraccion: number): string { return `${(fraccion * 100).toLocaleString('es-MX', { maximumFractionDigits: 1 })}%` }