feat(config): modulo Configuraciones admin + permisos por rol

- Tablas ConfiguracionSistema (7 configs) + RolConfiguracion (6 roles)
- Endpoints: config/sistema GET/PATCH, config/roles GET/PATCH,
  config/usuarios GET/PATCH, config/reset-roles POST, config/acciones GET
- modulosDeRol() async con fallback a MATRIZ estatica
- auth/me devuelve acciones[] segun rol del usuario
- saludMonday.estaDisponible() lee modo_local_forzado de DB (cache 30s)
- Landing: vista Configuraciones con 4 tabs (Modo, Roles, Usuarios, Sistema)
- Mi-dia: boton Descartar se deshabilita si !descartar_lead
- 630 API tests + 24 landing tests + 20 mi-dia tests verdes

Seed: npm run seed:config
This commit is contained in:
2026-09-06 09:46:19 +00:00
parent 50e6eca7da
commit b2634f97ab
35 changed files with 2572 additions and 33 deletions

View File

@@ -0,0 +1,240 @@
import { useEffect, useState, useCallback } from 'react'
import {
getRolesConfig,
patchRolConfig,
resetRoles,
type RolConfig,
} from '../../services/config'
const MODULOS_DISPONIBLES = [
'mi-dia',
'cotizador',
'alta-cliente',
'alta-afluencia',
'cobranza',
'chat',
'mi-tablero',
'tablero-comercial',
'contactos',
]
const ACCIONES_DISPONIBLES = [
'descartar_lead',
'editar_cotizacion',
'asignar_vendedor',
'ver_reportes',
'exportar_pdf',
'ver_precios',
]
/** Descripción amigable de cada acción */
const ACCION_LABELS: Record<string, string> = {
descartar_lead: 'Descartar leads',
editar_cotizacion: 'Editar cotizaciones',
asignar_vendedor: 'Asignar vendedor',
ver_reportes: 'Ver reportes',
exportar_pdf: 'Exportar PDF',
ver_precios: 'Ver precios',
}
/** Descripción amigable de cada módulo */
const MODULO_LABELS: Record<string, string> = {
'mi-dia': 'Mi día',
cotizador: 'Cotizador',
'alta-cliente': 'Alta cliente',
'alta-afluencia': 'Alta afluencia',
cobranza: 'Cobranza',
chat: 'Chat IA',
'mi-tablero': 'Mi tablero',
'tablero-comercial': 'Tablero comercial',
contactos: 'Contactos',
}
interface RolEstado {
modulos: string[]
acciones: Record<string, boolean>
}
export function RolesPermisos() {
const [roles, setRoles] = useState<RolConfig[]>([])
const [edicion, setEdicion] = useState<Record<string, RolEstado>>({})
const [cargando, setCargando] = useState(true)
const [guardando, setGuardando] = useState(false)
const [aviso, setAviso] = useState('')
const [error, setError] = useState('')
const cargar = useCallback(async () => {
setCargando(true)
setError('')
try {
const data = await getRolesConfig()
setRoles(data)
// Construir estado de edición
const estado: Record<string, RolEstado> = {}
for (const r of data) {
estado[r.rol] = {
modulos: JSON.parse(r.modulos) as string[],
acciones: JSON.parse(r.acciones) as Record<string, boolean>,
}
}
setEdicion(estado)
} catch {
setError('No se pudieron cargar los roles.')
} finally {
setCargando(false)
}
}, [])
useEffect(() => { void cargar() }, [cargar])
const toggleModulo = (rol: string, modulo: string) => {
setEdicion((prev) => {
const actual = prev[rol]
if (!actual) return prev
const modulos = actual.modulos.includes(modulo)
? actual.modulos.filter((m) => m !== modulo)
: [...actual.modulos, modulo]
return { ...prev, [rol]: { ...actual, modulos } }
})
}
const toggleAccion = (rol: string, accion: string) => {
setEdicion((prev) => {
const actual = prev[rol]
if (!actual) return prev
const acciones = { ...actual.acciones, [accion]: !actual.acciones[accion] }
return { ...prev, [rol]: { ...actual, acciones } }
})
}
const guardarTodo = async () => {
setGuardando(true)
setAviso('')
setError('')
try {
await Promise.all(
Object.entries(edicion).map(([rol, estado]) =>
patchRolConfig(rol, {
modulos: estado.modulos,
acciones: estado.acciones,
}),
),
)
setAviso('Todos los roles se actualizaron correctamente.')
} catch {
setError('No se pudieron guardar los cambios. Intenta de nuevo.')
} finally {
setGuardando(false)
}
}
const restaurar = async () => {
if (!window.confirm('¿Restaurar todos los roles a sus valores por defecto?')) return
setGuardando(true)
setAviso('')
setError('')
try {
await resetRoles()
await cargar()
setAviso('Roles restaurados a valores por defecto.')
} catch {
setError('No se pudieron restaurar los roles.')
} finally {
setGuardando(false)
}
}
if (cargando) {
return (
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
<p className="text-sm text-[#64748B]">Cargando configuración de roles…</p>
</div>
)
}
return (
<div className="space-y-4">
{/* Botones de acción */}
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() => void guardarTodo()}
disabled={guardando}
className="rounded-lg bg-[#2563EB] px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-[#1D4ED8] disabled:opacity-50"
>
{guardando ? 'Guardando…' : 'Guardar cambios'}
</button>
<button
type="button"
onClick={() => void restaurar()}
disabled={guardando}
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] disabled:opacity-50"
>
Restaurar valores por defecto
</button>
</div>
{/* Feedback */}
{aviso && (
<p role="status" className="text-sm font-medium text-green-600">{aviso}</p>
)}
{error && (
<p role="alert" className="text-sm text-red-600">{error}</p>
)}
{/* Tabla de roles */}
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-[#E5E7EB] bg-[#F9FAFB]">
<th className="px-4 py-3 font-medium text-[#111827]">Rol</th>
{MODULOS_DISPONIBLES.map((m) => (
<th key={m} className="px-3 py-3 text-center text-xs font-medium text-[#64748B]">
{MODULO_LABELS[m] ?? m}
</th>
))}
{ACCIONES_DISPONIBLES.map((a) => (
<th key={a} className="px-3 py-3 text-center text-xs font-medium text-[#64748B]">
{ACCION_LABELS[a] ?? a}
</th>
))}
</tr>
</thead>
<tbody>
{roles.map((r) => {
const estado = edicion[r.rol]
if (!estado) return null
return (
<tr key={r.rol} className="border-b border-[#E5E7EB] last:border-0">
<td className="whitespace-nowrap px-4 py-3 font-medium text-[#111827]">
{r.rol}
</td>
{MODULOS_DISPONIBLES.map((m) => (
<td key={m} className="px-3 py-3 text-center">
<input
type="checkbox"
checked={estado.modulos.includes(m)}
onChange={() => toggleModulo(r.rol, m)}
className="h-4 w-4 rounded border-[#D1D5DB] text-accent focus:ring-accent/20"
/>
</td>
))}
{ACCIONES_DISPONIBLES.map((a) => (
<td key={a} className="px-3 py-3 text-center">
<input
type="checkbox"
checked={!!estado.acciones[a]}
onChange={() => toggleAccion(r.rol, a)}
className="h-4 w-4 rounded border-[#D1D5DB] text-accent focus:ring-accent/20"
/>
</td>
))}
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}