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

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