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:
111
apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx
Normal file
111
apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
225
apps/alta-clientes/src/components/AltaAfluenciaForm.tsx
Normal file
225
apps/alta-clientes/src/components/AltaAfluenciaForm.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -93,5 +93,9 @@ export const SLOTS_DOCUMENTOS: { slot: SlotDocumento; etiqueta: string }[] = [
|
|||||||
|
|
||||||
export const TAMANO_MAX_DOCUMENTO = 10 * 1024 * 1024 // 10 MB
|
export const TAMANO_MAX_DOCUMENTO = 10 * 1024 * 1024 // 10 MB
|
||||||
|
|
||||||
|
// Catálogos del tablero Leads de Monday (labels exactos, verificados ago-2026)
|
||||||
|
export const ORIGENES_LEAD = ['WhatsApp', 'Web', 'Recomendado', 'Facebook', 'Otro'] as const
|
||||||
|
export const DESARROLLOS_LEAD = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'] as const
|
||||||
|
|
||||||
export const ASESOR_DEFAULT = 'ESMERALDA BECERRA DOMINGUEZ'
|
export const ASESOR_DEFAULT = 'ESMERALDA BECERRA DOMINGUEZ'
|
||||||
export const AUTORIDAD_ID_DEFAULT = 'INSTITUTO NACIONAL ELECTORAL'
|
export const AUTORIDAD_ID_DEFAULT = 'INSTITUTO NACIONAL ELECTORAL'
|
||||||
|
|||||||
@@ -39,11 +39,40 @@ afterEach(() => {
|
|||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('AltaClientePage — pantalla de entrada', () => {
|
||||||
|
it('muestra las dos opciones de alta', async () => {
|
||||||
|
render(<AltaClientePage />)
|
||||||
|
|
||||||
|
expect(await screen.findByRole('button', { name: /Alta Afluencia/ })).toBeTruthy()
|
||||||
|
expect(screen.getByRole('button', { name: /Alta de Cliente/ })).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('"Alta de Cliente" navega al picker (borradores + búsqueda de lead)', async () => {
|
||||||
|
render(<AltaClientePage />)
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /Alta de Cliente/ }))
|
||||||
|
|
||||||
|
expect(await screen.findByLabelText(/Buscar lead/)).toBeTruthy()
|
||||||
|
expect(screen.getByText('Borrador previo')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('"Alta Afluencia" muestra el formulario y "Volver" regresa a la entrada', async () => {
|
||||||
|
render(<AltaClientePage />)
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /Alta Afluencia/ }))
|
||||||
|
expect(await screen.findByLabelText(/^Nombre/)).toBeTruthy()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Volver' }))
|
||||||
|
expect(await screen.findByRole('button', { name: /Alta de Cliente/ })).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('AltaClientePage — alta desde lead de Monday', () => {
|
describe('AltaClientePage — alta desde lead de Monday', () => {
|
||||||
it('al elegir un lead el wizard arranca con datosGenerales prellenados', async () => {
|
it('al elegir un lead el wizard arranca con datosGenerales prellenados', async () => {
|
||||||
render(<AltaClientePage />)
|
render(<AltaClientePage />)
|
||||||
|
|
||||||
// Picker visible con la sección de búsqueda
|
// Entrada → Alta de Cliente → picker con la sección de búsqueda
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /Alta de Cliente/ }))
|
||||||
const busqueda = await screen.findByLabelText(/Buscar lead/)
|
const busqueda = await screen.findByLabelText(/Buscar lead/)
|
||||||
fireEvent.change(busqueda, { target: { value: 'maria' } })
|
fireEvent.change(busqueda, { target: { value: 'maria' } })
|
||||||
fireEvent.click(await screen.findByText('María Fernanda López'))
|
fireEvent.click(await screen.findByText('María Fernanda López'))
|
||||||
@@ -55,3 +84,36 @@ describe('AltaClientePage — alta desde lead de Monday', () => {
|
|||||||
expect((screen.getByLabelText(/Celular/i) as HTMLInputElement).value).toBe('4771234567')
|
expect((screen.getByLabelText(/Celular/i) as HTMLInputElement).value).toBe('4771234567')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('AltaClientePage — alta desde cotización (?cotizacion=<id>)', () => {
|
||||||
|
it('se salta la entrada y va directo al wizard prellenado', async () => {
|
||||||
|
window.history.pushState({}, '', '/?cotizacion=cot-1')
|
||||||
|
fetchMock.mockImplementation((input) => {
|
||||||
|
const url = String(input)
|
||||||
|
if (url.includes('/cotizaciones/cot-1')) {
|
||||||
|
return Promise.resolve(
|
||||||
|
respuestaOk({
|
||||||
|
clienteNombre: 'Cliente Prueba',
|
||||||
|
clienteEmail: 'cliente@correo.com',
|
||||||
|
clienteTelefono: '6645550000',
|
||||||
|
desarrollo: 'SENDA',
|
||||||
|
unidadCodigo: 'U-101',
|
||||||
|
precioFinal: 1500000,
|
||||||
|
enganche: 150000,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`fetch inesperado: ${url}`))
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
render(<AltaClientePage />)
|
||||||
|
|
||||||
|
// Wizard directo, sin pasar por la entrada
|
||||||
|
const nombre = (await screen.findByLabelText(/Nombre completo/)) as HTMLInputElement
|
||||||
|
expect(nombre.value).toBe('CLIENTE PRUEBA')
|
||||||
|
expect(screen.queryByRole('button', { name: /Alta Afluencia/ })).toBeNull()
|
||||||
|
} finally {
|
||||||
|
window.history.pushState({}, '', '/')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import { cotizacionAPrellenado } from '../lib/cotizacionAlta'
|
import { cotizacionAPrellenado } from '../lib/cotizacionAlta'
|
||||||
import { leadAPrellenado } from '../lib/leadAlta'
|
import { leadAPrellenado } from '../lib/leadAlta'
|
||||||
import BusquedaLead from '../components/BusquedaLead'
|
import BusquedaLead from '../components/BusquedaLead'
|
||||||
|
import AltaAfluenciaForm from '../components/AltaAfluenciaForm'
|
||||||
|
|
||||||
/** Lee ?cotizacion=<id> del query string o del hash (#cotizacion=<id>) */
|
/** Lee ?cotizacion=<id> del query string o del hash (#cotizacion=<id>) */
|
||||||
function paramCotizacion(): string | null {
|
function paramCotizacion(): string | null {
|
||||||
@@ -55,7 +56,7 @@ export default function AltaClientePage() {
|
|||||||
const [errorValidacion, setErrorValidacion] = useState<string>('')
|
const [errorValidacion, setErrorValidacion] = useState<string>('')
|
||||||
|
|
||||||
// ---------- Borradores ----------
|
// ---------- Borradores ----------
|
||||||
type Pantalla = 'cargando' | 'picker' | 'wizard'
|
type Pantalla = 'cargando' | 'entrada' | 'picker' | 'afluencia' | 'wizard'
|
||||||
const [pantalla, setPantalla] = useState<Pantalla>('cargando')
|
const [pantalla, setPantalla] = useState<Pantalla>('cargando')
|
||||||
const [borradores, setBorradores] = useState<BorradorResumen[]>([])
|
const [borradores, setBorradores] = useState<BorradorResumen[]>([])
|
||||||
const [borradorId, setBorradorId] = useState<string | null>(null)
|
const [borradorId, setBorradorId] = useState<string | null>(null)
|
||||||
@@ -68,7 +69,8 @@ export default function AltaClientePage() {
|
|||||||
const [ligaCopiada, setLigaCopiada] = useState(false)
|
const [ligaCopiada, setLigaCopiada] = useState(false)
|
||||||
const [errorLiga, setErrorLiga] = useState('')
|
const [errorLiga, setErrorLiga] = useState('')
|
||||||
|
|
||||||
// Al entrar: ?cotizacion=<id> prellena el alta; si no, ofrece borradores
|
// Al entrar: ?cotizacion=<id> prellena el alta y salta directo al wizard;
|
||||||
|
// si no, la entrada ofrece Alta Afluencia (captura ligera) y Alta de Cliente
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cotizacionId = paramCotizacion()
|
const cotizacionId = paramCotizacion()
|
||||||
if (cotizacionId) {
|
if (cotizacionId) {
|
||||||
@@ -85,11 +87,11 @@ export default function AltaClientePage() {
|
|||||||
listarBorradores()
|
listarBorradores()
|
||||||
.then((lista) => {
|
.then((lista) => {
|
||||||
setBorradores(lista)
|
setBorradores(lista)
|
||||||
// El picker es siempre la entrada: además de los borradores ofrece
|
setPantalla('entrada')
|
||||||
// "Empezar nuevo" y la búsqueda de leads de Monday.
|
|
||||||
setPantalla('picker')
|
|
||||||
})
|
})
|
||||||
.catch(() => setPantalla('wizard')) // sin conexión: el wizard funciona igual
|
// Sin conexión: la entrada no depende de los borradores y conserva la
|
||||||
|
// opción de afluencia (el alta de cliente funcionará cuando vuelva la red)
|
||||||
|
.catch(() => setPantalla('entrada'))
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -196,6 +198,59 @@ export default function AltaClientePage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pantalla === 'entrada') {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white">
|
||||||
|
<header className="border-b border-neutral-200 px-4 py-6">
|
||||||
|
<div className="mx-auto flex max-w-6xl items-baseline gap-3">
|
||||||
|
<span className="text-sm font-bold uppercase tracking-widest">FRASA</span>
|
||||||
|
<h1 className="text-2xl font-light tracking-wide">Alta de Clientes</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="mx-auto max-w-6xl px-4 py-16">
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPantalla('afluencia')}
|
||||||
|
className="border border-neutral-200 bg-white p-8 text-left transition-colors duration-150 hover:border-accent"
|
||||||
|
>
|
||||||
|
<h2 className="text-lg font-light tracking-wide text-neutral-900">Alta Afluencia</h2>
|
||||||
|
<p className="mt-2 text-xs font-light text-neutral-500">
|
||||||
|
Captura rápida del interesado; entra al embudo de ventas.
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPantalla('picker')}
|
||||||
|
className="border border-neutral-200 bg-white p-8 text-left transition-colors duration-150 hover:border-accent"
|
||||||
|
>
|
||||||
|
<h2 className="text-lg font-light tracking-wide text-neutral-900">Alta de Cliente</h2>
|
||||||
|
<p className="mt-2 text-xs font-light text-neutral-500">
|
||||||
|
Formato completo para quien aparta.
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pantalla === 'afluencia') {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white">
|
||||||
|
<header className="border-b border-neutral-200 px-4 py-6">
|
||||||
|
<div className="mx-auto flex max-w-6xl items-baseline gap-3">
|
||||||
|
<span className="text-sm font-bold uppercase tracking-widest">FRASA</span>
|
||||||
|
<h1 className="text-2xl font-light tracking-wide">Alta Afluencia</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="mx-auto max-w-6xl px-4 py-16">
|
||||||
|
<AltaAfluenciaForm onVolver={() => setPantalla('entrada')} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (pantalla === 'picker') {
|
if (pantalla === 'picker') {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-white">
|
||||||
|
|||||||
@@ -183,6 +183,57 @@ export async function buscarLeads(q: string): Promise<LeadResumen[]> {
|
|||||||
return (await res.json()) as LeadResumen[]
|
return (await res.json()) as LeadResumen[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LeadCreado {
|
||||||
|
id: string
|
||||||
|
estado: string
|
||||||
|
oportunidadId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resultado del alta de afluencia: distingue duplicado (409) de fallo de red */
|
||||||
|
export type ResultadoCrearLead =
|
||||||
|
| { ok: true; lead: LeadCreado }
|
||||||
|
| { ok: false; motivo: 'duplicado'; leadId: string; nombre: string }
|
||||||
|
| { ok: false; motivo: 'conexion' }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /leads — captura ligera de afluencia: crea el lead en Monday y su
|
||||||
|
* oportunidad en etapa Afluencia (round-robin). Si el teléfono ya existe el
|
||||||
|
* API responde 409 con el lead existente (motivo 'duplicado').
|
||||||
|
*/
|
||||||
|
export async function crearLead(payload: {
|
||||||
|
nombre: string
|
||||||
|
telefono: string
|
||||||
|
email?: string
|
||||||
|
origen?: string
|
||||||
|
desarrollo?: string
|
||||||
|
notas?: string
|
||||||
|
}): Promise<ResultadoCrearLead> {
|
||||||
|
let res: Response
|
||||||
|
try {
|
||||||
|
res = await fetch(
|
||||||
|
`${API_URL}/leads`,
|
||||||
|
conAuth({
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[api] crearLead: sin conexión al servidor', err)
|
||||||
|
return { ok: false, motivo: 'conexion' }
|
||||||
|
}
|
||||||
|
manejar401(res)
|
||||||
|
if (res.status === 201) {
|
||||||
|
return { ok: true, lead: (await res.json()) as LeadCreado }
|
||||||
|
}
|
||||||
|
if (res.status === 409) {
|
||||||
|
const cuerpo = (await res.json()) as { leadId: string; nombre: string }
|
||||||
|
return { ok: false, motivo: 'duplicado', leadId: cuerpo.leadId, nombre: cuerpo.nombre }
|
||||||
|
}
|
||||||
|
console.warn(`[api] crearLead: HTTP ${res.status}`)
|
||||||
|
return { ok: false, motivo: 'conexion' }
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Liga remota de firma (auth) ----------
|
// ---------- Liga remota de firma (auth) ----------
|
||||||
|
|
||||||
export interface LigaRemota {
|
export interface LigaRemota {
|
||||||
|
|||||||
Reference in New Issue
Block a user