From 2b87bf66a7fbcbf7d545f647b64e223d56d7c1a2 Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Tue, 1 Sep 2026 20:24:26 +0000 Subject: [PATCH] feat(alta): separar en Alta Afluencia y Alta de Cliente MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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= sigue saltando directo al wizard - Tests: 107 verdes (11 nuevos); smoke en vivo OK y limpio --- .../src/components/AltaAfluenciaForm.test.tsx | 111 +++++++++ .../src/components/AltaAfluenciaForm.tsx | 225 ++++++++++++++++++ apps/alta-clientes/src/data/constantes.ts | 4 + .../src/pages/AltaClientePage.test.tsx | 64 ++++- .../src/pages/AltaClientePage.tsx | 67 +++++- apps/alta-clientes/src/services/api.ts | 51 ++++ 6 files changed, 515 insertions(+), 7 deletions(-) create mode 100644 apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx create mode 100644 apps/alta-clientes/src/components/AltaAfluenciaForm.tsx diff --git a/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx b/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx new file mode 100644 index 0000000..1b995c6 --- /dev/null +++ b/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx @@ -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 + +const respuesta = (data: unknown, status = 201) => + new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } }) + +let fetchMock: ReturnType> + +beforeEach(() => { + fetchMock = vi.fn() + 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( 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( 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( 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( 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( 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( 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() + fireEvent.click(screen.getByRole('button', { name: 'Volver' })) + expect(onVolver).toHaveBeenCalled() + }) +}) diff --git a/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx b/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx new file mode 100644 index 0000000..4b13b67 --- /dev/null +++ b/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx @@ -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({}) + 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 ( +
+

+ Registrado en afluencia — se asignará por round-robin +

+
+ + +
+
+ ) + } + + return ( +
void enviar(e)} noValidate className="max-w-md"> +
+ + + + + + + + + + + +
+ + {aviso && ( +

+ {aviso} +

+ )} + +
+ + +
+
+ ) +} diff --git a/apps/alta-clientes/src/data/constantes.ts b/apps/alta-clientes/src/data/constantes.ts index 3922969..ea7108e 100644 --- a/apps/alta-clientes/src/data/constantes.ts +++ b/apps/alta-clientes/src/data/constantes.ts @@ -93,5 +93,9 @@ export const SLOTS_DOCUMENTOS: { slot: SlotDocumento; etiqueta: string }[] = [ 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 AUTORIDAD_ID_DEFAULT = 'INSTITUTO NACIONAL ELECTORAL' diff --git a/apps/alta-clientes/src/pages/AltaClientePage.test.tsx b/apps/alta-clientes/src/pages/AltaClientePage.test.tsx index 524a690..c50b2ed 100644 --- a/apps/alta-clientes/src/pages/AltaClientePage.test.tsx +++ b/apps/alta-clientes/src/pages/AltaClientePage.test.tsx @@ -39,11 +39,40 @@ afterEach(() => { vi.unstubAllGlobals() }) +describe('AltaClientePage — pantalla de entrada', () => { + it('muestra las dos opciones de alta', async () => { + render() + + 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() + + 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() + + 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', () => { it('al elegir un lead el wizard arranca con datosGenerales prellenados', async () => { render() - // 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/) fireEvent.change(busqueda, { target: { value: 'maria' } }) 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') }) }) + +describe('AltaClientePage — alta desde cotización (?cotizacion=)', () => { + 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() + + // 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({}, '', '/') + } + }) +}) diff --git a/apps/alta-clientes/src/pages/AltaClientePage.tsx b/apps/alta-clientes/src/pages/AltaClientePage.tsx index 4ab1465..bd71488 100644 --- a/apps/alta-clientes/src/pages/AltaClientePage.tsx +++ b/apps/alta-clientes/src/pages/AltaClientePage.tsx @@ -24,6 +24,7 @@ import { import { cotizacionAPrellenado } from '../lib/cotizacionAlta' import { leadAPrellenado } from '../lib/leadAlta' import BusquedaLead from '../components/BusquedaLead' +import AltaAfluenciaForm from '../components/AltaAfluenciaForm' /** Lee ?cotizacion= del query string o del hash (#cotizacion=) */ function paramCotizacion(): string | null { @@ -55,7 +56,7 @@ export default function AltaClientePage() { const [errorValidacion, setErrorValidacion] = useState('') // ---------- Borradores ---------- - type Pantalla = 'cargando' | 'picker' | 'wizard' + type Pantalla = 'cargando' | 'entrada' | 'picker' | 'afluencia' | 'wizard' const [pantalla, setPantalla] = useState('cargando') const [borradores, setBorradores] = useState([]) const [borradorId, setBorradorId] = useState(null) @@ -68,7 +69,8 @@ export default function AltaClientePage() { const [ligaCopiada, setLigaCopiada] = useState(false) const [errorLiga, setErrorLiga] = useState('') - // Al entrar: ?cotizacion= prellena el alta; si no, ofrece borradores + // Al entrar: ?cotizacion= prellena el alta y salta directo al wizard; + // si no, la entrada ofrece Alta Afluencia (captura ligera) y Alta de Cliente useEffect(() => { const cotizacionId = paramCotizacion() if (cotizacionId) { @@ -85,11 +87,11 @@ export default function AltaClientePage() { listarBorradores() .then((lista) => { setBorradores(lista) - // El picker es siempre la entrada: además de los borradores ofrece - // "Empezar nuevo" y la búsqueda de leads de Monday. - setPantalla('picker') + setPantalla('entrada') }) - .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 }, []) @@ -196,6 +198,59 @@ export default function AltaClientePage() { ) } + if (pantalla === 'entrada') { + return ( +
+
+
+ FRASA +

Alta de Clientes

+
+
+
+
+ + +
+
+
+ ) + } + + if (pantalla === 'afluencia') { + return ( +
+
+
+ FRASA +

Alta Afluencia

+
+
+
+ setPantalla('entrada')} /> +
+
+ ) + } + if (pantalla === 'picker') { return (
diff --git a/apps/alta-clientes/src/services/api.ts b/apps/alta-clientes/src/services/api.ts index e90825c..a874589 100644 --- a/apps/alta-clientes/src/services/api.ts +++ b/apps/alta-clientes/src/services/api.ts @@ -183,6 +183,57 @@ export async function buscarLeads(q: string): Promise { 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 { + 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) ---------- export interface LigaRemota {