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

@@ -838,4 +838,203 @@ describe('MondayService', () => {
expect(Object.keys(parsed).sort()).toEqual(['color_e', 'date_fa', 'date_fl', 'text_u'])
})
})
describe('getUltimosCambios (activity log por item)', () => {
const log = (id: string, event: string, createdAt: string, userId: string, pulseId: number) => ({
id,
event,
created_at: createdAt,
user_id: userId,
data: JSON.stringify({ pulse_id: pulseId }),
})
it('itemIds vacío: Map vacío sin llamar a Monday', async () => {
const llamadas = mockearFetch(() => respuestaOk({}))
const resultado = await crearServicio().getUltimosCambios('444', [])
expect(resultado.size).toBe(0)
expect(llamadas).toHaveLength(0)
})
it('una query con todos los itemIds; se queda con el evento MÁS RECIENTE por item', async () => {
const llamadas = mockearFetch(() =>
respuestaOk({
boards: [
{
activity_logs: [
log('a1', 'create_pulse', '1757000000000000', '106736727', 1),
log('a2', 'update_status', '1757001000000000', '111111111', 1),
log('a3', 'create_update', '1757000500000000', '106736727', 2),
],
},
],
}),
)
const resultado = await crearServicio().getUltimosCambios('444', ['1', '2'])
expect(llamadas).toHaveLength(1)
expect(llamadas[0]?.body.variables).toMatchObject({ ids: ['444'], itemIds: ['1', '2'], limit: 10 })
// created_at en µs (> 1e15) → se divide entre 1e6
expect(resultado.get('1')).toEqual({
fechaISO: new Date(1757001000000).toISOString(),
usuarioId: 111111111,
evento: 'update_status',
})
expect(resultado.get('2')).toEqual({
fechaISO: new Date(1757000500000).toISOString(),
usuarioId: 106736727,
evento: 'create_update',
})
})
it('created_at en ms (≤ 1e15) se usa directo; user_id vacío → null; payload sin pulse_id se ignora', async () => {
mockearFetch(() =>
respuestaOk({
boards: [
{
activity_logs: [
log('a1', 'update_column_value', '1757000000000', '', 7),
{ id: 'a2', event: 'x', created_at: '1757000000000000', user_id: '5', data: 'no-json' },
],
},
],
}),
)
const resultado = await crearServicio().getUltimosCambios('444', ['7'])
expect(resultado.size).toBe(1)
expect(resultado.get('7')).toEqual({
fechaISO: new Date(1757000000000).toISOString(),
usuarioId: null,
evento: 'update_column_value',
})
})
it('created_at de 17 dígitos (100ns, formato real de Monday) → /1e4 a ms', async () => {
mockearFetch(() =>
respuestaOk({
boards: [{ activity_logs: [log('a1', 'create_pulse', '17888408206077900', '106736727', 3)] }],
}),
)
const resultado = await crearServicio().getUltimosCambios('444', ['3'])
const fecha = resultado.get('3')?.fechaISO
expect(fecha?.slice(0, 4)).toBe('2026') // sin la regla de 17 dígitos saldría año 2558+
})
})
describe('crearMotivoNoCompra (grupo por categoría)', () => {
const COLUMNAS_MOTIVOS = [
{ id: 'color_cat', title: 'Categoria', type: 'status' },
{ id: 'text_des', title: 'Descripcion', type: 'text' },
]
function mockearBoardMotivos(): LlamadaFetch[] {
return mockearFetch((body) => {
if (body.query.includes('settings_str')) {
return respuestaOk({
boards: [
{ columns: [{ id: 'color_cat', title: 'Categoria', settings_str: '{"labels":{"1":"Precio","2":"Timing"}}' }] },
],
})
}
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_MOTIVOS }] })
return respuestaOk({ create_item: { id: 'motivo-1' } })
})
}
it('crea el item directo en el grupo de su categoría', async () => {
const llamadas = mockearBoardMotivos()
const id = await crearServicio().crearMotivoNoCompra({
nombre: 'Precio — Lead Prueba',
categoria: 'Precio',
descripcion: 'Lo encontró caro',
})
expect(id).toBe('motivo-1')
const mutacion = llamadas.find((l) => l.body.query.includes('create_item'))
expect(mutacion?.body.variables['boardId']).toBe('888') // tablero Motivos No Compra
expect(mutacion?.body.variables['groupId']).toBe('group_mm6sze0z') // grupo Precio
const parsed = JSON.parse(String(mutacion?.body.variables['columnValues'])) as Record<string, unknown>
expect(parsed['color_cat']).toEqual({ label: 'Precio' })
expect(parsed['text_des']).toBe('Lo encontró caro')
})
it('normaliza trim/case al buscar el grupo (Ubicacion sin acento)', async () => {
const llamadas = mockearBoardMotivos()
await crearServicio().crearMotivoNoCompra({ nombre: 'U — Lead', categoria: ' ubicacion ' })
const mutacion = llamadas.find((l) => l.body.query.includes('create_item'))
expect(mutacion?.body.variables['groupId']).toBe('group_mm6sqern')
})
it('categoría sin grupo mapeado: group_id null (grupo default) sin tronar', async () => {
const llamadas = mockearBoardMotivos()
const id = await crearServicio().crearMotivoNoCompra({ nombre: 'Otro — Lead', categoria: 'Otro raro' })
expect(id).toBe('motivo-1')
const mutacion = llamadas.find((l) => l.body.query.includes('create_item'))
expect(mutacion?.body.variables['groupId']).toBeNull()
})
})
describe('getContactos (módulo Contactos de la landing)', () => {
const COLUMNAS_CONTACTOS = [
{ id: 'phone_tel', title: 'Teléfono', type: 'phone' },
{ id: 'email_em', title: 'Email', type: 'email' },
{ id: 'color_est', title: 'Estado', type: 'status' },
]
const itemContacto = (id: string, updatedAt: string, estado: { text: string; value: string | null }) => ({
id,
name: `Contacto ${id}`,
updated_at: updatedAt,
column_values: [
{ id: 'phone_tel', text: `551111000${id}`, value: '{"phone":"5511110000"}' },
{ id: 'email_em', text: `c${id}@correo.mx`, value: '{"email":"c@correo.mx"}' },
{ id: 'color_est', text: estado.text, value: estado.value },
],
})
it('mapea nombre/teléfono/email/estado y ordena por updated_at desc', async () => {
mockearFetch((body) => {
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_CONTACTOS }] })
return respuestaOk({
boards: [
{
items_page: {
cursor: null,
items: [
itemContacto('1', '2026-08-01T10:00:00Z', { text: 'CLIENTE', value: '{"index":1}' }),
itemContacto('2', '2026-09-01T10:00:00Z', { text: 'PROSPECTO', value: '{"index":2}' }),
],
},
},
],
})
})
const contactos = await crearServicio().getContactos()
expect(contactos.map((c) => c.id)).toEqual(['2', '1'])
expect(contactos[0]).toEqual({
id: '2',
nombre: 'Contacto 2',
telefono: '5511110002',
email: 'c2@correo.mx',
estado: 'PROSPECTO',
updatedAt: '2026-09-01T10:00:00Z',
})
})
it('estado con value null (nunca escrito) se lee como vacío, no el default del tablero', async () => {
mockearFetch((body) => {
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_CONTACTOS }] })
return respuestaOk({
boards: [
{
items_page: {
cursor: null,
items: [itemContacto('1', '2026-09-01T10:00:00Z', { text: 'PROSPECTO', value: null })],
},
},
],
})
})
const contactos = await crearServicio().getContactos()
expect(contactos[0]?.estado).toBe('')
})
})
})