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:
@@ -1,34 +1,82 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { MODULOS } from './modulos'
|
||||
import { MODULOS, esModuloVisible, type Modulo } from './modulos'
|
||||
|
||||
const porId = (id: string): Modulo => {
|
||||
const modulo = MODULOS.find((m) => m.id === id)
|
||||
if (!modulo) throw new Error(`Módulo ${id} no existe`)
|
||||
return modulo
|
||||
}
|
||||
|
||||
describe('MODULOS', () => {
|
||||
it('expone los 6 módulos del ecosistema', () => {
|
||||
expect(MODULOS).toHaveLength(6)
|
||||
expect(MODULOS.map((m) => m.id)).toEqual(['cotizador', 'alta-clientes', 'cobranza', 'agente-ia', 'tableros', 'mi-dia'])
|
||||
it('expone los 7 módulos del ecosistema', () => {
|
||||
expect(MODULOS).toHaveLength(7)
|
||||
expect(MODULOS.map((m) => m.id)).toEqual([
|
||||
'cotizador',
|
||||
'alta-clientes',
|
||||
'clientes',
|
||||
'chat-hermes',
|
||||
'tableros',
|
||||
'mi-dia',
|
||||
'contactos',
|
||||
])
|
||||
})
|
||||
|
||||
it('cada módulo tiene nombre, descripción y URL válida', () => {
|
||||
it('cada módulo tiene nombre, descripción y claves ligadas a la matriz de roles', () => {
|
||||
for (const modulo of MODULOS) {
|
||||
expect(modulo.nombre.length).toBeGreaterThan(0)
|
||||
expect(modulo.descripcion.length).toBeGreaterThan(0)
|
||||
expect(modulo.url).toMatch(/^\/[a-z]+\/$/)
|
||||
expect(modulo.claves.length).toBeGreaterThan(0)
|
||||
if (modulo.url !== undefined) expect(modulo.url).toMatch(/^\/[a-z]+\/$/)
|
||||
}
|
||||
})
|
||||
|
||||
it('apunta a los prefijos correctos (same-origin detrás de NGINX)', () => {
|
||||
const urls = Object.fromEntries(MODULOS.map((m) => [m.id, m.url]))
|
||||
expect(urls['cotizador']).toBe('/quote/')
|
||||
expect(urls['alta-clientes']).toBe('/client/')
|
||||
expect(urls['cobranza']).toBe('/pay/')
|
||||
expect(urls['agente-ia']).toBe('/ai/')
|
||||
expect(urls['tableros']).toBe('/dash/')
|
||||
expect(urls['mi-dia']).toBe('/hoy/')
|
||||
expect(porId('cotizador').url).toBe('/quote/')
|
||||
expect(porId('alta-clientes').url).toBe('/client/')
|
||||
expect(porId('clientes').url).toBe('/pay/')
|
||||
expect(porId('chat-hermes').url).toBe('/ai/')
|
||||
expect(porId('tableros').url).toBe('/dash/')
|
||||
expect(porId('mi-dia').url).toBe('/hoy/')
|
||||
})
|
||||
|
||||
it('Contactos es vista interna (sin url externa)', () => {
|
||||
expect(porId('contactos').url).toBeUndefined()
|
||||
})
|
||||
|
||||
it('liga cada módulo a la matriz de roles del API', () => {
|
||||
expect(porId('cotizador').claves).toEqual(['cotizador'])
|
||||
expect(porId('alta-clientes').claves).toEqual(['alta-cliente', 'alta-afluencia'])
|
||||
expect(porId('clientes').claves).toEqual(['cobranza'])
|
||||
expect(porId('chat-hermes').claves).toEqual(['chat'])
|
||||
expect(porId('tableros').claves).toEqual(['mi-tablero', 'tablero-comercial'])
|
||||
expect(porId('mi-dia').claves).toEqual(['mi-dia'])
|
||||
expect(porId('contactos').claves).toEqual(['contactos'])
|
||||
})
|
||||
|
||||
it('no hay ids ni URLs duplicadas', () => {
|
||||
const ids = MODULOS.map((m) => m.id)
|
||||
const urls = MODULOS.map((m) => m.url)
|
||||
const urls = MODULOS.map((m) => m.url).filter((u) => u !== undefined)
|
||||
expect(new Set(ids).size).toBe(MODULOS.length)
|
||||
expect(new Set(urls).size).toBe(MODULOS.length)
|
||||
expect(new Set(urls).size).toBe(urls.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('esModuloVisible', () => {
|
||||
it('sin sesión (null) muestra todos los módulos', () => {
|
||||
for (const modulo of MODULOS) expect(esModuloVisible(modulo, null)).toBe(true)
|
||||
})
|
||||
|
||||
it('con sesión, un módulo es visible si ALGUNA de sus claves está permitida', () => {
|
||||
const vendedor = ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat']
|
||||
expect(MODULOS.filter((m) => esModuloVisible(m, vendedor)).map((m) => m.id)).toEqual([
|
||||
'cotizador',
|
||||
'alta-clientes',
|
||||
'chat-hermes',
|
||||
'tableros', // visible por 'mi-tablero'
|
||||
'mi-dia',
|
||||
'contactos',
|
||||
])
|
||||
// 'cobranza' no está permitida → Clientes no se muestra
|
||||
expect(esModuloVisible(porId('clientes'), vendedor)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,11 @@ export interface Modulo {
|
||||
id: string
|
||||
nombre: string
|
||||
descripcion: string
|
||||
url: string
|
||||
// Claves de la matriz de roles del API (GET /auth/me → modulos): el módulo
|
||||
// es visible si ALGUNA de sus claves está permitida. Sin sesión se muestran todos.
|
||||
claves: string[]
|
||||
// Los módulos sin url abren una vista interna de la landing (no navegan fuera)
|
||||
url?: string
|
||||
}
|
||||
|
||||
// Rutas relativas: la landing se sirve desde el mismo NGINX que las apps.
|
||||
@@ -11,36 +15,58 @@ export const MODULOS: Modulo[] = [
|
||||
id: 'cotizador',
|
||||
nombre: 'Cotizador',
|
||||
descripcion: 'Propuestas comerciales y PDF en tiempo real',
|
||||
claves: ['cotizador'],
|
||||
url: '/quote/',
|
||||
},
|
||||
{
|
||||
id: 'alta-clientes',
|
||||
nombre: 'Alta de clientes',
|
||||
descripcion: 'Expediente digital, firmas y formatos PLD',
|
||||
claves: ['alta-cliente', 'alta-afluencia'],
|
||||
url: '/client/',
|
||||
},
|
||||
{
|
||||
id: 'cobranza',
|
||||
nombre: 'Cobranza',
|
||||
id: 'clientes',
|
||||
nombre: 'Clientes',
|
||||
descripcion: 'Planes de enganche, vencidos y estados de cuenta',
|
||||
claves: ['cobranza'],
|
||||
url: '/pay/',
|
||||
},
|
||||
{
|
||||
id: 'agente-ia',
|
||||
nombre: 'Chat con Hermes',
|
||||
id: 'chat-hermes',
|
||||
nombre: 'Chat Hermes',
|
||||
descripcion: 'Asistente IA con la base de conocimiento de Frasa',
|
||||
claves: ['chat'],
|
||||
url: '/ai/',
|
||||
},
|
||||
{
|
||||
id: 'tableros',
|
||||
nombre: 'Tableros',
|
||||
descripcion: 'KPIs comerciales, inventario y cobranza en tiempo real',
|
||||
claves: ['mi-tablero', 'tablero-comercial'],
|
||||
url: '/dash/',
|
||||
},
|
||||
{
|
||||
id: 'mi-dia',
|
||||
nombre: 'Mi día',
|
||||
descripcion: 'Agenda del vendedor: leads, actividades y captura rápida',
|
||||
claves: ['mi-dia'],
|
||||
url: '/hoy/',
|
||||
},
|
||||
{
|
||||
id: 'contactos',
|
||||
nombre: 'Contactos',
|
||||
descripcion: 'Directorio de contactos del CRM: teléfono, email y estado',
|
||||
claves: ['contactos'],
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* ¿El módulo se muestra en el grid? Con sesión, solo si alguna de sus claves
|
||||
* está en los módulos permitidos del rol; sin sesión se muestran todos (cada
|
||||
* app mantiene su propio login wall).
|
||||
*/
|
||||
export function esModuloVisible(modulo: Modulo, permitidos: string[] | null): boolean {
|
||||
if (permitidos === null) return true
|
||||
return modulo.claves.some((clave) => permitidos.includes(clave))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user