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:
59
apps/dashboards/src/components/Perfilamiento.tsx
Normal file
59
apps/dashboards/src/components/Perfilamiento.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 />}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user