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,6 @@
-- AlterTable
ALTER TABLE "Cliente" ADD COLUMN "capturadoPorId" TEXT;
-- CreateIndex
CREATE INDEX "Cliente_capturadoPorId_idx" ON "Cliente"("capturadoPorId");

View File

@@ -53,10 +53,14 @@ model Cliente {
firmaPng String? // base64 opcional
mondayContactoId String?
mondayApartadoId String?
// Usuario.id de quien inició el alta (sub del JWT); null en altas históricas
// o creadas sin auth (AUTH_ENABLED=false). Alimenta "Mis clientes" de Mi día.
capturadoPorId String?
createdAt DateTime @default(now())
documentos Documento[]
@@index([curp])
@@index([capturadoPorId])
}
model Documento {

View File

@@ -176,6 +176,20 @@ describe('iniciarProgreso (POST /api/clientes/progreso/iniciar)', () => {
expect(status).toHaveBeenCalledWith(201)
expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondayContactoId: null })
})
it('guarda capturadoPorId con el sub del JWT (null sin token)', async () => {
const { iniciarProgreso } = await import('./clientes.controller.js')
const req = reqFake({ datosGenerales: { nombreCompleto: 'Ana Demo' } })
;(req as unknown as { usuarioToken: { sub: string } }).usuarioToken = { sub: 'usr-1' }
await iniciarProgreso(req, resFake().res)
expect(prismaMock.cliente.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ capturadoPorId: 'usr-1' }) }),
)
await iniciarProgreso(reqFake({ datosGenerales: { nombreCompleto: 'Ana Demo' } }), resFake().res)
expect(prismaMock.cliente.create).toHaveBeenLastCalledWith(
expect.objectContaining({ data: expect.objectContaining({ capturadoPorId: null }) }),
)
})
})
describe('actualizarProgreso (PATCH /api/clientes/progreso/:id)', () => {

View File

@@ -163,4 +163,16 @@ describe('crearCliente (POST /api/clientes) — leadMondayId del funnel', () =>
expect(status).toHaveBeenCalledWith(201)
expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayContactoId: 'cont-1' })
})
it('guarda capturadoPorId con el sub del JWT (null sin token)', async () => {
const { crearCliente } = await import('./clientes.controller.js')
await crearCliente({ body: altaBase, usuarioToken: { sub: 'usr-1' } } as unknown as Request, resFake().res)
expect(prismaMock.cliente.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ capturadoPorId: 'usr-1' }) }),
)
await crearCliente(reqFake(altaBase), resFake().res)
expect(prismaMock.cliente.create).toHaveBeenLastCalledWith(
expect.objectContaining({ data: expect.objectContaining({ capturadoPorId: null }) }),
)
})
})

View File

@@ -211,6 +211,7 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
celular: dg.celular ?? null,
proyecto: vc.proyecto,
unidadCodigo: vc.ubicacion ?? '', // la "ubicación" del formato es el código de unidad (U-101, etc.)
capturadoPorId: req.usuarioToken?.sub ?? null,
payloadJson: JSON.stringify(body),
firmaPng: body.firma?.firmaDataUrl ?? null,
},
@@ -348,6 +349,7 @@ export async function iniciarProgreso(req: Request, res: Response): Promise<void
email: dg.email ?? null,
celular: dg.celular ?? null,
proyecto: '', // se define al completar el alta
capturadoPorId: req.usuarioToken?.sub ?? null,
payloadJson: JSON.stringify(body),
},
})

View File

@@ -32,12 +32,19 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise
orderBy: { createdAt: 'desc' },
})
// Altas de clientes iniciadas por el usuario (registro local)
const clientes = await prisma.cliente.findMany({
where: { capturadoPorId: usuario.id },
orderBy: { createdAt: 'desc' },
})
res.json(
await resumenMiDia(
mondayService,
{ nombre: usuario.nombre, rol: usuario.rol, mondayUserId: usuario.mondayUserId },
nombresPorMondayId,
capturas,
clientes,
),
)
}

View File

