feat: mis clientes en Mi dia + perfilamiento de la afluencia en dashboards

- Cliente.capturadoPorId: el alta (directa y progresiva) registra al
  usuario que la inicio; el panel Mi dia muestra 'Mis clientes' con su
  estado (En captura / Cliente) sin depender de Monday.
- LeadMonday ahora lee las 11 columnas de perfil del tablero Leads
  (estado civil, edad, rango salarial, tipo de empleo/economia,
  califica, credito, modelo, etc.).
- GET /api/reportes/resumen agrega leads.perfil con conteos por
  dimension (valores vacios se omiten; edad en buckets).
- /dash/ gana la seccion 'Perfilamiento de la afluencia' con
  mini-graficas de barras por dimension.
This commit is contained in:
2026-09-02 02:39:19 +00:00
parent 895a831ff8
commit 4030faee25
18 changed files with 551 additions and 14 deletions

View File

@@ -0,0 +1,59 @@
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import { EstadoVacio, TituloSeccion } from './comunes'
import { rebanadasDe, type ResumenReportes } from '../lib/reportes'
// Dimensiones del perfilamiento de la afluencia (board Leads) en el orden del tablero
const DIMENSIONES: { titulo: string; registro: (perfil: ResumenReportes['leads']['perfil']) => Record<string, number> }[] = [
{ titulo: 'Estado civil', registro: (p) => p.porEstadoCivil },
{ titulo: 'Rango salarial', registro: (p) => p.porRangoSalarial },
{ titulo: 'Edad', registro: (p) => p.porEdad },
{ titulo: 'Tipo de empleo', registro: (p) => p.porTipoEmpleo },
{ titulo: 'Tipo de economía', registro: (p) => p.porTipoEconomia },
{ titulo: 'Califica', registro: (p) => p.porCalifica },
{ titulo: 'Crédito', registro: (p) => p.porCredito },
{ titulo: 'Desarrollo', registro: (p) => p.porDesarrollo },
{ titulo: 'Modelo de interés', registro: (p) => p.porModelo },
{ titulo: 'Medio de contacto', registro: (p) => p.porMedioContacto },
{ titulo: 'Lo quiere para', registro: (p) => p.porLoQuierePara },
]
function TarjetaDimension({ titulo, datos }: { titulo: string; datos: Record<string, number> }) {
const rebanadas = rebanadasDe(datos)
return (
<div className="border border-neutral-200 p-4">
<p className="mb-2 text-xs font-bold uppercase tracking-widest text-neutral-500">{titulo}</p>
{rebanadas.length === 0 ? (
<EstadoVacio mensaje="Sin datos" />
) : (
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={rebanadas} layout="vertical" margin={{ top: 4, right: 24, left: 24, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 11 }} />
<YAxis type="category" dataKey="nombre" width={90} tick={{ fontSize: 11 }} />
<Tooltip />
<Bar dataKey="valor" name="Leads" fill="#1B4332" isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div>
)}
</div>
)
}
/**
* Sección "Perfilamiento de la afluencia": una mini-gráfica de barras
* horizontales por dimensión capturada en el board Leads.
*/
export default function Perfilamiento({ perfil }: { perfil: ResumenReportes['leads']['perfil'] }) {
return (
<section aria-label="Perfilamiento de la afluencia" className="mt-12">
<TituloSeccion>Perfilamiento de la afluencia</TituloSeccion>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{DIMENSIONES.map((d) => (
<TarjetaDimension key={d.titulo} titulo={d.titulo} datos={d.registro(perfil)} />
))}
</div>
</section>
)
}

View File

@@ -16,6 +16,7 @@ import {
YAxis,
} from 'recharts'
import KpiCard from './KpiCard'
import Perfilamiento from './Perfilamiento'
import SeccionEquipo from './SeccionEquipo'
import { EstadoVacio, TituloSeccion } from './comunes'
import {
@@ -244,6 +245,8 @@ export default function TableroGeneral({ mostrarEquipo }: { mostrarEquipo: boole
</section>
</div>
<Perfilamiento perfil={resumen.leads.perfil} />
{mostrarEquipo && <SeccionEquipo />}
</>
)}

View File

@@ -5,6 +5,7 @@ import {
datosPipeline,
formatoMoneda,
formatoPorcentaje,
rebanadasDe,
unidadesApiladas,
unidadesEnEtapa,
type ResumenReportes,
@@ -13,7 +14,26 @@ import {
function resumen(overrides: Partial<ResumenReportes> = {}): ResumenReportes {
return {
generadoEn: '2026-08-22T12:00:00.000Z',
leads: { total: 40, nuevosHoy: 3, nuevosSemana: 12, porEstado: {}, porOrigen: {} },
leads: {
total: 40,
nuevosHoy: 3,
nuevosSemana: 12,
porEstado: {},
porOrigen: {},
perfil: {
porDesarrollo: { ATELIER: 10, DIAGONAL: 8 },
porModelo: {},
porEstadoCivil: { Soltero: 15, Casado: 9 },
porTipoEmpleo: {},
porTipoEconomia: {},
porCalifica: { Si: 20 },
porCredito: {},
porRangoSalarial: {},
porMedioContacto: {},
porLoQuierePara: {},
porEdad: { '25–34': 12, '15–24': 5 },
},
},
oportunidades: {
total: 20,
activas: 15,
@@ -121,6 +141,20 @@ describe('datosCobranza', () => {
})
})
describe('rebanadasDe (perfilamiento)', () => {
it('ordena desc y filtra los valores en 0', () => {
expect(rebanadasDe({ Soltero: 15, Casado: 9, Viudo: 0, 'Union Libre': 22 })).toEqual([
{ nombre: 'Union Libre', valor: 22 },
{ nombre: 'Soltero', valor: 15 },
{ nombre: 'Casado', valor: 9 },
])
})
it('registro vacío devuelve lista vacía (la tarjeta muestra "Sin datos")', () => {
expect(rebanadasDe({})).toEqual([])
})
})
describe('formatos', () => {
it('moneda MXN sin centavos', () => {
expect(formatoMoneda(125000)).toBe('$125,000')

View File

@@ -12,6 +12,20 @@ export interface ResumenReportes {
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
@@ -121,6 +135,17 @@ export function datosCobranza(resumen: ResumenReportes): Rebanada[] {
]
}
/**
* 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 */