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:
57
apps/alta-clientes/src/pages/AltaClientePage.test.tsx
Normal file
57
apps/alta-clientes/src/pages/AltaClientePage.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user