feat(afluencia): captura completa de perfil en alta de afluencia

Agrega al formulario ligero y a POST /api/leads: tipo de propiedad,
medio de contacto, edad, estado civil, tipo de empleo, ocupacion,
rango salarial, credito, tipo de economia, califica y lo quiere para.
Selects obligatorios (estado civil, tipo de empleo, tipo de economia,
califica) con las etiquetas sembradas como labels status en el tablero
Leads de Monday.
This commit is contained in:
2026-09-02 01:38:13 +00:00
parent f7070c9c35
commit 52667df041
7 changed files with 388 additions and 27 deletions

View File

@@ -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()

View File

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

View File

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

View File

@@ -37,7 +37,7 @@ describe('AltaAfluenciaForm', () => {
it('con email inválido no llama al API', () => {
render(<AltaAfluenciaForm onVolver={() => 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(<AltaAfluenciaForm onVolver={() => 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(<AltaAfluenciaForm onVolver={() => 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(<AltaAfluenciaForm onVolver={() => 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(<AltaAfluenciaForm onVolver={() => 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<string, unknown>
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(<AltaAfluenciaForm onVolver={() => 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/ }))

View File

@@ -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 (
<form onSubmit={(e) => void enviar(e)} noValidate className="max-w-md">
<div className="space-y-6">
<label htmlFor="afluencia-nombre" className="block">
<form onSubmit={(e) => void enviar(e)} noValidate className="max-w-2xl">
<div className="grid gap-6 sm:grid-cols-2">
<label htmlFor="afluencia-nombre" className="block sm:col-span-2">
<span className={labelClase}>
Nombre<span className="text-red-700"> *</span>
</span>
@@ -158,7 +196,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro
</label>
<label htmlFor="afluencia-email" className="block">
<span className={labelClase}>Email</span>
<span className={labelClase}>Correo</span>
<input
id="afluencia-email"
type="email"
@@ -169,23 +207,6 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro
{errores.email && <p className="mt-1 text-xs text-red-700">{errores.email}</p>}
</label>
<label htmlFor="afluencia-origen" className="block">
<span className={labelClase}>Origen</span>
<select
id="afluencia-origen"
value={form.origen}
onChange={cambiar('origen')}
className={inputClase}
>
<option value="">— Selecciona —</option>
{ORIGENES_LEAD.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
</label>
<label htmlFor="afluencia-desarrollo" className="block">
<span className={labelClase}>Desarrollo de interés</span>
<select
@@ -203,7 +224,183 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro
</select>
</label>
<label htmlFor="afluencia-notas" className="block">
<label htmlFor="afluencia-tipo-propiedad" className="block">
<span className={labelClase}>Departamento o casa de interés</span>
<select
id="afluencia-tipo-propiedad"
value={form.tipoPropiedad}
onChange={cambiar('tipoPropiedad')}
className={inputClase}
>
<option value="">— Selecciona —</option>
{TIPOS_PROPIEDAD_LEAD.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</label>
<label htmlFor="afluencia-medio-contacto" className="block">
<span className={labelClase}>Medio de contacto</span>
<input
id="afluencia-medio-contacto"
type="text"
value={form.medioContacto}
onChange={cambiar('medioContacto')}
className={inputClase}
/>
</label>
<label htmlFor="afluencia-origen" className="block">
<span className={labelClase}>¿Cómo se enteró de nosotros?</span>
<select
id="afluencia-origen"
value={form.origen}
onChange={cambiar('origen')}
className={inputClase}
>
<option value="">— Selecciona —</option>
{ORIGENES_LEAD.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
</label>
<label htmlFor="afluencia-edad" className="block">
<span className={labelClase}>Edad</span>
<input
id="afluencia-edad"
type="number"
min={15}
max={120}
value={form.edad}
onChange={cambiar('edad')}
className={inputClase}
/>
</label>
<label htmlFor="afluencia-estado-civil" className="block">
<span className={labelClase}>Estado civil</span>
<select
id="afluencia-estado-civil"
value={form.estadoCivil}
onChange={cambiar('estadoCivil')}
className={inputClase}
>
<option value="">— Selecciona —</option>
{ESTADOS_CIVILES.map((e) => (
<option key={e.valor} value={e.etiqueta}>
{e.etiqueta}
</option>
))}
</select>
</label>
<label htmlFor="afluencia-tipo-empleo" className="block">
<span className={labelClase}>Tipo de empleo</span>
<select
id="afluencia-tipo-empleo"
value={form.tipoEmpleo}
onChange={cambiar('tipoEmpleo')}
className={inputClase}
>
<option value="">— Selecciona —</option>
{TIPOS_EMPLEO.map((t) => (
<option key={t.valor} value={t.etiqueta}>
{t.etiqueta}
</option>
))}
</select>
</label>
<label htmlFor="afluencia-ocupacion" className="block">
<span className={labelClase}>Ocupación</span>
<input
id="afluencia-ocupacion"
type="text"
value={form.ocupacion}
onChange={cambiar('ocupacion')}
className={inputClase}
/>
</label>
<label htmlFor="afluencia-rango-salarial" className="block">
<span className={labelClase}>Rango salarial</span>
<input
id="afluencia-rango-salarial"
type="text"
value={form.rangoSalarial}
onChange={cambiar('rangoSalarial')}
className={inputClase}
/>
</label>
<label htmlFor="afluencia-credito" className="block">
<span className={labelClase}>Crédito</span>
<select
id="afluencia-credito"
value={form.credito}
onChange={cambiar('credito')}
className={inputClase}
>
<option value="">— Selecciona —</option>
{TIPOS_CREDITO.map((c) => (
<option key={c.valor} value={c.etiqueta}>
{c.etiqueta}
</option>
))}
</select>
</label>
<label htmlFor="afluencia-tipo-economia" className="block">
<span className={labelClase}>Tipo de economía</span>
<select
id="afluencia-tipo-economia"
value={form.tipoEconomia}
onChange={cambiar('tipoEconomia')}
className={inputClase}
>
<option value="">— Selecciona —</option>
{TIPOS_ECONOMIA.map((t) => (
<option key={t.valor} value={t.etiqueta}>
{t.etiqueta}
</option>
))}
</select>
</label>
<label htmlFor="afluencia-califica" className="block">
<span className={labelClase}>Califica</span>
<select
id="afluencia-califica"
value={form.califica}
onChange={cambiar('califica')}
className={inputClase}
>
<option value="">— Selecciona —</option>
{OPCIONES_CALIFICA.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</label>
<label htmlFor="afluencia-lo-quiere-para" className="block">
<span className={labelClase}>Lo quiere para</span>
<input
id="afluencia-lo-quiere-para"
type="text"
value={form.loQuierePara}
onChange={cambiar('loQuierePara')}
className={inputClase}
/>
</label>
<label htmlFor="afluencia-notas" className="block sm:col-span-2">
<span className={labelClase}>Notas</span>
<input
id="afluencia-notas"

View File

@@ -97,5 +97,9 @@ export const TAMANO_MAX_DOCUMENTO = 10 * 1024 * 1024 // 10 MB
export const ORIGENES_LEAD = ['WhatsApp', 'Web', 'Recomendado', 'Facebook', 'Otro'] as const
export const DESARROLLOS_LEAD = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'] as const
// Labels de las columnas status nuevas del tablero Leads (captura de afluencia)
export const TIPOS_PROPIEDAD_LEAD = ['Casa', 'Departamento'] as const
export const OPCIONES_CALIFICA = ['Sí', 'No', 'Por confirmar'] as const
export const ASESOR_DEFAULT = 'ESMERALDA BECERRA DOMINGUEZ'
export const AUTORIDAD_ID_DEFAULT = 'INSTITUTO NACIONAL ELECTORAL'

View File

@@ -259,6 +259,17 @@ export async function crearLead(payload: {
origen?: string
desarrollo?: string
notas?: string
tipoPropiedad?: string
medioContacto?: string
edad?: string
estadoCivil?: string
tipoEmpleo?: string
ocupacion?: string
rangoSalarial?: string
credito?: string
tipoEconomia?: string
califica?: string
loQuierePara?: string
}): Promise<ResultadoCrearLead> {
let res: Response
try {