feat(alta): separar en Alta Afluencia y Alta de Cliente

- Nueva pantalla de entrada en /client con dos opciones:
  · Alta Afluencia: captura ligera (nombre, teléfono, email, origen,
    desarrollo de interés, notas) → POST /api/leads (lead + oportunidad
    Afluencia + round-robin, todo ya existente en el API)
  · Alta de Cliente: formato completo actual (picker + wizard intactos)
- Manejo de duplicado por teléfono (409 con nombre del lead existente)
- ?cotizacion=<id> sigue saltando directo al wizard
- Tests: 107 verdes (11 nuevos); smoke en vivo OK y limpio
This commit is contained in:
2026-09-01 20:24:26 +00:00
parent 98fedbd8d3
commit 2b87bf66a7
6 changed files with 515 additions and 7 deletions

View File

@@ -0,0 +1,111 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import AltaAfluenciaForm from './AltaAfluenciaForm'
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
const respuesta = (data: unknown, status = 201) =>
new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } })
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
beforeEach(() => {
fetchMock = vi.fn<FetchFn>()
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
const llenarRequeridos = () => {
fireEvent.change(screen.getByLabelText(/^Nombre/), { target: { value: 'Ana Demo' } })
fireEvent.change(screen.getByLabelText(/^Teléfono/), { target: { value: '6641234567' } })
}
describe('AltaAfluenciaForm', () => {
it('sin nombre ni teléfono no llama al API y muestra los errores', () => {
render(<AltaAfluenciaForm onVolver={() => undefined} />)
fireEvent.click(screen.getByRole('button', { name: /Registrar en afluencia/ }))
expect(fetchMock).not.toHaveBeenCalled()
expect(screen.getByText('El nombre es obligatorio')).toBeTruthy()
expect(screen.getByText('El teléfono es obligatorio')).toBeTruthy()
})
it('con email inválido no llama al API', () => {
render(<AltaAfluenciaForm onVolver={() => undefined} />)
llenarRequeridos()
fireEvent.change(screen.getByLabelText(/^Email/), { target: { value: 'correo-malo' } })
fireEvent.click(screen.getByRole('button', { name: /Registrar en afluencia/ }))
expect(fetchMock).not.toHaveBeenCalled()
expect(screen.getByText('Correo electrónico inválido')).toBeTruthy()
})
it('al registrar (201) muestra el mensaje de éxito', async () => {
fetchMock.mockResolvedValueOnce(respuesta({ id: 'l-1', estado: 'NUEVO', oportunidadId: 'op-1' }))
render(<AltaAfluenciaForm onVolver={() => undefined} />)
llenarRequeridos()
fireEvent.change(screen.getByLabelText(/^Origen/), { target: { value: 'Web' } })
fireEvent.change(screen.getByLabelText(/Desarrollo de interés/), { target: { value: 'SENDA' } })
fireEvent.click(screen.getByRole('button', { name: /Registrar en afluencia/ }))
expect((await screen.findByRole('status')).textContent).toContain(
'Registrado en afluencia — se asignará por round-robin',
)
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toContain('/leads')
expect(JSON.parse(String(init.body))).toMatchObject({
nombre: 'Ana Demo',
telefono: '6641234567',
origen: 'Web',
desarrollo: 'SENDA',
})
})
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),
)
render(<AltaAfluenciaForm onVolver={() => undefined} />)
llenarRequeridos()
fireEvent.click(screen.getByRole('button', { name: /Registrar en afluencia/ }))
expect((await screen.findByRole('alert')).textContent).toContain(
'Ya existe un lead con ese teléfono: ANA DEMO.',
)
})
it('con error de red muestra un mensaje reintentable', async () => {
fetchMock.mockRejectedValueOnce(new Error('red caída'))
render(<AltaAfluenciaForm onVolver={() => undefined} />)
llenarRequeridos()
fireEvent.click(screen.getByRole('button', { name: /Registrar en afluencia/ }))
expect((await screen.findByRole('alert')).textContent).toContain(
'No se pudo registrar el lead. Revisa tu conexión e intenta de nuevo.',
)
})
it('"Capturar otro" limpia el formulario tras el éxito', async () => {
fetchMock.mockResolvedValueOnce(respuesta({ id: 'l-1', estado: 'NUEVO', oportunidadId: 'op-1' }))
render(<AltaAfluenciaForm onVolver={() => undefined} />)
llenarRequeridos()
fireEvent.click(screen.getByRole('button', { name: /Registrar en afluencia/ }))
fireEvent.click(await screen.findByRole('button', { name: /Capturar otro/ }))
expect((screen.getByLabelText(/^Nombre/) as HTMLInputElement).value).toBe('')
expect((screen.getByLabelText(/^Teléfono/) as HTMLInputElement).value).toBe('')
expect(screen.queryByRole('status')).toBeNull()
})
it('"Volver" avisa a la pantalla de entrada', () => {
const onVolver = vi.fn()
render(<AltaAfluenciaForm onVolver={onVolver} />)
fireEvent.click(screen.getByRole('button', { name: 'Volver' }))
expect(onVolver).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,225 @@
import { useState, type FormEvent } from 'react'
import { z } from 'zod'
import { crearLead } from '../services/api'
import { DESARROLLOS_LEAD, ORIGENES_LEAD } from '../data/constantes'
interface Props {
onVolver: () => void
}
const emailSchema = z.string().trim().email()
const inputClase =
'w-full border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none'
const labelClase = 'mb-1 block text-xs font-bold uppercase tracking-widest text-neutral-500'
interface Errores {
nombre?: string
telefono?: string
email?: string
}
const FORM_INICIAL = { nombre: '', telefono: '', email: '', origen: '', desarrollo: '', notas: '' }
/**
* Captura ligera del interesado al entrar al embudo (POST /leads): crea el
* lead en Monday y su oportunidad en etapa Afluencia. El alta completa de
* cliente sigue siendo el wizard del picker.
*/
export default function AltaAfluenciaForm({ onVolver }: Props) {
const [form, setForm] = useState(FORM_INICIAL)
const [errores, setErrores] = useState<Errores>({})
const [enviando, setEnviando] = useState(false)
const [exito, setExito] = useState(false)
const [aviso, setAviso] = useState('')
const cambiar = (campo: keyof typeof FORM_INICIAL) => (e: { target: { value: string } }) => {
setForm((prev) => ({ ...prev, [campo]: e.target.value }))
setErrores((prev) => ({ ...prev, [campo]: undefined }))
}
const validar = (): boolean => {
const nuevos: Errores = {}
if (!form.nombre.trim()) nuevos.nombre = 'El nombre es obligatorio'
if (!form.telefono.trim()) nuevos.telefono = 'El teléfono es obligatorio'
if (form.email.trim() && !emailSchema.safeParse(form.email).success) {
nuevos.email = 'Correo electrónico inválido'
}
setErrores(nuevos)
return Object.keys(nuevos).length === 0
}
const enviar = async (e: FormEvent) => {
e.preventDefault()
setAviso('')
if (!validar()) return
setEnviando(true)
const res = await crearLead({
nombre: form.nombre.trim(),
telefono: form.telefono.trim(),
...(form.email.trim() ? { email: form.email.trim() } : {}),
...(form.origen ? { origen: form.origen } : {}),
...(form.desarrollo ? { desarrollo: form.desarrollo } : {}),
...(form.notas.trim() ? { notas: form.notas.trim() } : {}),
})
setEnviando(false)
if (res.ok) {
setExito(true)
} else if (res.motivo === 'duplicado') {
setAviso(`Ya existe un lead con ese teléfono: ${res.nombre}.`)
} else {
setAviso('No se pudo registrar el lead. Revisa tu conexión e intenta de nuevo.')
}
}
const capturarOtro = () => {
setForm(FORM_INICIAL)
setErrores({})
setAviso('')
setExito(false)
}
if (exito) {
return (
<div className="max-w-md">
<p
role="status"
className="border border-accent bg-white px-4 py-3 text-xs font-bold uppercase tracking-widest text-accent"
>
Registrado en afluencia — se asignará por round-robin
</p>
<div className="mt-6 flex gap-3">
<button
type="button"
onClick={capturarOtro}
className="bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:bg-neutral-900"
>
Capturar otro
</button>
<button
type="button"
onClick={onVolver}
className="border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
>
Volver
</button>
</div>
</div>
)
}
return (
<form onSubmit={(e) => void enviar(e)} noValidate className="max-w-md">
<div className="space-y-6">
<label htmlFor="afluencia-nombre" className="block">
<span className={labelClase}>
Nombre<span className="text-red-700"> *</span>
</span>
<input
id="afluencia-nombre"
type="text"
value={form.nombre}
onChange={cambiar('nombre')}
className={`${inputClase} ${errores.nombre ? 'border-red-400' : ''}`}
/>
{errores.nombre && <p className="mt-1 text-xs text-red-700">{errores.nombre}</p>}
</label>
<label htmlFor="afluencia-telefono" className="block">
<span className={labelClase}>
Teléfono<span className="text-red-700"> *</span>
</span>
<input
id="afluencia-telefono"
type="tel"
value={form.telefono}
onChange={cambiar('telefono')}
className={`${inputClase} ${errores.telefono ? 'border-red-400' : ''}`}
/>
{errores.telefono && <p className="mt-1 text-xs text-red-700">{errores.telefono}</p>}
</label>
<label htmlFor="afluencia-email" className="block">
<span className={labelClase}>Email</span>
<input
id="afluencia-email"
type="email"
value={form.email}
onChange={cambiar('email')}
className={`${inputClase} ${errores.email ? 'border-red-400' : ''}`}
/>
{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
id="afluencia-desarrollo"
value={form.desarrollo}
onChange={cambiar('desarrollo')}
className={inputClase}
>
<option value="">Sin especificar</option>
{DESARROLLOS_LEAD.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
</label>
<label htmlFor="afluencia-notas" className="block">
<span className={labelClase}>Notas</span>
<input
id="afluencia-notas"
type="text"
value={form.notas}
onChange={cambiar('notas')}
className={inputClase}
/>
</label>
</div>
{aviso && (
<p role="alert" className="mt-6 border border-red-300 bg-red-50 px-4 py-3 text-xs text-red-700">
{aviso}
</p>
)}
<div className="mt-8 flex gap-3">
<button
type="submit"
disabled={enviando}
className="bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:bg-neutral-900 disabled:opacity-50"
>
{enviando ? 'Registrando…' : 'Registrar en afluencia'}
</button>
<button
type="button"
onClick={onVolver}
className="border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
>
Volver
</button>
</div>
</form>
)
}