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