@@ -83,7 +83,7 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
lead({ id: '6', estado: 'NUEVO', vendedorIds: [OTRO_ID] }),
],
})
const r = await resumenMiDia(monday, vendedor, new Map(), [], AHORA)
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
expect(r.sinMapeoMonday).toBe(false)
expect(r.misLeads.map((l) => l.id)).toEqual(['1', '2'])
expect(r.misLeads[1]?.telefono).toBe('4771234567')
@@ -101,7 +101,7 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
actividad({ id: 'a7', fecha: '' }), // sin fecha: no
],
})
const r = await resumenMiDia(monday, vendedor, new Map(), [], AHORA)
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
// Ordenadas por fecha: vencida primero
expect(r.misActividadesHoy.map((a) => a.id)).toEqual(['a3', 'a1', 'a2'])
expect(r.conteos.actividadesAbiertas).toBe(5) // abiertas mías: a1,a2,a3,a4,a7
@@ -118,7 +118,7 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
apartado({ id: 'p6', fechaLimite: '2026-09-01', estado: 'Cancelacion' }),
],
})
const r = await resumenMiDia(monday, vendedor, new Map(), [], AHORA)
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
expect(r.apartadosPorVencer.map((a) => a.id)).toEqual(['p1', 'p2'])
expect(r.apartadosPorVencer[0]?.diasRestantes).toBe(0)
expect(r.apartadosPorVencer[1]?.diasRestantes).toBe(7)
@@ -130,7 +130,7 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
actividades: [actividad({ id: 'a1' })],
apartados: [apartado({ id: 'p1' })],
})
const r = await resumenMiDia(monday, sinMapeo, new Map(), [], AHORA)
const r = await resumenMiDia(monday, sinMapeo, new Map(), [], [], AHORA)
expect(r.sinMapeoMonday).toBe(true)
expect(r.misLeads).toEqual([])
expect(r.misActividadesHoy).toEqual([])
@@ -148,12 +148,12 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
lead({ id: '4', createdAt: '2026-08-27T08:00:00', vendedorIds: [OTRO_ID] }), // de otro
],
})
const r = await resumenMiDia(monday, vendedor, new Map(), [], AHORA)
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
expect(r.conteos.leadsNuevosSemana).toBe(2)
})
it('oportunidades se omiten con nota (el board no tiene columna de asesor)', async () => {
const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), [], AHORA)
const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), [], [], AHORA)
expect(r.misOportunidades).toBeNull()
expect(r.notaOportunidades).toContain('asesor')
})
@@ -171,10 +171,10 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
})
const nombres = new Map([[MI_ID, 'Martin Zavala']])
const rVendedor = await resumenMiDia(monday, vendedor, nombres, [], AHORA)
const rVendedor = await resumenMiDia(monday, vendedor, nombres, [], [], AHORA)
expect(rVendedor.equipo).toBeUndefined()
const rCoord = await resumenMiDia(monday, coordinador, nombres, [], AHORA)
const rCoord = await resumenMiDia(monday, coordinador, nombres, [], [], AHORA)
expect(rCoord.equipo?.leadsNuevosSinAsignar).toBe(1)
expect(rCoord.equipo?.porVendedor).toEqual([
{ mondayUserId: OTRO_ID, nombre: null, leadsAsignados: 2, leadsNuevosSemana: 1 },
@@ -185,7 +185,7 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
it('corporativo y admin también ven equipo', async () => {
const monday = mondayMock({ leads: [lead({ id: '1' })] })
for (const rol of ['corporativo', 'admin']) {
const r = await resumenMiDia(monday, { nombre: 'X', rol, mondayUserId: null }, new Map(), [], AHORA)
const r = await resumenMiDia(monday, { nombre: 'X', rol, mondayUserId: null }, new Map(), [], [], AHORA)
expect(r.equipo).toBeDefined()
}
})
@@ -197,7 +197,7 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
const capturas = [
{ leadId: '100', nombreLead: 'Ana López', telefono: '6641112233', createdAt: new Date('2026-08-27T09:00:00Z') },
]
const r = await resumenMiDia(monday, vendedor, new Map(), capturas, AHORA)
const r = await resumenMiDia(monday, vendedor, new Map(), capturas, [], AHORA)
// Aunque el lead quedó asignado a OTRO por round-robin, la captura es mía
expect(r.misCapturas).toEqual([
{
@@ -216,7 +216,7 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
const capturas = [
{ leadId: 'borrado-1', nombreLead: 'Lead Borrado', telefono: null, createdAt: new Date('2026-08-26T09:00:00Z') },
]
const r = await resumenMiDia(monday, vendedor, new Map(), capturas, AHORA)
const r = await resumenMiDia(monday, vendedor, new Map(), capturas, [], AHORA)
expect(r.misCapturas).toEqual([
{ leadId: 'borrado-1', nombre: 'Lead Borrado', telefono: '', estado: '', origen: '', capturadoEn: '2026-08-26T09:00:00.000Z' },
])
@@ -228,14 +228,65 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
{ leadId: '1', nombreLead: 'Antiguo', telefono: null, createdAt: new Date('2026-08-20T09:00:00Z') },
{ leadId: '2', nombreLead: 'Reciente', telefono: null, createdAt: new Date('2026-08-27T09:00:00Z') },
]
const r = await resumenMiDia(monday, vendedor, new Map(), capturas, AHORA)
const r = await resumenMiDia(monday, vendedor, new Map(), capturas, [], AHORA)
expect(r.misCapturas.map((c) => c.leadId)).toEqual(['2', '1'])
})
it('sin capturas: misCapturas va vacío', async () => {
const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), [], AHORA)
const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), [], [], AHORA)
expect(r.misCapturas).toEqual([])
})
it('mis clientes: mapeo con completado según proyecto (vacío = en captura)', async () => {
const clientes = [
{
id: 'c1',
nombreCompleto: 'Ana En Captura',
proyecto: '',
unidadCodigo: null,
createdAt: new Date('2026-08-26T09:00:00Z'),
},
{
id: 'c2',
nombreCompleto: 'Luis Completado',
proyecto: 'DIAGONAL',
unidadCodigo: 'U-101',
createdAt: new Date('2026-08-27T09:00:00Z'),
},
]
const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), [], clientes, AHORA)
expect(r.misClientes).toEqual([
{
id: 'c2',
nombre: 'Luis Completado',
proyecto: 'DIAGONAL',
unidadCodigo: 'U-101',
completado: true,
capturadoEn: '2026-08-27T09:00:00.000Z',
},
{
id: 'c1',
nombre: 'Ana En Captura',
proyecto: '',
unidadCodigo: '',
completado: false,
capturadoEn: '2026-08-26T09:00:00.000Z',
},
])
})
it('mis clientes: proyecto con solo espacios cuenta como en captura', async () => {
const clientes = [
{ id: 'c1', nombreCompleto: 'Espacios', proyecto: ' ', unidadCodigo: null, createdAt: new Date('2026-08-26T09:00:00Z') },
]
const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), [], clientes, AHORA)
expect(r.misClientes[0]?.completado).toBe(false)
})
it('sin clientes: misClientes va vacío (no requiere Monday)', async () => {
const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), [], [], AHORA)
expect(r.misClientes).toEqual([])
})
})
describe('clasificadores de estado', () => {

View File

@@ -59,11 +59,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 // true cuando el alta ya tiene proyecto (pasó a CLIENTE)
capturadoEn: string // ISO
}
export interface ResumenMiDia {
generadoEn: string
sinMapeoMonday: boolean
misLeads: LeadMiDia[]
misCapturas: CapturaMiDia[]
misClientes: ClienteMiDia[]
misActividadesHoy: ActividadMiDia[]
apartadosPorVencer: ApartadoPorVencer[]
// null = el tablero Oportunidades no tiene columna people de asesor (hoy no existe)
@@ -114,6 +124,8 @@ export async function resumenMiDia(
nombresPorMondayId: Map<number, string>,
/** Capturas locales del usuario (tabla LeadCaptura), ya filtradas por él */
capturas: { leadId: string; nombreLead: string; telefono: string | null; createdAt: Date }[] = [],
/** Clientes cuyo alta inició el usuario (tabla Cliente), ya filtrados por él */
clientes: { id: string; nombreCompleto: string; proyecto: string; unidadCodigo: string | null; createdAt: Date }[] = [],
ahora: Date = new Date(),
): Promise<ResumenMiDia> {
const miId = usuario.mondayUserId
@@ -161,6 +173,19 @@ export async function resumenMiDia(
})
.sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn))
// "Mis clientes" es 100% local (no requiere Monday): el alta en progreso
// lleva proyecto '', el alta completada lleva el proyecto lleno
const misClientes: ClienteMiDia[] = clientes
.map((c) => ({
id: c.id,
nombre: c.nombreCompleto,
proyecto: c.proyecto,
unidadCodigo: c.unidadCodigo ?? '',
completado: c.proyecto.trim() !== '',
capturadoEn: c.createdAt.toISOString(),
}))
.sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn))
const misActividades = sinMapeo ? [] : actividades.filter((a) => a.responsableIds.includes(miId))
const misActividadesHoy: ActividadMiDia[] = misActividades
.filter((a) => a.estado.trim().toUpperCase() === 'ABIERTO' && a.fecha !== '' && a.fecha <= manana)
@@ -196,6 +221,7 @@ export async function resumenMiDia(
sinMapeoMonday: sinMapeo,
misLeads,
misCapturas,
misClientes,
misActividadesHoy,
apartadosPorVencer,
// El tablero Oportunidades no tiene columna people de asesor: no se puede

View File

@@ -247,4 +247,128 @@ describe('MondayService', () => {
expect(parsed['date_fc']).toEqual({ date: '2026-08-21' })
expect(parsed['text_t1']).toBeUndefined() // no se envían columnas vacías
})
describe('getLeads / mapearLead (perfilamiento)', () => {
const COLUMNAS_LEADS_BASE = [
{ id: 'people_v', title: 'Vendedor', type: 'people' },
{ id: 'color_e', title: 'Estado', type: 'status' },
]
const COLUMNAS_PERFIL = [
{ id: 'color_des', title: 'Desarrollo', type: 'status' },
{ id: 'color_mod', title: 'Modelo de Interés', type: 'status' },
{ id: 'text_mc', title: 'Medio de Contacto', type: 'text' },
{ id: 'numeric_edad', title: 'Edad', type: 'numbers' },
{ id: 'color_ec', title: 'Estado Civil', type: 'status' },
{ id: 'color_te', title: 'Tipo de Empleo', type: 'status' },
{ id: 'color_cr', title: 'Crédito', type: 'status' },
{ id: 'color_teco', title: 'Tipo de Economía', type: 'status' },
{ id: 'color_cal', title: 'Califica', type: 'status' },
{ id: 'text_rs', title: 'Rango Salarial', type: 'text' },
{ id: 'text_lqp', title: 'Lo Quiere Para', type: 'text' },
]
const itemLead = (id: string, extras: { id: string; text: string }[] = []) => ({
id,
name: `Lead ${id}`,
created_at: '2026-08-20T10:00:00Z',
updated_at: '2026-08-21T10:00:00Z',
column_values: [
{ id: 'people_v', text: '', value: '{"personsAndTeams":[{"id":106736727,"kind":"person"}]}' },
{ id: 'color_e', text: 'NUEVO', value: null },
...extras.map((e) => ({ ...e, value: null })),
],
})
it('mapea las columnas de perfilamiento cuando existen en el board', async () => {
mockearFetch((body) => {
if (body.query.includes('columns')) {
return respuestaOk({ boards: [{ columns: [...COLUMNAS_LEADS_BASE, ...COLUMNAS_PERFIL] }] })
}
return respuestaOk({
boards: [
{
items_page: {
cursor: null,
items: [
itemLead('1', [
{ id: 'color_des', text: 'ATELIER' },
{ id: 'color_mod', text: '1R' },
{ id: 'text_mc', text: 'WhatsApp' },
{ id: 'numeric_edad', text: '32' },
{ id: 'color_ec', text: 'Soltero' },
{ id: 'color_te', text: 'Empleado' },
{ id: 'color_cr', text: 'Bancario' },
{ id: 'color_teco', text: 'Asalariado' },
{ id: 'color_cal', text: 'Si' },
{ id: 'text_rs', text: '20-30 mil' },
{ id: 'text_lqp', text: 'Habitarla' },
]),
],
},
},
],
})
})
const leads = await crearServicio().getLeads()
expect(leads[0]).toMatchObject({
desarrollo: 'ATELIER',
modeloInteres: '1R',
medioContacto: 'WhatsApp',
edad: 32,
estadoCivil: 'Soltero',
tipoEmpleo: 'Empleado',
credito: 'Bancario',
tipoEconomia: 'Asalariado',
califica: 'Si',
rangoSalarial: '20-30 mil',
loQuierePara: 'Habitarla',
})
})
it('sin las columnas de perfilamiento devuelve \'\' / null y no truena', async () => {
mockearFetch((body) => {
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_LEADS_BASE }] })
return respuestaOk({ boards: [{ items_page: { cursor: null, items: [itemLead('1')] } }] })
})
const leads = await crearServicio().getLeads()
expect(leads[0]).toMatchObject({
desarrollo: '',
modeloInteres: '',
medioContacto: '',
edad: null,
estadoCivil: '',
tipoEmpleo: '',
credito: '',
tipoEconomia: '',
califica: '',
rangoSalarial: '',
loQuierePara: '',
})
})
it('edad no numérica o vacía mapea a null', async () => {
mockearFetch((body) => {
if (body.query.includes('columns')) {
return respuestaOk({ boards: [{ columns: [...COLUMNAS_LEADS_BASE, ...COLUMNAS_PERFIL] }] })
}
return respuestaOk({
boards: [
{
items_page: {
cursor: null,
items: [
itemLead('1', [{ id: 'numeric_edad', text: 'N/A' }]),
itemLead('2', [{ id: 'numeric_edad', text: '' }]),
],
},
},
],
})
})
const leads = await crearServicio().getLeads()
expect(leads.map((l) => l.edad)).toEqual([null, null])
})
})
})

