feat(mejoras): 25 mejoras UX en todos los módulos + purga Monday

Backend: mi-dia siempre local, vocabulario canónico, AplicacionPago con usuario
Mi día: modal español real, cita desde modal, score falso eliminado
Cotizador: barra sticky, persistencia local con folio
Alta Clientes: redirect eliminado, foto conectada, asesor desde sesión, refs dinámicas
Cobranza: contrato Mora arreglado, link a Plan, abono próximo pago, WhatsApp
Landing: contactos locales, ModalContacto eliminado, ModoTrabajo eliminado
Dashboards: MiTablero unificado, funnel con descartados, drill-down, rango fechas
Chat FRASA: corpus 5 desarrollos, 6 herramientas, historial conversaciones

69 archivos, 7 apps construidas
This commit is contained in:
2026-09-11 06:52:37 +00:00
parent 075d47c304
commit 64b01246d6
69 changed files with 1600 additions and 1133 deletions

View File

@@ -12,7 +12,13 @@ export async function listarEndpoint(req: Request, res: Response): Promise<void>
const pagina = req.query.pagina ? Number(req.query.pagina) : undefined
const limite = req.query.limite ? Number(req.query.limite) : undefined
const resultado = await clienteMaestroService.listar({ busqueda, desarrollo, estatus, pagina, limite })
const resultado = await clienteMaestroService.listar({
busqueda,
desarrollo,
estatus,
...(pagina !== undefined ? { pagina } : {}),
...(limite !== undefined ? { limite } : {}),
})
res.json(resultado)
}

View File

