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:
134
apps/landing/src/components/ConfiguracionCuenta.test.tsx
Normal file
134
apps/landing/src/components/ConfiguracionCuenta.test.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { ConfiguracionCuenta } from './ConfiguracionCuenta'
|
||||
|
||||
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||
|
||||
/** Stub sencillo de localStorage (jsdom no lo expone como global con vitest 2) */
|
||||
function crearStorageStub(): Storage {
|
||||
const mapa = new Map<string, string>()
|
||||
return {
|
||||
getItem: (k: string) => mapa.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void mapa.set(k, v),
|
||||
removeItem: (k: string) => void mapa.delete(k),
|
||||
clear: () => mapa.clear(),
|
||||
key: () => null,
|
||||
get length() {
|
||||
return mapa.size
|
||||
},
|
||||
} as Storage
|
||||
}
|
||||
|
||||
const USUARIOS_MONDAY = [
|
||||
{ id: 13676, nombre: 'Erick Peña', email: 'erick@frasa.mx' },
|
||||
{ id: 20400, nombre: 'Cristina Barrera', email: 'cristina@frasa.mx' },
|
||||
]
|
||||
|
||||
const sesionFake = {
|
||||
token: 'jwt-de-prueba',
|
||||
usuario: { nombre: 'ERICK ISRAEL PEÑA RAMOS', rol: 'vendedor', asignacion: 'MONTEBELLO', mondayUserId: null },
|
||||
}
|
||||
|
||||
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>()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
vi.stubGlobal('localStorage', crearStorageStub())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('ConfiguracionCuenta sin sesión', () => {
|
||||
it('muestra el formulario de login y no llama al API', () => {
|
||||
render(<ConfiguracionCuenta />)
|
||||
expect(screen.getByRole('heading', { name: 'Configuración de cuenta' })).toBeTruthy()
|
||||
expect(screen.getByLabelText('Usuario')).toBeTruthy()
|
||||
expect(screen.getByLabelText('Contraseña')).toBeTruthy()
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('login fallido muestra el mensaje de error', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('{}', { status: 401 }))
|
||||
render(<ConfiguracionCuenta />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Usuario'), { target: { value: 'nadie' } })
|
||||
fireEvent.change(screen.getByLabelText('Contraseña'), { target: { value: 'mala1' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Entrar' }))
|
||||
|
||||
expect((await screen.findByRole('alert')).textContent).toContain('Usuario o contraseña incorrectos')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConfiguracionCuenta con sesión', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('frasa_auth', JSON.stringify(sesionFake))
|
||||
})
|
||||
|
||||
it('muestra el perfil, el dropdown de usuarios Monday y guarda el vínculo con PATCH', async () => {
|
||||
fetchMock.mockImplementation((input, init) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith('/auth/me')) return Promise.resolve(respuestaOk(sesionFake.usuario))
|
||||
if (url.endsWith('/monday/usuarios')) return Promise.resolve(respuestaOk(USUARIOS_MONDAY))
|
||||
if (url.endsWith('/usuarios/me/monday') && init?.method === 'PATCH') {
|
||||
return Promise.resolve(respuestaOk({ ...sesionFake.usuario, mondayUserId: 13676 }))
|
||||
}
|
||||
return Promise.reject(new Error(`fetch inesperado: ${url}`))
|
||||
})
|
||||
|
||||
render(<ConfiguracionCuenta />)
|
||||
|
||||
// Perfil y dropdown cargados
|
||||
expect(await screen.findByText('ERICK ISRAEL PEÑA RAMOS')).toBeTruthy()
|
||||
const dropdown = await screen.findByLabelText('Tu usuario de Monday')
|
||||
expect(screen.getByText('sin vincular')).toBeTruthy()
|
||||
|
||||
fireEvent.change(dropdown, { target: { value: '13676' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Guardar' }))
|
||||
|
||||
await waitFor(() => {
|
||||
const llamadaPatch = fetchMock.mock.calls.find(
|
||||
([url, init]) => String(url).endsWith('/usuarios/me/monday') && init?.method === 'PATCH',
|
||||
)
|
||||
expect(llamadaPatch).toBeTruthy()
|
||||
expect(JSON.parse(String(llamadaPatch?.[1]?.body))).toEqual({ mondayUserId: 13676 })
|
||||
})
|
||||
expect((await screen.findByRole('status')).textContent).toContain('Vínculo con Monday guardado')
|
||||
// El PATCH fue con Bearer
|
||||
const [, initPatch] = fetchMock.mock.calls.find(
|
||||
([url, init]) => String(url).endsWith('/usuarios/me/monday') && init?.method === 'PATCH',
|
||||
) as [string, RequestInit]
|
||||
expect((initPatch.headers as Record<string, string>)['Authorization']).toBe('Bearer jwt-de-prueba')
|
||||
})
|
||||
|
||||
it('si falla /monday/usuarios muestra error reintentable y el reintento carga la lista', async () => {
|
||||
let intentos = 0
|
||||
fetchMock.mockImplementation((input) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith('/auth/me')) return Promise.resolve(respuestaOk(sesionFake.usuario))
|
||||
if (url.endsWith('/monday/usuarios')) {
|
||||
intentos += 1
|
||||
return intentos === 1
|
||||
? Promise.resolve(new Response('{}', { status: 500 }))
|
||||
: Promise.resolve(respuestaOk(USUARIOS_MONDAY))
|
||||
}
|
||||
return Promise.reject(new Error(`fetch inesperado: ${url}`))
|
||||
})
|
||||
|
||||
render(<ConfiguracionCuenta />)
|
||||
|
||||
expect(
|
||||
await screen.findByText('No se pudo cargar la lista de usuarios de Monday.'),
|
||||
).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reintentar' }))
|
||||
expect(await screen.findByLabelText('Tu usuario de Monday')).toBeTruthy()
|
||||
expect(intentos).toBe(2)
|
||||
})
|
||||
})
|
||||
251
apps/landing/src/components/ConfiguracionCuenta.tsx
Normal file
251
apps/landing/src/components/ConfiguracionCuenta.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { ErrorLogin, getSesion, login, logout, type UsuarioSesion } from '../services/auth'
|
||||
import { getMe, getUsuariosMonday, guardarVinculoMonday, type UsuarioMonday } from '../services/api'
|
||||
|
||||
const labelEstilo = 'block text-xs font-bold tracking-widest text-neutral-500 uppercase'
|
||||
const inputEstilo =
|
||||
'mt-2 w-full border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-900 focus:border-neutral-900'
|
||||
|
||||
/**
|
||||
* Sección "Configuración de cuenta": login inline y, con sesión, el vínculo
|
||||
* con el usuario de Monday (alimenta round-robin, mis-leads y alertas).
|
||||
*/
|
||||
export function ConfiguracionCuenta() {
|
||||
const [conSesion, setConSesion] = useState(() => getSesion() !== null)
|
||||
|
||||
// ---------- Login ----------
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [entrando, setEntrando] = useState(false)
|
||||
const [errorLogin, setErrorLogin] = useState('')
|
||||
|
||||
// ---------- Perfil + usuarios Monday ----------
|
||||
const [perfil, setPerfil] = useState<UsuarioSesion | null>(null)
|
||||
const [cargandoPerfil, setCargandoPerfil] = useState(false)
|
||||
const [usuarios, setUsuarios] = useState<UsuarioMonday[] | null>(null)
|
||||
const [errorUsuarios, setErrorUsuarios] = useState('')
|
||||
const [seleccion, setSeleccion] = useState('')
|
||||
const [guardando, setGuardando] = useState(false)
|
||||
const [aviso, setAviso] = useState('')
|
||||
const [errorGuardar, setErrorGuardar] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!conSesion) return
|
||||
setCargandoPerfil(true)
|
||||
getMe()
|
||||
.then((u) => {
|
||||
setPerfil(u)
|
||||
setSeleccion(u.mondayUserId !== null ? String(u.mondayUserId) : '')
|
||||
})
|
||||
.catch(() => setErrorGuardar('No se pudo cargar tu perfil.'))
|
||||
.finally(() => setCargandoPerfil(false))
|
||||
cargarUsuarios()
|
||||
}, [conSesion])
|
||||
|
||||
const cargarUsuarios = () => {
|
||||
setErrorUsuarios('')
|
||||
getUsuariosMonday()
|
||||
.then(setUsuarios)
|
||||
.catch(() => {
|
||||
setUsuarios(null)
|
||||
setErrorUsuarios('No se pudo cargar la lista de usuarios de Monday.')
|
||||
})
|
||||
}
|
||||
|
||||
const entrar = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setEntrando(true)
|
||||
setErrorLogin('')
|
||||
try {
|
||||
await login(username, password)
|
||||
setUsername('')
|
||||
setPassword('')
|
||||
setConSesion(true)
|
||||
} catch (err) {
|
||||
setErrorLogin(err instanceof ErrorLogin ? err.message : 'No se pudo iniciar sesión')
|
||||
} finally {
|
||||
setEntrando(false)
|
||||
}
|
||||
}
|
||||
|
||||
const guardar = async (mondayUserId: number | null) => {
|
||||
setGuardando(true)
|
||||
setAviso('')
|
||||
setErrorGuardar('')
|
||||
try {
|
||||
const actualizado = await guardarVinculoMonday(mondayUserId)
|
||||
setPerfil(actualizado)
|
||||
setSeleccion(actualizado.mondayUserId !== null ? String(actualizado.mondayUserId) : '')
|
||||
setAviso(mondayUserId === null ? 'Vínculo con Monday eliminado' : 'Vínculo con Monday guardado')
|
||||
} catch {
|
||||
setErrorGuardar('No se pudo guardar el vínculo. Intenta de nuevo.')
|
||||
} finally {
|
||||
setGuardando(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cerrarSesion = () => {
|
||||
logout()
|
||||
setPerfil(null)
|
||||
setUsuarios(null)
|
||||
setConSesion(false)
|
||||
}
|
||||
|
||||
const vinculado =
|
||||
perfil?.mondayUserId != null ? (usuarios?.find((u) => u.id === perfil.mondayUserId) ?? null) : null
|
||||
|
||||
return (
|
||||
<section aria-labelledby="titulo-cuenta" className="mt-24 border-t border-neutral-200 pt-16">
|
||||
<h2 id="titulo-cuenta" className="text-xs font-bold tracking-widest text-accent uppercase">
|
||||
Configuración de cuenta
|
||||
</h2>
|
||||
|
||||
{!conSesion ? (
|
||||
<form onSubmit={(e) => void entrar(e)} className="mt-8 max-w-sm">
|
||||
<p className="text-sm leading-relaxed text-neutral-500">
|
||||
Inicia sesión para vincular tu usuario de Monday.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<label htmlFor="cuenta-username" className={labelEstilo}>
|
||||
Usuario
|
||||
</label>
|
||||
<input
|
||||
id="cuenta-username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className={inputEstilo}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<label htmlFor="cuenta-password" className={labelEstilo}>
|
||||
Contraseña
|
||||
</label>
|
||||
<input
|
||||
id="cuenta-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={inputEstilo}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{errorLogin && (
|
||||
<p role="alert" className="mt-4 text-xs text-red-700">
|
||||
{errorLogin}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={entrando}
|
||||
className="mt-6 bg-accent px-5 py-2.5 text-xs font-bold tracking-widest text-white uppercase transition-colors duration-150 hover:bg-neutral-900 disabled:opacity-50"
|
||||
>
|
||||
{entrando ? 'Entrando…' : 'Entrar'}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="mt-8 max-w-md">
|
||||
{cargandoPerfil && <p className="text-sm text-neutral-500">Cargando tu perfil…</p>}
|
||||
|
||||
{perfil && (
|
||||
<>
|
||||
<p className="text-sm text-neutral-900">
|
||||
Sesión: <span className="font-medium">{perfil.nombre}</span>
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-neutral-500">
|
||||
Vínculo Monday actual:{' '}
|
||||
{perfil.mondayUserId === null ? (
|
||||
<span className="text-neutral-400">sin vincular</span>
|
||||
) : vinculado ? (
|
||||
<span className="text-neutral-900">
|
||||
{vinculado.nombre} ({vinculado.email})
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-neutral-900">ID {perfil.mondayUserId}</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{errorUsuarios ? (
|
||||
<div className="mt-6">
|
||||
<p role="alert" className="text-xs text-red-700">
|
||||
{errorUsuarios}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cargarUsuarios}
|
||||
className="mt-3 border border-neutral-900 bg-transparent px-4 py-2 text-xs font-bold tracking-widest text-neutral-900 uppercase transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
) : usuarios === null ? (
|
||||
<p className="mt-6 text-sm text-neutral-500">Cargando usuarios de Monday…</p>
|
||||
) : (
|
||||
<div className="mt-6">
|
||||
<label htmlFor="cuenta-monday" className={labelEstilo}>
|
||||
Tu usuario de Monday
|
||||
</label>
|
||||
<select
|
||||
id="cuenta-monday"
|
||||
value={seleccion}
|
||||
onChange={(e) => setSeleccion(e.target.value)}
|
||||
className={inputEstilo}
|
||||
>
|
||||
<option value="">— Sin vincular —</option>
|
||||
{usuarios.map((u) => (
|
||||
<option key={u.id} value={String(u.id)}>
|
||||
{u.nombre} ({u.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={guardando}
|
||||
onClick={() => void guardar(seleccion === '' ? null : Number(seleccion))}
|
||||
className="bg-accent px-5 py-2.5 text-xs font-bold tracking-widest text-white uppercase transition-colors duration-150 hover:bg-neutral-900 disabled:opacity-50"
|
||||
>
|
||||
{guardando ? 'Guardando…' : 'Guardar'}
|
||||
</button>
|
||||
{perfil.mondayUserId !== null && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={guardando}
|
||||
onClick={() => void guardar(null)}
|
||||
className="border border-red-300 bg-white px-5 py-2.5 text-xs font-bold tracking-widest text-red-700 uppercase transition-colors duration-150 hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
Desvincular
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{aviso && (
|
||||
<p role="status" className="mt-4 text-xs font-bold tracking-widest text-accent uppercase">
|
||||
{aviso}
|
||||
</p>
|
||||
)}
|
||||
{errorGuardar && (
|
||||
<p role="alert" className="mt-4 text-xs text-red-700">
|
||||
{errorGuardar}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={cerrarSesion}
|
||||
className="mt-8 border border-neutral-900 bg-transparent px-4 py-2 text-xs font-bold tracking-widest text-neutral-900 uppercase transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
|
||||
>
|
||||
Cerrar sesión
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user