From 4030faee2501797c5a5d186dae7032c635999eef Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Wed, 2 Sep 2026 02:39:19 +0000 Subject: [PATCH] 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. --- .../migration.sql | 6 + api-gateway/prisma/schema.prisma | 4 + .../clientes-progreso.controller.test.ts | 14 ++ .../controllers/clientes.controller.test.ts | 12 ++ .../src/controllers/clientes.controller.ts | 2 + .../src/controllers/mi-dia.controller.ts | 7 + .../src/services/mi-dia.service.test.ts | 77 +++++++++-- api-gateway/src/services/mi-dia.service.ts | 26 ++++ .../src/services/monday.service.test.ts | 124 ++++++++++++++++++ api-gateway/src/services/monday.service.ts | 31 +++++ .../src/services/reportes.service.test.ts | 46 +++++++ api-gateway/src/services/reportes.service.ts | 49 +++++++ .../src/components/Perfilamiento.tsx | 59 +++++++++ .../src/components/TableroGeneral.tsx | 3 + apps/dashboards/src/lib/reportes.test.ts | 36 ++++- apps/dashboards/src/lib/reportes.ts | 25 ++++ apps/mi-dia/src/lib/mi-dia.ts | 10 ++ apps/mi-dia/src/pages/MiDiaPage.tsx | 34 +++++ 18 files changed, 551 insertions(+), 14 deletions(-) create mode 100644 api-gateway/prisma/migrations/20260902120000_cliente_capturado_por/migration.sql create mode 100644 apps/dashboards/src/components/Perfilamiento.tsx diff --git a/api-gateway/prisma/migrations/20260902120000_cliente_capturado_por/migration.sql b/api-gateway/prisma/migrations/20260902120000_cliente_capturado_por/migration.sql new file mode 100644 index 0000000..6235174 --- /dev/null +++ b/api-gateway/prisma/migrations/20260902120000_cliente_capturado_por/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "Cliente" ADD COLUMN "capturadoPorId" TEXT; + +-- CreateIndex +CREATE INDEX "Cliente_capturadoPorId_idx" ON "Cliente"("capturadoPorId"); + diff --git a/api-gateway/prisma/schema.prisma b/api-gateway/prisma/schema.prisma index 6621f53..4629505 100644 --- a/api-gateway/prisma/schema.prisma +++ b/api-gateway/prisma/schema.prisma @@ -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 { diff --git a/api-gateway/src/controllers/clientes-progreso.controller.test.ts b/api-gateway/src/controllers/clientes-progreso.controller.test.ts index 58501e9..5ac0750 100644 --- a/api-gateway/src/controllers/clientes-progreso.controller.test.ts +++ b/api-gateway/src/controllers/clientes-progreso.controller.test.ts @@ -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)', () => { diff --git a/api-gateway/src/controllers/clientes.controller.test.ts b/api-gateway/src/controllers/clientes.controller.test.ts index 429d0da..1d02f35 100644 --- a/api-gateway/src/controllers/clientes.controller.test.ts +++ b/api-gateway/src/controllers/clientes.controller.test.ts @@ -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 }) }), + ) + }) }) diff --git a/api-gateway/src/controllers/clientes.controller.ts b/api-gateway/src/controllers/clientes.controller.ts index e92dbea..bbe4026 100644 --- a/api-gateway/src/controllers/clientes.controller.ts +++ b/api-gateway/src/controllers/clientes.controller.ts @@ -211,6 +211,7 @@ export async function crearCliente(req: Request, res: Response): Promise { 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 { 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', () => { diff --git a/api-gateway/src/services/mi-dia.service.ts b/api-gateway/src/services/mi-dia.service.ts index 05794cd..22ddfdd 100644 --- a/api-gateway/src/services/mi-dia.service.ts +++ b/api-gateway/src/services/mi-dia.service.ts @@ -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, /** 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 { 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 diff --git a/api-gateway/src/services/monday.service.test.ts b/api-gateway/src/services/monday.service.test.ts index 819177b..3587af7 100644 --- a/api-gateway/src/services/monday.service.test.ts +++ b/api-gateway/src/services/monday.service.test.ts @@ -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]) + }) + }) }) diff --git a/api-gateway/src/services/monday.service.ts b/api-gateway/src/services/monday.service.ts index 0c09559..796b7b6 100644 --- a/api-gateway/src/services/monday.service.ts +++ b/api-gateway/src/services/monday.service.ts @@ -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'), } } diff --git a/api-gateway/src/services/reportes.service.test.ts b/api-gateway/src/services/reportes.service.test.ts index b514ff6..60cb166 100644 --- a/api-gateway/src/services/reportes.service.test.ts +++ b/api-gateway/src/services/reportes.service.test.ts @@ -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) ---------- diff --git a/api-gateway/src/services/reportes.service.ts b/api-gateway/src/services/reportes.service.ts index 49a8dda..8895ab3 100644 --- a/api-gateway/src/services/reportes.service.ts +++ b/api-gateway/src/services/reportes.service.ts @@ -31,6 +31,20 @@ export interface ResumenReportes { nuevosSemana: number // últimos 7 días naturales porEstado: Record porOrigen: Record + /** Perfilamiento de la afluencia: solo valores capturados (sin '(sin dato)') */ + perfil: { + porDesarrollo: Record + porModelo: Record + porEstadoCivil: Record + porTipoEmpleo: Record + porTipoEconomia: Record + porCalifica: Record + porCredito: Record + porRangoSalarial: Record + porMedioContacto: Record + porLoQuierePara: Record + porEdad: Record // buckets '15–24' | '25–34' | '35–44' | '45–54' | '55+' + } } oportunidades: { total: number @@ -77,6 +91,26 @@ function contarPor(items: readonly string[]): Record { return out } +/** Igual que contarPor pero sin bucket '(sin dato)': los vacíos se saltan (perfilamiento) */ +function contarPorNoVacios(items: readonly string[]): Record { + const out: Record = {} + 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, diff --git a/apps/dashboards/src/components/Perfilamiento.tsx b/apps/dashboards/src/components/Perfilamiento.tsx new file mode 100644 index 0000000..b486281 --- /dev/null +++ b/apps/dashboards/src/components/Perfilamiento.tsx @@ -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 }[] = [ + { 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 }) { + const rebanadas = rebanadasDe(datos) + return ( +
+

{titulo}

+ {rebanadas.length === 0 ? ( + + ) : ( +
+ + + + + + + + + +
+ )} +
+ ) +} + +/** + * 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 ( +
+ Perfilamiento de la afluencia +
+ {DIMENSIONES.map((d) => ( + + ))} +
+
+ ) +} diff --git a/apps/dashboards/src/components/TableroGeneral.tsx b/apps/dashboards/src/components/TableroGeneral.tsx index 6c7155b..adc3d9d 100644 --- a/apps/dashboards/src/components/TableroGeneral.tsx +++ b/apps/dashboards/src/components/TableroGeneral.tsx @@ -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 + + {mostrarEquipo && } )} diff --git a/apps/dashboards/src/lib/reportes.test.ts b/apps/dashboards/src/lib/reportes.test.ts index d4c478c..f7899d7 100644 --- a/apps/dashboards/src/lib/reportes.test.ts +++ b/apps/dashboards/src/lib/reportes.test.ts @@ -5,6 +5,7 @@ import { datosPipeline, formatoMoneda, formatoPorcentaje, + rebanadasDe, unidadesApiladas, unidadesEnEtapa, type ResumenReportes, @@ -13,7 +14,26 @@ import { function resumen(overrides: Partial = {}): 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') diff --git a/apps/dashboards/src/lib/reportes.ts b/apps/dashboards/src/lib/reportes.ts index 232d9a5..670b457 100644 --- a/apps/dashboards/src/lib/reportes.ts +++ b/apps/dashboards/src/lib/reportes.ts @@ -12,6 +12,20 @@ export interface ResumenReportes { nuevosSemana: number porEstado: Record porOrigen: Record + /** Perfilamiento de la afluencia: solo valores capturados (sin '(sin dato)') */ + perfil: { + porDesarrollo: Record + porModelo: Record + porEstadoCivil: Record + porTipoEmpleo: Record + porTipoEconomia: Record + porCalifica: Record + porCredito: Record + porRangoSalarial: Record + porMedioContacto: Record + porLoQuierePara: Record + porEdad: Record // 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): 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 */ diff --git a/apps/mi-dia/src/lib/mi-dia.ts b/apps/mi-dia/src/lib/mi-dia.ts index 7fd775e..26b0060 100644 --- a/apps/mi-dia/src/lib/mi-dia.ts +++ b/apps/mi-dia/src/lib/mi-dia.ts @@ -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 } | null diff --git a/apps/mi-dia/src/pages/MiDiaPage.tsx b/apps/mi-dia/src/pages/MiDiaPage.tsx index 60653c6..473a99d 100644 --- a/apps/mi-dia/src/pages/MiDiaPage.tsx +++ b/apps/mi-dia/src/pages/MiDiaPage.tsx @@ -398,6 +398,40 @@ export default function MiDiaPage() { )} +
+ Mis clientes + {resumen.misClientes.length === 0 ? ( + + ) : ( +
    + {resumen.misClientes.map((c) => ( +
  • +
    +

    {c.nombre}

    +

    + {[c.proyecto, c.unidadCodigo].filter((p) => p !== '').join(' · ') || 'Alta sin proyecto'} +

    +
    +
    + + {c.completado ? 'Cliente' : 'En captura'} + + + {formatoFechaCorta(c.capturadoEn.slice(0, 10))} + +
    +
  • + ))} +
+ )} +
+
Captura rápida de lead