@@ -110,7 +110,7 @@ describe('detalleContactoEndpoint (fallback local)', () => {
expect(mondayMock.getContactoDetalle).not.toHaveBeenCalled()
const detalle = json.mock.calls[0]?.[0] as Record<string, unknown>
expect(detalle['contacto']).toMatchObject({ nombre: 'ANA DEMO', Teléfono: '6649998877' })
expect(detalle['contacto']).toMatchObject({ nombre: 'ANA DEMO', telefono: '6649998877' })
expect(detalle['propiedad']).toBeNull()
expect((detalle['relaciones'] as Record<string, unknown[]>)['oportunidades']).toHaveLength(1)
expect(Array.isArray(detalle['historial'])).toBe(true)

View File

@@ -185,6 +185,7 @@ type MondayLeads = Pick<
| 'buscarLeadPorTelefono'
| 'buscarOportunidadPorTelefono'
| 'getLeads'
| 'getOportunidades'
| 'labelsDeColumna'
| 'obtenerNombreItem'
| 'columnaIdPorTitulo'

View File

@@ -12,9 +12,6 @@ const prismaMock = vi.hoisted(() => ({
leadCaptura: {
findMany: vi.fn(),
},
leadOportunidad: {
findMany: vi.fn(),
},
cliente: {
findMany: vi.fn(),
},
@@ -36,7 +33,6 @@ vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
import { resumenMiDiaEndpoint } from './mi-dia.controller.js'
import { saludMonday } from '../services/salud-monday.service.js'
const usuarioFake = {
id: 'u-1',
@@ -80,12 +76,9 @@ const reqFake = { usuarioToken: { sub: 'u-1' } } as unknown as Request
beforeEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible')
saludMonday.restablecer()
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake)
prismaMock.usuario.findMany.mockResolvedValue([])
prismaMock.leadCaptura.findMany.mockResolvedValue([])
prismaMock.leadOportunidad.findMany.mockResolvedValue([])
prismaMock.cliente.findMany.mockResolvedValue([])
prismaMock.actividadLocal.findMany.mockResolvedValue([])
mondayMock.getUsuariosWorkspace.mockResolvedValue([])
@@ -96,9 +89,8 @@ beforeEach(() => {
mondayMock.getUltimosCambios.mockResolvedValue(new Map())
})
describe('resumenMiDiaEndpoint (modo local y unión)', () => {
it('Monday caído (circuit breaker): resumen 100% local, sin llamadas a Monday', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
describe('resumenMiDiaEndpoint (siempre local, Monday purgado)', () => {
it('responde 100% desde LeadCaptura: Monday no se llama nunca', async () => {
prismaMock.leadCaptura.findMany.mockResolvedValue([
capturaFake({ leadId: 'loc_l1', board: 'LEADS', etapa: 'NUEVO', pendienteSync: true }),
capturaFake({ leadId: 'loc_o1', board: 'OPORTUNIDADES', etapa: 'Afluencia', pendienteSync: true }),
@@ -109,15 +101,16 @@ describe('resumenMiDiaEndpoint (modo local y unión)', () => {
expect(mondayMock.getLeads).not.toHaveBeenCalled()
expect(mondayMock.getOportunidades).not.toHaveBeenCalled()
expect(mondayMock.getApartados).not.toHaveBeenCalled()
expect(mondayMock.getUsuariosWorkspace).not.toHaveBeenCalled()
const resumen = json.mock.calls[0]?.[0] as Record<string, unknown>
expect((resumen["misLeads"] as unknown[]).length).toBe(1) // solo leads, no oportunidades
expect((resumen['misLeads'] as unknown[]).length).toBe(1) // solo leads, no oportunidades
expect((resumen['misOportunidades'] as { items: unknown[] }).items).toHaveLength(1)
expect(resumen['misActividadesHoy']).toEqual([])
expect(resumen['conteos']).toMatchObject({ actividadesAbiertas: 0 })
})
it('Monday se cae a media lectura (502): cae al resumen local', async () => {
mondayMock.getLeads.mockRejectedValue(new AppError(502, 'Monday no respondió'))
it('las capturas locales llegan con pendienteSync (badge ⏳ del front)', async () => {
prismaMock.leadCaptura.findMany.mockResolvedValue([
capturaFake({ leadId: 'loc_l1', board: 'LEADS', pendienteSync: true }),
])
@@ -126,41 +119,11 @@ describe('resumenMiDiaEndpoint (modo local y unión)', () => {
await resumenMiDiaEndpoint(reqFake, res)
const resumen = json.mock.calls[0]?.[0] as Record<string, unknown>
expect((resumen['misLeads'] as { id: string }[]).map((l) => l.id)).toEqual(['loc_l1'])
expect((resumen['misLeads'] as { id: string; pendienteSync?: boolean }[]).map((l) => l.id)).toEqual(['loc_l1'])
expect((resumen['misLeads'] as { pendienteSync?: boolean }[])[0]?.pendienteSync).toBe(true)
})
it('Monday vivo: unión — las capturas pendientes se agregan con pendienteSync (badge ⏳)', async () => {
mondayMock.getLeads.mockResolvedValue([
{
id: '9001',
nombre: 'Lead Monday',
createdAt: '2026-09-01T10:00:00.000Z',
updatedAt: '2026-09-02T10:00:00.000Z',
estado: 'Nuevo',
origen: 'Web',
telefono: '',
email: '',
vendedorIds: [123],
},
])
prismaMock.leadCaptura.findMany.mockResolvedValue([
capturaFake({ leadId: 'loc_n1', board: 'LEADS', etapa: 'NUEVO', pendienteSync: true, updatedAt: new Date('2026-09-05T10:00:00') }),
])
const { res, json } = resFake()
await resumenMiDiaEndpoint(reqFake, res)
const resumen = json.mock.calls[0]?.[0] as Record<string, unknown>
const leads = resumen['misLeads'] as { id: string; pendienteSync?: boolean }[]
expect(leads.map((l) => l.id)).toEqual(['loc_n1', '9001']) // el local, más reciente, primero
expect(leads[0]?.pendienteSync).toBe(true)
expect(leads[1]?.pendienteSync).toBeUndefined() // el de Monday no lleva flag
// Y la captura quedó marcada en Mis capturas
const capturas = resumen['misCapturas'] as { leadId: string; pendienteSync?: boolean; estado: string }[]
expect(capturas.find((c) => c.leadId === 'loc_n1')).toMatchObject({ pendienteSync: true, estado: 'NUEVO' })
})
it('Monday vivo y sin pendientes: respuesta idéntica a la de hoy (sin flags)', async () => {
it('sin capturas: listas vacías y sin flags de sync', async () => {
const { res, json } = resFake()
await resumenMiDiaEndpoint(reqFake, res)
@@ -170,8 +133,20 @@ describe('resumenMiDiaEndpoint (modo local y unión)', () => {
expect(JSON.stringify(resumen)).not.toContain('pendienteSync')
})
it('un error no-Monday se propaga (no se traga nada)', async () => {
mondayMock.getLeads.mockRejectedValue(new AppError(500, 'bug'))
it('los usuarios del equipo solo se consultan para roles coordinador/admin/corporativo', async () => {
await resumenMiDiaEndpoint(reqFake, resFake().res)
expect(prismaMock.usuario.findMany).not.toHaveBeenCalled()
prismaMock.usuario.findUnique.mockResolvedValue({ ...usuarioFake, rol: 'coordinador' })
await resumenMiDiaEndpoint(reqFake, resFake().res)
expect(prismaMock.usuario.findMany).toHaveBeenCalledWith({
where: { activo: true },
select: { id: true, nombre: true, rol: true, activo: true },
})
})
it('un error de BD se propaga (no se traga nada)', async () => {
prismaMock.leadCaptura.findMany.mockRejectedValue(new AppError(500, 'bug'))
await expect(resumenMiDiaEndpoint(reqFake, resFake().res)).rejects.toThrow('bug')
})
})

View File

@@ -1,18 +1,15 @@
import type { Request, Response } from 'express'
import { prisma } from '../services/prisma.js'
import { mondayService } from '../services/monday.service.js'
import { resumenMiDia, resumenMiDiaLocal, unirPendientesLocales, type ActividadLocalMiDia, type UsuarioLocal } from '../services/mi-dia.service.js'
import { saludMonday } from '../services/salud-monday.service.js'
import { esFalloMonday } from '../services/sync-monday.service.js'
import { config } from '../config/index.js'
import { resumenMiDiaLocal, type ActividadLocalMiDia, type UsuarioLocal } from '../services/mi-dia.service.js'
import { AppError } from '../utils/errors.js'
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin']
/**
* GET /api/mi-dia/resumen — agenda del vendedor logueado.
* El "mío" se resuelve por Usuario.mondayUserId (mapeo usuario↔Monday);
* sin mapeo, las listas personales van vacías con sinMapeoMonday: true.
* SIEMPRE responde desde datos locales (LeadCaptura / Cliente / ActividadLocal):
* Monday ya no participa en este endpoint. El "mío" se resuelve por el
* usuarioId de cada captura; admin/dirección ven todas.
*/
export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise<void> {
// Con AUTH_ENABLED=false (arranque temprano/tests) no hay token: se usa el admin
@@ -22,27 +19,6 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise
: await prisma.usuario.findUnique({ where: { username: 'admin' } })
if (!usuario || !usuario.activo) throw new AppError(401, 'Credenciales inválidas')
// Nombres de los usuarios ya mapeados a Monday (para la sección equipo)
const mapeados = await prisma.usuario.findMany({
where: { mondayUserId: { not: null }, activo: true },
select: { nombre: true, mondayUserId: true },
})
const nombresPorMondayId = new Map<number, string>(
mapeados.filter((m) => m.mondayUserId !== null).map((m) => [m.mondayUserId!, m.nombre]),
)
// TODOS los usuarios del workspace de Monday (el "último cambio" de un lead
// puede ser de alguien sin cuenta en el ecosistema); si ya estaba mapeado
// gana el nombre del ecosistema. Si Monday falla, el resumen sale igual.
try {
for (const u of await mondayService.getUsuariosWorkspace()) {
if (!nombresPorMondayId.has(u.id)) nombresPorMondayId.set(u.id, u.nombre)
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('No se pudieron listar los usuarios del workspace de Monday:', err)
}
// Capturas de afluencia: admin ve todas, otros solo las suyas
const esAdmin = usuario.rol === 'admin' || usuario.rol === 'direccion_general' || usuario.rol === 'director_ventas'
const capturas = await prisma.leadCaptura.findMany({
@@ -50,92 +26,40 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise
orderBy: { createdAt: 'desc' },
})
// "Lo mío" incluye las oportunidades ligadas a mis capturas (mapping
// LeadOportunidad: conversión con "Agendo" y afluencias históricas)
const mappings = await prisma.leadOportunidad.findMany({
where: { leadId: { in: capturas.map((c) => c.leadId) } },
})
const capturadosIds = new Set([
...capturas.map((c) => c.leadId),
...capturas.map((c) => c.oportunidadId).filter((id): id is string => id !== null),
...mappings.map((m) => m.oportunidadId),
])
// Altas de clientes: admin ve todas, otros solo las suyas
const clientes = await prisma.cliente.findMany({
where: esAdmin ? {} : { capturadoPorId: usuario.id },
orderBy: { createdAt: 'desc' },
})
// Actividades locales del usuario
const actividadesRaw = await prisma.actividadLocal.findMany({
where: { usuarioId: usuario.id },
orderBy: { createdAt: 'desc' },
})
const actividades: ActividadLocalMiDia[] = actividadesRaw.map((a) => ({
id: a.id,
titulo: a.titulo,
descripcion: a.descripcion,
usuarioId: a.usuarioId,
leadCapturaId: a.leadCapturaId,
fechaVencimiento: a.fechaVencimiento,
fechaCierre: a.fechaCierre,
estado: a.estado,
tipo: a.tipo,
}))
// Usuarios activos (para la sección equipo, solo coordinador/admin)
let usuarios: UsuarioLocal[] = []
if (ROLES_CON_EQUIPO.includes(usuario.rol)) {
const usuariosRaw = await prisma.usuario.findMany({
where: { activo: true },
select: { id: true, nombre: true, rol: true, activo: true },
})
usuarios = usuariosRaw.map((u) => ({ id: u.id, nombre: u.nombre, rol: u.rol, activo: u.activo }))
}
const usuarioMiDia = { nombre: usuario.nombre, rol: usuario.rol, mondayUserId: usuario.mondayUserId }
// Monday caído (circuit breaker / MODO_LOCAL_FORZADO): todo desde local
if (!saludMonday.estaDisponible()) {
// Actividades locales del usuario
const actividadesRaw = await prisma.actividadLocal.findMany({
where: { usuarioId: usuario.id },
orderBy: { createdAt: 'desc' },
})
const actividades: ActividadLocalMiDia[] = actividadesRaw.map((a) => ({
id: a.id,
titulo: a.titulo,
descripcion: a.descripcion,
usuarioId: a.usuarioId,
leadCapturaId: a.leadCapturaId,
fechaVencimiento: a.fechaVencimiento,
fechaCierre: a.fechaCierre,
estado: a.estado,
tipo: a.tipo,
}))
// Usuarios activos (para la sección equipo, solo coordinador/admin)
let usuarios: UsuarioLocal[] = []
if (ROLES_CON_EQUIPO.includes(usuario.rol)) {
const usuariosRaw = await prisma.usuario.findMany({
where: { activo: true },
select: { id: true, nombre: true, rol: true, activo: true },
})
usuarios = usuariosRaw.map((u) => ({ id: u.id, nombre: u.nombre, rol: u.rol, activo: u.activo }))
}
res.json(resumenMiDiaLocal(usuarioMiDia, capturas, clientes, new Date(), actividades, usuarios))
return
}
try {
const resumen = await resumenMiDia(
mondayService,
usuarioMiDia,
nombresPorMondayId,
capturas,
clientes,
new Date(),
config.automatizaciones.vendedorCompartidoId > 0 ? config.automatizaciones.vendedorCompartidoId : null,
capturadosIds,
)
// Unión: capturas locales aún no sincronizadas se agregan con badge ⏳
res.json(unirPendientesLocales(resumen, capturas.filter((c) => c.pendienteSync), usuario.nombre))
} catch (err) {
// Monday se cayó a media lectura: mismo resumen, pero todo local
if (!esFalloMonday(err)) throw err
const actividadesRaw = await prisma.actividadLocal.findMany({
where: { usuarioId: usuario.id },
orderBy: { createdAt: 'desc' },
})
const actividades: ActividadLocalMiDia[] = actividadesRaw.map((a) => ({
id: a.id, titulo: a.titulo, descripcion: a.descripcion,
usuarioId: a.usuarioId, leadCapturaId: a.leadCapturaId,
fechaVencimiento: a.fechaVencimiento, fechaCierre: a.fechaCierre,
estado: a.estado, tipo: a.tipo,
}))
let usuarios: UsuarioLocal[] = []
if (ROLES_CON_EQUIPO.includes(usuario.rol)) {
const usuariosRaw = await prisma.usuario.findMany({
where: { activo: true },
select: { id: true, nombre: true, rol: true, activo: true },
})
usuarios = usuariosRaw.map((u) => ({ id: u.id, nombre: u.nombre, rol: u.rol, activo: u.activo }))
}
res.json(resumenMiDiaLocal(usuarioMiDia, capturas, clientes, new Date(), actividades, usuarios))
}
res.json(resumenMiDiaLocal(usuarioMiDia, capturas, clientes, new Date(), actividades, usuarios))
}

View File

@@ -259,6 +259,8 @@ export async function registrarAbonoEndpoint(req: Request, res: Response): Promi
fechaPago: new Date(body.fechaPago),
monto: aplicacion.monto,
referencia: aplicacion.referencia,
// Quien capturó el abono: sub del JWT (null si AUTH_ENABLED=false o token sin sub)
capturadoPorId: req.usuarioToken?.sub ?? null,
},
}),
prisma.pago.update({

View File

@@ -90,7 +90,12 @@ export function crearController(svc: RecordatorioVendedorService) {
const usuarioId = req.usuarioToken?.sub
if (!usuarioId) throw new Error('Auth requerida')
const completadosStr = req.query['completados'] as string | undefined
const opts: { completados?: boolean; desde?: string | null; hasta?: string | null } = {
const opts: {
completados?: boolean
desde?: string | null
hasta?: string | null
clienteClave?: string | null
} = {
desde: (req.query['desde'] as string) ?? null,
hasta: (req.query['hasta'] as string) ?? null,
}

View File

@@ -8,9 +8,11 @@ import { resumenMora } from '../services/mora.service.js'
/** Singleton del agregador (cache en memoria de 60 s; los tests instancian el suyo) */
export const reportesService = new ReportesService(mondayService, cargarPlanesDominio)
/** GET /api/reportes/resumen — todos los KPIs del dashboard en una sola respuesta */
export async function resumenReportes(_req: Request, res: Response): Promise<void> {
res.json(await reportesService.resumen())
/** GET /api/reportes/resumen — todos los KPIs del dashboard en una sola respuesta
* ?dias=7|30|90 acota leads/oportunidades al rango (default: todo el histórico) */
export async function resumenReportes(req: Request, res: Response): Promise<void> {
const dias = Number(req.query['dias'])
res.json(await reportesService.resumen(Number.isFinite(dias) && dias > 0 ? Math.floor(dias) : undefined))
}
/** GET /api/reportes/scoring — scores de todos los leads activos */

View File

@@ -92,7 +92,7 @@ describe('actualizarVinculoMondayEndpoint (PATCH /api/usuarios/me/monday)', () =
rol: 'vendedor',
asignacion: 'MONTEBELLO',
mondayUserId: 13676,
modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat'],
modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro'],
acciones: [],
})
})