diff --git a/api-gateway/src/controllers/leads.controller.test.ts b/api-gateway/src/controllers/leads.controller.test.ts index 5a861b2..7bc6bb5 100644 --- a/api-gateway/src/controllers/leads.controller.test.ts +++ b/api-gateway/src/controllers/leads.controller.test.ts @@ -101,6 +101,38 @@ describe('crearLead (POST /api/leads)', () => { expect(monday.leadsCreados[0]).toMatchObject({ email: 'lead@correo.com', desarrolloLabel: 'SENDA' }) }) + it('pasa los campos de calificación al item de Monday (labels listos para el board)', async () => { + const monday = mondayMock() + const r = await crearLead(monday, { + ...datosBase, + tipoPropiedad: 'Casa', + medioContacto: 'WhatsApp', + edad: 35, + estadoCivil: 'Casado(a)', + tipoEmpleo: 'Asalariado', + ocupacion: 'Ingeniero', + rangoSalarial: '$20,000 - $30,000', + credito: 'Infonavit', + tipoEconomia: 'Economía mexicana', + califica: 'Sí', + loQuierePara: 'Vivir ahí', + }) + expect(r).toEqual({ id: 'lead-nuevo-1', oportunidadId: null }) + expect(monday.leadsCreados[0]).toMatchObject({ + tipoPropiedadLabel: 'Casa', + medioContacto: 'WhatsApp', + edad: 35, + estadoCivilLabel: 'Casado(a)', + tipoEmpleoLabel: 'Asalariado', + ocupacion: 'Ingeniero', + rangoSalarial: '$20,000 - $30,000', + creditoLabel: 'Infonavit', + tipoEconomiaLabel: 'Economía mexicana', + calificaLabel: 'Sí', + loQuierePara: 'Vivir ahí', + }) + }) + it('con funnel: asegura la oportunidad de Afluencia con los datos del lead', async () => { const monday = mondayMock() const funnel = funnelMock() diff --git a/api-gateway/src/controllers/leads.controller.ts b/api-gateway/src/controllers/leads.controller.ts index 1793cd6..28b5e45 100644 --- a/api-gateway/src/controllers/leads.controller.ts +++ b/api-gateway/src/controllers/leads.controller.ts @@ -22,6 +22,19 @@ const crearLeadSchema = z.object({ origen: z.string().trim().min(1).optional(), desarrollo: z.string().trim().min(1).optional(), notas: z.string().trim().min(1).optional(), + // Campos de calificación de la afluencia (opcionales; los labels fijos del + // front coinciden con los sembrados en Monday, no se validan contra el board) + tipoPropiedad: z.string().trim().min(1).optional(), + medioContacto: z.string().trim().min(1).optional(), + edad: z.coerce.number().int().min(15).max(120).optional(), + estadoCivil: z.string().trim().min(1).optional(), + tipoEmpleo: z.string().trim().min(1).optional(), + ocupacion: z.string().trim().min(1).optional(), + rangoSalarial: z.string().trim().min(1).optional(), + credito: z.string().trim().min(1).optional(), + tipoEconomia: z.string().trim().min(1).optional(), + califica: z.string().trim().min(1).optional(), + loQuierePara: z.string().trim().min(1).optional(), }) const cambiarEstadoSchema = z.object({ @@ -112,6 +125,17 @@ export async function crearLead( origenLabel: datos.origen, desarrolloLabel: datos.desarrollo, notas: datos.notas, + tipoPropiedadLabel: datos.tipoPropiedad, + medioContacto: datos.medioContacto, + edad: datos.edad, + estadoCivilLabel: datos.estadoCivil, + tipoEmpleoLabel: datos.tipoEmpleo, + ocupacion: datos.ocupacion, + rangoSalarial: datos.rangoSalarial, + creditoLabel: datos.credito, + tipoEconomiaLabel: datos.tipoEconomia, + calificaLabel: datos.califica, + loQuierePara: datos.loQuierePara, }) let oportunidadId: string | null = null diff --git a/api-gateway/src/services/monday.service.ts b/api-gateway/src/services/monday.service.ts index 017ad93..89b0b95 100644 --- a/api-gateway/src/services/monday.service.ts +++ b/api-gateway/src/services/monday.service.ts @@ -882,6 +882,17 @@ export class MondayService { Origen: datos.origenLabel, Desarrollo: datos.desarrolloLabel, Notas: datos.notas, + 'Tipo de Propiedad': datos.tipoPropiedadLabel, + 'Medio de Contacto': datos.medioContacto, + Edad: datos.edad, + 'Estado Civil': datos.estadoCivilLabel, + 'Tipo de Empleo': datos.tipoEmpleoLabel, + Ocupación: datos.ocupacion, + 'Rango Salarial': datos.rangoSalarial, + Crédito: datos.creditoLabel, + 'Tipo de Economía': datos.tipoEconomiaLabel, + Califica: datos.calificaLabel, + 'Lo Quiere Para': datos.loQuierePara, }) return this.crearItem(boardId, datos.nombre, columnValues) } @@ -1083,6 +1094,18 @@ export interface DatosLead { origenLabel?: string | undefined // label real de la columna status 'Origen' del board Leads desarrolloLabel?: string | undefined // label real de la columna status 'Desarrollo' del board Leads notas?: string | undefined + // Campos de calificación del interesado (labels reales de las columnas status del board Leads) + tipoPropiedadLabel?: string | undefined + medioContacto?: string | undefined + edad?: number | undefined + estadoCivilLabel?: string | undefined + tipoEmpleoLabel?: string | undefined + ocupacion?: string | undefined + rangoSalarial?: string | undefined + creditoLabel?: string | undefined + tipoEconomiaLabel?: string | undefined + calificaLabel?: string | undefined + loQuierePara?: string | undefined } /** Singleton usado por los controllers (los tests instancian el suyo) */ diff --git a/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx b/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx index 78cecdc..130a8b8 100644 --- a/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx +++ b/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx @@ -37,7 +37,7 @@ describe('AltaAfluenciaForm', () => { it('con email inválido no llama al API', () => { render( undefined} onPasarAAltaCliente={() => undefined} />) llenarRequeridos() - fireEvent.change(screen.getByLabelText(/^Email/), { target: { value: 'correo-malo' } }) + fireEvent.change(screen.getByLabelText(/^Correo/), { target: { value: 'correo-malo' } }) fireEvent.click(screen.getByRole('button', { name: /Registrar en afluencia/ })) expect(fetchMock).not.toHaveBeenCalled() @@ -48,7 +48,7 @@ describe('AltaAfluenciaForm', () => { fetchMock.mockResolvedValueOnce(respuesta({ id: 'l-1', estado: 'NUEVO', oportunidadId: 'op-1' })) render( undefined} onPasarAAltaCliente={() => undefined} />) llenarRequeridos() - fireEvent.change(screen.getByLabelText(/^Origen/), { target: { value: 'Web' } }) + fireEvent.change(screen.getByLabelText(/¿Cómo se enteró de nosotros\?/), { target: { value: 'Web' } }) fireEvent.change(screen.getByLabelText(/Desarrollo de interés/), { target: { value: 'SENDA' } }) fireEvent.click(screen.getByRole('button', { name: /Registrar en afluencia/ })) @@ -65,6 +65,76 @@ describe('AltaAfluenciaForm', () => { }) }) + it('renderiza los selects de calificación con las opciones de los catálogos', () => { + render( undefined} onPasarAAltaCliente={() => undefined} />) + + const opcionesDe = (label: RegExp) => + Array.from((screen.getByLabelText(label) as HTMLSelectElement).options).map((o) => o.value) + + expect(opcionesDe(/Departamento o casa de interés/)).toEqual(['', 'Casa', 'Departamento']) + expect(opcionesDe(/^Estado civil/)).toEqual(['', 'Soltero(a)', 'Casado(a)', 'Unión libre', 'Divorciado(a)', 'Viudo(a)']) + expect(opcionesDe(/Tipo de empleo/)).toEqual(['', 'Asalariado', 'Independiente']) + expect(opcionesDe(/^Crédito/)).toEqual([ + '', + 'Infonavit', + 'Apoyo Infonavit', + 'Cofinavit', + 'Segundo crédito', + 'Contado', + 'Fovissste', + 'Cofi-Fovissste', + 'Bancario', + ]) + expect(opcionesDe(/Tipo de economía/)).toEqual(['', 'Economía mexicana', 'Economía americana', 'Mixta']) + expect(opcionesDe(/^Califica/)).toEqual(['', 'Sí', 'No', 'Por confirmar']) + }) + + it('el payload incluye los campos de calificación capturados', async () => { + fetchMock.mockResolvedValueOnce(respuesta({ id: 'l-1', estado: 'NUEVO', oportunidadId: 'op-1' })) + render( undefined} onPasarAAltaCliente={() => undefined} />) + llenarRequeridos() + fireEvent.change(screen.getByLabelText(/Departamento o casa de interés/), { target: { value: 'Casa' } }) + fireEvent.change(screen.getByLabelText(/Medio de contacto/), { target: { value: 'Llamada' } }) + fireEvent.change(screen.getByLabelText(/^Edad/), { target: { value: '35' } }) + fireEvent.change(screen.getByLabelText(/^Estado civil/), { target: { value: 'Casado(a)' } }) + fireEvent.change(screen.getByLabelText(/Tipo de empleo/), { target: { value: 'Asalariado' } }) + fireEvent.change(screen.getByLabelText(/^Ocupación/), { target: { value: 'Ingeniero' } }) + fireEvent.change(screen.getByLabelText(/Rango salarial/), { target: { value: '$20,000 - $30,000' } }) + fireEvent.change(screen.getByLabelText(/^Crédito/), { target: { value: 'Infonavit' } }) + fireEvent.change(screen.getByLabelText(/Tipo de economía/), { target: { value: 'Economía mexicana' } }) + fireEvent.change(screen.getByLabelText(/^Califica/), { target: { value: 'Sí' } }) + fireEvent.change(screen.getByLabelText(/Lo quiere para/), { target: { value: 'Vivir ahí' } }) + fireEvent.click(screen.getByRole('button', { name: /Registrar en afluencia/ })) + + await screen.findByRole('status') + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(JSON.parse(String(init.body))).toMatchObject({ + tipoPropiedad: 'Casa', + medioContacto: 'Llamada', + edad: '35', + estadoCivil: 'Casado(a)', + tipoEmpleo: 'Asalariado', + ocupacion: 'Ingeniero', + rangoSalarial: '$20,000 - $30,000', + credito: 'Infonavit', + tipoEconomia: 'Economía mexicana', + califica: 'Sí', + loQuierePara: 'Vivir ahí', + }) + }) + + it('sin campos de calificación el payload no los incluye', async () => { + fetchMock.mockResolvedValueOnce(respuesta({ id: 'l-1', estado: 'NUEVO', oportunidadId: 'op-1' })) + render( undefined} onPasarAAltaCliente={() => undefined} />) + llenarRequeridos() + fireEvent.click(screen.getByRole('button', { name: /Registrar en afluencia/ })) + + await screen.findByRole('status') + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + const cuerpo = JSON.parse(String(init.body)) as Record + expect(cuerpo).toEqual({ nombre: 'Ana Demo', telefono: '6641234567' }) + }) + it('con 409 muestra el duplicado con el nombre del lead existente', async () => { fetchMock.mockResolvedValueOnce( respuesta({ error: 'Ya existe un lead con ese teléfono', leadId: 'l-9', nombre: 'ANA DEMO' }, 409), @@ -114,7 +184,7 @@ describe('AltaAfluenciaForm', () => { const onPasar = vi.fn() render( undefined} onPasarAAltaCliente={onPasar} />) llenarRequeridos() - fireEvent.change(screen.getByLabelText(/^Email/), { target: { value: 'ana@correo.com' } }) + fireEvent.change(screen.getByLabelText(/^Correo/), { target: { value: 'ana@correo.com' } }) fireEvent.click(screen.getByRole('button', { name: /Registrar en afluencia/ })) fireEvent.click(await screen.findByRole('button', { name: /Pasar a Alta de Cliente/ })) diff --git a/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx b/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx index 8b67923..4197121 100644 --- a/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx +++ b/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx @@ -1,7 +1,16 @@ import { useState, type FormEvent } from 'react' import { z } from 'zod' import { crearLead, type LeadResumen } from '../services/api' -import { DESARROLLOS_LEAD, ORIGENES_LEAD } from '../data/constantes' +import { + DESARROLLOS_LEAD, + ESTADOS_CIVILES, + OPCIONES_CALIFICA, + ORIGENES_LEAD, + TIPOS_CREDITO, + TIPOS_ECONOMIA, + TIPOS_EMPLEO, + TIPOS_PROPIEDAD_LEAD, +} from '../data/constantes' interface Props { onVolver: () => void @@ -23,7 +32,25 @@ interface Errores { email?: string } -const FORM_INICIAL = { nombre: '', telefono: '', email: '', origen: '', desarrollo: '', notas: '' } +const FORM_INICIAL = { + nombre: '', + telefono: '', + email: '', + origen: '', + desarrollo: '', + tipoPropiedad: '', + medioContacto: '', + edad: '', + estadoCivil: '', + tipoEmpleo: '', + ocupacion: '', + rangoSalarial: '', + credito: '', + tipoEconomia: '', + califica: '', + loQuierePara: '', + notas: '', +} /** * Captura ligera del interesado al entrar al embudo (POST /leads): crea el @@ -64,6 +91,17 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro ...(form.email.trim() ? { email: form.email.trim() } : {}), ...(form.origen ? { origen: form.origen } : {}), ...(form.desarrollo ? { desarrollo: form.desarrollo } : {}), + ...(form.tipoPropiedad ? { tipoPropiedad: form.tipoPropiedad } : {}), + ...(form.medioContacto.trim() ? { medioContacto: form.medioContacto.trim() } : {}), + ...(form.edad.trim() ? { edad: form.edad.trim() } : {}), + ...(form.estadoCivil ? { estadoCivil: form.estadoCivil } : {}), + ...(form.tipoEmpleo ? { tipoEmpleo: form.tipoEmpleo } : {}), + ...(form.ocupacion.trim() ? { ocupacion: form.ocupacion.trim() } : {}), + ...(form.rangoSalarial.trim() ? { rangoSalarial: form.rangoSalarial.trim() } : {}), + ...(form.credito ? { credito: form.credito } : {}), + ...(form.tipoEconomia ? { tipoEconomia: form.tipoEconomia } : {}), + ...(form.califica ? { califica: form.califica } : {}), + ...(form.loQuierePara.trim() ? { loQuierePara: form.loQuierePara.trim() } : {}), ...(form.notas.trim() ? { notas: form.notas.trim() } : {}), }) setEnviando(false) @@ -127,9 +165,9 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro } return ( -
void enviar(e)} noValidate className="max-w-md"> -
-