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 */

View File

@@ -36,11 +36,21 @@ export interface CapturaMiDia {
capturadoEn: string // ISO
}
export interface ClienteMiDia {
id: string
nombre: string
proyecto: string // '' = alta aún en captura
unidadCodigo: string
completado: boolean
capturadoEn: string // ISO
}
export interface ResumenMiDia {
generadoEn: string
sinMapeoMonday: boolean
misLeads: LeadMiDia[]
misCapturas: CapturaMiDia[]
misClientes: ClienteMiDia[]
misActividadesHoy: ActividadMiDia[]
apartadosPorVencer: ApartadoPorVencer[]
misOportunidades: { porEtapa: Record<string, number> } | null

View File

@@ -398,6 +398,40 @@ export default function MiDiaPage() {
)}
</section>
<section aria-label="Mis clientes" className="mt-12">
<TituloSeccion>Mis clientes</TituloSeccion>
{resumen.misClientes.length === 0 ? (
<EstadoVacio mensaje="Aún no registras clientes" />
) : (
<ul className="space-y-2">
{resumen.misClientes.map((c) => (
<li key={c.id} className="flex flex-wrap items-center justify-between gap-3 border border-neutral-200 px-4 py-3">
<div>
<p className="text-sm font-medium">{c.nombre}</p>
<p className="mt-1 text-xs text-neutral-500">
{[c.proyecto, c.unidadCodigo].filter((p) => p !== '').join(' · ') || 'Alta sin proyecto'}
</p>
</div>
<div className="flex items-center gap-3">
<span
className={`border px-2 py-1 text-xs font-bold uppercase tracking-widest ${
c.completado
? 'border-neutral-300 text-neutral-500'
: 'border-amber-700 text-amber-700'
}`}
>
{c.completado ? 'Cliente' : 'En captura'}
</span>
<span className="text-xs font-bold uppercase tracking-widest text-neutral-500">
{formatoFechaCorta(c.capturadoEn.slice(0, 10))}
</span>
</div>
</li>
))}
</ul>
)}
</section>
<section aria-label="Captura rápida de lead" className="mt-12">
<TituloSeccion>Captura rápida de lead</TituloSeccion>
<form onSubmit={enviarCaptura} className="grid gap-4 border border-neutral-200 p-6 sm:grid-cols-2">