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:
2026-08-28 18:36:08 +00:00
parent d99d90d344
commit 1692dde887
28 changed files with 2501 additions and 41 deletions

View 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')
})
})

View 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>
)
}