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

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