View File

@@ -39,6 +39,19 @@ export interface LeadMonday {
telefono: string // columna phone 'Teléfono' ('' si no tiene)
email: string // columna email 'Email' ('' si no tiene)
vendedorIds: number[] // user IDs asignados en la columna people 'Vendedor'
// Perfilamiento (alimenta las gráficas de /dash/); todos tolerantes: si la
// columna no existe en el board o viene vacía, quedan '' / null
desarrollo?: string // status 'Desarrollo'
modeloInteres?: string // status 'Modelo de Interés'
medioContacto?: string // text 'Medio de Contacto'
edad?: number | null // numbers 'Edad' (null si vacía o no numérica)
estadoCivil?: string // status 'Estado Civil'
tipoEmpleo?: string // status 'Tipo de Empleo'
credito?: string // status 'Crédito'
tipoEconomia?: string // status 'Tipo de Economía'
califica?: string // status 'Califica'
rangoSalarial?: string // text 'Rango Salarial'
loQuierePara?: string // text 'Lo Quiere Para'
}
/** Actividad del tablero Actividades (agenda de seguimiento del CRM) */
@@ -656,6 +669,13 @@ export class MondayService {
const colOrigen = cols.get('Origen')
const colTelefono = cols.get('Teléfono')
const colEmail = cols.get('Email')
// Perfilamiento: lectura tolerante por título ('' / null si falta la columna)
const textoDe = (titulo: string): string => {
const col = cols.get(titulo)
return col ? (porId.get(col.id)?.text ?? '') : ''
}
const edadRaw = textoDe('Edad')
const edadNum = Number(edadRaw)
return {
id: item.id,
nombre: item.name,
@@ -666,6 +686,17 @@ export class MondayService {
telefono: colTelefono ? (porId.get(colTelefono.id)?.text ?? '') : '',
email: colEmail ? (porId.get(colEmail.id)?.text ?? '') : '',
vendedorIds,
desarrollo: textoDe('Desarrollo'),
modeloInteres: textoDe('Modelo de Interés'),
medioContacto: textoDe('Medio de Contacto'),
edad: edadRaw.trim() === '' || Number.isNaN(edadNum) ? null : edadNum,
estadoCivil: textoDe('Estado Civil'),
tipoEmpleo: textoDe('Tipo de Empleo'),
credito: textoDe('Crédito'),
tipoEconomia: textoDe('Tipo de Economía'),
califica: textoDe('Califica'),
rangoSalarial: textoDe('Rango Salarial'),
loQuierePara: textoDe('Lo Quiere Para'),
}
}

View File

@@ -213,6 +213,52 @@ describe('construirResumen', () => {
expect(r.cobranza.planesAlCorriente).toBe(0)
expect(r.cobranza.planesConVencidos).toBe(2)
})
it('perfil: cuenta solo valores capturados (los vacíos no van a ningún bucket)', () => {
const r = construirResumen(
{
...DATOS_BASE,
leads: [
lead('1', { estadoCivil: 'Soltero', desarrollo: 'ATELIER', credito: 'Bancario', califica: 'Si' }),
lead('2', { estadoCivil: 'Soltero', desarrollo: 'ATELIER', rangoSalarial: '20-30 mil' }),
lead('3', { estadoCivil: '', desarrollo: ' ' }), // sin dato: no cuenta en ninguna dimensión
],
},
AHORA,
)
expect(r.leads.perfil.porEstadoCivil).toEqual({ Soltero: 2 })
expect(r.leads.perfil.porDesarrollo).toEqual({ ATELIER: 2 })
expect(r.leads.perfil.porCredito).toEqual({ Bancario: 1 })
expect(r.leads.perfil.porCalifica).toEqual({ Si: 1 })
expect(r.leads.perfil.porRangoSalarial).toEqual({ '20-30 mil': 1 })
// Dimensiones sin ningún dato van como objeto vacío
expect(r.leads.perfil.porModelo).toEqual({})
expect(r.leads.perfil.porTipoEmpleo).toEqual({})
expect(r.leads.perfil.porTipoEconomia).toEqual({})
expect(r.leads.perfil.porMedioContacto).toEqual({})
expect(r.leads.perfil.porLoQuierePara).toEqual({})
})
it('perfil: buckets de edad y salto de edad null', () => {
const r = construirResumen(
{
...DATOS_BASE,
leads: [
lead('1', { edad: 18 }), // 15–24
lead('2', { edad: 24 }), // 15–24
lead('3', { edad: 25 }), // 25–34
lead('4', { edad: 39 }), // 35–44
lead('5', { edad: 50 }), // 45–54
lead('6', { edad: 55 }), // 55+
lead('7', { edad: 70 }), // 55+
lead('8', { edad: null }), // sin dato: se salta
lead('9'), // edad undefined: se salta
],
},
AHORA,
)
expect(r.leads.perfil.porEdad).toEqual({ '15–24': 2, '25–34': 1, '35–44': 1, '45–54': 1, '55+': 2 })
})
})
// ---------- ReportesService (cache en memoria) ----------

