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:
67
api-gateway/src/config/roles.test.ts
Normal file
67
api-gateway/src/config/roles.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { MODULOS, esRolValido, modulosDeRol } from './roles.js'
|
||||
|
||||
const ESPERADOS: Record<string, string[]> = {
|
||||
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)
|
||||
})
|
||||
})
|
||||
69
api-gateway/src/config/roles.ts
Normal file
69
api-gateway/src/config/roles.ts
Normal file
@@ -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<Rol, readonly Modulo[]> = {
|
||||
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<string, Rol> = {
|
||||
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)
|
||||
}
|
||||
47
api-gateway/src/controllers/contactos.controller.test.ts
Normal file
47
api-gateway/src/controllers/contactos.controller.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
10
api-gateway/src/controllers/contactos.controller.ts
Normal file
10
api-gateway/src/controllers/contactos.controller.ts
Normal file
@@ -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<void> {
|
||||
res.json(await mondayService.getContactos())
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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> = {}): 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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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<MondayService, 'getLeads' | 'getOportunidades' | 'getActividades' | 'getApartados'>
|
||||
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<string, number> = {}
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<void> => 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<string, string> = {
|
||||
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<string> {
|
||||
private async crearItem(boardId: string, nombre: string, columnValues: string, groupId?: string): Promise<string> {
|
||||
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 = "<motivo> — <item origen>") */
|
||||
/** Item en el tablero Motivos No Compra (nombre = "<motivo> — <item origen>"),
|
||||
* creado directo en el grupo de su categoría (si está mapeada) */
|
||||
async crearMotivoNoCompra(datos: DatosMotivoNoCompra): Promise<string> {
|
||||
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<Map<string, UltimoCambioMonday>> {
|
||||
const resultado = new Map<string, UltimoCambioMonday>()
|
||||
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<ContactoMonday[]> {
|
||||
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}].
|
||||
|
||||
@@ -79,11 +79,19 @@ export default function ChatPage() {
|
||||
<div className="flex min-h-screen flex-col bg-[#F8FAFC] text-[#111827]">
|
||||
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
|
||||
<div className="mx-auto flex h-14 w-full max-w-3xl items-center justify-between px-4 sm:px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="/"
|
||||
className="rounded-lg px-2 py-1.5 text-xs font-medium text-[#64748B] transition-all duration-100 ease-out hover:bg-[#F9FAFB] hover:text-[#111827]"
|
||||
>
|
||||
← Menú
|
||||
</a>
|
||||
<span className="text-sm tracking-wide">
|
||||
<span className="font-bold">FRASA</span>
|
||||
<span className="text-[#F97316]"> · </span>
|
||||
<span className="font-light text-[#64748B]">ASISTENTE IA</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={nuevaConversacion}
|
||||
|
||||
@@ -10,6 +10,12 @@ export default function AppHeader({ titulo, acciones }: Props) {
|
||||
return (
|
||||
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
|
||||
<div className="mx-auto flex min-h-14 max-w-[1280px] flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2 sm:px-6">
|
||||
<a
|
||||
href="/"
|
||||
className="rounded-lg px-2 py-1.5 text-xs font-medium text-[#64748B] transition-all duration-100 ease-out hover:bg-[#F9FAFB] hover:text-[#111827]"
|
||||
>
|
||||
← Menú
|
||||
</a>
|
||||
<span className="text-sm font-bold tracking-widest text-[#111827]">
|
||||
FRASA<span className="text-[#F97316]">.</span>
|
||||
</span>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta name="theme-color" content="#1B4332" />
|
||||
<link rel="manifest" href="/pay/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/pay/icons/icon-192.png" />
|
||||
<title>FRASA Desarrollos — Cobranza</title>
|
||||
<title>FRASA Desarrollos — Clientes</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Frasa · Cobranza",
|
||||
"short_name": "Cobranza",
|
||||
"name": "Frasa · Clientes",
|
||||
"short_name": "Clientes",
|
||||
"start_url": "/pay/",
|
||||
"scope": "/pay/",
|
||||
"display": "standalone",
|
||||
|
||||
@@ -6,11 +6,19 @@ export default function Layout({ children }: { children: ReactNode }) {
|
||||
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
|
||||
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
|
||||
<div className="mx-auto flex h-16 max-w-[1280px] items-center justify-between px-4 sm:px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="/"
|
||||
className="rounded-lg px-2 py-1.5 text-xs font-medium text-[#64748B] transition-all duration-100 ease-out hover:bg-[#F9FAFB] hover:text-[#111827]"
|
||||
>
|
||||
← Menú
|
||||
</a>
|
||||
<Link to="/" className="text-sm tracking-widest">
|
||||
<span className="font-bold">FRASA</span>
|
||||
<span className="text-[#F97316]">.</span>{' '}
|
||||
<span className="font-light text-[#64748B]">COBRANZA</span>
|
||||
<span className="font-light text-[#64748B]">CLIENTES</span>
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="flex items-center gap-3 text-sm sm:gap-6">
|
||||
<Link
|
||||
to="/"
|
||||
|
||||
@@ -20,12 +20,20 @@ export default function AuthGate({ children }: { children: ReactNode }) {
|
||||
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
|
||||
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
|
||||
<div className="mx-auto flex h-16 max-w-[1280px] items-center justify-between gap-4 px-4 sm:px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="/"
|
||||
className="rounded-lg px-2 py-1.5 text-xs font-medium text-[#64748B] transition-all duration-100 ease-out hover:bg-[#F9FAFB] hover:text-[#111827]"
|
||||
>
|
||||
← Menú
|
||||
</a>
|
||||
<p className="text-sm text-[#111827]">
|
||||
<span className="font-bold">
|
||||
FRASA DESARROLLOS<span className="text-[#F97316]">.</span>
|
||||
</span>{' '}
|
||||
<span className="hidden font-normal text-[#64748B] sm:inline">— Cotizador</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 sm:gap-4">
|
||||
<span className="hidden text-xs font-medium text-[#64748B] md:inline">
|
||||
{sesion.usuario.nombre} · {sesion.usuario.rol}
|
||||
|
||||
@@ -1,52 +1,76 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import MiTablero from '../components/MiTablero'
|
||||
import TableroGeneral from '../components/TableroGeneral'
|
||||
import type { AccionesTablero } from '../components/comunes'
|
||||
import { esRolConEquipo } from '../lib/mi-tablero'
|
||||
import { getSesion } from '../services/auth'
|
||||
import { getMe, getSesion } from '../services/auth'
|
||||
|
||||
type Vista = 'mia' | 'general'
|
||||
|
||||
const VISTAS: { id: Vista; etiqueta: string }[] = [
|
||||
{ id: 'mia', etiqueta: 'Mi tablero' },
|
||||
{ id: 'general', etiqueta: 'Tablero general' },
|
||||
const VISTAS: { id: Vista; etiqueta: string; clave: string }[] = [
|
||||
{ id: 'mia', etiqueta: 'Mi tablero', clave: 'mi-tablero' },
|
||||
{ id: 'general', etiqueta: 'Tablero comercial', clave: 'tablero-comercial' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Tableros con vista por rol: el vendedor entra a "Mi tablero" (sus KPIs y el
|
||||
* funnel de sus leads); coordinador/corporativo/admin entran a "General" con
|
||||
* la sección Equipo. El header sticky concentra la marca, el segmented control
|
||||
* de vistas y el botón "Actualizar" de la vista activa.
|
||||
* Tableros con vista por rol: cada tab se muestra solo si el módulo está
|
||||
* permitido en la matriz de roles del API (GET /auth/me → modulos). Con un
|
||||
* solo tab permitido no hay segmented control: la vista va directa. La
|
||||
* sección Equipo del tablero comercial sigue la regla de rol (esRolConEquipo).
|
||||
*/
|
||||
export default function DashboardPage() {
|
||||
const rol = getSesion()?.usuario.rol ?? 'vendedor'
|
||||
const sesion = getSesion()
|
||||
const rol = sesion?.usuario.rol ?? 'vendedor'
|
||||
const conEquipo = esRolConEquipo(rol)
|
||||
const [vista, setVista] = useState<Vista>(conEquipo ? 'general' : 'mia')
|
||||
// null = aún sin dato del API (sesiones viejas sin modulos): no se filtra
|
||||
const [permitidos, setPermitidos] = useState<string[] | null>(() => sesion?.usuario.modulos ?? null)
|
||||
const [elegida, setElegida] = useState<Vista | null>(null)
|
||||
const [acciones, setAcciones] = useState<AccionesTablero | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
getMe()
|
||||
.then((usuario) => setPermitidos(usuario.modulos ?? null))
|
||||
.catch(() => {}) // si /me falla se mantiene lo que hubiera
|
||||
}, [])
|
||||
|
||||
const permitidas = VISTAS.filter((v) => permitidos === null || permitidos.includes(v.clave))
|
||||
const vista: Vista | null =
|
||||
permitidas.length === 0
|
||||
? null
|
||||
: permitidas.some((v) => v.id === elegida)
|
||||
? elegida
|
||||
: permitidas[0]!.id
|
||||
|
||||
return (
|
||||
<div>
|
||||
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
|
||||
<div className="mx-auto flex min-h-14 max-w-[1280px] flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-2 sm:px-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<a
|
||||
href="/"
|
||||
className="rounded-lg px-2 py-1.5 text-xs font-medium text-[#64748B] transition-all duration-100 ease-out hover:bg-[#F9FAFB] hover:text-[#111827]"
|
||||
>
|
||||
← Menú
|
||||
</a>
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent text-xs font-bold text-white">
|
||||
F
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-[#111827]">FRASA Desarrollos</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{permitidas.length > 1 && (
|
||||
<div
|
||||
className="inline-flex rounded-lg border border-[#E5E7EB] bg-[#F1F5F9] p-1"
|
||||
role="tablist"
|
||||
aria-label="Vista del tablero"
|
||||
>
|
||||
{VISTAS.map((v) => (
|
||||
{permitidas.map((v) => (
|
||||
<button
|
||||
key={v.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={vista === v.id}
|
||||
onClick={() => setVista(v.id)}
|
||||
onClick={() => setElegida(v.id)}
|
||||
className={
|
||||
vista === v.id
|
||||
? 'rounded-md bg-white px-3 py-1.5 text-xs font-medium text-[#111827] shadow-sm transition-all duration-100 ease-out'
|
||||
@@ -57,6 +81,8 @@ export default function DashboardPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{vista !== null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => acciones?.actualizar()}
|
||||
@@ -65,10 +91,15 @@ export default function DashboardPage() {
|
||||
>
|
||||
{acciones?.cargando ? 'Actualizando…' : 'Actualizar'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{vista === 'mia' ? (
|
||||
{vista === null ? (
|
||||
<main className="mx-auto max-w-[1280px] px-4 py-16 sm:px-6">
|
||||
<p className="text-sm text-[#64748B]">Tu rol no tiene tableros asignados por ahora.</p>
|
||||
</main>
|
||||
) : vista === 'mia' ? (
|
||||
<MiTablero alCambiarAcciones={setAcciones} />
|
||||
) : (
|
||||
<TableroGeneral mostrarEquipo={conEquipo} alCambiarAcciones={setAcciones} />
|
||||
|
||||
@@ -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<UsuarioSesion> {
|
||||
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.
|
||||
|
||||
@@ -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<Vista>('home')
|
||||
// Módulos permitidos por el rol (GET /auth/me); null = sin sesión → se muestran todos
|
||||
const [permitidos, setPermitidos] = useState<string[] | null>(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 (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Navbar />
|
||||
<VistaContactos onVolver={() => setVista('home')} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Navbar />
|
||||
@@ -24,8 +65,12 @@ export default function App() {
|
||||
|
||||
<nav aria-label="Módulos del ecosistema">
|
||||
<ul className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{MODULOS.map((modulo) => (
|
||||
<TarjetaModulo key={modulo.id} modulo={modulo} />
|
||||
{MODULOS.filter((modulo) => esModuloVisible(modulo, permitidos)).map((modulo) => (
|
||||
<TarjetaModulo
|
||||
key={modulo.id}
|
||||
modulo={modulo}
|
||||
onAbrir={modulo.id === 'contactos' ? () => setVista('contactos') : undefined}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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() {
|
||||
<p className="text-sm text-[#111827]">
|
||||
Sesión: <span className="font-medium">{perfil.nombre}</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[#64748B]">Rol: {formatearRol(perfil.rol)}</p>
|
||||
<p className="mt-2 text-sm text-[#64748B]">
|
||||
Vínculo Monday actual:{' '}
|
||||
{perfil.mondayUserId === null ? (
|
||||
|
||||
@@ -2,27 +2,26 @@ 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<string, string> = {
|
||||
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()
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
href={modulo.url}
|
||||
className="group flex h-full flex-col rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm transition-all duration-200 ease-in-out hover:shadow-md"
|
||||
>
|
||||
const contenido = (
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50 text-sm font-semibold text-accent"
|
||||
@@ -34,7 +33,22 @@ export function TarjetaModulo({ modulo }: Props) {
|
||||
<span className="mt-auto pt-6 text-sm font-medium text-accent transition-colors duration-100 ease-out group-hover:text-accent-hover">
|
||||
Entrar <span aria-hidden="true">→</span>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
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 (
|
||||
<li>
|
||||
{modulo.url ? (
|
||||
<a href={modulo.url} className={estilo}>
|
||||
{contenido}
|
||||
</a>
|
||||
) : (
|
||||
<button type="button" onClick={() => onAbrir?.(modulo)} className={estilo}>
|
||||
{contenido}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
79
apps/landing/src/components/VistaContactos.tsx
Normal file
79
apps/landing/src/components/VistaContactos.tsx
Normal file
@@ -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<Contacto[] | null>(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 (
|
||||
<main className="mx-auto w-full max-w-[1280px] flex-1 px-6 py-12 sm:py-16">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onVolver}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
← Volver
|
||||
</button>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-[#111827] sm:text-3xl">Contactos</h1>
|
||||
</div>
|
||||
|
||||
<section aria-label="Lista de contactos" className="mt-8">
|
||||
{cargando ? (
|
||||
<p className="text-sm text-[#64748B]">Cargando contactos…</p>
|
||||
) : error ? (
|
||||
<div>
|
||||
<p role="alert" className="text-sm text-red-600">
|
||||
{error}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cargar}
|
||||
className="mt-3 rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
) : !contactos || contactos.length === 0 ? (
|
||||
<p className="text-sm text-[#64748B]">No hay contactos para mostrar.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
||||
{contactos.map((contacto) => (
|
||||
<li key={contacto.id} className="flex flex-wrap items-center gap-x-4 gap-y-1 px-6 py-4">
|
||||
<span className="text-sm font-semibold text-[#111827]">{contacto.nombre}</span>
|
||||
{contacto.estado && (
|
||||
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-accent">
|
||||
{contacto.estado}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-[#64748B]">{contacto.telefono || 'Sin teléfono'}</span>
|
||||
<span className="text-sm text-[#64748B]">{contacto.email || 'Sin email'}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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<UsuarioSesion> {
|
||||
const resp = await fetch(`${API_URL}/auth/me`, conAuth())
|
||||
manejar401(resp)
|
||||
@@ -14,6 +14,23 @@ export async function getMe(): Promise<UsuarioSesion> {
|
||||
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<Contacto[]> {
|
||||
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<UsuarioMonday[]> {
|
||||
const resp = await fetch(`${API_URL}/monday/usuarios`, conAuth())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -150,6 +150,12 @@ function FilaLead({
|
||||
{lead.telefono || 'Sin teléfono'}
|
||||
{lead.origen ? ` · ${lead.origen}` : ''}
|
||||
</p>
|
||||
{lead.ultimoCambio && (
|
||||
<p className="mt-0.5 text-[11px] text-[#9CA3AF]">
|
||||
Último cambio: {lead.ultimoCambio.detalle} · {lead.ultimoCambio.usuario ?? '—'} ·{' '}
|
||||
{formatoFechaCorta(lead.ultimoCambio.fechaISO.slice(0, 10))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
|
||||
{lead.estado || 'Sin estado'}
|
||||
@@ -290,6 +296,12 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
|
||||
<div className="mx-auto flex h-14 max-w-[1280px] items-center justify-between gap-4 px-4 sm:px-6">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<a
|
||||
href="/"
|
||||
className="shrink-0 rounded-lg px-2 py-1.5 text-xs font-medium text-[#4B5563] transition-colors hover:bg-[#F9FAFB] hover:text-accent"
|
||||
>
|
||||
← Menú
|
||||
</a>
|
||||
<p className="shrink-0 text-xs font-semibold uppercase tracking-wider text-accent">
|
||||
FRASA <span aria-hidden="true" className="text-[#F97316]">·</span> Mi día
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user