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:
@@ -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/ }))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user