Roles ampliados (10) y matriz de permisos por rol

- Roles: admin, médico, recepción, enfermería, cosmetología, psicóloga,
  farmacia, entrenador, marketing + lectura (compatibilidad)
- Matriz de permisos por rol persistida en Odoo (ir.config_parameter)
  y editable en Configuración → Roles (secciones × roles)
- Prioridad: permisos personales del usuario > matriz por rol > defaults
- Protección: admin siempre conserva /configuracion
This commit is contained in:
2026-08-14 08:47:27 +00:00
parent be42974030
commit 281fb34588
9 changed files with 346 additions and 29 deletions

View File

@@ -0,0 +1,132 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Save, Info, Lock } from 'lucide-react';
import { Button, Skeleton, toast } from './ui';
import { odooApi, type FrontendRole } from '../services/odoo';
import { ROLE_LABELS } from '../lib/auth';
import { MENU_ITEMS } from '../lib/menu-items';
const ROLES = Object.keys(ROLE_LABELS) as FrontendRole[];
type Matrix = Record<string, FrontendRole[]>;
const RolesPanel: FC = () => {
const [matrix, setMatrix] = useState<Matrix | null>(null);
const [saved, setSaved] = useState<Matrix | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getRolePermissions();
if (res.status === 'success') {
setMatrix(res.matrix);
setSaved(res.matrix);
} else {
toast.error(res.message || 'No se pudieron cargar los permisos');
}
} catch (err) {
toast.error('Error al cargar permisos por rol');
console.error(err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const dirty = matrix !== null && saved !== null && JSON.stringify(matrix) !== JSON.stringify(saved);
const toggle = (href: string, role: FrontendRole) => {
if (!matrix) return;
if (href === '/configuracion' && role === 'admin') return; // siempre requerido
const current = matrix[href] || [];
const next = current.includes(role) ? current.filter((r) => r !== role) : [...current, role];
setMatrix({ ...matrix, [href]: next });
};
const save = async () => {
if (!matrix) return;
try {
setSaving(true);
const res = await odooApi.updateRolePermissions(matrix);
if (res.status === 'success') {
setMatrix(res.matrix);
setSaved(res.matrix);
toast.success('Permisos por rol guardados');
} else {
toast.error(res.message || 'No se pudieron guardar los permisos');
}
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al guardar permisos');
console.error(err);
} finally {
setSaving(false);
}
};
if (loading || !matrix) {
return <Skeleton count={6} className="h-10 w-full" />;
}
return (
<div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4">
<p className="text-sm text-theme-muted flex items-start gap-2 max-w-2xl">
<Info size={16} className="mt-0.5 shrink-0" />
Aplica a todos los usuarios del rol al recargar su sesión. Los permisos personales de un usuario van encima.
</p>
<div className="flex items-center gap-3 shrink-0">
{dirty && <span className="text-xs font-medium text-amber-600">Cambios sin guardar</span>}
<Button onClick={save} loading={saving} disabled={!dirty}>
<Save size={16} className="mr-2" />
Guardar
</Button>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-3 py-2 sticky left-0 bg-theme-bg">Sección</th>
{ROLES.map((r) => (
<th key={r} className="px-2 py-2 text-center font-medium">{ROLE_LABELS[r]}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{MENU_ITEMS.map((item) => (
<tr key={item.href} className="hover:bg-theme-bg">
<td className="px-3 py-2 font-medium text-theme-heading whitespace-nowrap sticky left-0 bg-theme-surface">
{item.label}
</td>
{ROLES.map((role) => {
const locked = item.href === '/configuracion' && role === 'admin';
const checked = (matrix[item.href] || []).includes(role);
return (
<td key={role} className="px-2 py-2 text-center">
<input
type="checkbox"
checked={checked}
disabled={locked}
onChange={() => toggle(item.href, role)}
title={locked ? 'El admin siempre requiere Configuración' : `${item.label} — ${ROLE_LABELS[role]}`}
className="rounded border-theme-border-strong disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
/>
{locked && <Lock size={10} className="inline ml-1 text-theme-muted" />}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
export default RolesPanel;

View File

@@ -17,7 +17,7 @@ import { ROLE_LABELS, useAuth } from '../lib/auth';
import { MENU_ITEMS } from '../lib/menu-items';
const roleOptions: { value: FrontendRole; label: string }[] = (
['admin', 'recepcion', 'medico', 'lectura'] as FrontendRole[]
Object.keys(ROLE_LABELS) as FrontendRole[]
).map((r) => ({ value: r, label: ROLE_LABELS[r] }));
const roleBadge = (role: FrontendRole) => {