View File

@@ -31,6 +31,20 @@ export interface ResumenReportes {
nuevosSemana: number // últimos 7 días naturales
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
@@ -77,6 +91,26 @@ function contarPor(items: readonly string[]): Record<string, number> {
return out
}
/** Igual que contarPor pero sin bucket '(sin dato)': los vacíos se saltan (perfilamiento) */
function contarPorNoVacios(items: readonly string[]): Record<string, number> {
const out: Record<string, number> = {}
for (const item of items) {
const clave = item.trim()
if (clave === '') continue
out[clave] = (out[clave] ?? 0) + 1
}
return out
}
/** Bucket de edad para la gráfica de perfilamiento; null (sin dato) se salta */
function bucketEdad(edad: number): string {
if (edad < 25) return '15–24'
if (edad < 35) return '25–34'
if (edad < 45) return '35–44'
if (edad < 55) return '45–54'
return '55+'
}
/** Inicio del día local de `ahora` (para "nuevos hoy") */
function inicioDelDia(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
@@ -153,6 +187,21 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor
nuevosSemana: creadosEn(hace7dias),
porEstado: contarPor(datos.leads.map((l) => l.estado)),
porOrigen: contarPor(datos.leads.map((l) => l.origen)),
perfil: {
porDesarrollo: contarPorNoVacios(datos.leads.map((l) => l.desarrollo ?? '')),
porModelo: contarPorNoVacios(datos.leads.map((l) => l.modeloInteres ?? '')),
porEstadoCivil: contarPorNoVacios(datos.leads.map((l) => l.estadoCivil ?? '')),
porTipoEmpleo: contarPorNoVacios(datos.leads.map((l) => l.tipoEmpleo ?? '')),
porTipoEconomia: contarPorNoVacios(datos.leads.map((l) => l.tipoEconomia ?? '')),
porCalifica: contarPorNoVacios(datos.leads.map((l) => l.califica ?? '')),
porCredito: contarPorNoVacios(datos.leads.map((l) => l.credito ?? '')),
porRangoSalarial: contarPorNoVacios(datos.leads.map((l) => l.rangoSalarial ?? '')),
porMedioContacto: contarPorNoVacios(datos.leads.map((l) => l.medioContacto ?? '')),
porLoQuierePara: contarPorNoVacios(datos.leads.map((l) => l.loQuierePara ?? '')),
porEdad: contarPorNoVacios(
datos.leads.filter((l) => l.edad != null).map((l) => bucketEdad(l.edad!)),
),
},
},
oportunidades: {
total: totalOps,

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">