fase 2.1: roles editables (modelo skeen.role) y permisos por accion (43 claves, 9 grupos) con enforcement en backend y gating en frontend; editor de roles/permisos/menus en Configuracion
This commit is contained in:
@@ -1,62 +1,187 @@
|
||||
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 { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Save, Info, Lock, Plus, Pencil, Ban, RotateCcw } from 'lucide-react';
|
||||
import { Button, Input, Select, Modal, Badge, Skeleton, toast } from './ui';
|
||||
import { odooApi, type FrontendRole, type Role, type PermGroup, type PermsResponse } from '../services/odoo';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { MENU_ITEMS, menuItemLabel } from '../lib/menu-items';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
|
||||
const ROLES = Object.keys(ROLE_LABELS) as FrontendRole[];
|
||||
|
||||
type Matrix = Record<string, FrontendRole[]>;
|
||||
type Tab = 'roles' | 'permisos' | 'menus';
|
||||
|
||||
// Roles del sistema protectores: no se pueden desactivar.
|
||||
const PROTEGIDOS = ['admin', 'lectura'];
|
||||
|
||||
const RolesPanel: FC = () => {
|
||||
const { term } = useGlosario();
|
||||
const [matrix, setMatrix] = useState<Matrix | null>(null);
|
||||
const [saved, setSaved] = useState<Matrix | null>(null);
|
||||
const { user } = useAuth();
|
||||
const [tab, setTab] = useState<Tab>('roles');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// ---- Estado de la lista de roles ----
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
|
||||
// ---- Estado de permisos ----
|
||||
const [perms, setPerms] = useState<PermsResponse | null>(null);
|
||||
const [permRole, setPermRole] = useState<string>('recepcion');
|
||||
const [permDraft, setPermDraft] = useState<Record<string, boolean>>({});
|
||||
const [permSaved, setPermSaved] = useState<Record<string, boolean>>({});
|
||||
const [permSaving, setPermSaving] = useState(false);
|
||||
|
||||
// ---- Estado de la matriz de menús ----
|
||||
const [matrix, setMatrix] = useState<Matrix | null>(null);
|
||||
const [matrixSaved, setMatrixSaved] = useState<Matrix | null>(null);
|
||||
const [menuSaving, setMenuSaving] = useState(false);
|
||||
|
||||
// ---- Modal de rol ----
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Role | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', rank: 50, sequence: 10, description: '', active: true });
|
||||
|
||||
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');
|
||||
const [rr, rp] = await Promise.all([
|
||||
odooApi.listRoles({ includeInactive: true }),
|
||||
odooApi.getRolePermissions(),
|
||||
]);
|
||||
if (rr.status === 'success') setRoles(rr.roles);
|
||||
if (rp.status === 'success') {
|
||||
setMatrix(rp.matrix);
|
||||
setMatrixSaved(rp.matrix);
|
||||
}
|
||||
// Los permisos por acción solo los puede leer admin/gerencia.
|
||||
try {
|
||||
const rperm = await odooApi.getPermisos();
|
||||
if (rperm.status === 'success') {
|
||||
setPerms(rperm);
|
||||
const draft = rperm.por_rol[permRole] || {};
|
||||
setPermDraft(draft);
|
||||
setPermSaved(draft);
|
||||
}
|
||||
} catch {
|
||||
// rol sin permiso de edición de roles: la pestaña Permisos avisa
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar permisos por rol');
|
||||
toast.error('Error al cargar roles y permisos');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [permRole]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const dirty = matrix !== null && saved !== null && JSON.stringify(matrix) !== JSON.stringify(saved);
|
||||
// Al cambiar de rol seleccionado, cargar su borrador de permisos.
|
||||
useEffect(() => {
|
||||
if (!perms) return;
|
||||
const draft = perms.por_rol[permRole] || {};
|
||||
setPermDraft(draft);
|
||||
setPermSaved(draft);
|
||||
}, [permRole, perms]);
|
||||
|
||||
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 activeRoles = useMemo(() => roles.filter((r) => r.active), [roles]);
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Pestaña Roles
|
||||
// ---------------------------------------------------------------
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm({ name: '', rank: 50, sequence: 10, description: '', active: true });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!matrix) return;
|
||||
const openEdit = (r: Role) => {
|
||||
setEditing(r);
|
||||
setForm({
|
||||
name: r.name,
|
||||
rank: r.rank,
|
||||
sequence: r.sequence,
|
||||
description: r.description || '',
|
||||
active: r.active,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const submitRole = async () => {
|
||||
if (!form.name.trim()) {
|
||||
toast.error('El nombre del rol es obligatorio');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSaving(true);
|
||||
const res = await odooApi.updateRolePermissions(matrix);
|
||||
setSubmitting(true);
|
||||
if (editing) {
|
||||
await odooApi.updateRole(editing.id, {
|
||||
name: form.name.trim(),
|
||||
rank: form.rank,
|
||||
sequence: form.sequence,
|
||||
description: form.description,
|
||||
active: form.active,
|
||||
});
|
||||
toast.success('Rol actualizado');
|
||||
} else {
|
||||
await odooApi.createRole({
|
||||
name: form.name.trim(),
|
||||
rank: form.rank,
|
||||
sequence: form.sequence,
|
||||
description: form.description,
|
||||
active: form.active,
|
||||
});
|
||||
toast.success('Rol creado (arranca con permisos de Lectura)');
|
||||
}
|
||||
setModalOpen(false);
|
||||
await load();
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Error al guardar el rol');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const desactivarRol = async (r: Role) => {
|
||||
if (!window.confirm(`¿Desactivar el rol "${r.name}"? Los usuarios que lo tengan conservarán el rol.`)) return;
|
||||
try {
|
||||
const res = await odooApi.deleteRole(r.id);
|
||||
toast.success(res.message || 'Rol desactivado');
|
||||
await load();
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudo desactivar el rol');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Pestaña Permisos
|
||||
// ---------------------------------------------------------------
|
||||
const permDirty = JSON.stringify(permDraft) !== JSON.stringify(permSaved);
|
||||
|
||||
const togglePerm = (key: string) => {
|
||||
setPermDraft((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
};
|
||||
|
||||
const setAllPerms = (value: boolean, group: PermGroup | null) => {
|
||||
setPermDraft((prev) => {
|
||||
const next = { ...prev };
|
||||
const grupos = group ? [group] : (perms?.catalogo || []);
|
||||
for (const g of grupos) for (const p of g.permisos) next[p.key] = value;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const savePerms = async () => {
|
||||
if (!perms) return;
|
||||
try {
|
||||
setPermSaving(true);
|
||||
const res = await odooApi.putPermisos({ role_code: permRole, permisos: permDraft });
|
||||
if (res.status === 'success') {
|
||||
setMatrix(res.matrix);
|
||||
setSaved(res.matrix);
|
||||
toast.success('Permisos por rol guardados');
|
||||
setPerms({ ...perms, por_rol: res.por_rol });
|
||||
setPermSaved(permDraft);
|
||||
toast.success('Permisos guardados (aplican al recargar la sesión de cada usuario)');
|
||||
} else {
|
||||
toast.error(res.message || 'No se pudieron guardar los permisos');
|
||||
}
|
||||
@@ -65,68 +190,315 @@ const RolesPanel: FC = () => {
|
||||
toast.error(msg || 'Error al guardar permisos');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setPermSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !matrix) {
|
||||
// ---------------------------------------------------------------
|
||||
// Pestaña Menús
|
||||
// ---------------------------------------------------------------
|
||||
const menuDirty = matrix !== null && matrixSaved !== null && JSON.stringify(matrix) !== JSON.stringify(matrixSaved);
|
||||
|
||||
const toggleMenu = (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 saveMenus = async () => {
|
||||
if (!matrix) return;
|
||||
try {
|
||||
setMenuSaving(true);
|
||||
const res = await odooApi.updateRolePermissions(matrix);
|
||||
if (res.status === 'success') {
|
||||
setMatrix(res.matrix);
|
||||
setMatrixSaved(res.matrix);
|
||||
toast.success('Permisos de menú guardados');
|
||||
} else {
|
||||
toast.error(res.message || 'No se pudieron guardar los menús');
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Error al guardar menús');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setMenuSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Skeleton count={6} className="h-10 w-full" />;
|
||||
}
|
||||
|
||||
const puedeEditar = user?.role === 'admin' || user?.role === 'gerencia';
|
||||
|
||||
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 className="flex flex-wrap gap-1.5 mb-4">
|
||||
{([['roles', 'Roles'], ['permisos', 'Permisos'], ['menus', 'Menús']] as [Tab, string][]).map(([k, label]) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
onClick={() => setTab(k)}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-full transition ${
|
||||
tab === k ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</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">
|
||||
{menuItemLabel(item, term)}
|
||||
</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" />}
|
||||
{/* ---------------- Roles ---------------- */}
|
||||
{tab === 'roles' && (
|
||||
<>
|
||||
<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" />
|
||||
Los roles se pueden crear, renombrar y ajustar de rango. Los del sistema
|
||||
(marcados) no se pueden eliminar; nunca se cambia su código porque los usuarios
|
||||
ya guardados lo usan.
|
||||
</p>
|
||||
{puedeEditar && (
|
||||
<Button onClick={openCreate} className="shrink-0">
|
||||
<Plus size={16} className="mr-2" />
|
||||
Nuevo rol
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-theme-bg">
|
||||
<tr>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Rol</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Código</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Rango</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Estado</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{roles.map((r) => (
|
||||
<tr key={r.id} className="hover:bg-theme-bg">
|
||||
<td className="p-3 text-sm font-medium text-theme-heading">
|
||||
{r.name}
|
||||
{r.is_system && <Badge variant="default" className="ml-2">Sistema</Badge>}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<td className="p-3 text-sm font-mono text-theme-muted">{r.code}</td>
|
||||
<td className="p-3 text-sm text-theme-muted">{r.rank}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={r.active ? 'success' : 'default'}>{r.active ? 'Activo' : 'Inactivo'}</Badge>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{puedeEditar && (
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(r)} title="Editar">
|
||||
<Pencil size={16} className="text-theme-muted" />
|
||||
</Button>
|
||||
)}
|
||||
{puedeEditar && r.active && !r.is_system && !PROTEGIDOS.includes(r.code) && (
|
||||
<Button variant="ghost" size="sm" onClick={() => desactivarRol(r)} title="Desactivar">
|
||||
<Ban size={16} className="text-rose-500" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ---------------- Permisos ---------------- */}
|
||||
{tab === 'permisos' && (
|
||||
!perms ? (
|
||||
<p className="text-sm text-theme-muted">No tienes permiso para ver o editar los permisos por acción.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-56">
|
||||
<Select
|
||||
label="Rol"
|
||||
options={activeRoles.map((r) => ({ value: r.code, label: r.name }))}
|
||||
value={permRole}
|
||||
onChange={(e) => setPermRole(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-5">
|
||||
<Button variant="outline" size="sm" onClick={() => setAllPerms(true, null)}>Todo</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setAllPerms(false, null)}>Ninguno</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{permDirty && <span className="text-xs font-medium text-amber-600">Cambios sin guardar</span>}
|
||||
<Button onClick={savePerms} loading={permSaving} disabled={!permDirty}>
|
||||
<Save size={16} className="mr-2" />
|
||||
Guardar permisos
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{permRole === 'admin' && (
|
||||
<p className="text-xs text-theme-muted mb-3">
|
||||
El rol Administrador siempre tiene todos los permisos, sin importar lo que marques aquí.
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{perms.catalogo.map((g) => (
|
||||
<div key={g.grupo} className="border border-theme-border rounded-xl p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-semibold text-theme-heading">{g.label}</h4>
|
||||
<div className="flex gap-1">
|
||||
<button type="button" className="text-xs text-theme-muted hover:text-theme-heading" onClick={() => setAllPerms(true, g)}>todo</button>
|
||||
<span className="text-xs text-theme-muted">/</span>
|
||||
<button type="button" className="text-xs text-theme-muted hover:text-theme-heading" onClick={() => setAllPerms(false, g)}>ninguno</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{g.permisos.map((p) => (
|
||||
<label key={p.key} className="flex items-center gap-2 text-sm text-theme-heading cursor-pointer p-1.5 rounded-lg hover:bg-theme-bg">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!permDraft[p.key]}
|
||||
onChange={() => togglePerm(p.key)}
|
||||
className="rounded border-theme-border-strong"
|
||||
/>
|
||||
{p.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* ---------------- Menús ---------------- */}
|
||||
{tab === 'menus' && (
|
||||
matrix === null ? (
|
||||
<Skeleton count={6} className="h-10 w-full" />
|
||||
) : (
|
||||
<>
|
||||
<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" />
|
||||
Qué secciones ve cada rol. Aplica al recargar su sesión. Los permisos personales de un usuario van encima.
|
||||
</p>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{menuDirty && <span className="text-xs font-medium text-amber-600">Cambios sin guardar</span>}
|
||||
<Button onClick={saveMenus} loading={menuSaving} disabled={!menuDirty}>
|
||||
<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.filter((r) => r.active).map((r) => (
|
||||
<th key={r.code} className="px-2 py-2 text-center font-medium">{r.name}</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">
|
||||
{menuItemLabel(item, term)}
|
||||
</td>
|
||||
{roles.filter((r) => r.active).map((r) => {
|
||||
const locked = item.href === '/configuracion' && r.code === 'admin';
|
||||
const checked = (matrix[item.href] || []).includes(r.code);
|
||||
return (
|
||||
<td key={r.code} className="px-2 py-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={locked || !puedeEditar}
|
||||
onChange={() => toggleMenu(item.href, r.code)}
|
||||
title={locked ? 'El admin siempre requiere Configuración' : `${menuItemLabel(item, term)} — ${r.name}`}
|
||||
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>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Modal crear/editar rol */}
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
title={editing ? `Editar rol — ${editing.name}` : 'Nuevo rol'}
|
||||
maxWidth="md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={submitRole} loading={submitting}>{editing ? 'Guardar' : 'Crear'}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Nombre"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="ej. Terapeuta"
|
||||
/>
|
||||
{editing && (
|
||||
<p className="text-xs text-theme-muted">
|
||||
Código: <span className="font-mono">{editing.code}</span> (no se puede cambiar)
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Rango (mayor = más acceso)"
|
||||
type="number"
|
||||
value={String(form.rank)}
|
||||
onChange={(e) => setForm({ ...form, rank: parseInt(e.target.value || '0', 10) })}
|
||||
/>
|
||||
<Input
|
||||
label="Orden"
|
||||
type="number"
|
||||
value={String(form.sequence)}
|
||||
onChange={(e) => setForm({ ...form, sequence: parseInt(e.target.value || '0', 10) })}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Descripción (opcional)"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
/>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-theme-heading cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.active}
|
||||
onChange={(e) => setForm({ ...form, active: e.target.checked })}
|
||||
className="rounded border-theme-border-strong"
|
||||
/>
|
||||
Rol activo
|
||||
</label>
|
||||
{!perms && (
|
||||
<p className="text-xs text-theme-muted flex items-center gap-1">
|
||||
<RotateCcw size={12} /> Los roles nuevos heredan los permisos de Lectura; ajústalos en la pestaña Permisos.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { Plus, Pencil, KeyRound, ShieldCheck, Trash2, AlertTriangle } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
@@ -12,15 +12,11 @@ import {
|
||||
MobileCard,
|
||||
toast,
|
||||
} from './ui';
|
||||
import { odooApi, type FrontendUser, type FrontendRole, type AgendaPerm, AGENDA_PERM_ITEMS } from '../services/odoo';
|
||||
import { ROLE_LABELS, useAuth } from '../lib/auth';
|
||||
import { odooApi, type FrontendUser, type FrontendRole, type AgendaPerm, type Role, type PermGroup, AGENDA_PERM_ITEMS } from '../services/odoo';
|
||||
import { ROLE_LABELS, roleLabel, useAuth } from '../lib/auth';
|
||||
import { MENU_ITEMS, menuItemLabel } from '../lib/menu-items';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
|
||||
const roleOptions: { value: FrontendRole; label: string }[] = (
|
||||
Object.keys(ROLE_LABELS) as FrontendRole[]
|
||||
).map((r) => ({ value: r, label: ROLE_LABELS[r] }));
|
||||
|
||||
const roleBadge = (role: FrontendRole) => {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
@@ -50,12 +46,32 @@ const UsuariosPanel: FC = () => {
|
||||
const [agendaCustom, setAgendaCustom] = useState(false);
|
||||
const [agendaSelected, setAgendaSelected] = useState<AgendaPerm[]>([]);
|
||||
const [permSaving, setPermSaving] = useState(false);
|
||||
// Roles configurables (para el selector) y permisos que hereda cada rol
|
||||
// (solo lectura, informativo).
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [permCatalogo, setPermCatalogo] = useState<PermGroup[]>([]);
|
||||
const [permsByRole, setPermsByRole] = useState<Record<string, Record<string, boolean>>>({});
|
||||
const [permDefaults, setPermDefaults] = useState<Record<string, Record<string, boolean>>>({});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.listFrontendUsers();
|
||||
if (res.status === 'success') setUsers(res.users);
|
||||
// Roles disponibles para asignar (solo activos).
|
||||
try {
|
||||
const rr = await odooApi.listRoles();
|
||||
if (rr.status === 'success') setRoles(rr.roles);
|
||||
} catch { /* sin roles dinámicos: fallback a ROLE_LABELS */ }
|
||||
// Permisos por rol (informativo). Requiere ser admin/gerencia.
|
||||
try {
|
||||
const rp = await odooApi.getPermisos();
|
||||
if (rp.status === 'success') {
|
||||
setPermCatalogo(rp.catalogo);
|
||||
setPermsByRole(rp.por_rol);
|
||||
setPermDefaults(rp.defaults);
|
||||
}
|
||||
} catch { /* sin permiso: solo se oculta la leyenda */ }
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar usuarios');
|
||||
console.error(err);
|
||||
@@ -66,6 +82,22 @@ const UsuariosPanel: FC = () => {
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const roleOptions: { value: string; label: string }[] = useMemo(() => {
|
||||
const activos = roles.filter((r) => r.active);
|
||||
if (activos.length) return activos.map((r) => ({ value: r.code, label: r.name }));
|
||||
return Object.keys(ROLE_LABELS).map((r) => ({ value: r, label: ROLE_LABELS[r] }));
|
||||
}, [roles]);
|
||||
|
||||
// Permisos que hereda un rol: del endpoint o del espejo de defaults.
|
||||
const inheritedPerms = useCallback((role: string): { key: string; label: string }[] => {
|
||||
const valores = permsByRole[role] || permDefaults[role] || {};
|
||||
const out: { key: string; label: string }[] = [];
|
||||
for (const g of permCatalogo) {
|
||||
for (const p of g.permisos) if (valores[p.key]) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}, [permsByRole, permDefaults, permCatalogo]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm({ login: '', name: '', password: '', role: 'recepcion', active: true });
|
||||
@@ -225,7 +257,7 @@ const UsuariosPanel: FC = () => {
|
||||
<tr key={u.id} className="hover:bg-theme-bg">
|
||||
<td className="p-3 text-sm font-mono text-theme-heading">{u.login}</td>
|
||||
<td className="p-3 text-sm font-medium text-theme-heading">{u.name}</td>
|
||||
<td className="p-3"><Badge variant={roleBadge(u.role)}>{ROLE_LABELS[u.role]}</Badge></td>
|
||||
<td className="p-3"><Badge variant={roleBadge(u.role)}>{roleLabel(u.role, u.role_name)}</Badge></td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">
|
||||
{u.allowed_menus && u.allowed_menus.length > 0
|
||||
? `${u.allowed_menus.length} de ${MENU_ITEMS.length} secciones`
|
||||
@@ -264,7 +296,7 @@ const UsuariosPanel: FC = () => {
|
||||
title={u.name}
|
||||
subtitle={u.login}
|
||||
rows={[
|
||||
{ label: 'Rol', value: <Badge variant={roleBadge(u.role)}>{ROLE_LABELS[u.role]}</Badge> },
|
||||
{ label: 'Rol', value: <Badge variant={roleBadge(u.role)}>{roleLabel(u.role, u.role_name)}</Badge> },
|
||||
{ label: 'Estado', value: <Badge variant={u.active !== false ? 'success' : 'default'}>{u.active !== false ? 'Activo' : 'Inactivo'}</Badge> },
|
||||
{ label: 'Secciones', value: u.allowed_menus && u.allowed_menus.length > 0 ? `${u.allowed_menus.length} de ${MENU_ITEMS.length}` : 'Por rol' },
|
||||
]}
|
||||
@@ -320,6 +352,27 @@ const UsuariosPanel: FC = () => {
|
||||
value={form.role}
|
||||
onChange={(e) => setForm({ ...form, role: e.target.value as FrontendRole })}
|
||||
/>
|
||||
{permCatalogo.length > 0 && (
|
||||
<div className="rounded-xl border border-theme-border p-3 bg-theme-bg">
|
||||
<p className="text-xs font-medium text-theme-heading mb-1.5">
|
||||
Permisos que hereda este rol ({inheritedPerms(form.role).length})
|
||||
</p>
|
||||
{inheritedPerms(form.role).length === 0 ? (
|
||||
<p className="text-xs text-theme-muted">Sin permisos de escritura; solo consulta básica.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{inheritedPerms(form.role).map((p) => (
|
||||
<span key={p.key} className="text-[11px] px-1.5 py-0.5 rounded bg-theme-accent-bg text-theme-muted">
|
||||
{p.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[11px] text-theme-muted mt-2">
|
||||
Se editan en Configuración › Roles › Permisos. Los permisos de Agenda de abajo son un ajuste fino por usuario.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
label={editing ? 'Nueva contraseña (dejar vacío para no cambiar)' : 'Contraseña (mín. 8)'}
|
||||
type="password"
|
||||
|
||||
@@ -2,13 +2,14 @@ import { createContext, useContext, useEffect, useState, useCallback } from 'rea
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { odooApi, type FrontendUser, type FrontendRole, type AgendaPerm, type ClinicBrand, BRAND_DEFAULT } from '../services/odoo';
|
||||
import { applyBrandColors } from './brand-colors';
|
||||
import { defaultPermFor } from './permissions';
|
||||
|
||||
const TOKEN_KEY = 'skeen_token';
|
||||
const USER_KEY = 'skeen_user';
|
||||
const MENU_ROLES_KEY = 'skeen_menu_roles';
|
||||
const BRAND_KEY_LS = 'skeen_clinic_brand';
|
||||
|
||||
const ROLE_RANK: Record<FrontendRole, number> = {
|
||||
const ROLE_RANK: Record<string, number> = {
|
||||
admin: 100,
|
||||
medico: 50,
|
||||
recepcion: 50,
|
||||
@@ -78,6 +79,9 @@ interface AuthContextValue {
|
||||
hasRole: (min: FrontendRole) => boolean;
|
||||
canSee: (href: string) => boolean;
|
||||
canAgenda: (perm: AgendaPerm) => boolean;
|
||||
// Permiso por acción (catálogo skeen_whatsapp/permissions.py). La UI oculta
|
||||
// o deshabilita según esto; el backend sigue siendo la autoridad.
|
||||
can: (permKey: string) => boolean;
|
||||
// Brand de la clínica (white-label). BRAND_DEFAULT hasta que /brand responde.
|
||||
brand: ClinicBrand;
|
||||
setBrand: (b: ClinicBrand) => void;
|
||||
@@ -271,8 +275,18 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
return perms.includes(perm);
|
||||
};
|
||||
|
||||
// Permisos por acción. El backend manda user.perms en /auth/me y /auth/login;
|
||||
// si no viniera (sesión cacheada previa al despliegue) se usa el espejo de
|
||||
// defaults de lib/permissions.ts. El admin siempre puede.
|
||||
const can = (permKey: string): boolean => {
|
||||
if (!user) return false;
|
||||
if (user.role === 'admin') return true;
|
||||
if (user.perms && typeof user.perms[permKey] === 'boolean') return user.perms[permKey];
|
||||
return defaultPermFor(user.role, permKey);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, loading, bootError, retryBoot, login, logout, hasRole, canSee, canAgenda, brand, setBrand }}>
|
||||
<AuthContext.Provider value={{ user, token, loading, bootError, retryBoot, login, logout, hasRole, canSee, canAgenda, can, brand, setBrand }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
@@ -284,7 +298,7 @@ export const useAuth = (): AuthContextValue => {
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export const ROLE_LABELS: Record<FrontendRole, string> = {
|
||||
export const ROLE_LABELS: Record<string, string> = {
|
||||
admin: 'Administrador',
|
||||
medico: 'Médico',
|
||||
recepcion: 'Recepción',
|
||||
@@ -296,3 +310,7 @@ export const ROLE_LABELS: Record<FrontendRole, string> = {
|
||||
marketing: 'Marketing',
|
||||
lectura: 'Lectura',
|
||||
};
|
||||
|
||||
/** Nombre visible de un rol: el configurado (si se conoce) o el código. */
|
||||
export const roleLabel = (role: string, name?: string): string =>
|
||||
name || ROLE_LABELS[role] || role;
|
||||
|
||||
75
frontend/src/lib/permissions.ts
Normal file
75
frontend/src/lib/permissions.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// Espejo de skeen_whatsapp/permissions.py. Sirve de respaldo mientras /auth/me
|
||||
// no ha respondido o hay una sesión cacheada de antes del despliegue, y para
|
||||
// que el editor de roles muestre algo si /permisos falla. El backend es la
|
||||
// autoridad: si algo no cuadra, la UI esconde de más, nunca de menos.
|
||||
|
||||
export const PERM_KEYS: string[] = [
|
||||
'agenda.ver', 'agenda.agendar', 'agenda.editar', 'agenda.eliminar',
|
||||
'agenda.mover', 'agenda.visitas', 'agenda.todas', 'agenda.bloquear',
|
||||
'pacientes.ver', 'pacientes.expediente', 'pacientes.crear', 'pacientes.editar',
|
||||
'pacientes.eliminar', 'pacientes.exportar', 'pacientes.saldos',
|
||||
'visitas.ver', 'visitas.crear', 'visitas.editar', 'visitas.eliminar', 'visitas.archivos',
|
||||
'ventas.vender', 'ventas.cobrar', 'ventas.descuentos', 'ventas.cancelar',
|
||||
'ventas.devolver', 'ventas.reimprimir',
|
||||
'inventario.ver', 'inventario.ajustar', 'inventario.mover', 'inventario.costos',
|
||||
'servicios.ver', 'servicios.editar',
|
||||
'reportes.ver', 'reportes.exportar', 'caja.ver', 'caja.cerrar',
|
||||
'whatsapp.ver', 'whatsapp.responder', 'whatsapp.asignar',
|
||||
'config.usuarios', 'config.roles', 'config.editar', 'config.auditoria',
|
||||
];
|
||||
|
||||
const W = ['admin', 'medico', 'recepcion', 'enfermeria', 'cosmetologia',
|
||||
'psicologa', 'farmacia', 'gerencia', 'marketing'];
|
||||
const WN = W.filter((r) => r !== 'marketing');
|
||||
const MONEY = W.filter((r) => r !== 'medico' && r !== 'marketing');
|
||||
const AG = ['admin', 'gerencia'];
|
||||
const AD = ['admin'];
|
||||
const ALL = [...W, 'lectura'];
|
||||
|
||||
const READ_PERM_KEYS = new Set([
|
||||
'agenda.ver', 'agenda.todas',
|
||||
'pacientes.ver', 'pacientes.expediente', 'pacientes.exportar', 'pacientes.saldos',
|
||||
'visitas.ver',
|
||||
'inventario.ver',
|
||||
'servicios.ver',
|
||||
'reportes.ver', 'reportes.exportar', 'caja.ver',
|
||||
'ventas.reimprimir',
|
||||
'whatsapp.ver',
|
||||
]);
|
||||
|
||||
const DEFAULT_PERM_ROLES: Record<string, string[]> = {
|
||||
'agenda.ver': ALL, 'agenda.agendar': W, 'agenda.editar': W, 'agenda.eliminar': W,
|
||||
'agenda.mover': W, 'agenda.visitas': W, 'agenda.todas': W, 'agenda.bloquear': W,
|
||||
'pacientes.ver': ALL, 'pacientes.expediente': ALL, 'pacientes.crear': W,
|
||||
'pacientes.editar': W, 'pacientes.eliminar': W, 'pacientes.exportar': ALL,
|
||||
'pacientes.saldos': ALL,
|
||||
'visitas.ver': ALL, 'visitas.crear': W, 'visitas.editar': W,
|
||||
'visitas.eliminar': W, 'visitas.archivos': W,
|
||||
'ventas.vender': MONEY, 'ventas.cobrar': WN, 'ventas.descuentos': MONEY,
|
||||
'ventas.cancelar': AG, 'ventas.devolver': MONEY, 'ventas.reimprimir': ALL,
|
||||
'inventario.ver': ALL, 'inventario.ajustar': WN, 'inventario.mover': WN,
|
||||
'inventario.costos': WN,
|
||||
'servicios.ver': ALL, 'servicios.editar': AD,
|
||||
'reportes.ver': ALL, 'reportes.exportar': ALL, 'caja.ver': ALL, 'caja.cerrar': MONEY,
|
||||
'whatsapp.ver': W, 'whatsapp.responder': W, 'whatsapp.asignar': W,
|
||||
'config.usuarios': AG, 'config.roles': AG, 'config.editar': AG, 'config.auditoria': AD,
|
||||
};
|
||||
|
||||
export const BASE_ROLES = ['admin', 'medico', 'recepcion', 'enfermeria',
|
||||
'cosmetologia', 'psicologa', 'farmacia', 'gerencia', 'marketing', 'lectura'];
|
||||
|
||||
export const DEFAULT_PERMS_BY_ROLE: Record<string, Record<string, boolean>> =
|
||||
Object.fromEntries(BASE_ROLES.map((role) => [
|
||||
role,
|
||||
Object.fromEntries(PERM_KEYS.map((key) => [
|
||||
key,
|
||||
role === 'lectura' ? READ_PERM_KEYS.has(key) : (DEFAULT_PERM_ROLES[key] || []).includes(role),
|
||||
])),
|
||||
]));
|
||||
|
||||
/** Default de un permiso para un rol (incluye roles personalizados = lectura). */
|
||||
export const defaultPermFor = (role: string, key: string): boolean => {
|
||||
const base = BASE_ROLES.includes(role) ? role : 'lectura';
|
||||
return DEFAULT_PERMS_BY_ROLE[base]?.[key]
|
||||
?? (READ_PERM_KEYS.has(key) && base === 'lectura');
|
||||
};
|
||||
@@ -43,17 +43,24 @@ const SUBCATALOGOS = [
|
||||
const LEGACY_STORAGE_KEY = 'skeen_clinic_settings';
|
||||
|
||||
const Configuracion: FC = () => {
|
||||
const { user, brand, setBrand } = useAuth();
|
||||
const { user, brand, setBrand, can } = useAuth();
|
||||
// Gerencia gestiona catálogos, usuarios y roles; la sección "Clínica" (datos
|
||||
// de contacto, tipo de cambio y comisiones) queda para administración del sistema.
|
||||
const esAdmin = user?.role === 'admin';
|
||||
// Cada pestaña requiere su permiso por acción (Configuración › Roles › Permisos).
|
||||
const TABS_PERM: Record<string, string> = {
|
||||
usuarios: 'config.usuarios',
|
||||
roles: 'config.roles',
|
||||
auditoria: 'config.auditoria',
|
||||
};
|
||||
const tabsVisibles = useMemo(
|
||||
() => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key))),
|
||||
[esAdmin],
|
||||
() => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key)))
|
||||
.filter((t) => can(TABS_PERM[t.key] || 'config.editar')),
|
||||
[esAdmin, can],
|
||||
);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tabPedida = searchParams.get('tab') || 'clinica';
|
||||
const tab = tabsVisibles.some((t) => t.key === tabPedida) ? tabPedida : tabsVisibles[0].key;
|
||||
const tab = tabsVisibles.some((t) => t.key === tabPedida) ? tabPedida : (tabsVisibles[0]?.key || 'clinica');
|
||||
const setTab = (t: string) => setSearchParams({ tab: t });
|
||||
const [subcatalogo, setSubcatalogo] = useState<string>(() => {
|
||||
const sub = searchParams.get('sub');
|
||||
@@ -284,7 +291,13 @@ const Configuracion: FC = () => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'usuarios' ? (
|
||||
{tabsVisibles.length === 0 ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<p className="text-sm text-theme-muted">No tienes permiso para ninguna sección de Configuración.</p>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : tab === 'usuarios' ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<UsuariosPanel />
|
||||
|
||||
@@ -34,7 +34,7 @@ const PASOS = [
|
||||
] as const;
|
||||
|
||||
const Cortes: FC = () => {
|
||||
const { brand } = useAuth();
|
||||
const { brand, can } = useAuth();
|
||||
const [closings, setClosings] = useState<CashClosing[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -479,7 +479,8 @@ const Cortes: FC = () => {
|
||||
<ChevronRight size={16} className="ml-1" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={cerrarCaja} loading={submitting}>
|
||||
<Button onClick={cerrarCaja} loading={submitting} disabled={!can('caja.cerrar')}
|
||||
title={can('caja.cerrar') ? undefined : 'Sin permiso para cerrar caja'}>
|
||||
<Lock size={16} className="mr-1.5" />
|
||||
Cerrar caja
|
||||
</Button>
|
||||
|
||||
@@ -122,7 +122,7 @@ const CRITICAL_ALERTS: { key: keyof Patient; label: string }[] = [
|
||||
];
|
||||
|
||||
const Pacientes: FC = () => {
|
||||
const { brand } = useAuth();
|
||||
const { brand, can } = useAuth();
|
||||
const { term } = useGlosario();
|
||||
const { labelFor: branchLabel } = useSucursales();
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
@@ -525,7 +525,8 @@ const Pacientes: FC = () => {
|
||||
return (
|
||||
<Layout title={term('patient_plural')} subtitle="Directorio de pacientes">
|
||||
<PageHeader title={term('patient_plural')} subtitle="Busca, crea y gestiona pacientes">
|
||||
<Button variant="outline" onClick={exportPatients} loading={exporting} disabled={patients.length === 0}>
|
||||
<Button variant="outline" onClick={exportPatients} loading={exporting} disabled={patients.length === 0 || !can('pacientes.exportar')}
|
||||
title={can('pacientes.exportar') ? undefined : 'Sin permiso para exportar'}>
|
||||
<Download size={16} className="mr-2" />
|
||||
Exportar CSV
|
||||
</Button>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '../components/ui';
|
||||
import { odooApi, type Service, type Patient, type InventoryItem, type PosCheckoutResult, type Doctor, type Paquete } from '../services/odoo';
|
||||
import type { PatientPackage } from '../services/odoo';
|
||||
import { useAuth } from '../lib/auth';
|
||||
|
||||
const fmtMoney = (n: number) =>
|
||||
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 });
|
||||
@@ -52,6 +53,7 @@ interface TicketLine {
|
||||
|
||||
const Pos: FC = () => {
|
||||
const { openPatient } = usePatientDrawer();
|
||||
const { can } = useAuth();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// Catálogo
|
||||
const [catMode, setCatMode] = useState<'servicios' | 'paquetes' | 'articulos'>('servicios');
|
||||
@@ -818,7 +820,9 @@ const Pos: FC = () => {
|
||||
value={discount}
|
||||
onChange={(e) => setDiscount(e.target.value)}
|
||||
placeholder="0"
|
||||
className="w-28 border border-theme-border-strong rounded-lg px-2 py-1 text-sm text-right"
|
||||
disabled={!can('ventas.descuentos')}
|
||||
title={can('ventas.descuentos') ? undefined : 'Sin permiso para aplicar descuentos'}
|
||||
className="w-28 border border-theme-border-strong rounded-lg px-2 py-1 text-sm text-right disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
@@ -840,8 +844,8 @@ const Pos: FC = () => {
|
||||
<Button
|
||||
className="w-full mt-1 !py-3 text-base"
|
||||
onClick={abrirCobro}
|
||||
disabled={lines.length === 0 || !paciente}
|
||||
title="Cobrar (F9)"
|
||||
disabled={lines.length === 0 || !paciente || !can('ventas.vender')}
|
||||
title={can('ventas.vender') ? 'Cobrar (F9)' : 'Sin permiso para vender'}
|
||||
>
|
||||
<ShoppingBag size={18} className="mr-2" />
|
||||
Cobrar {fmtMoney(total)}
|
||||
|
||||
@@ -142,7 +142,7 @@ interface CashReport {
|
||||
|
||||
const ReportesContent: FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { hasRole, canSee, brand } = useAuth();
|
||||
const { hasRole, canSee, can, brand } = useAuth();
|
||||
const esAdmin = hasRole('admin');
|
||||
// PuedeVer una pestana = canSee('/reportes?tab=x'): la matriz guarda claves
|
||||
// por pestana y, cuando no existe la del tab, canSee cae al prefijo
|
||||
@@ -287,13 +287,13 @@ const ReportesContent: FC = () => {
|
||||
return (
|
||||
<Layout title="Reportes" subtitle="Análisis del mes y día">
|
||||
<PageHeader title="Reportes" subtitle={tabInfo?.desc || 'Análisis de la operación'}>
|
||||
{tab === 'general' && (
|
||||
{tab === 'general' && can('reportes.exportar') && (
|
||||
<Button variant="outline" onClick={exportExcel} disabled={loading || !salesReport} loading={exporting}>
|
||||
<Download size={16} className="mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
)}
|
||||
{puedeTab('exportar') && (
|
||||
{puedeTab('exportar') && can('reportes.exportar') && (
|
||||
<Button
|
||||
variant={tab === 'exportar' ? 'primary' : 'outline'}
|
||||
onClick={() => setTab('exportar')}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { odooApi, type Service } from '../services/odoo';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useServiceCategories } from '../lib/service-categories';
|
||||
|
||||
interface ServiceForm {
|
||||
@@ -49,6 +50,8 @@ const emptyForm: ServiceForm = {
|
||||
|
||||
const Servicios: FC = () => {
|
||||
const { term } = useGlosario();
|
||||
const { can } = useAuth();
|
||||
const puedeEditar = can('servicios.editar');
|
||||
const { categories, labelFor } = useServiceCategories();
|
||||
const categoryOptions = useMemo(
|
||||
() => [{ value: '', label: 'Todas las categorías' }, ...categories.map((c) => ({ value: c.code, label: c.name }))],
|
||||
@@ -286,10 +289,12 @@ const Servicios: FC = () => {
|
||||
return (
|
||||
<Layout title={term('service_plural')} subtitle="Catálogo de tratamientos">
|
||||
<PageHeader title={term('service_plural')} subtitle="Gestiona precios, paquetes y favoritos">
|
||||
<Button onClick={openCreate}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Nuevo {term('service_singular').toLowerCase()}
|
||||
</Button>
|
||||
{puedeEditar && (
|
||||
<Button onClick={openCreate}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Nuevo {term('service_singular').toLowerCase()}
|
||||
</Button>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4 sm:mb-6">
|
||||
@@ -404,12 +409,16 @@ const Servicios: FC = () => {
|
||||
>
|
||||
<ChevronDown size={16} className="text-theme-muted" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => openDuplicate(s)} title="Duplicar servicio">
|
||||
<Copy size={15} className="text-theme-muted" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(s)} title="Editar">
|
||||
<Edit2 size={16} className="text-theme-muted" />
|
||||
</Button>
|
||||
{puedeEditar && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={() => openDuplicate(s)} title="Duplicar servicio">
|
||||
<Copy size={15} className="text-theme-muted" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(s)} title="Editar">
|
||||
<Edit2 size={16} className="text-theme-muted" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -47,8 +47,9 @@ const PAGE_SIZE = 50;
|
||||
* Ventas. Permiso de cancelación: admin y gerencia (el API lo valida).
|
||||
*/
|
||||
const Tickets: FC = () => {
|
||||
const { user } = useAuth();
|
||||
const { user, can } = useAuth();
|
||||
const puedeCorregir = user?.role === 'admin' || user?.role === 'gerencia';
|
||||
const puedeCancelar = can('ventas.cancelar');
|
||||
const prefs = readDateFilterPrefs(PREF_KEY);
|
||||
const [preset, setPreset] = useState<DatePreset>(prefs?.preset || 'hoy');
|
||||
const [desde, setDesde] = useState(prefs?.start || rangeForPreset('hoy').start);
|
||||
@@ -304,7 +305,7 @@ const Tickets: FC = () => {
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
)}
|
||||
{s.state !== 'cancelled' && (
|
||||
{s.state !== 'cancelled' && puedeCancelar && (
|
||||
<Button size="sm" variant="ghost" onClick={() => abrirCancelar(s)} title="Cancelar ticket (nota de crédito)">
|
||||
<XCircle size={14} className="text-rose-600" />
|
||||
</Button>
|
||||
@@ -350,7 +351,7 @@ const Tickets: FC = () => {
|
||||
<Pencil size={14} className="mr-1.5" /> Corregir
|
||||
</Button>
|
||||
)}
|
||||
{detalle && detalle.state !== 'cancelled' && (
|
||||
{detalle && detalle.state !== 'cancelled' && puedeCancelar && (
|
||||
<Button variant="danger" onClick={() => { const d = detalle; setDetalle(null); abrirCancelar(d); }}>
|
||||
<XCircle size={14} className="mr-1.5" /> Cancelar ticket
|
||||
</Button>
|
||||
|
||||
@@ -27,7 +27,6 @@ import { DATE_PRESETS, rangeForPreset, readDateFilterPrefs, writeDateFilterPrefs
|
||||
|
||||
// Rango de fechas recordado entre visitas (igual que los presets de la agenda)
|
||||
const FILTERS_KEY = 'skeen_ventas_filtros';
|
||||
const ROLES_CANCELAN = ['admin', 'gerencia'];
|
||||
|
||||
interface LineForm {
|
||||
service_id: string;
|
||||
@@ -47,8 +46,10 @@ const paymentMethods = ['Efectivo', 'Tarjeta', 'Transferencia', 'MercadoPago'];
|
||||
|
||||
const Ventas: FC = () => {
|
||||
const { openPatient } = usePatientDrawer();
|
||||
const { user, brand } = useAuth();
|
||||
const puedeCancelar = ROLES_CANCELAN.includes(user?.role || '');
|
||||
const { brand, can } = useAuth();
|
||||
const puedeCancelar = can('ventas.cancelar');
|
||||
const puedeCobrar = can('ventas.cobrar');
|
||||
const puedeDevolver = can('ventas.devolver');
|
||||
const [sales, setSales] = useState<Sale[]>([]);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
@@ -491,13 +492,13 @@ const Ventas: FC = () => {
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setPrintSale(s); }} title="Reimprimir ticket">
|
||||
<Printer size={16} className="text-theme-heading" />
|
||||
</Button>
|
||||
{s.amount_due > 0 && !cancelada && (
|
||||
{s.amount_due > 0 && !cancelada && puedeCobrar && (
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); openPay(s); }} title="Pagar">
|
||||
<CreditCard size={16} className="text-theme-heading mr-1.5" />
|
||||
Pagar
|
||||
</Button>
|
||||
)}
|
||||
{s.amount_paid > 0 && !cancelada && (
|
||||
{s.amount_paid > 0 && !cancelada && puedeDevolver && (
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); openRefund(s); }} title="Devolución">
|
||||
<Undo2 size={16} className="text-amber-600 mr-1.5" />
|
||||
Devolución
|
||||
@@ -611,12 +612,12 @@ const Ventas: FC = () => {
|
||||
<Button variant="ghost" size="sm" onClick={() => setPrintSale(s)} title="Reimprimir ticket">
|
||||
<Printer size={16} className="text-theme-heading" />
|
||||
</Button>
|
||||
{s.amount_due > 0 && s.state !== 'cancelled' && (
|
||||
{s.amount_due > 0 && s.state !== 'cancelled' && puedeCobrar && (
|
||||
<Button variant="ghost" size="sm" onClick={() => openPay(s)}>
|
||||
<CreditCard size={16} className="text-theme-heading" />
|
||||
</Button>
|
||||
)}
|
||||
{s.amount_paid > 0 && s.state !== 'cancelled' && (
|
||||
{s.amount_paid > 0 && s.state !== 'cancelled' && puedeDevolver && (
|
||||
<Button variant="ghost" size="sm" onClick={() => openRefund(s)} title="Devolución">
|
||||
<Undo2 size={16} className="text-amber-600" />
|
||||
</Button>
|
||||
|
||||
@@ -885,7 +885,9 @@ export interface WacrmLead {
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export type FrontendRole = 'admin' | 'medico' | 'recepcion' | 'enfermeria' | 'cosmetologia' | 'psicologa' | 'farmacia' | 'gerencia' | 'marketing' | 'lectura';
|
||||
// Los roles son configurables (modelo skeen.role): el código es libre, por eso
|
||||
// el tipo ya no es una unión cerrada. ROLE_LABELS queda solo como respaldo.
|
||||
export type FrontendRole = string;
|
||||
|
||||
// Acciones de la Agenda (espejo de AGENDA_PERMS en skeen_whatsapp/roles.py).
|
||||
export type AgendaPerm = 'ver' | 'agendar' | 'editar' | 'eliminar' | 'visitas';
|
||||
@@ -996,6 +998,8 @@ export interface FrontendUser {
|
||||
login: string;
|
||||
name: string;
|
||||
role: FrontendRole;
|
||||
// Nombre del rol configurado en skeen.role (para roles personalizados).
|
||||
role_name?: string;
|
||||
must_change_password?: boolean;
|
||||
last_login?: string | null;
|
||||
active?: boolean;
|
||||
@@ -1004,11 +1008,54 @@ export interface FrontendUser {
|
||||
agenda_perms?: AgendaPerm[] | null;
|
||||
// Lista propia del usuario; null = hereda del rol. La usa solo el editor.
|
||||
agenda_perms_custom?: AgendaPerm[] | null;
|
||||
// Permisos por acción vigentes (rol + overrides), resueltos por el backend.
|
||||
perms?: Record<string, boolean> | null;
|
||||
// Solo rol 'medico': hr.employee vinculado (null si no hay vínculo)
|
||||
employee_id?: number | null;
|
||||
employee_name?: string | null;
|
||||
}
|
||||
|
||||
// ---- Roles configurables (skeen.role) ----
|
||||
export interface Role {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
rank: number;
|
||||
sequence: number;
|
||||
active: boolean;
|
||||
is_system: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface RoleUserRef {
|
||||
code: string;
|
||||
name: string;
|
||||
rank: number;
|
||||
active: boolean;
|
||||
is_system: boolean;
|
||||
}
|
||||
|
||||
// ---- Permisos por acción ----
|
||||
export interface PermItem {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface PermGroup {
|
||||
grupo: string;
|
||||
label: string;
|
||||
permisos: PermItem[];
|
||||
}
|
||||
|
||||
export interface PermsResponse {
|
||||
status: string;
|
||||
catalogo: PermGroup[];
|
||||
por_rol: Record<string, Record<string, boolean>>;
|
||||
defaults: Record<string, Record<string, boolean>>;
|
||||
roles: RoleUserRef[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface Receta {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -1552,6 +1599,40 @@ export const odooApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
// ---- Roles configurables ----
|
||||
async listRoles(opts?: { includeInactive?: boolean }): Promise<{ status: string; roles: Role[]; total: number; message?: string }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/roles`, {
|
||||
params: opts?.includeInactive ? { include_inactive: 1 } : undefined,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async createRole(payload: { name: string; rank?: number; sequence?: number; description?: string; active?: boolean }): Promise<{ status: string; role: Role; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/roles`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateRole(id: number, payload: { name?: string; rank?: number; sequence?: number; active?: boolean; description?: string }): Promise<{ status: string; role: Role; message?: string }> {
|
||||
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/roles/${id}`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async deleteRole(id: number): Promise<{ status: string; role?: Role; en_uso?: number; message?: string }> {
|
||||
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/roles/${id}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
// ---- Permisos por acción ----
|
||||
async getPermisos(): Promise<PermsResponse> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/permisos`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async putPermisos(payload: { role_code?: string; permisos?: Record<string, boolean>; por_rol?: Record<string, Record<string, boolean>> }): Promise<{ status: string; por_rol: Record<string, Record<string, boolean>>; guardados: number; message?: string }> {
|
||||
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/permisos`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
// Health
|
||||
async healthCheck(): Promise<unknown> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/health`);
|
||||
|
||||
Reference in New Issue
Block a user