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,57 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import AltaClientePage from './AltaClientePage'
import type { LeadResumen } from '../services/api'
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
const LEAD: LeadResumen = {
id: 'lead-9',
nombre: 'María Fernanda López',
telefono: '4771234567',
email: 'maria@correo.com',
estado: 'NUEVO',
origen: 'Web',
}
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>().mockImplementation((input) => {
const url = String(input)
if (url.includes('/borradores')) {
// Un borrador para que la pantalla inicial sea el picker
return Promise.resolve(
respuestaOk([{ id: 'b-1', nombre: 'Borrador previo', paso: 0, updatedAt: '2026-08-20T10:00:00Z' }]),
)
}
if (url.includes('/leads/buscar')) return Promise.resolve(respuestaOk([LEAD]))
return Promise.reject(new Error(`fetch inesperado: ${url}`))
})
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
describe('AltaClientePage — alta desde lead de Monday', () => {
it('al elegir un lead el wizard arranca con datosGenerales prellenados', async () => {
render(<AltaClientePage />)
// Picker visible con la sección de búsqueda
const busqueda = await screen.findByLabelText(/Buscar lead/)
fireEvent.change(busqueda, { target: { value: 'maria' } })
fireEvent.click(await screen.findByText('María Fernanda López'))
// Wizard paso 1 con los campos del lead ya capturados
const nombre = (await screen.findByLabelText(/Nombre completo/)) as HTMLInputElement
expect(nombre.value).toBe('MARÍA FERNANDA LÓPEZ')
expect((screen.getByLabelText(/Correo electrónico|Email/i) as HTMLInputElement).value).toBe('maria@correo.com')
expect((screen.getByLabelText(/Celular/i) as HTMLInputElement).value).toBe('4771234567')
})
})

View File

