feat: roles con modulos por rol + mejoras Mi dia y descarte

- Matriz de roles (vendedor, administracion_venta, director_ventas,
  escrituracion, direccion_general, admin) con modulos visibles por rol;
  auth/me y login incluyen 'modulos'; PATCH /usuarios/:id/rol (admin).
- Landing: Cobranza -> Clientes, Chat IA -> Chat Hermes, nuevo modulo
  Contactos (vista interna leyendo el tablero Contactos), grid filtrado
  por rol, rol visible en Configuracion de cuenta.
- Dashboards: tab 'Tablero comercial' y tabs filtrados por rol.
- Boton '<- Menu' en el header de todas las apps.
- Mi dia: cada lead muestra el ultimo cambio (que, quien, cuando) desde
  el activity log de Monday (normalizacion de created_at 100ns/us/ms).
- Descarte: el motivo se crea en el grupo de su categoria en el tablero
  Motivos No Compra.
This commit is contained in:
2026-09-04 09:12:01 +00:00
parent 42cfe937cf
commit f72354f775
33 changed files with 1224 additions and 114 deletions

View File

@@ -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">
<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 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]"
>
&larr; 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}

View File

@@ -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]"
>
&larr; Menú
</a>
<span className="text-sm font-bold tracking-widest text-[#111827]">
FRASA<span className="text-[#F97316]">.</span>
</span>

View File

@@ -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>

View File

@@ -1,6 +1,6 @@
{
"name": "Frasa · Cobranza",
"short_name": "Cobranza",
"name": "Frasa · Clientes",
"short_name": "Clientes",
"start_url": "/pay/",
"scope": "/pay/",
"display": "standalone",

View File

@@ -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">
<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>
</Link>
<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]"
>
&larr; 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]">CLIENTES</span>
</Link>
</div>
<nav className="flex items-center gap-3 text-sm sm:gap-6">
<Link
to="/"

View File

@@ -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">
<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 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]"
>
&larr; 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}

View File

@@ -1,74 +1,105 @@
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]"
>
&larr; 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">
<div
className="inline-flex rounded-lg border border-[#E5E7EB] bg-[#F1F5F9] p-1"
role="tablist"
aria-label="Vista del tablero"
>
{VISTAS.map((v) => (
<button
key={v.id}
type="button"
role="tab"
aria-selected={vista === v.id}
onClick={() => setVista(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'
: 'rounded-md px-3 py-1.5 text-xs font-medium text-[#64748B] transition-all duration-100 ease-out hover:text-[#111827]'
}
>
{v.etiqueta}
</button>
))}
</div>
<button
type="button"
onClick={() => acciones?.actualizar()}
disabled={!acciones || acciones.cargando}
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 disabled:opacity-50"
>
{acciones?.cargando ? 'Actualizando…' : 'Actualizar'}
</button>
{permitidas.length > 1 && (
<div
className="inline-flex rounded-lg border border-[#E5E7EB] bg-[#F1F5F9] p-1"
role="tablist"
aria-label="Vista del tablero"
>
{permitidas.map((v) => (
<button
key={v.id}
type="button"
role="tab"
aria-selected={vista === 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'
: 'rounded-md px-3 py-1.5 text-xs font-medium text-[#64748B] transition-all duration-100 ease-out hover:text-[#111827]'
}
>
{v.etiqueta}
</button>
))}
</div>
)}
{vista !== null && (
<button
type="button"
onClick={() => acciones?.actualizar()}
disabled={!acciones || acciones.cargando}
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 disabled:opacity-50"
>
{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} />

View File

@@ -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.

View File

@@ -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>

View File

@@ -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()

View File

@@ -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 ? (

View File

@@ -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<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()
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"
>
{iniciales}
</span>
<h2 className="mt-4 text-base font-semibold text-[#111827]">{modulo.nombre}</h2>
<p className="mt-1 text-sm leading-relaxed text-[#64748B]">{modulo.descripcion}</p>
<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">&rarr;</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>
<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"
>
<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"
>
{iniciales}
</span>
<h2 className="mt-4 text-base font-semibold text-[#111827]">{modulo.nombre}</h2>
<p className="mt-1 text-sm leading-relaxed text-[#64748B]">{modulo.descripcion}</p>
<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">&rarr;</span>
</span>
</a>
{modulo.url ? (
<a href={modulo.url} className={estilo}>
{contenido}
</a>
) : (
<button type="button" onClick={() => onAbrir?.(modulo)} className={estilo}>
{contenido}
</button>
)}
</li>
)
}

View 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]"
>
&larr; 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>
)
}

View File

@@ -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)
})
})

View File

@@ -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))
}

View File

@@ -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())

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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>