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:
@@ -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,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">→</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">→</span>
|
||||
</span>
|
||||
</a>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user