feat: roles con modulos por rol + mejoras Mi dia y descarte

- Matriz de roles (vendedor, administracion_venta, director_ventas,
  escrituracion, direccion_general, admin) con modulos visibles por rol;
  auth/me y login incluyen 'modulos'; PATCH /usuarios/:id/rol (admin).
- Landing: Cobranza -> Clientes, Chat IA -> Chat Hermes, nuevo modulo
  Contactos (vista interna leyendo el tablero Contactos), grid filtrado
  por rol, rol visible en Configuracion de cuenta.
- Dashboards: tab 'Tablero comercial' y tabs filtrados por rol.
- Boton '<- Menu' en el header de todas las apps.
- Mi dia: cada lead muestra el ultimo cambio (que, quien, cuando) desde
  el activity log de Monday (normalizacion de created_at 100ns/us/ms).
- Descarte: el motivo se crea en el grupo de su categoria en el tablero
  Motivos No Compra.
This commit is contained in:
2026-09-04 09:12:01 +00:00
parent 42cfe937cf
commit f72354f775
33 changed files with 1224 additions and 114 deletions

View File

@@ -1,12 +1,19 @@
import { describe, expect, it } from 'vitest'
import {
detalleEventoUltimoCambio,
esEstadoFinalApartado,
esEstadoTerminalLead,
esEtapaTerminal,
resumenMiDia,
type UsuarioMiDia,
} from './mi-dia.service.js'
import type { ActividadMonday, ApartadoMonday, LeadMonday, OportunidadMonday } from './monday.service.js'
import type {
ActividadMonday,
ApartadoMonday,
LeadMonday,
OportunidadMonday,
UltimoCambioMonday,
} from './monday.service.js'
const MI_ID = 106736727
const OTRO_ID = 111111111
@@ -71,6 +78,8 @@ function mondayMock(datos: {
oportunidades?: OportunidadMonday[]
actividades?: ActividadMonday[]
apartados?: ApartadoMonday[]
/** boardId → (itemId → último cambio); boards de test: Leads '444', Oportunidades '555' */
ultimosCambios?: Record<string, Map<string, UltimoCambioMonday>>
}) {
return {
async getLeads() {
@@ -85,6 +94,9 @@ function mondayMock(datos: {
async getApartados() {
return datos.apartados ?? []
},
async getUltimosCambios(boardId: string, _itemIds: string[]) {
return datos.ultimosCambios?.[boardId] ?? new Map<string, UltimoCambioMonday>()
},
}
}
@@ -118,6 +130,63 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
expect(r.misLeads[0]?.telefono).toBe('4771234567')
})
it('ultimoCambio: último evento por item, agrupando ids por fuente y resolviendo el nombre', async () => {
const monday = mondayMock({
leads: [lead({ id: 'l1', updatedAt: '2026-08-28T08:00:00Z' })],
oportunidades: [oportunidad({ id: 'o1', updatedAt: '2026-08-27T08:00:00Z' })],
ultimosCambios: {
'444': new Map([['l1', { fechaISO: '2026-08-28T10:00:00.000Z', usuarioId: MI_ID, evento: 'update_status' }]]),
'555': new Map([['o1', { fechaISO: '2026-08-28T09:00:00.000Z', usuarioId: OTRO_ID, evento: 'create_pulse' }]]),
},
})
const nombres = new Map([[MI_ID, 'Martin Zavala']]) // OTRO_ID no está mapeado
const r = await resumenMiDia(monday, vendedor, nombres, [], [], AHORA)
expect(r.misLeads.find((l) => l.id === 'l1')?.ultimoCambio).toEqual({
fechaISO: '2026-08-28T10:00:00.000Z',
usuario: 'Martin Zavala',
detalle: 'Actualización',
})
// usuarioId sin nombre en el mapa → fallback null
expect(r.misLeads.find((l) => l.id === 'o1')?.ultimoCambio).toEqual({
fechaISO: '2026-08-28T09:00:00.000Z',
usuario: null,
detalle: 'Alta',
})
})
it('ultimoCambio: null cuando el item no tiene eventos en el activity log', async () => {
const monday = mondayMock({
leads: [lead({ id: 'l1' })],
ultimosCambios: { '444': new Map() }, // sin eventos
})
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
expect(r.misLeads[0]?.ultimoCambio).toBeNull()
})
it('ultimoCambio: usuarioId null (evento de automatización) → usuario null', async () => {
const monday = mondayMock({
leads: [lead({ id: 'l1' })],
ultimosCambios: {
'444': new Map([['l1', { fechaISO: '2026-08-28T10:00:00.000Z', usuarioId: null, evento: 'move_to_group' }]]),
},
})
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
expect(r.misLeads[0]?.ultimoCambio).toEqual({
fechaISO: '2026-08-28T10:00:00.000Z',
usuario: null,
detalle: 'Cambio de grupo',
})
})
it('detalleEventoUltimoCambio: humaniza los eventos conocidos y pasa el crudo si no', () => {
expect(detalleEventoUltimoCambio('create_pulse')).toBe('Alta')
expect(detalleEventoUltimoCambio('create_update')).toBe('Nota agregada')
expect(detalleEventoUltimoCambio('update_column_value')).toBe('Actualización')
expect(detalleEventoUltimoCambio('update_status')).toBe('Actualización')
expect(detalleEventoUltimoCambio('move_to_group')).toBe('Cambio de grupo')
expect(detalleEventoUltimoCambio('update_name')).toBe('update_name')
})
it('misOportunidades: conteo de MIS oportunidades por etapa (incluye terminales)', async () => {
const monday = mondayMock({
oportunidades: [