From f72354f77585b4361ae7bb18be51442f81935c4c Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Fri, 4 Sep 2026 09:12:01 +0000 Subject: [PATCH] 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. --- api-gateway/src/config/roles.test.ts | 67 ++++++ api-gateway/src/config/roles.ts | 69 ++++++ .../controllers/contactos.controller.test.ts | 47 +++++ .../src/controllers/contactos.controller.ts | 10 + .../src/controllers/mi-dia.controller.ts | 12 ++ .../controllers/usuarios.controller.test.ts | 56 +++++ .../src/controllers/usuarios.controller.ts | 27 ++- api-gateway/src/routes/index.ts | 5 + api-gateway/src/services/auth.service.test.ts | 7 + api-gateway/src/services/auth.service.ts | 4 + .../src/services/mi-dia.service.test.ts | 71 ++++++- api-gateway/src/services/mi-dia.service.ts | 52 ++++- .../src/services/monday.service.test.ts | 199 ++++++++++++++++++ api-gateway/src/services/monday.service.ts | 168 ++++++++++++++- apps/agente-ia/src/pages/ChatPage.tsx | 18 +- .../src/components/AppHeader.tsx | 6 + apps/cobranza/index.html | 2 +- apps/cobranza/public/manifest.webmanifest | 4 +- apps/cobranza/src/components/Layout.tsx | 18 +- apps/cotizador/src/components/AuthGate.tsx | 20 +- apps/dashboards/src/pages/DashboardPage.tsx | 115 ++++++---- apps/dashboards/src/services/auth.ts | 11 + apps/landing/src/App.tsx | 51 ++++- .../components/ConfiguracionCuenta.test.tsx | 9 +- .../src/components/ConfiguracionCuenta.tsx | 9 + apps/landing/src/components/TarjetaModulo.tsx | 52 +++-- .../landing/src/components/VistaContactos.tsx | 79 +++++++ apps/landing/src/data/modulos.test.ts | 78 +++++-- apps/landing/src/data/modulos.ts | 36 +++- apps/landing/src/services/api.ts | 19 +- apps/landing/src/services/auth.ts | 3 + apps/mi-dia/src/lib/mi-dia.ts | 2 + apps/mi-dia/src/pages/MiDiaPage.tsx | 12 ++ 33 files changed, 1224 insertions(+), 114 deletions(-) create mode 100644 api-gateway/src/config/roles.test.ts create mode 100644 api-gateway/src/config/roles.ts create mode 100644 api-gateway/src/controllers/contactos.controller.test.ts create mode 100644 api-gateway/src/controllers/contactos.controller.ts create mode 100644 apps/landing/src/components/VistaContactos.tsx diff --git a/api-gateway/src/config/roles.test.ts b/api-gateway/src/config/roles.test.ts new file mode 100644 index 0000000..57fa470 --- /dev/null +++ b/api-gateway/src/config/roles.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { MODULOS, esRolValido, modulosDeRol } from './roles.js' + +const ESPERADOS: Record = { + vendedor: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat'], + administracion_venta: [ + 'mi-dia', + 'alta-cliente', + 'alta-afluencia', + 'cotizador', + 'mi-tablero', + 'contactos', + 'chat', + 'cobranza', + ], + director_ventas: [ + 'mi-dia', + 'alta-cliente', + 'alta-afluencia', + 'cotizador', + 'mi-tablero', + 'contactos', + 'chat', + 'cobranza', + 'tablero-comercial', + ], + escrituracion: [ + 'mi-dia', + 'alta-cliente', + 'alta-afluencia', + 'cotizador', + 'mi-tablero', + 'contactos', + 'chat', + 'cobranza', + ], + direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat'], + admin: [...MODULOS], +} + +describe('modulosDeRol', () => { + it.each(Object.entries(ESPERADOS))('rol %s devuelve exactamente su lista', (rol, esperados) => { + expect(modulosDeRol(rol)).toEqual(esperados) + }) + + it('roles legacy (coordinador, corporativo) se tratan como director_ventas', () => { + expect(modulosDeRol('coordinador')).toEqual(ESPERADOS['director_ventas']) + expect(modulosDeRol('corporativo')).toEqual(ESPERADOS['director_ventas']) + }) + + it('rol desconocido cae al fallback seguro (vendedor)', () => { + expect(modulosDeRol('otro')).toEqual(ESPERADOS['vendedor']) + expect(modulosDeRol('')).toEqual(ESPERADOS['vendedor']) + }) + + it('tolera mayúsculas y espacios', () => { + expect(modulosDeRol(' Admin ')).toEqual([...MODULOS]) + }) +}) + +describe('esRolValido', () => { + it('acepta solo los roles de la matriz nueva', () => { + for (const rol of Object.keys(ESPERADOS)) expect(esRolValido(rol)).toBe(true) + expect(esRolValido('coordinador')).toBe(false) + expect(esRolValido('vendedorx')).toBe(false) + }) +}) diff --git a/api-gateway/src/config/roles.ts b/api-gateway/src/config/roles.ts new file mode 100644 index 0000000..d594627 --- /dev/null +++ b/api-gateway/src/config/roles.ts @@ -0,0 +1,69 @@ +// Matriz de módulos visibles por rol (definida con el cliente, sep-2026). +// Es la fuente de verdad del ecosistema: GET /auth/me la expone como +// `modulos` y las apps filtran su navegación con ella. + +export const MODULOS = [ + 'mi-dia', + 'alta-cliente', + 'alta-afluencia', + 'cotizador', + 'mi-tablero', + 'tablero-comercial', + 'cobranza', + 'contactos', + 'chat', +] as const +export type Modulo = (typeof MODULOS)[number] + +export const ROLES = [ + 'vendedor', + 'administracion_venta', + 'director_ventas', + 'escrituracion', + 'direccion_general', + 'admin', +] as const +export type Rol = (typeof ROLES)[number] + +const MODULOS_VENDEDOR: readonly Modulo[] = [ + 'mi-dia', + 'alta-cliente', + 'alta-afluencia', + 'cotizador', + 'mi-tablero', + 'contactos', + 'chat', +] + +const MATRIZ: Record = { + vendedor: MODULOS_VENDEDOR, + administracion_venta: [...MODULOS_VENDEDOR, 'cobranza'], + director_ventas: [...MODULOS_VENDEDOR, 'cobranza', 'tablero-comercial'], + escrituracion: [...MODULOS_VENDEDOR, 'cobranza'], + direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat'], + admin: MODULOS, +} + +// Roles que ya existen en la BD pero no en la matriz nueva: se homologan a +// director_ventas (su equivalente funcional en la operación actual). +const ROLES_LEGACY: Record = { + coordinador: 'director_ventas', + corporativo: 'director_ventas', +} + +/** + * Módulos visibles para un rol. Roles legacy ('coordinador', 'corporativo') se + * tratan como director_ventas; un rol desconocido cae a vendedor (fallback + * seguro: es el conjunto más chico de módulos operativos). + */ +export function modulosDeRol(rol: string): Modulo[] { + const clave = rol.trim().toLowerCase() + const rolEfectivo: Rol = + (ROLES as readonly string[]).includes(clave) ? (clave as Rol) : (ROLES_LEGACY[clave] ?? 'vendedor') + return [...MATRIZ[rolEfectivo]] +} + +/** ¿Es un rol válido de la matriz nueva? (para PATCH /usuarios/:id/rol) */ +export function esRolValido(rol: string): boolean { + return (ROLES as readonly string[]).includes(rol) +} diff --git a/api-gateway/src/controllers/contactos.controller.test.ts b/api-gateway/src/controllers/contactos.controller.test.ts new file mode 100644 index 0000000..21b8bc9 --- /dev/null +++ b/api-gateway/src/controllers/contactos.controller.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Request, Response } from 'express' +import { listarContactosEndpoint } from './contactos.controller.js' + +// ---------- Monday mockeado (los tests no tocan la red) ---------- + +const mondayMock = vi.hoisted(() => ({ getContactos: vi.fn() })) + +vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock })) + +const CONTACTOS = [ + { + id: '1', + nombre: 'JUAN PÉREZ', + telefono: '5512345678', + email: 'juan@correo.mx', + estado: 'CLIENTE', + updatedAt: '2026-09-01T10:00:00Z', + }, +] + +function resFake() { + const json = vi.fn() + const status = vi.fn().mockReturnValue({ json }) + return { res: { json, status } as unknown as Response, status, json } +} + +beforeEach(() => { + vi.clearAllMocks() + mondayMock.getContactos.mockResolvedValue(CONTACTOS) +}) + +describe('listarContactosEndpoint (GET /api/contactos)', () => { + it('devuelve los contactos del tablero de Monday', async () => { + const { res, json } = resFake() + + await listarContactosEndpoint({} as Request, res) + + expect(json).toHaveBeenCalledWith(CONTACTOS) + }) + + it('propaga el error si Monday falla (el error handler responde 5xx)', async () => { + mondayMock.getContactos.mockRejectedValue(new Error('Monday caído')) + const { res } = resFake() + await expect(listarContactosEndpoint({} as Request, res)).rejects.toThrow('Monday caído') + }) +}) diff --git a/api-gateway/src/controllers/contactos.controller.ts b/api-gateway/src/controllers/contactos.controller.ts new file mode 100644 index 0000000..ea9589a --- /dev/null +++ b/api-gateway/src/controllers/contactos.controller.ts @@ -0,0 +1,10 @@ +import type { Request, Response } from 'express' +import { mondayService } from '../services/monday.service.js' + +/** + * GET /api/contactos (Bearer) → contactos del tablero Contactos de Monday + * (nombre, teléfono, email, estado), lo más reciente primero, máx 100. + */ +export async function listarContactosEndpoint(_req: Request, res: Response): Promise { + res.json(await mondayService.getContactos()) +} diff --git a/api-gateway/src/controllers/mi-dia.controller.ts b/api-gateway/src/controllers/mi-dia.controller.ts index f8d61f1..875a60c 100644 --- a/api-gateway/src/controllers/mi-dia.controller.ts +++ b/api-gateway/src/controllers/mi-dia.controller.ts @@ -26,6 +26,18 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise 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 registradas por el usuario (registro local) const capturas = await prisma.leadCaptura.findMany({ where: { usuarioId: usuario.id }, diff --git a/api-gateway/src/controllers/usuarios.controller.test.ts b/api-gateway/src/controllers/usuarios.controller.test.ts index dba4b22..c822adb 100644 --- a/api-gateway/src/controllers/usuarios.controller.test.ts +++ b/api-gateway/src/controllers/usuarios.controller.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Request, Response } from 'express' import type { Usuario } from '@prisma/client' import { + actualizarRolEndpoint, actualizarVinculoMondayEndpoint, listarUsuariosMondayEndpoint, } from './usuarios.controller.js' @@ -91,6 +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'], }) }) @@ -137,3 +139,57 @@ describe('actualizarVinculoMondayEndpoint (PATCH /api/usuarios/me/monday)', () = expect(prismaMock.usuario.update).not.toHaveBeenCalled() }) }) + +describe('actualizarRolEndpoint (PATCH /api/usuarios/:id/rol)', () => { + function reqAdmin(overrides: Partial = {}): Request { + return { + body: { rol: 'escrituracion' }, + params: { id: 'u-9' }, + usuarioToken: { sub: 'u-1', username: 'admin', rol: 'admin' }, + ...overrides, + } as Request + } + + it('admin con rol válido: actualiza y devuelve el perfil con sus módulos', async () => { + prismaMock.usuario.update.mockResolvedValue(usuarioFake({ id: 'u-9', rol: 'escrituracion' })) + const { res, json } = resFake() + + await actualizarRolEndpoint(reqAdmin(), res) + + expect(prismaMock.usuario.update).toHaveBeenCalledWith({ + where: { id: 'u-9' }, + data: { rol: 'escrituracion' }, + }) + expect(json).toHaveBeenCalledWith( + expect.objectContaining({ rol: 'escrituracion', modulos: expect.arrayContaining(['cobranza']) }), + ) + }) + + it('sin rol admin: 403 y no toca la BD', async () => { + const { res } = resFake() + const error = await actualizarRolEndpoint( + reqAdmin({ usuarioToken: { sub: 'u-2', username: 'x', rol: 'vendedor' } }), + res, + ).catch((e: unknown) => e) + expect(error).toMatchObject({ status: 403 }) + expect(prismaMock.usuario.update).not.toHaveBeenCalled() + }) + + it('rol inválido: 400 y no toca la BD', async () => { + const { res } = resFake() + const error = await actualizarRolEndpoint(reqAdmin({ body: { rol: 'coordinador' } }), res).catch( + (e: unknown) => e, + ) + expect(error).toMatchObject({ status: 400 }) + expect(prismaMock.usuario.update).not.toHaveBeenCalled() + }) + + it('sin token verificado: 400', async () => { + const { res } = resFake() + await expect(actualizarRolEndpoint(reqAdmin({ usuarioToken: undefined }), res)).rejects.toThrow( + 'Falta el token verificado', + ) + expect(prismaMock.usuario.update).not.toHaveBeenCalled() + }) +}) + diff --git a/api-gateway/src/controllers/usuarios.controller.ts b/api-gateway/src/controllers/usuarios.controller.ts index 4b2fca9..54da32f 100644 --- a/api-gateway/src/controllers/usuarios.controller.ts +++ b/api-gateway/src/controllers/usuarios.controller.ts @@ -3,7 +3,8 @@ import { z } from 'zod' import { mondayService } from '../services/monday.service.js' import { prisma } from '../services/prisma.js' import { aPublico } from '../services/auth.service.js' -import { BadRequestError } from '../utils/errors.js' +import { esRolValido } from '../config/roles.js' +import { AppError, BadRequestError } from '../utils/errors.js' /** * Vínculo usuario del ecosistema ↔ usuario de Monday (people). Es el mapeo @@ -42,3 +43,27 @@ export async function actualizarVinculoMondayEndpoint(req: Request, res: Respons }) res.json(aPublico(usuario)) } + +const cambiarRolSchema = z.object({ + rol: z.string().min(1), +}) + +/** + * PATCH /api/usuarios/:id/rol (Bearer, solo admin) { rol } → usuario público + * actualizado. Solo roles de la matriz nueva (config/roles.ts); los legacy + * ('coordinador', 'corporativo') ya no se asignan. + */ +export async function actualizarRolEndpoint(req: Request, res: Response): Promise { + if (!req.usuarioToken) throw new BadRequestError('Falta el token verificado') + if (req.usuarioToken.rol !== 'admin') { + throw new AppError(403, 'Solo un administrador puede cambiar roles') + } + const { rol } = cambiarRolSchema.parse(req.body) + if (!esRolValido(rol)) throw new BadRequestError(`Rol inválido: ${rol}`) + + const usuario = await prisma.usuario.update({ + where: { id: req.params.id as string }, + data: { rol }, + }) + res.json(aPublico(usuario)) +} diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts index 1daf3ea..fa992a1 100644 --- a/api-gateway/src/routes/index.ts +++ b/api-gateway/src/routes/index.ts @@ -53,10 +53,12 @@ import { descartarLeadEndpoint, } from '../controllers/leads.controller.js' import { + actualizarRolEndpoint, actualizarVinculoMondayEndpoint, listarUsuariosMondayEndpoint, } from '../controllers/usuarios.controller.js' import { resumenMiDiaEndpoint } from '../controllers/mi-dia.controller.js' +import { listarContactosEndpoint } from '../controllers/contactos.controller.js' import { webhookMonday } from '../controllers/webhooks.controller.js' import { correrRoundRobin, @@ -135,6 +137,8 @@ apiRouter.post('/oportunidades/:mondayItemId/perder', perderOportunidadEndpoint) apiRouter.get('/mi-dia/resumen', resumenMiDiaEndpoint) +apiRouter.get('/contactos', listarContactosEndpoint) + // GET estático: va ANTES de /leads/:itemId para no colisionar con el parámetro apiRouter.get('/leads/buscar', buscarLeadsEndpoint) apiRouter.post('/leads', crearLeadEndpoint) @@ -146,6 +150,7 @@ apiRouter.post('/afluencias', crearAfluenciaEndpoint) apiRouter.get('/monday/usuarios', listarUsuariosMondayEndpoint) apiRouter.patch('/usuarios/me/monday', actualizarVinculoMondayEndpoint) +apiRouter.patch('/usuarios/:id/rol', actualizarRolEndpoint) apiRouter.get('/reportes/resumen', resumenReportes) diff --git a/api-gateway/src/services/auth.service.test.ts b/api-gateway/src/services/auth.service.test.ts index e1f3db6..2d5e034 100644 --- a/api-gateway/src/services/auth.service.test.ts +++ b/api-gateway/src/services/auth.service.test.ts @@ -54,6 +54,7 @@ describe('login', () => { rol: 'vendedor', asignacion: 'MONTEBELLO', mondayUserId: null, + modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat'], }) expect(verificarToken(resultado.token).username).toBe('erick israel pena ramos') }) @@ -98,6 +99,12 @@ describe('me', () => { expect(resultado.nombre).toBe('ERICK ISRAEL PEÑA RAMOS') }) + it('incluye los módulos visibles del rol (matriz de config/roles)', async () => { + prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake({ rol: 'direccion_general' })) + const resultado = await me({ sub: 'u-1', username: 'x', rol: 'direccion_general' }) + expect(resultado.modulos).toEqual(['mi-dia', 'tablero-comercial', 'cobranza', 'chat']) + }) + it('rechaza si el usuario ya no existe o está inactivo', async () => { prismaMock.usuario.findUnique.mockResolvedValue(null) await expect(me({ sub: 'x', username: 'x', rol: 'vendedor' })).rejects.toThrow('Credenciales inválidas') diff --git a/api-gateway/src/services/auth.service.ts b/api-gateway/src/services/auth.service.ts index b163ee5..6196b62 100644 --- a/api-gateway/src/services/auth.service.ts +++ b/api-gateway/src/services/auth.service.ts @@ -3,6 +3,7 @@ import jwt from 'jsonwebtoken' import type { Usuario } from '@prisma/client' import { prisma } from './prisma.js' import { config } from '../config/index.js' +import { modulosDeRol, type Modulo } from '../config/roles.js' import { AppError } from '../utils/errors.js' export interface UsuarioPublico { @@ -11,6 +12,8 @@ export interface UsuarioPublico { asignacion: string | null // User ID de Monday vinculado (null = sin vincular; ver PATCH /usuarios/me/monday) mondayUserId: number | null + // Módulos del ecosistema visibles para el rol (matriz de config/roles.ts) + modulos: Modulo[] } export interface LoginResult { @@ -37,6 +40,7 @@ export function aPublico(usuario: Usuario): UsuarioPublico { rol: usuario.rol, asignacion: usuario.asignacion, mondayUserId: usuario.mondayUserId, + modulos: modulosDeRol(usuario.rol), } } diff --git a/api-gateway/src/services/mi-dia.service.test.ts b/api-gateway/src/services/mi-dia.service.test.ts index e0e0f3c..a8523ac 100644 --- a/api-gateway/src/services/mi-dia.service.test.ts +++ b/api-gateway/src/services/mi-dia.service.test.ts @@ -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> }) { 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() + }, } } @@ -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: [ diff --git a/api-gateway/src/services/mi-dia.service.ts b/api-gateway/src/services/mi-dia.service.ts index 768e76d..d717643 100644 --- a/api-gateway/src/services/mi-dia.service.ts +++ b/api-gateway/src/services/mi-dia.service.ts @@ -1,4 +1,5 @@ import type { MondayService } from './monday.service.js' +import { config } from '../config/index.js' /** * Agregador "Mi día": lo que un vendedor necesita al abrir la app /hoy. @@ -39,6 +40,8 @@ export interface LeadMiDia { updatedAt: string /** Tablero del item: 'lead' (tablero Leads) u 'oportunidad' (Oportunidades) */ fuente: 'lead' | 'oportunidad' + /** Último evento del activity log del board; null si el item no tiene eventos */ + ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null } export interface ActividadMiDia { @@ -105,10 +108,30 @@ export interface ResumenMiDia { } } -type MondayMiDia = Pick +type MondayMiDia = Pick< + MondayService, + 'getLeads' | 'getOportunidades' | 'getActividades' | 'getApartados' | 'getUltimosCambios' +> const MS_DIA = 24 * 60 * 60 * 1000 +/** Texto legible del evento del activity log (los nombres de columna no vienen legibles en el payload) */ +export function detalleEventoUltimoCambio(evento: string): string { + switch (evento) { + case 'create_pulse': + return 'Alta' + case 'create_update': + return 'Nota agregada' + case 'update_column_value': + case 'update_status': + return 'Actualización' + case 'move_to_group': + return 'Cambio de grupo' + default: + return evento + } +} + /** Fecha local 'YYYY-MM-DD' desplazada n días desde `base` */ function fechaLocal(base: Date, dias: number): string { const d = new Date(base.getTime() + dias * MS_DIA) @@ -180,6 +203,7 @@ export async function resumenMiDia( origen: l.origen, updatedAt: l.updatedAt, fuente: 'lead', + ultimoCambio: null, })) const mios = sinMapeo ? [] : oportunidades.filter((o) => o.asesorIds.includes(miId)) const misLeadsOportunidades: LeadMiDia[] = mios @@ -193,11 +217,37 @@ export async function resumenMiDia( origen: o.origen, updatedAt: o.updatedAt, fuente: 'oportunidad', + ultimoCambio: null, })) const misLeads: LeadMiDia[] = [...misLeadsLeads, ...misLeadsOportunidades].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt), ) + // "Último cambio" por item: UNA llamada al activity log por tablero (máx 2), + // agrupando los ids por fuente. El usuario se resuelve con el mapa + // mondayUserId → nombre que pasa el controller (ecosistema + workspace). + const [cambiosLeads, cambiosOportunidades] = await Promise.all([ + monday.getUltimosCambios( + config.monday.boards.leads, + misLeads.filter((l) => l.fuente === 'lead').map((l) => l.id), + ), + monday.getUltimosCambios( + config.monday.boards.oportunidades, + misLeads.filter((l) => l.fuente === 'oportunidad').map((l) => l.id), + ), + ]) + const cambiosPorFuente = { lead: cambiosLeads, oportunidad: cambiosOportunidades } + for (const lead of misLeads) { + const cambio = cambiosPorFuente[lead.fuente].get(lead.id) + lead.ultimoCambio = cambio + ? { + fechaISO: cambio.fechaISO, + usuario: cambio.usuarioId !== null ? (nombresPorMondayId.get(cambio.usuarioId) ?? null) : null, + detalle: detalleEventoUltimoCambio(cambio.evento), + } + : null + } + // Conteo de MIS oportunidades por etapa (incluye terminales: es el resumen // del pipeline personal, no la lista de trabajo activo) const porEtapa: Record = {} diff --git a/api-gateway/src/services/monday.service.test.ts b/api-gateway/src/services/monday.service.test.ts index 7ad6319..9f7d2dd 100644 --- a/api-gateway/src/services/monday.service.test.ts +++ b/api-gateway/src/services/monday.service.test.ts @@ -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 + 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('') + }) + }) }) diff --git a/api-gateway/src/services/monday.service.ts b/api-gateway/src/services/monday.service.ts index 932cca1..d2744cc 100644 --- a/api-gateway/src/services/monday.service.ts +++ b/api-gateway/src/services/monday.service.ts @@ -81,6 +81,16 @@ export interface ApartadoMonday { estado: string // label de la columna status 'Estado' } +/** Contacto del tablero Contactos (módulo Contactos de la landing) */ +export interface ContactoMonday { + id: string + nombre: string + telefono: string // columna phone 'Teléfono' ('' si no tiene) + email: string // columna email 'Email' ('' si no tiene) + estado: string // label de la columna status 'Estado' ('' si nunca se escribió) + updatedAt: string // ISO (updated_at del item en Monday) +} + /** Oportunidad del tablero Oportunidades (pipeline comercial) */ export interface OportunidadMonday { id: string @@ -116,6 +126,22 @@ export interface UsuarioMonday { email: string } +/** Entrada del activity log de Monday (boards { activity_logs }) */ +interface ActivityLogMonday { + id: string + event: string + created_at: string // string numérico, normalmente en microsegundos + user_id: string + data: string // JSON string; pulse_id identifica el item +} + +/** Último evento del activity log de un item (para "Último cambio" de Mi día) */ +export interface UltimoCambioMonday { + fechaISO: string + usuarioId: number | null // user_id de Monday; null si no es numérico + evento: string // event crudo del activity log (create_pulse, update_status, ...) +} + interface GraphQLErrorItem { message: string extensions?: { code?: string } @@ -135,6 +161,21 @@ const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms const CACHE_UNIDADES_MS = 5 * 60 * 1000 // 5 minutos +/** + * Grupo del tablero Motivos No Compra por categoría (ids verificados sep-2026). + * El descarte crea el motivo directo en su grupo; si la categoría no está + * mapeada, el item cae en el grupo default del tablero. + */ +export const GRUPOS_MOTIVOS_NO_COMPRA: Record = { + Timing: 'group_mm6spcsj', + Cliente: 'group_mm6sp7nr', + Competencia: 'group_mm6sbdfy', + Financiamiento: 'group_mm6ssymc', + Ubicacion: 'group_mm6sqern', // sin acento: así está el grupo en Monday + Producto: 'group_mm6s7j0b', + Precio: 'group_mm6sze0z', +} + export interface MondayServiceOptions { apiKey?: string apiUrl?: string @@ -418,12 +459,12 @@ export class MondayService { return JSON.stringify(out) } - private async crearItem(boardId: string, nombre: string, columnValues: string): Promise { + private async crearItem(boardId: string, nombre: string, columnValues: string, groupId?: string): Promise { const data = await this.graphql<{ create_item: { id: string } }>( - `mutation ($boardId: ID!, $itemName: String!, $columnValues: JSON!) { - create_item(board_id: $boardId, item_name: $itemName, column_values: $columnValues) { id } + `mutation ($boardId: ID!, $itemName: String!, $columnValues: JSON!, $groupId: String) { + create_item(board_id: $boardId, item_name: $itemName, column_values: $columnValues, group_id: $groupId) { id } }`, - { boardId, itemName: nombre, columnValues }, + { boardId, itemName: nombre, columnValues, groupId: groupId ?? null }, ) return data.create_item.id } @@ -660,7 +701,8 @@ export class MondayService { // ---------- Motivos No Compra / Actividades (tableros CRM FRASA) ---------- - /** Item en el tablero Motivos No Compra (nombre = " — ") */ + /** Item en el tablero Motivos No Compra (nombre = " — "), + * creado directo en el grupo de su categoría (si está mapeada) */ async crearMotivoNoCompra(datos: DatosMotivoNoCompra): Promise { const boardId = config.monday.boards.motivos if (boardId === '') throw new AppError(500, 'MONDAY_BOARD_MOTIVOS no está configurado') @@ -668,7 +710,11 @@ export class MondayService { Categoria: datos.categoria, Descripcion: datos.descripcion, }) - return this.crearItem(boardId, datos.nombre, columnValues) + const buscada = datos.categoria.trim().toUpperCase() + const groupId = Object.entries(GRUPOS_MOTIVOS_NO_COMPRA).find( + ([categoria]) => categoria.toUpperCase() === buscada, + )?.[1] + return this.crearItem(boardId, datos.nombre, columnValues, groupId) } /** Actividad de seguimiento ligada a un lead (bitácora del CRM) */ @@ -904,6 +950,52 @@ export class MondayService { // ---------- Mi día (CRM ligero para vendedores) ---------- + /** + * Último evento del activity log por item: UNA query por tablero con todos + * los itemIds (limit = items × 5, por si hay varios eventos por item). El + * item se identifica por el pulse_id del payload `data` (JSON string). El + * created_at viene como string numérico; por magnitud se normaliza a ms: + * > 1e16 ⇒ centésimas de µs (así llega hoy, 17 dígitos), > 1e14 ⇒ µs, + * si no ms. itemIds vacío ⇒ Map vacío sin llamar a Monday. + */ + async getUltimosCambios(boardId: string, itemIds: string[]): Promise> { + const resultado = new Map() + if (itemIds.length === 0) return resultado + const data = await this.graphql<{ boards: { activity_logs: ActivityLogMonday[] }[] }>( + `query ($ids: [ID!], $itemIds: [ID!], $limit: Int!) { + boards(ids: $ids) { + activity_logs(item_ids: $itemIds, limit: $limit) { id event created_at user_id data } + } + }`, + { ids: [boardId], itemIds, limit: itemIds.length * 5 }, + ) + for (const log of data.boards[0]?.activity_logs ?? []) { + let itemId: string | undefined + try { + const datos = JSON.parse(log.data) as { pulse_id?: number | string } + itemId = datos.pulse_id === undefined ? undefined : String(datos.pulse_id) + } catch { + continue + } + if (itemId === undefined) continue + const crudo = Number(log.created_at) + if (!Number.isFinite(crudo)) continue + // Normaliza a ms por magnitud: 100ns (17 dígitos) /1e4, µs /1e3, ms directo + const ms = crudo > 1e16 ? crudo / 1e4 : crudo > 1e14 ? crudo / 1e3 : crudo + const fechaISO = new Date(ms).toISOString() + // Las fechas ISO comparan bien como strings: solo nos quedamos con el más reciente + const actual = resultado.get(itemId) + if (actual && actual.fechaISO >= fechaISO) continue + const userId = Number(log.user_id) + resultado.set(itemId, { + fechaISO, + usuarioId: Number.isFinite(userId) && userId > 0 ? userId : null, + evento: log.event, + }) + } + return resultado + } + /** Parsea el value JSON de una columna people a los user IDs (solo personas) */ private peopleIds(raw: string | null | undefined): number[] { if (!raw) return [] @@ -1028,6 +1120,70 @@ export class MondayService { }) } + /** + * Contactos del tablero Contactos (módulo Contactos de la landing): nombre, + * teléfono, email y estado, ordenados por updated_at desc, máx 100. + */ + async getContactos(): Promise { + const boardId = config.monday.boards.contactos + const cols = await this.columnasDe(boardId) + const colTelefono = cols.get('Teléfono') + const colEmail = cols.get('Email') + const colEstado = cols.get('Estado') + + const items: ItemConFechas[] = [] + let cursor: string | null = null + do { + const data: { boards: { items_page: { cursor: string | null; items: ItemConFechas[] } }[] } = + cursor === null + ? await this.graphql( + `query ($ids: [ID!]) { + boards(ids: $ids) { + items_page(limit: 100) { + cursor + items { id name updated_at column_values { id text value } } + } + } + }`, + { ids: [boardId] }, + ) + : await this.graphql( + `query ($ids: [ID!], $cursor: String!) { + boards(ids: $ids) { + items_page(limit: 100, cursor: $cursor) { + cursor + items { id name updated_at column_values { id text value } } + } + } + }`, + { ids: [boardId], cursor }, + ) + const page = data.boards[0]?.items_page + if (!page) break + items.push(...page.items) + cursor = page.cursor + } while (cursor !== null) + + return items + .map((item) => { + const porId = new Map(item.column_values.map((cv) => [cv.id, cv])) + // Misma corrección que mapearLead: status nunca escrito → value null y + // `text` muestra el default del tablero; el estado real es vacío + const estadoCv = colEstado ? porId.get(colEstado.id) : undefined + const estado = estadoCv?.value ? (estadoCv.text ?? '') : '' + return { + id: item.id, + nombre: item.name, + telefono: colTelefono ? (porId.get(colTelefono.id)?.text ?? '') : '', + email: colEmail ? (porId.get(colEmail.id)?.text ?? '') : '', + estado, + updatedAt: item.updated_at, + } + }) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + .slice(0, 100) + } + /** * Labels de una columna status/dropdown (settings_str del board). * Monday los devuelve como objeto {index: label} o como arreglo [{id, name}]. diff --git a/apps/agente-ia/src/pages/ChatPage.tsx b/apps/agente-ia/src/pages/ChatPage.tsx index e6b1b39..c5937b5 100644 --- a/apps/agente-ia/src/pages/ChatPage.tsx +++ b/apps/agente-ia/src/pages/ChatPage.tsx @@ -79,11 +79,19 @@ export default function ChatPage() {
- - FRASA - · - ASISTENTE IA - +
+ + ← Menú + + + FRASA + · + ASISTENTE IA + +
- ))} -
- + {permitidas.length > 1 && ( +
+ {permitidas.map((v) => ( + + ))} +
+ )} + {vista !== null && ( + + )}
- {vista === 'mia' ? ( + {vista === null ? ( +
+

Tu rol no tiene tableros asignados por ahora.

+
+ ) : vista === 'mia' ? ( ) : ( diff --git a/apps/dashboards/src/services/auth.ts b/apps/dashboards/src/services/auth.ts index 70cd037..970dd94 100644 --- a/apps/dashboards/src/services/auth.ts +++ b/apps/dashboards/src/services/auth.ts @@ -6,6 +6,9 @@ export interface UsuarioSesion { nombre: string rol: string asignacion: string | null + // Módulos del ecosistema visibles para el rol (matriz del API; presente en + // /auth/me y /auth/login desde sep-2026) + modulos?: string[] } export interface Sesion { @@ -76,6 +79,14 @@ export function manejar401(res: Response): void { if (typeof window !== 'undefined') window.location.reload() } +/** GET /auth/me — perfil fresco del usuario autenticado (incluye modulos del rol) */ +export async function getMe(): Promise { + const res = await fetch(`${API_URL}/auth/me`, conAuth()) + manejar401(res) + if (!res.ok) throw new Error(`Error ${res.status}`) + return (await res.json()) as UsuarioSesion +} + /** * POST /auth/cambiar-password. Lanza Error con el mensaje del API cuando * falla (p. ej. "La contraseña actual no es correcta"); no toca la sesión. diff --git a/apps/landing/src/App.tsx b/apps/landing/src/App.tsx index 4e5ad7c..04b382f 100644 --- a/apps/landing/src/App.tsx +++ b/apps/landing/src/App.tsx @@ -1,9 +1,50 @@ -import { MODULOS } from './data/modulos' +import { useEffect, useState } from 'react' +import { MODULOS, esModuloVisible } from './data/modulos' import { Navbar } from './components/Navbar' import { TarjetaModulo } from './components/TarjetaModulo' import { ConfiguracionCuenta } from './components/ConfiguracionCuenta' +import { VistaContactos } from './components/VistaContactos' +import { getToken } from './services/auth' +import { getMe } from './services/api' + +type Vista = 'home' | 'contactos' export default function App() { + const [vista, setVista] = useState('home') + // Módulos permitidos por el rol (GET /auth/me); null = sin sesión → se muestran todos + const [permitidos, setPermitidos] = useState(null) + + useEffect(() => { + let cancelado = false + const cargar = () => { + if (!getToken()) { + setPermitidos(null) + return + } + // Si /me falla se conserva lo anterior (o todos): el login wall de cada app sigue + getMe() + .then((usuario) => { + if (!cancelado) setPermitidos(usuario.modulos ?? []) + }) + .catch(() => {}) + } + cargar() + window.addEventListener('frasa:sesion', cargar) + return () => { + cancelado = true + window.removeEventListener('frasa:sesion', cargar) + } + }, []) + + if (vista === 'contactos') { + return ( +
+ + setVista('home')} /> +
+ ) + } + return (
@@ -24,8 +65,12 @@ export default function App() { diff --git a/apps/landing/src/components/ConfiguracionCuenta.test.tsx b/apps/landing/src/components/ConfiguracionCuenta.test.tsx index 6b78b1c..062065d 100644 --- a/apps/landing/src/components/ConfiguracionCuenta.test.tsx +++ b/apps/landing/src/components/ConfiguracionCuenta.test.tsx @@ -27,7 +27,13 @@ const USUARIOS_MONDAY = [ const sesionFake = { token: 'jwt-de-prueba', - usuario: { nombre: 'ERICK ISRAEL PEÑA RAMOS', rol: 'vendedor', asignacion: 'MONTEBELLO', mondayUserId: null }, + usuario: { + nombre: 'ERICK ISRAEL PEÑA RAMOS', + rol: 'vendedor', + asignacion: 'MONTEBELLO', + mondayUserId: null, + modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat'], + }, } const respuestaOk = (data: unknown) => @@ -87,6 +93,7 @@ describe('ConfiguracionCuenta con sesión', () => { // Perfil y dropdown cargados expect(await screen.findByText('ERICK ISRAEL PEÑA RAMOS')).toBeTruthy() + expect(screen.getByText('Rol: Vendedor')).toBeTruthy() const dropdown = await screen.findByLabelText('Tu usuario de Monday') expect(screen.getByText('sin vincular')).toBeTruthy() diff --git a/apps/landing/src/components/ConfiguracionCuenta.tsx b/apps/landing/src/components/ConfiguracionCuenta.tsx index 9dfc78e..ef242b6 100644 --- a/apps/landing/src/components/ConfiguracionCuenta.tsx +++ b/apps/landing/src/components/ConfiguracionCuenta.tsx @@ -6,6 +6,14 @@ const labelEstilo = 'block text-xs font-medium text-[#4B5563]' const inputEstilo = 'mt-2 w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm text-[#111827] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20' +/** 'director_ventas' → 'Director Ventas' (texto discreto del perfil) */ +function formatearRol(rol: string): string { + return rol + .split('_') + .map((palabra) => palabra.charAt(0).toUpperCase() + palabra.slice(1)) + .join(' ') +} + /** * Sección "Configuración de cuenta": login inline y, con sesión, el vínculo * con el usuario de Monday (alimenta round-robin, mis-leads y alertas). @@ -162,6 +170,7 @@ export function ConfiguracionCuenta() {

Sesión: {perfil.nombre}

+

Rol: {formatearRol(perfil.rol)}

Vínculo Monday actual:{' '} {perfil.mondayUserId === null ? ( diff --git a/apps/landing/src/components/TarjetaModulo.tsx b/apps/landing/src/components/TarjetaModulo.tsx index deda967..90211ea 100644 --- a/apps/landing/src/components/TarjetaModulo.tsx +++ b/apps/landing/src/components/TarjetaModulo.tsx @@ -2,39 +2,53 @@ import type { Modulo } from '../data/modulos' interface Props { modulo: Modulo + // Solo aplica a módulos sin url (vista interna de la landing, p. ej. Contactos) + onAbrir?: (modulo: Modulo) => void } // Iniciales del "icono" de cada tarjeta; fallback: primeras 2 letras del nombre const INICIALES: Record = { cotizador: 'CO', 'alta-clientes': 'AC', - cobranza: 'CB', - 'agente-ia': 'IA', + clientes: 'CL', + 'chat-hermes': 'IA', tableros: 'TB', 'mi-dia': 'MD', + contactos: 'CT', } /** Tarjeta de acceso a un módulo del ecosistema */ -export function TarjetaModulo({ modulo }: Props) { +export function TarjetaModulo({ modulo, onAbrir }: Props) { const iniciales = INICIALES[modulo.id] ?? modulo.nombre.slice(0, 2).toUpperCase() + const contenido = ( + <> + +

{modulo.nombre}

+

{modulo.descripcion}

+ + Entrar + + + ) + const estilo = + 'group flex h-full w-full flex-col rounded-xl border border-[#E5E7EB] bg-white p-6 text-left shadow-sm transition-all duration-200 ease-in-out hover:shadow-md' + return (
  • - - -

    {modulo.nombre}

    -

    {modulo.descripcion}

    - - Entrar - -
    + {modulo.url ? ( + + {contenido} + + ) : ( + + )}
  • ) } diff --git a/apps/landing/src/components/VistaContactos.tsx b/apps/landing/src/components/VistaContactos.tsx new file mode 100644 index 0000000..04614c2 --- /dev/null +++ b/apps/landing/src/components/VistaContactos.tsx @@ -0,0 +1,79 @@ +import { useCallback, useEffect, useState } from 'react' +import { getContactos, type Contacto } from '../services/api' + +interface Props { + onVolver: () => void +} + +/** + * Vista interna "Contactos": lista del tablero Contactos de Monday (lo más + * reciente primero). Vive dentro de la landing (no hay router); se abre desde + * la tarjeta Contactos del grid. + */ +export function VistaContactos({ onVolver }: Props) { + const [contactos, setContactos] = useState(null) + const [cargando, setCargando] = useState(true) + const [error, setError] = useState('') + + const cargar = useCallback(() => { + setCargando(true) + setError('') + getContactos() + .then(setContactos) + .catch(() => setError('No se pudieron cargar los contactos.')) + .finally(() => setCargando(false)) + }, []) + + useEffect(() => cargar(), [cargar]) + + return ( +
    +
    + +

    Contactos

    +
    + +
    + {cargando ? ( +

    Cargando contactos…

    + ) : error ? ( +
    +

    + {error} +

    + +
    + ) : !contactos || contactos.length === 0 ? ( +

    No hay contactos para mostrar.

    + ) : ( +
      + {contactos.map((contacto) => ( +
    • + {contacto.nombre} + {contacto.estado && ( + + {contacto.estado} + + )} + {contacto.telefono || 'Sin teléfono'} + {contacto.email || 'Sin email'} +
    • + ))} +
    + )} +
    +
    + ) +} diff --git a/apps/landing/src/data/modulos.test.ts b/apps/landing/src/data/modulos.test.ts index 154548b..e38353d 100644 --- a/apps/landing/src/data/modulos.test.ts +++ b/apps/landing/src/data/modulos.test.ts @@ -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) }) }) diff --git a/apps/landing/src/data/modulos.ts b/apps/landing/src/data/modulos.ts index 745c878..8ac4384 100644 --- a/apps/landing/src/data/modulos.ts +++ b/apps/landing/src/data/modulos.ts @@ -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)) +} diff --git a/apps/landing/src/services/api.ts b/apps/landing/src/services/api.ts index 50b7940..e3ca3b4 100644 --- a/apps/landing/src/services/api.ts +++ b/apps/landing/src/services/api.ts @@ -6,7 +6,7 @@ export interface UsuarioMonday { email: string } -/** GET /auth/me — perfil del usuario autenticado (incluye mondayUserId) */ +/** GET /auth/me — perfil del usuario autenticado (incluye mondayUserId y modulos) */ export async function getMe(): Promise { const resp = await fetch(`${API_URL}/auth/me`, conAuth()) manejar401(resp) @@ -14,6 +14,23 @@ export async function getMe(): Promise { return (await resp.json()) as UsuarioSesion } +export interface Contacto { + id: string + nombre: string + telefono: string + email: string + estado: string + updatedAt: string +} + +/** GET /contactos — contactos del tablero Contactos de Monday (máx 100) */ +export async function getContactos(): Promise { + const resp = await fetch(`${API_URL}/contactos`, conAuth()) + manejar401(resp) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + return (await resp.json()) as Contacto[] +} + /** GET /monday/usuarios — usuarios activos del workspace para el dropdown */ export async function getUsuariosMonday(): Promise { const resp = await fetch(`${API_URL}/monday/usuarios`, conAuth()) diff --git a/apps/landing/src/services/auth.ts b/apps/landing/src/services/auth.ts index 04a4148..4db7c09 100644 --- a/apps/landing/src/services/auth.ts +++ b/apps/landing/src/services/auth.ts @@ -7,6 +7,9 @@ export interface UsuarioSesion { rol: string asignacion: string | null mondayUserId: number | null + // Módulos del ecosistema visibles para el rol (matriz del API; presente en + // /auth/me y /auth/login desde sep-2026) + modulos?: string[] } export interface Sesion { diff --git a/apps/mi-dia/src/lib/mi-dia.ts b/apps/mi-dia/src/lib/mi-dia.ts index 4f71e03..3da7024 100644 --- a/apps/mi-dia/src/lib/mi-dia.ts +++ b/apps/mi-dia/src/lib/mi-dia.ts @@ -10,6 +10,8 @@ export interface LeadMiDia { updatedAt: string /** Tablero del item: 'lead' (tablero Leads) u 'oportunidad' (Oportunidades) */ fuente: 'lead' | 'oportunidad' + /** Último evento del activity log del board; null si el item no tiene eventos */ + ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null } export interface ActividadMiDia { diff --git a/apps/mi-dia/src/pages/MiDiaPage.tsx b/apps/mi-dia/src/pages/MiDiaPage.tsx index c424251..202d26b 100644 --- a/apps/mi-dia/src/pages/MiDiaPage.tsx +++ b/apps/mi-dia/src/pages/MiDiaPage.tsx @@ -150,6 +150,12 @@ function FilaLead({ {lead.telefono || 'Sin teléfono'} {lead.origen ? ` · ${lead.origen}` : ''}

    + {lead.ultimoCambio && ( +

    + Último cambio: {lead.ultimoCambio.detalle} · {lead.ultimoCambio.usuario ?? '—'} ·{' '} + {formatoFechaCorta(lead.ultimoCambio.fechaISO.slice(0, 10))} +

    + )}
    {lead.estado || 'Sin estado'} @@ -290,6 +296,12 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
    + + ← Menú +

    FRASA Mi día