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:
240
apps/landing/src/components/config/RolesPermisos.tsx
Normal file
240
apps/landing/src/components/config/RolesPermisos.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user