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

@@ -35,6 +35,29 @@ const descartarSchema = z.object({
comentario: z.string().trim().min(1).optional(),
})
// ---------- Búsqueda de leads (prellenado del alta de clientes) ----------
const LIMITE_BUSQUEDA_LEADS = 15
/** Minúsculas y sin diacríticos, para comparar nombres/teléfonos con acentos */
const sinAcentos = (s: string): string =>
s
.normalize('NFD')
.replace(/[̀-ͯ]/g, '') // diacríticos U+0300–U+036F
.toLowerCase()
/**
* Filtra leads por q en nombre/teléfono/email (case-insensitive, sin acentos),
* ordena por actualización reciente y limita a 15 resultados.
*/
export function buscarLeads(leads: LeadMonday[], q: string): LeadMonday[] {
const needle = sinAcentos(q)
return leads
.filter((l) => [l.nombre, l.telefono, l.email].some((campo) => sinAcentos(campo).includes(needle)))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.slice(0, LIMITE_BUSQUEDA_LEADS)
}
type MondayLeads = Pick<
MondayService,
| 'cambiarValorColumna'
@@ -144,3 +167,23 @@ export async function descartarLeadEndpoint(req: Request, res: Response): Promis
const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario)
res.json({ itemId, estado: 'DESCARTADO', motivo: body.motivo, motivoItemId })
}
/**
* GET /api/leads/buscar?q=texto — autocompletado para prellenar el alta de
* clientes desde un lead. q mínimo 2 caracteres; devuelve máximo 15.
*/
export async function buscarLeadsEndpoint(req: Request, res: Response): Promise<void> {
const q = String(req.query['q'] ?? '').trim()
if (q.length < 2) throw new AppError(400, 'La búsqueda requiere al menos 2 caracteres')
const encontrados = buscarLeads(await mondayService.getLeads(), q)
res.json(
encontrados.map((l) => ({
id: l.id,
nombre: l.nombre,
telefono: l.telefono,
email: l.email,
estado: l.estado,
origen: l.origen,
})),
)
}