@@ -19,8 +19,11 @@ import {
obtenerCotizacion,
subirDocumentos,
type BorradorResumen,
type LeadResumen,
} from '../services/api'
import { cotizacionAPrellenado } from '../lib/cotizacionAlta'
import { leadAPrellenado } from '../lib/leadAlta'
import BusquedaLead from '../components/BusquedaLead'
/** Lee ?cotizacion=<id> del query string o del hash (#cotizacion=<id>) */
function paramCotizacion(): string | null {
@@ -82,7 +85,9 @@ export default function AltaClientePage() {
listarBorradores()
.then((lista) => {
setBorradores(lista)
setPantalla(lista.length > 0 ? 'picker' : 'wizard')
// El picker es siempre la entrada: además de los borradores ofrece
// "Empezar nuevo" y la búsqueda de leads de Monday.
setPantalla('picker')
})
.catch(() => setPantalla('wizard')) // sin conexión: el wizard funciona igual
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -103,11 +108,9 @@ export default function AltaClientePage() {
const borrarBorrador = async (id: string) => {
try {
await eliminarBorrador(id)
setBorradores((prev) => {
const restantes = prev.filter((b) => b.id !== id)
if (restantes.length === 0) setPantalla('wizard')
return restantes
})
// Al borrar el último borrador el picker sigue siendo la pantalla de
// entrada (ofrece "Empezar nuevo" y la búsqueda de leads).
setBorradores((prev) => prev.filter((b) => b.id !== id))
} catch {
setErrorCarga('No se pudo eliminar el borrador.')
}
@@ -120,6 +123,16 @@ export default function AltaClientePage() {
setPantalla('wizard')
}
// Alta nueva prellenada desde un lead de Monday (nombre, email, celular)
const empezarDesdeLead = (lead: LeadResumen) => {
reiniciar()
setBorradorId(null)
setCargoDeBorrador(false)
actualizar('datosGenerales', { ...datos.datosGenerales, ...leadAPrellenado(lead) })
actualizar('leadMondayId', lead.id)
setPantalla('wizard')
}
const guardar = async () => {
try {
const b = await guardarBorrador({ ...(borradorId ? { id: borradorId } : {}), paso, datos })
@@ -193,39 +206,43 @@ export default function AltaClientePage() {
</div>
</header>
<main className="mx-auto max-w-6xl px-4 py-16">
<h2 className="text-xs font-bold uppercase tracking-widest text-neutral-500">
Tienes {borradores.length} borrador{borradores.length === 1 ? '' : 'es'} guardado
{borradores.length === 1 ? '' : 's'}
</h2>
{borradores.length > 0 && (
<>
<h2 className="text-xs font-bold uppercase tracking-widest text-neutral-500">
Tienes {borradores.length} borrador{borradores.length === 1 ? '' : 'es'} guardado
{borradores.length === 1 ? '' : 's'}
</h2>
<ul className="mt-6 divide-y divide-neutral-200 border border-neutral-200 bg-white">
{borradores.map((b) => (
<li key={b.id} className="flex flex-wrap items-center justify-between gap-4 px-4 py-3">
<div className="min-w-0">
<p className="truncate text-sm text-neutral-900">{b.nombre}</p>
<p className="mt-1 text-xs font-light text-neutral-500">
Paso {b.paso + 1} de {PASOS.length} · {formatoFechaHora(b.updatedAt)}
</p>
</div>
<div className="flex shrink-0 gap-2">
<button
type="button"
onClick={() => void continuarBorrador(b.id)}
className="border border-accent bg-white px-3 py-1.5 text-xs font-bold uppercase tracking-widest text-accent transition-colors duration-150 hover:bg-accent hover:text-white"
>
Continuar
</button>
<button
type="button"
onClick={() => void borrarBorrador(b.id)}
className="border border-red-300 bg-white px-3 py-1.5 text-xs font-bold uppercase tracking-widest text-red-700 transition-colors duration-150 hover:bg-red-50"
>
Eliminar
</button>
</div>
</li>
))}
</ul>
</>
)}
{errorCarga && <p className="mt-4 text-xs text-red-700">{errorCarga}</p>}
<ul className="mt-6 divide-y divide-neutral-200 border border-neutral-200 bg-white">
{borradores.map((b) => (
<li key={b.id} className="flex flex-wrap items-center justify-between gap-4 px-4 py-3">
<div className="min-w-0">
<p className="truncate text-sm text-neutral-900">{b.nombre}</p>
<p className="mt-1 text-xs font-light text-neutral-500">
Paso {b.paso + 1} de {PASOS.length} · {formatoFechaHora(b.updatedAt)}
</p>
</div>
<div className="flex shrink-0 gap-2">
<button
type="button"
onClick={() => void continuarBorrador(b.id)}
className="border border-accent bg-white px-3 py-1.5 text-xs font-bold uppercase tracking-widest text-accent transition-colors duration-150 hover:bg-accent hover:text-white"
>
Continuar
</button>
<button
type="button"
onClick={() => void borrarBorrador(b.id)}
className="border border-red-300 bg-white px-3 py-1.5 text-xs font-bold uppercase tracking-widest text-red-700 transition-colors duration-150 hover:bg-red-50"
>
Eliminar
</button>
</div>
</li>
))}
</ul>
<button
type="button"
onClick={empezarNuevo}
@@ -233,6 +250,18 @@ export default function AltaClientePage() {
>
Empezar nuevo
</button>
<section aria-labelledby="titulo-lead" className="mt-12 border-t border-neutral-200 pt-8">
<h3 id="titulo-lead" className="text-xs font-bold uppercase tracking-widest text-neutral-500">
Desde un lead de Monday
</h3>
<p className="mt-2 text-xs font-light text-neutral-500">
Empieza un alta nueva con el nombre, email y celular del lead ya capturados.
</p>
<div className="mt-4">
<BusquedaLead onSeleccion={empezarDesdeLead} />
</div>
</section>
</main>
</div>
)