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:
@@ -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('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user