feat: vínculo con usuario Monday en landing + alta prellenada desde leads
API: - GET /api/monday/usuarios: usuarios activos no-guest del workspace - PATCH /api/usuarios/me/monday: vincula/desvincula el mondayUserId del usuario autenticado (valida contra el workspace) - GET /api/leads/buscar?q=: búsqueda de leads por nombre/teléfono/email (sin acentos, orden updatedAt desc, límite 15) - aPublico/me ahora incluyen mondayUserId Landing (/): - Nueva sección 'Configuración de cuenta': login inline, selector del usuario de Monday, guardar/desvincular, cerrar sesión Alta de clientes (/client): - Búsqueda de leads de Monday con debounce en la pantalla de entrada; al elegir uno prellena nombre/email/celular y guarda leadMondayId - El picker (borradores + alta nueva + búsqueda de lead) es ahora siempre la pantalla de entrada Tests: api 294, landing 8, alta-clientes 96 — todos verdes
This commit is contained in:
69
apps/alta-clientes/src/components/BusquedaLead.test.tsx
Normal file
69
apps/alta-clientes/src/components/BusquedaLead.test.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import BusquedaLead from './BusquedaLead'
|
||||
import type { LeadResumen } from '../services/api'
|
||||
|
||||
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||
|
||||
const LEADS: LeadResumen[] = [
|
||||
{ id: 'lead-1', nombre: 'MARÍA FERNANDA LÓPEZ', telefono: '4771234567', email: 'maria@correo.com', estado: 'NUEVO', origen: 'Web' },
|
||||
{ id: 'lead-2', nombre: 'Juan Pérez', telefono: '', email: 'juan@correo.com', estado: 'CONTACTADO', origen: '' },
|
||||
]
|
||||
|
||||
const respuestaOk = (data: unknown) =>
|
||||
new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn<FetchFn>().mockResolvedValue(respuestaOk(LEADS))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
const llamadasBusqueda = () => fetchMock.mock.calls.filter(([url]) => String(url).includes('/leads/buscar'))
|
||||
|
||||
describe('BusquedaLead', () => {
|
||||
it('con menos de 2 caracteres no llama al API', async () => {
|
||||
render(<BusquedaLead onSeleccion={() => undefined} />)
|
||||
fireEvent.change(screen.getByLabelText(/Buscar lead/), { target: { value: 'm' } })
|
||||
await new Promise((r) => setTimeout(r, 400)) // más que el debounce
|
||||
expect(llamadasBusqueda()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renderiza los resultados de la búsqueda (nombre, teléfono, estado)', async () => {
|
||||
render(<BusquedaLead onSeleccion={() => undefined} />)
|
||||
fireEvent.change(screen.getByLabelText(/Buscar lead/), { target: { value: 'maria' } })
|
||||
|
||||
expect(await screen.findByText('MARÍA FERNANDA LÓPEZ')).toBeTruthy()
|
||||
expect(screen.getByText(/4771234567 · NUEVO/)).toBeTruthy()
|
||||
expect(screen.getByText('Juan Pérez')).toBeTruthy()
|
||||
const [url] = llamadasBusqueda()[0] as [string]
|
||||
expect(url).toContain('/leads/buscar?q=maria')
|
||||
})
|
||||
|
||||
it('al elegir un resultado avisa con el lead completo', async () => {
|
||||
const onSeleccion = vi.fn()
|
||||
render(<BusquedaLead onSeleccion={onSeleccion} />)
|
||||
fireEvent.change(screen.getByLabelText(/Buscar lead/), { target: { value: 'maria' } })
|
||||
|
||||
fireEvent.click(await screen.findByText('MARÍA FERNANDA LÓPEZ'))
|
||||
expect(onSeleccion).toHaveBeenCalledWith(LEADS[0])
|
||||
})
|
||||
|
||||
it('muestra "sin resultados" y errores de red', async () => {
|
||||
render(<BusquedaLead onSeleccion={() => undefined} />)
|
||||
|
||||
fetchMock.mockResolvedValueOnce(respuestaOk([]))
|
||||
fireEvent.change(screen.getByLabelText(/Buscar lead/), { target: { value: 'nadie' } })
|
||||
expect(await screen.findByText('Sin leads que coincidan.')).toBeTruthy()
|
||||
|
||||
fetchMock.mockRejectedValueOnce(new Error('red caída'))
|
||||
fireEvent.change(screen.getByLabelText(/Buscar lead/), { target: { value: 'otro' } })
|
||||
expect((await screen.findByRole('alert')).textContent).toContain('No se pudo buscar en Monday')
|
||||
})
|
||||
})
|
||||
92
apps/alta-clientes/src/components/BusquedaLead.tsx
Normal file
92
apps/alta-clientes/src/components/BusquedaLead.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { buscarLeads, type LeadResumen } from '../services/api'
|
||||
|
||||
const DEBOUNCE_MS = 300
|
||||
|
||||
interface Props {
|
||||
onSeleccion: (lead: LeadResumen) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Buscador con debounce de leads de Monday (GET /leads/buscar). No llama al
|
||||
* API con menos de 2 caracteres; al elegir un resultado avisa con onSeleccion.
|
||||
*/
|
||||
export default function BusquedaLead({ onSeleccion }: Props) {
|
||||
const [q, setQ] = useState('')
|
||||
const [resultados, setResultados] = useState<LeadResumen[] | null>(null)
|
||||
const [buscando, setBuscando] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const texto = q.trim()
|
||||
if (texto.length < 2) {
|
||||
setResultados(null)
|
||||
setError('')
|
||||
setBuscando(false)
|
||||
return
|
||||
}
|
||||
setBuscando(true)
|
||||
const timer = setTimeout(() => {
|
||||
buscarLeads(texto)
|
||||
.then((lista) => {
|
||||
setResultados(lista)
|
||||
setError('')
|
||||
})
|
||||
.catch(() => {
|
||||
setResultados(null)
|
||||
setError('No se pudo buscar en Monday. Intenta de nuevo.')
|
||||
})
|
||||
.finally(() => setBuscando(false))
|
||||
}, DEBOUNCE_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}, [q])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="busqueda-lead"
|
||||
className="mb-1 block text-xs font-bold uppercase tracking-widest text-neutral-500"
|
||||
>
|
||||
Buscar lead (nombre, teléfono o email)
|
||||
</label>
|
||||
<input
|
||||
id="busqueda-lead"
|
||||
type="search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Escribe al menos 2 caracteres…"
|
||||
className="w-full max-w-md border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 focus:border-neutral-900"
|
||||
/>
|
||||
|
||||
{buscando && <p className="mt-3 text-xs font-light text-neutral-500">Buscando…</p>}
|
||||
{error && (
|
||||
<p role="alert" className="mt-3 text-xs text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{!buscando && !error && resultados !== null && resultados.length === 0 && (
|
||||
<p className="mt-3 text-xs font-light text-neutral-500">Sin leads que coincidan.</p>
|
||||
)}
|
||||
|
||||
{resultados !== null && resultados.length > 0 && (
|
||||
<ul className="mt-3 max-w-md divide-y divide-neutral-200 border border-neutral-200 bg-white">
|
||||
{resultados.map((lead) => (
|
||||
<li key={lead.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSeleccion(lead)}
|
||||
className="flex w-full flex-wrap items-baseline justify-between gap-2 px-4 py-3 text-left transition-colors duration-150 hover:bg-neutral-50"
|
||||
>
|
||||
<span className="text-sm text-neutral-900">{lead.nombre}</span>
|
||||
<span className="text-xs font-light text-neutral-500">
|
||||
{lead.telefono || 'sin teléfono'}
|
||||
{lead.estado ? ` · ${lead.estado}` : ''}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user