feat: roles con modulos por rol + mejoras Mi dia y descarte
- Matriz de roles (vendedor, administracion_venta, director_ventas, escrituracion, direccion_general, admin) con modulos visibles por rol; auth/me y login incluyen 'modulos'; PATCH /usuarios/:id/rol (admin). - Landing: Cobranza -> Clientes, Chat IA -> Chat Hermes, nuevo modulo Contactos (vista interna leyendo el tablero Contactos), grid filtrado por rol, rol visible en Configuracion de cuenta. - Dashboards: tab 'Tablero comercial' y tabs filtrados por rol. - Boton '<- Menu' en el header de todas las apps. - Mi dia: cada lead muestra el ultimo cambio (que, quien, cuando) desde el activity log de Monday (normalizacion de created_at 100ns/us/ms). - Descarte: el motivo se crea en el grupo de su categoria en el tablero Motivos No Compra.
This commit is contained in:
@@ -1,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]"
|
||||
>
|
||||
← 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} />
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user