Files
FRASA-CAS/apps/dashboards/src/lib/reportes.ts
Consult AS Technology 135e3e6de5 feat(leads): doble via de captura — lead rapido a Leads, afluencia a Oportunidades
- POST /api/leads: captura ligera al tablero Leads (sin oportunidad),
  self-assign a Vendedor o round-robin; tambien cubre la captura manual
  desde la UI de Monday (webhook create_item re-registrado).
- POST /api/afluencias (nuevo): la forma completa sigue cayendo en
  Oportunidades con todo el perfil (etapa Afluencia, columna Asesor).
- Round-robin dual (ambos tableros), Mi dia mezcla leads+oportunidades
  con insignia de origen, acciones con parametro fuente, reportes:
  leads desde Leads, perfilamiento desde Oportunidades, funnel
  Leads->Afluencia->...->Ganado.
2026-09-04 06:09:38 +00:00

164 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, number>
porOrigen: Record<string, number>
/** Perfilamiento de la afluencia: solo valores capturados (sin '(sin dato)') */
perfil: {
porDesarrollo: Record<string, number>
porModelo: Record<string, number>
porEstadoCivil: Record<string, number>
porTipoEmpleo: Record<string, number>
porTipoEconomia: Record<string, number>
porCalifica: Record<string, number>
porCredito: Record<string, number>
porRangoSalarial: Record<string, number>
porMedioContacto: Record<string, number>
porLoQuierePara: Record<string, number>
porEdad: Record<string, number> // buckets '15–24' | '25–34' | '35–44' | '45–54' | '55+'
}
}
oportunidades: {
total: number
activas: number
porEtapa: Record<string, number>
tasaAparto: number
tasaGanado: number
}
unidades: {
total: number
disponibles: number
porDesarrollo: { desarrollo: string; total: number; porStatus: Record<string, number> }[]
/** 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<string, number>): 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 })}%`
}