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 type { FC } from 'react';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Save, Info, Lock } from 'lucide-react';
|
import { Save, Info, Lock, Plus, Pencil, Ban, RotateCcw } from 'lucide-react';
|
||||||
import { Button, Skeleton, toast } from './ui';
|
import { Button, Input, Select, Modal, Badge, Skeleton, toast } from './ui';
|
||||||
import { odooApi, type FrontendRole } from '../services/odoo';
|
import { odooApi, type FrontendRole, type Role, type PermGroup, type PermsResponse } from '../services/odoo';
|
||||||
import { ROLE_LABELS } from '../lib/auth';
|
import { useAuth } from '../lib/auth';
|
||||||
import { MENU_ITEMS, menuItemLabel } from '../lib/menu-items';
|
import { MENU_ITEMS, menuItemLabel } from '../lib/menu-items';
|
||||||
import { useGlosario } from '../lib/glosario';
|
import { useGlosario } from '../lib/glosario';
|
||||||
|
|
||||||
const ROLES = Object.keys(ROLE_LABELS) as FrontendRole[];
|
|
||||||
|
|
||||||
type Matrix = Record<string, 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 RolesPanel: FC = () => {
|
||||||
const { term } = useGlosario();
|
const { term } = useGlosario();
|
||||||
const [matrix, setMatrix] = useState<Matrix | null>(null);
|
const { user } = useAuth();
|
||||||
const [saved, setSaved] = useState<Matrix | null>(null);
|
const [tab, setTab] = useState<Tab>('roles');
|
||||||
const [loading, setLoading] = useState(true);
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await odooApi.getRolePermissions();
|
const [rr, rp] = await Promise.all([
|
||||||
if (res.status === 'success') {
|
odooApi.listRoles({ includeInactive: true }),
|
||||||
setMatrix(res.matrix);
|
odooApi.getRolePermissions(),
|
||||||
setSaved(res.matrix);
|
]);
|
||||||
} else {
|
if (rr.status === 'success') setRoles(rr.roles);
|
||||||
toast.error(res.message || 'No se pudieron cargar los permisos');
|
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) {
|
} catch (err) {
|
||||||
toast.error('Error al cargar permisos por rol');
|
toast.error('Error al cargar roles y permisos');
|
||||||
console.error(err);
|
console.error(err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [permRole]);
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
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) => {
|
const activeRoles = useMemo(() => roles.filter((r) => r.active), [roles]);
|
||||||
if (!matrix) return;
|
|
||||||
if (href === '/configuracion' && role === 'admin') return; // siempre requerido
|
// ---------------------------------------------------------------
|
||||||
const current = matrix[href] || [];
|
// Pestaña Roles
|
||||||
const next = current.includes(role) ? current.filter((r) => r !== role) : [...current, role];
|
// ---------------------------------------------------------------
|
||||||
setMatrix({ ...matrix, [href]: next });
|
const openCreate = () => {
|
||||||
|
setEditing(null);
|
||||||
|
setForm({ name: '', rank: 50, sequence: 10, description: '', active: true });
|
||||||
|
setModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const save = async () => {
|
const openEdit = (r: Role) => {
|
||||||
if (!matrix) return;
|
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 {
|
try {
|
||||||
setSaving(true);
|
setSubmitting(true);
|
||||||
const res = await odooApi.updateRolePermissions(matrix);
|
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') {
|
if (res.status === 'success') {
|
||||||
setMatrix(res.matrix);
|
setPerms({ ...perms, por_rol: res.por_rol });
|
||||||
setSaved(res.matrix);
|
setPermSaved(permDraft);
|
||||||
toast.success('Permisos por rol guardados');
|
toast.success('Permisos guardados (aplican al recargar la sesión de cada usuario)');
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message || 'No se pudieron guardar los permisos');
|
toast.error(res.message || 'No se pudieron guardar los permisos');
|
||||||
}
|
}
|
||||||
@@ -65,68 +190,315 @@ const RolesPanel: FC = () => {
|
|||||||
toast.error(msg || 'Error al guardar permisos');
|
toast.error(msg || 'Error al guardar permisos');
|
||||||
console.error(err);
|
console.error(err);
|
||||||
} finally {
|
} 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" />;
|
return <Skeleton count={6} className="h-10 w-full" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const puedeEditar = user?.role === 'admin' || user?.role === 'gerencia';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4">
|
<div className="flex flex-wrap gap-1.5 mb-4">
|
||||||
<p className="text-sm text-theme-muted flex items-start gap-2 max-w-2xl">
|
{([['roles', 'Roles'], ['permisos', 'Permisos'], ['menus', 'Menús']] as [Tab, string][]).map(([k, label]) => (
|
||||||
<Info size={16} className="mt-0.5 shrink-0" />
|
<button
|
||||||
Aplica a todos los usuarios del rol al recargar su sesión. Los permisos personales de un usuario van encima.
|
key={k}
|
||||||
</p>
|
type="button"
|
||||||
<div className="flex items-center gap-3 shrink-0">
|
onClick={() => setTab(k)}
|
||||||
{dirty && <span className="text-xs font-medium text-amber-600">Cambios sin guardar</span>}
|
className={`px-3 py-1.5 text-sm font-medium rounded-full transition ${
|
||||||
<Button onClick={save} loading={saving} disabled={!dirty}>
|
tab === k ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
||||||
<Save size={16} className="mr-2" />
|
}`}
|
||||||
Guardar
|
>
|
||||||
</Button>
|
{label}
|
||||||
</div>
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
{/* ---------------- Roles ---------------- */}
|
||||||
<table className="w-full text-sm">
|
{tab === 'roles' && (
|
||||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
<>
|
||||||
<tr>
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4">
|
||||||
<th className="text-left px-3 py-2 sticky left-0 bg-theme-bg">Sección</th>
|
<p className="text-sm text-theme-muted flex items-start gap-2 max-w-2xl">
|
||||||
{ROLES.map((r) => (
|
<Info size={16} className="mt-0.5 shrink-0" />
|
||||||
<th key={r} className="px-2 py-2 text-center font-medium">{ROLE_LABELS[r]}</th>
|
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
|
||||||
</tr>
|
ya guardados lo usan.
|
||||||
</thead>
|
</p>
|
||||||
<tbody className="divide-y divide-theme-border">
|
{puedeEditar && (
|
||||||
{MENU_ITEMS.map((item) => (
|
<Button onClick={openCreate} className="shrink-0">
|
||||||
<tr key={item.href} className="hover:bg-theme-bg">
|
<Plus size={16} className="mr-2" />
|
||||||
<td className="px-3 py-2 font-medium text-theme-heading whitespace-nowrap sticky left-0 bg-theme-surface">
|
Nuevo rol
|
||||||
{menuItemLabel(item, term)}
|
</Button>
|
||||||
</td>
|
)}
|
||||||
{ROLES.map((role) => {
|
</div>
|
||||||
const locked = item.href === '/configuracion' && role === 'admin';
|
<div className="overflow-x-auto">
|
||||||
const checked = (matrix[item.href] || []).includes(role);
|
<table className="w-full">
|
||||||
return (
|
<thead className="bg-theme-bg">
|
||||||
<td key={role} className="px-2 py-2 text-center">
|
<tr>
|
||||||
<input
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Rol</th>
|
||||||
type="checkbox"
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Código</th>
|
||||||
checked={checked}
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Rango</th>
|
||||||
disabled={locked}
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Estado</th>
|
||||||
onChange={() => toggle(item.href, role)}
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
|
||||||
title={locked ? 'El admin siempre requiere Configuración' : `${item.label} — ${ROLE_LABELS[role]}`}
|
</tr>
|
||||||
className="rounded border-theme-border-strong disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
|
</thead>
|
||||||
/>
|
<tbody className="divide-y">
|
||||||
{locked && <Lock size={10} className="inline ml-1 text-theme-muted" />}
|
{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>
|
</td>
|
||||||
);
|
<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>
|
||||||
</tr>
|
<td className="p-3">
|
||||||
))}
|
<Badge variant={r.active ? 'success' : 'default'}>{r.active ? 'Activo' : 'Inactivo'}</Badge>
|
||||||
</tbody>
|
</td>
|
||||||
</table>
|
<td className="p-3">
|
||||||
</div>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FC } from 'react';
|
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 { Plus, Pencil, KeyRound, ShieldCheck, Trash2, AlertTriangle } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -12,15 +12,11 @@ import {
|
|||||||
MobileCard,
|
MobileCard,
|
||||||
toast,
|
toast,
|
||||||
} from './ui';
|
} from './ui';
|
||||||
import { odooApi, type FrontendUser, type FrontendRole, type AgendaPerm, AGENDA_PERM_ITEMS } from '../services/odoo';
|
import { odooApi, type FrontendUser, type FrontendRole, type AgendaPerm, type Role, type PermGroup, AGENDA_PERM_ITEMS } from '../services/odoo';
|
||||||
import { ROLE_LABELS, useAuth } from '../lib/auth';
|
import { ROLE_LABELS, roleLabel, useAuth } from '../lib/auth';
|
||||||
import { MENU_ITEMS, menuItemLabel } from '../lib/menu-items';
|
import { MENU_ITEMS, menuItemLabel } from '../lib/menu-items';
|
||||||
import { useGlosario } from '../lib/glosario';
|
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) => {
|
const roleBadge = (role: FrontendRole) => {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
case 'admin':
|
case 'admin':
|
||||||
@@ -50,12 +46,32 @@ const UsuariosPanel: FC = () => {
|
|||||||
const [agendaCustom, setAgendaCustom] = useState(false);
|
const [agendaCustom, setAgendaCustom] = useState(false);
|
||||||
const [agendaSelected, setAgendaSelected] = useState<AgendaPerm[]>([]);
|
const [agendaSelected, setAgendaSelected] = useState<AgendaPerm[]>([]);
|
||||||
const [permSaving, setPermSaving] = useState(false);
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await odooApi.listFrontendUsers();
|
const res = await odooApi.listFrontendUsers();
|
||||||
if (res.status === 'success') setUsers(res.users);
|
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) {
|
} catch (err) {
|
||||||
toast.error('Error al cargar usuarios');
|
toast.error('Error al cargar usuarios');
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -66,6 +82,22 @@ const UsuariosPanel: FC = () => {
|
|||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
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 = () => {
|
const openCreate = () => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
setForm({ login: '', name: '', password: '', role: 'recepcion', active: true });
|
setForm({ login: '', name: '', password: '', role: 'recepcion', active: true });
|
||||||
@@ -225,7 +257,7 @@ const UsuariosPanel: FC = () => {
|
|||||||
<tr key={u.id} className="hover:bg-theme-bg">
|
<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-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 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">
|
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">
|
||||||
{u.allowed_menus && u.allowed_menus.length > 0
|
{u.allowed_menus && u.allowed_menus.length > 0
|
||||||
? `${u.allowed_menus.length} de ${MENU_ITEMS.length} secciones`
|
? `${u.allowed_menus.length} de ${MENU_ITEMS.length} secciones`
|
||||||
@@ -264,7 +296,7 @@ const UsuariosPanel: FC = () => {
|
|||||||
title={u.name}
|
title={u.name}
|
||||||
subtitle={u.login}
|
subtitle={u.login}
|
||||||
rows={[
|
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: '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' },
|
{ 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}
|
value={form.role}
|
||||||
onChange={(e) => setForm({ ...form, role: e.target.value as FrontendRole })}
|
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
|
<Input
|
||||||
label={editing ? 'Nueva contraseña (dejar vacío para no cambiar)' : 'Contraseña (mín. 8)'}
|
label={editing ? 'Nueva contraseña (dejar vacío para no cambiar)' : 'Contraseña (mín. 8)'}
|
||||||
type="password"
|
type="password"
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import { createContext, useContext, useEffect, useState, useCallback } from 'rea
|
|||||||
import type { FC, ReactNode } from 'react';
|
import type { FC, ReactNode } from 'react';
|
||||||
import { odooApi, type FrontendUser, type FrontendRole, type AgendaPerm, type ClinicBrand, BRAND_DEFAULT } from '../services/odoo';
|
import { odooApi, type FrontendUser, type FrontendRole, type AgendaPerm, type ClinicBrand, BRAND_DEFAULT } from '../services/odoo';
|
||||||
import { applyBrandColors } from './brand-colors';
|
import { applyBrandColors } from './brand-colors';
|
||||||
|
import { defaultPermFor } from './permissions';
|
||||||
|
|
||||||
const TOKEN_KEY = 'skeen_token';
|
const TOKEN_KEY = 'skeen_token';
|
||||||
const USER_KEY = 'skeen_user';
|
const USER_KEY = 'skeen_user';
|
||||||
const MENU_ROLES_KEY = 'skeen_menu_roles';
|
const MENU_ROLES_KEY = 'skeen_menu_roles';
|
||||||
const BRAND_KEY_LS = 'skeen_clinic_brand';
|
const BRAND_KEY_LS = 'skeen_clinic_brand';
|
||||||
|
|
||||||
const ROLE_RANK: Record<FrontendRole, number> = {
|
const ROLE_RANK: Record<string, number> = {
|
||||||
admin: 100,
|
admin: 100,
|
||||||
medico: 50,
|
medico: 50,
|
||||||
recepcion: 50,
|
recepcion: 50,
|
||||||
@@ -78,6 +79,9 @@ interface AuthContextValue {
|
|||||||
hasRole: (min: FrontendRole) => boolean;
|
hasRole: (min: FrontendRole) => boolean;
|
||||||
canSee: (href: string) => boolean;
|
canSee: (href: string) => boolean;
|
||||||
canAgenda: (perm: AgendaPerm) => 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 de la clínica (white-label). BRAND_DEFAULT hasta que /brand responde.
|
||||||
brand: ClinicBrand;
|
brand: ClinicBrand;
|
||||||
setBrand: (b: ClinicBrand) => void;
|
setBrand: (b: ClinicBrand) => void;
|
||||||
@@ -271,8 +275,18 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
|
|||||||
return perms.includes(perm);
|
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 (
|
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}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
@@ -284,7 +298,7 @@ export const useAuth = (): AuthContextValue => {
|
|||||||
return ctx;
|
return ctx;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ROLE_LABELS: Record<FrontendRole, string> = {
|
export const ROLE_LABELS: Record<string, string> = {
|
||||||
admin: 'Administrador',
|
admin: 'Administrador',
|
||||||
medico: 'Médico',
|
medico: 'Médico',
|
||||||
recepcion: 'Recepción',
|
recepcion: 'Recepción',
|
||||||
@@ -296,3 +310,7 @@ export const ROLE_LABELS: Record<FrontendRole, string> = {
|
|||||||
marketing: 'Marketing',
|
marketing: 'Marketing',
|
||||||
lectura: 'Lectura',
|
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 LEGACY_STORAGE_KEY = 'skeen_clinic_settings';
|
||||||
|
|
||||||
const Configuracion: FC = () => {
|
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
|
// 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.
|
// de contacto, tipo de cambio y comisiones) queda para administración del sistema.
|
||||||
const esAdmin = user?.role === 'admin';
|
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(
|
const tabsVisibles = useMemo(
|
||||||
() => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key))),
|
() => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key)))
|
||||||
[esAdmin],
|
.filter((t) => can(TABS_PERM[t.key] || 'config.editar')),
|
||||||
|
[esAdmin, can],
|
||||||
);
|
);
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const tabPedida = searchParams.get('tab') || 'clinica';
|
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 setTab = (t: string) => setSearchParams({ tab: t });
|
||||||
const [subcatalogo, setSubcatalogo] = useState<string>(() => {
|
const [subcatalogo, setSubcatalogo] = useState<string>(() => {
|
||||||
const sub = searchParams.get('sub');
|
const sub = searchParams.get('sub');
|
||||||
@@ -284,7 +291,13 @@ const Configuracion: FC = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
<UsuariosPanel />
|
<UsuariosPanel />
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const PASOS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const Cortes: FC = () => {
|
const Cortes: FC = () => {
|
||||||
const { brand } = useAuth();
|
const { brand, can } = useAuth();
|
||||||
const [closings, setClosings] = useState<CashClosing[]>([]);
|
const [closings, setClosings] = useState<CashClosing[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -479,7 +479,8 @@ const Cortes: FC = () => {
|
|||||||
<ChevronRight size={16} className="ml-1" />
|
<ChevronRight size={16} className="ml-1" />
|
||||||
</Button>
|
</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" />
|
<Lock size={16} className="mr-1.5" />
|
||||||
Cerrar caja
|
Cerrar caja
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ const CRITICAL_ALERTS: { key: keyof Patient; label: string }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const Pacientes: FC = () => {
|
const Pacientes: FC = () => {
|
||||||
const { brand } = useAuth();
|
const { brand, can } = useAuth();
|
||||||
const { term } = useGlosario();
|
const { term } = useGlosario();
|
||||||
const { labelFor: branchLabel } = useSucursales();
|
const { labelFor: branchLabel } = useSucursales();
|
||||||
const [patients, setPatients] = useState<Patient[]>([]);
|
const [patients, setPatients] = useState<Patient[]>([]);
|
||||||
@@ -525,7 +525,8 @@ const Pacientes: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<Layout title={term('patient_plural')} subtitle="Directorio de pacientes">
|
<Layout title={term('patient_plural')} subtitle="Directorio de pacientes">
|
||||||
<PageHeader title={term('patient_plural')} subtitle="Busca, crea y gestiona 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" />
|
<Download size={16} className="mr-2" />
|
||||||
Exportar CSV
|
Exportar CSV
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from '../components/ui';
|
} from '../components/ui';
|
||||||
import { odooApi, type Service, type Patient, type InventoryItem, type PosCheckoutResult, type Doctor, type Paquete } from '../services/odoo';
|
import { odooApi, type Service, type Patient, type InventoryItem, type PosCheckoutResult, type Doctor, type Paquete } from '../services/odoo';
|
||||||
import type { PatientPackage } from '../services/odoo';
|
import type { PatientPackage } from '../services/odoo';
|
||||||
|
import { useAuth } from '../lib/auth';
|
||||||
|
|
||||||
const fmtMoney = (n: number) =>
|
const fmtMoney = (n: number) =>
|
||||||
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 });
|
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 });
|
||||||
@@ -52,6 +53,7 @@ interface TicketLine {
|
|||||||
|
|
||||||
const Pos: FC = () => {
|
const Pos: FC = () => {
|
||||||
const { openPatient } = usePatientDrawer();
|
const { openPatient } = usePatientDrawer();
|
||||||
|
const { can } = useAuth();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
// Catálogo
|
// Catálogo
|
||||||
const [catMode, setCatMode] = useState<'servicios' | 'paquetes' | 'articulos'>('servicios');
|
const [catMode, setCatMode] = useState<'servicios' | 'paquetes' | 'articulos'>('servicios');
|
||||||
@@ -818,7 +820,9 @@ const Pos: FC = () => {
|
|||||||
value={discount}
|
value={discount}
|
||||||
onChange={(e) => setDiscount(e.target.value)}
|
onChange={(e) => setDiscount(e.target.value)}
|
||||||
placeholder="0"
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -840,8 +844,8 @@ const Pos: FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
className="w-full mt-1 !py-3 text-base"
|
className="w-full mt-1 !py-3 text-base"
|
||||||
onClick={abrirCobro}
|
onClick={abrirCobro}
|
||||||
disabled={lines.length === 0 || !paciente}
|
disabled={lines.length === 0 || !paciente || !can('ventas.vender')}
|
||||||
title="Cobrar (F9)"
|
title={can('ventas.vender') ? 'Cobrar (F9)' : 'Sin permiso para vender'}
|
||||||
>
|
>
|
||||||
<ShoppingBag size={18} className="mr-2" />
|
<ShoppingBag size={18} className="mr-2" />
|
||||||
Cobrar {fmtMoney(total)}
|
Cobrar {fmtMoney(total)}
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ interface CashReport {
|
|||||||
|
|
||||||
const ReportesContent: FC = () => {
|
const ReportesContent: FC = () => {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { hasRole, canSee, brand } = useAuth();
|
const { hasRole, canSee, can, brand } = useAuth();
|
||||||
const esAdmin = hasRole('admin');
|
const esAdmin = hasRole('admin');
|
||||||
// PuedeVer una pestana = canSee('/reportes?tab=x'): la matriz guarda claves
|
// PuedeVer una pestana = canSee('/reportes?tab=x'): la matriz guarda claves
|
||||||
// por pestana y, cuando no existe la del tab, canSee cae al prefijo
|
// por pestana y, cuando no existe la del tab, canSee cae al prefijo
|
||||||
@@ -287,13 +287,13 @@ const ReportesContent: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<Layout title="Reportes" subtitle="Análisis del mes y día">
|
<Layout title="Reportes" subtitle="Análisis del mes y día">
|
||||||
<PageHeader title="Reportes" subtitle={tabInfo?.desc || 'Análisis de la operación'}>
|
<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}>
|
<Button variant="outline" onClick={exportExcel} disabled={loading || !salesReport} loading={exporting}>
|
||||||
<Download size={16} className="mr-2" />
|
<Download size={16} className="mr-2" />
|
||||||
Exportar
|
Exportar
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{puedeTab('exportar') && (
|
{puedeTab('exportar') && can('reportes.exportar') && (
|
||||||
<Button
|
<Button
|
||||||
variant={tab === 'exportar' ? 'primary' : 'outline'}
|
variant={tab === 'exportar' ? 'primary' : 'outline'}
|
||||||
onClick={() => setTab('exportar')}
|
onClick={() => setTab('exportar')}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { odooApi, type Service } from '../services/odoo';
|
import { odooApi, type Service } from '../services/odoo';
|
||||||
import { useGlosario } from '../lib/glosario';
|
import { useGlosario } from '../lib/glosario';
|
||||||
|
import { useAuth } from '../lib/auth';
|
||||||
import { useServiceCategories } from '../lib/service-categories';
|
import { useServiceCategories } from '../lib/service-categories';
|
||||||
|
|
||||||
interface ServiceForm {
|
interface ServiceForm {
|
||||||
@@ -49,6 +50,8 @@ const emptyForm: ServiceForm = {
|
|||||||
|
|
||||||
const Servicios: FC = () => {
|
const Servicios: FC = () => {
|
||||||
const { term } = useGlosario();
|
const { term } = useGlosario();
|
||||||
|
const { can } = useAuth();
|
||||||
|
const puedeEditar = can('servicios.editar');
|
||||||
const { categories, labelFor } = useServiceCategories();
|
const { categories, labelFor } = useServiceCategories();
|
||||||
const categoryOptions = useMemo(
|
const categoryOptions = useMemo(
|
||||||
() => [{ value: '', label: 'Todas las categorías' }, ...categories.map((c) => ({ value: c.code, label: c.name }))],
|
() => [{ value: '', label: 'Todas las categorías' }, ...categories.map((c) => ({ value: c.code, label: c.name }))],
|
||||||
@@ -286,10 +289,12 @@ const Servicios: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<Layout title={term('service_plural')} subtitle="Catálogo de tratamientos">
|
<Layout title={term('service_plural')} subtitle="Catálogo de tratamientos">
|
||||||
<PageHeader title={term('service_plural')} subtitle="Gestiona precios, paquetes y favoritos">
|
<PageHeader title={term('service_plural')} subtitle="Gestiona precios, paquetes y favoritos">
|
||||||
<Button onClick={openCreate}>
|
{puedeEditar && (
|
||||||
<Plus size={16} className="mr-2" />
|
<Button onClick={openCreate}>
|
||||||
Nuevo {term('service_singular').toLowerCase()}
|
<Plus size={16} className="mr-2" />
|
||||||
</Button>
|
Nuevo {term('service_singular').toLowerCase()}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4 sm:mb-6">
|
<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" />
|
<ChevronDown size={16} className="text-theme-muted" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={() => openDuplicate(s)} title="Duplicar servicio">
|
{puedeEditar && (
|
||||||
<Copy size={15} className="text-theme-muted" />
|
<>
|
||||||
</Button>
|
<Button variant="ghost" size="sm" onClick={() => openDuplicate(s)} title="Duplicar servicio">
|
||||||
<Button variant="ghost" size="sm" onClick={() => openEdit(s)} title="Editar">
|
<Copy size={15} className="text-theme-muted" />
|
||||||
<Edit2 size={16} className="text-theme-muted" />
|
</Button>
|
||||||
</Button>
|
<Button variant="ghost" size="sm" onClick={() => openEdit(s)} title="Editar">
|
||||||
|
<Edit2 size={16} className="text-theme-muted" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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).
|
* Ventas. Permiso de cancelación: admin y gerencia (el API lo valida).
|
||||||
*/
|
*/
|
||||||
const Tickets: FC = () => {
|
const Tickets: FC = () => {
|
||||||
const { user } = useAuth();
|
const { user, can } = useAuth();
|
||||||
const puedeCorregir = user?.role === 'admin' || user?.role === 'gerencia';
|
const puedeCorregir = user?.role === 'admin' || user?.role === 'gerencia';
|
||||||
|
const puedeCancelar = can('ventas.cancelar');
|
||||||
const prefs = readDateFilterPrefs(PREF_KEY);
|
const prefs = readDateFilterPrefs(PREF_KEY);
|
||||||
const [preset, setPreset] = useState<DatePreset>(prefs?.preset || 'hoy');
|
const [preset, setPreset] = useState<DatePreset>(prefs?.preset || 'hoy');
|
||||||
const [desde, setDesde] = useState(prefs?.start || rangeForPreset('hoy').start);
|
const [desde, setDesde] = useState(prefs?.start || rangeForPreset('hoy').start);
|
||||||
@@ -304,7 +305,7 @@ const Tickets: FC = () => {
|
|||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{s.state !== 'cancelled' && (
|
{s.state !== 'cancelled' && puedeCancelar && (
|
||||||
<Button size="sm" variant="ghost" onClick={() => abrirCancelar(s)} title="Cancelar ticket (nota de crédito)">
|
<Button size="sm" variant="ghost" onClick={() => abrirCancelar(s)} title="Cancelar ticket (nota de crédito)">
|
||||||
<XCircle size={14} className="text-rose-600" />
|
<XCircle size={14} className="text-rose-600" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -350,7 +351,7 @@ const Tickets: FC = () => {
|
|||||||
<Pencil size={14} className="mr-1.5" /> Corregir
|
<Pencil size={14} className="mr-1.5" /> Corregir
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{detalle && detalle.state !== 'cancelled' && (
|
{detalle && detalle.state !== 'cancelled' && puedeCancelar && (
|
||||||
<Button variant="danger" onClick={() => { const d = detalle; setDetalle(null); abrirCancelar(d); }}>
|
<Button variant="danger" onClick={() => { const d = detalle; setDetalle(null); abrirCancelar(d); }}>
|
||||||
<XCircle size={14} className="mr-1.5" /> Cancelar ticket
|
<XCircle size={14} className="mr-1.5" /> Cancelar ticket
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import { DATE_PRESETS, rangeForPreset, readDateFilterPrefs, writeDateFilterPrefs
|
|||||||
|
|
||||||
// Rango de fechas recordado entre visitas (igual que los presets de la agenda)
|
// Rango de fechas recordado entre visitas (igual que los presets de la agenda)
|
||||||
const FILTERS_KEY = 'skeen_ventas_filtros';
|
const FILTERS_KEY = 'skeen_ventas_filtros';
|
||||||
const ROLES_CANCELAN = ['admin', 'gerencia'];
|
|
||||||
|
|
||||||
interface LineForm {
|
interface LineForm {
|
||||||
service_id: string;
|
service_id: string;
|
||||||
@@ -47,8 +46,10 @@ const paymentMethods = ['Efectivo', 'Tarjeta', 'Transferencia', 'MercadoPago'];
|
|||||||
|
|
||||||
const Ventas: FC = () => {
|
const Ventas: FC = () => {
|
||||||
const { openPatient } = usePatientDrawer();
|
const { openPatient } = usePatientDrawer();
|
||||||
const { user, brand } = useAuth();
|
const { brand, can } = useAuth();
|
||||||
const puedeCancelar = ROLES_CANCELAN.includes(user?.role || '');
|
const puedeCancelar = can('ventas.cancelar');
|
||||||
|
const puedeCobrar = can('ventas.cobrar');
|
||||||
|
const puedeDevolver = can('ventas.devolver');
|
||||||
const [sales, setSales] = useState<Sale[]>([]);
|
const [sales, setSales] = useState<Sale[]>([]);
|
||||||
const [services, setServices] = useState<Service[]>([]);
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
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">
|
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setPrintSale(s); }} title="Reimprimir ticket">
|
||||||
<Printer size={16} className="text-theme-heading" />
|
<Printer size={16} className="text-theme-heading" />
|
||||||
</Button>
|
</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">
|
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); openPay(s); }} title="Pagar">
|
||||||
<CreditCard size={16} className="text-theme-heading mr-1.5" />
|
<CreditCard size={16} className="text-theme-heading mr-1.5" />
|
||||||
Pagar
|
Pagar
|
||||||
</Button>
|
</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">
|
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); openRefund(s); }} title="Devolución">
|
||||||
<Undo2 size={16} className="text-amber-600 mr-1.5" />
|
<Undo2 size={16} className="text-amber-600 mr-1.5" />
|
||||||
Devolución
|
Devolución
|
||||||
@@ -611,12 +612,12 @@ const Ventas: FC = () => {
|
|||||||
<Button variant="ghost" size="sm" onClick={() => setPrintSale(s)} title="Reimprimir ticket">
|
<Button variant="ghost" size="sm" onClick={() => setPrintSale(s)} title="Reimprimir ticket">
|
||||||
<Printer size={16} className="text-theme-heading" />
|
<Printer size={16} className="text-theme-heading" />
|
||||||
</Button>
|
</Button>
|
||||||
{s.amount_due > 0 && s.state !== 'cancelled' && (
|
{s.amount_due > 0 && s.state !== 'cancelled' && puedeCobrar && (
|
||||||
<Button variant="ghost" size="sm" onClick={() => openPay(s)}>
|
<Button variant="ghost" size="sm" onClick={() => openPay(s)}>
|
||||||
<CreditCard size={16} className="text-theme-heading" />
|
<CreditCard size={16} className="text-theme-heading" />
|
||||||
</Button>
|
</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">
|
<Button variant="ghost" size="sm" onClick={() => openRefund(s)} title="Devolución">
|
||||||
<Undo2 size={16} className="text-amber-600" />
|
<Undo2 size={16} className="text-amber-600" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -885,7 +885,9 @@ export interface WacrmLead {
|
|||||||
created_at: string | null;
|
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).
|
// Acciones de la Agenda (espejo de AGENDA_PERMS en skeen_whatsapp/roles.py).
|
||||||
export type AgendaPerm = 'ver' | 'agendar' | 'editar' | 'eliminar' | 'visitas';
|
export type AgendaPerm = 'ver' | 'agendar' | 'editar' | 'eliminar' | 'visitas';
|
||||||
@@ -996,6 +998,8 @@ export interface FrontendUser {
|
|||||||
login: string;
|
login: string;
|
||||||
name: string;
|
name: string;
|
||||||
role: FrontendRole;
|
role: FrontendRole;
|
||||||
|
// Nombre del rol configurado en skeen.role (para roles personalizados).
|
||||||
|
role_name?: string;
|
||||||
must_change_password?: boolean;
|
must_change_password?: boolean;
|
||||||
last_login?: string | null;
|
last_login?: string | null;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
@@ -1004,11 +1008,54 @@ export interface FrontendUser {
|
|||||||
agenda_perms?: AgendaPerm[] | null;
|
agenda_perms?: AgendaPerm[] | null;
|
||||||
// Lista propia del usuario; null = hereda del rol. La usa solo el editor.
|
// Lista propia del usuario; null = hereda del rol. La usa solo el editor.
|
||||||
agenda_perms_custom?: AgendaPerm[] | null;
|
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)
|
// Solo rol 'medico': hr.employee vinculado (null si no hay vínculo)
|
||||||
employee_id?: number | null;
|
employee_id?: number | null;
|
||||||
employee_name?: string | 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 {
|
export interface Receta {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -1552,6 +1599,40 @@ export const odooApi = {
|
|||||||
return data;
|
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
|
// Health
|
||||||
async healthCheck(): Promise<unknown> {
|
async healthCheck(): Promise<unknown> {
|
||||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/health`);
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/health`);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
'depends': ['base', 'skeen_citas', 'skeen_monedero', 'skeen_pagos', 'skeen_ventas'],
|
'depends': ['base', 'skeen_citas', 'skeen_monedero', 'skeen_pagos', 'skeen_ventas'],
|
||||||
'data': [
|
'data': [
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
|
'data/roles.xml',
|
||||||
'data/frontend_users.xml',
|
'data/frontend_users.xml',
|
||||||
],
|
],
|
||||||
'installable': True,
|
'installable': True,
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import time
|
|||||||
|
|
||||||
from odoo.http import request, Response
|
from odoo.http import request, Response
|
||||||
|
|
||||||
from ..roles import ROLE_RANKS, get_menu_roles, AGENDA_PERMS, resolve_agenda_perms
|
from ..roles import ROLE_RANKS, get_menu_roles, AGENDA_PERMS, resolve_agenda_perms, min_rank
|
||||||
|
from ..permissions import has_perm, PERM_LABELS
|
||||||
|
|
||||||
TTL_SECONDS = 60 * 60 * 12 # 12 horas
|
TTL_SECONDS = 60 * 60 * 12 # 12 horas
|
||||||
SECRET_KEY = 'skeen.frontend.secret'
|
SECRET_KEY = 'skeen.frontend.secret'
|
||||||
@@ -158,10 +159,10 @@ def forbid_roles(*roles):
|
|||||||
def require_role(min_role='lectura'):
|
def require_role(min_role='lectura'):
|
||||||
"""Decorator: exige token válido y un rol con rango >= min_role.
|
"""Decorator: exige token válido y un rol con rango >= min_role.
|
||||||
|
|
||||||
Deja pasar OPTIONS (preflight CORS) sin autenticación.
|
Deja pasar OPTIONS (preflight CORS) sin autenticación. El rango mínimo se
|
||||||
|
resuelve en cada petición desde skeen.role (editable), con respaldo en
|
||||||
|
ROLE_RANKS.
|
||||||
"""
|
"""
|
||||||
required = RANK.get(min_role, 10)
|
|
||||||
|
|
||||||
def decorator(func):
|
def decorator(func):
|
||||||
@functools.wraps(func)
|
@functools.wraps(func)
|
||||||
def wrapper(self, *args, **kw):
|
def wrapper(self, *args, **kw):
|
||||||
@@ -170,7 +171,7 @@ def require_role(min_role='lectura'):
|
|||||||
user = current_user(request.env)
|
user = current_user(request.env)
|
||||||
if not user:
|
if not user:
|
||||||
return _json({'status': 'error', 'message': 'No autenticado'}, 401)
|
return _json({'status': 'error', 'message': 'No autenticado'}, 401)
|
||||||
if user.role_rank() < required:
|
if user.role_rank() < min_rank(request.env, min_role):
|
||||||
return _json({'status': 'error', 'message': 'Sin permiso'}, 403)
|
return _json({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||||
if user.role == 'medico' and any(
|
if user.role == 'medico' and any(
|
||||||
request.httprequest.path == p or request.httprequest.path.startswith(
|
request.httprequest.path == p or request.httprequest.path.startswith(
|
||||||
@@ -184,6 +185,33 @@ def require_role(min_role='lectura'):
|
|||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def require_perm(perm):
|
||||||
|
"""Decorator: exige token válido y el permiso por acción `perm`.
|
||||||
|
|
||||||
|
Se apila junto a @require_role: el rango sigue siendo el guard base y este
|
||||||
|
decorador refina la acción concreta contra 'skeen.role_perms' (defaults en
|
||||||
|
permissions.py). El rol admin siempre pasa. Un permiso ausente = acceso
|
||||||
|
denegado (403), nunca 200.
|
||||||
|
"""
|
||||||
|
def decorator(func):
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapper(self, *args, **kw):
|
||||||
|
if request.httprequest.method == 'OPTIONS':
|
||||||
|
return _json({}, 200)
|
||||||
|
user = current_user(request.env)
|
||||||
|
if not user:
|
||||||
|
return _json({'status': 'error', 'message': 'No autenticado'}, 401)
|
||||||
|
if not has_perm(user, perm):
|
||||||
|
return _json({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Sin permiso: %s' % PERM_LABELS.get(perm, perm),
|
||||||
|
}, 403)
|
||||||
|
request.frontend_user = user
|
||||||
|
return func(self, *args, **kw)
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Permisos de ACCION dentro de la Agenda (selector por usuario).
|
# Permisos de ACCION dentro de la Agenda (selector por usuario).
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ import re
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from .auth import sign_token, current_user, require_role, forbid_roles, verify_token
|
from .auth import sign_token, current_user, require_role, forbid_roles, verify_token
|
||||||
from .auth import require_agenda, agenda_perm_error
|
from .auth import require_agenda, agenda_perm_error, require_perm
|
||||||
from ..roles import ROLE_SELECTION, ROLE_KEYS, MENU_ROLES_KEY, get_menu_roles
|
from ..roles import ROLE_SELECTION, ROLE_KEYS, MENU_ROLES_KEY, get_menu_roles
|
||||||
from ..roles import AGENDA_PERM_KEYS
|
from ..roles import AGENDA_PERM_KEYS, role_keys, role_selection
|
||||||
|
from ..permissions import (PERMISSION_GROUPS, PERM_KEYS, PERM_LABELS,
|
||||||
|
ROLE_PERMS_KEY, has_perm, all_roles_perms,
|
||||||
|
default_role_perms, resolve_role_perms, set_role_perms)
|
||||||
from odoo.addons.skeen_citas.clinic_settings import (
|
from odoo.addons.skeen_citas.clinic_settings import (
|
||||||
agenda_bounds as clinic_agenda_bounds,
|
agenda_bounds as clinic_agenda_bounds,
|
||||||
get_settings as get_clinic_settings,
|
get_settings as get_clinic_settings,
|
||||||
@@ -278,6 +281,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/auth/users', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/auth/users', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_perm('config.usuarios')
|
||||||
def auth_users_list(self, **kw):
|
def auth_users_list(self, **kw):
|
||||||
try:
|
try:
|
||||||
if not self._puede_gestionar_usuarios():
|
if not self._puede_gestionar_usuarios():
|
||||||
@@ -289,6 +293,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/auth/users', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/auth/users', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_perm('config.usuarios')
|
||||||
def auth_users_create(self, **kw):
|
def auth_users_create(self, **kw):
|
||||||
try:
|
try:
|
||||||
if not self._puede_gestionar_usuarios():
|
if not self._puede_gestionar_usuarios():
|
||||||
@@ -302,7 +307,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
return json_response({'status': 'error', 'message': 'login y name son obligatorios'}, 400)
|
return json_response({'status': 'error', 'message': 'login y name son obligatorios'}, 400)
|
||||||
if len(password) < 8:
|
if len(password) < 8:
|
||||||
return json_response({'status': 'error', 'message': 'La contraseña debe tener al menos 8 caracteres'}, 400)
|
return json_response({'status': 'error', 'message': 'La contraseña debe tener al menos 8 caracteres'}, 400)
|
||||||
if role not in ROLE_KEYS:
|
if role not in role_keys(request.env):
|
||||||
return json_response({'status': 'error', 'message': 'Rol inválido'}, 400)
|
return json_response({'status': 'error', 'message': 'Rol inválido'}, 400)
|
||||||
if role == 'admin' and not self._puede_asignar_admin():
|
if role == 'admin' and not self._puede_asignar_admin():
|
||||||
return json_response({'status': 'error', 'message': 'Solo un administrador puede crear administradores'}, 403)
|
return json_response({'status': 'error', 'message': 'Solo un administrador puede crear administradores'}, 403)
|
||||||
@@ -319,6 +324,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/auth/users/<int:user_id>', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/auth/users/<int:user_id>', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_perm('config.usuarios')
|
||||||
def auth_users_update(self, user_id, **kw):
|
def auth_users_update(self, user_id, **kw):
|
||||||
try:
|
try:
|
||||||
if not self._puede_gestionar_usuarios():
|
if not self._puede_gestionar_usuarios():
|
||||||
@@ -348,7 +354,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
return json_response({'status': 'error', 'message': 'Ese usuario ya existe'}, 409)
|
return json_response({'status': 'error', 'message': 'Ese usuario ya existe'}, 409)
|
||||||
vals['login'] = login
|
vals['login'] = login
|
||||||
if 'role' in data:
|
if 'role' in data:
|
||||||
if data['role'] not in ROLE_KEYS:
|
if data['role'] not in role_keys(request.env):
|
||||||
return json_response({'status': 'error', 'message': 'Rol inválido'}, 400)
|
return json_response({'status': 'error', 'message': 'Rol inválido'}, 400)
|
||||||
if not self._puede_gestionar_roles():
|
if not self._puede_gestionar_roles():
|
||||||
return json_response({'status': 'error', 'message': 'Sin permiso para cambiar el rol'}, 403)
|
return json_response({'status': 'error', 'message': 'Sin permiso para cambiar el rol'}, 403)
|
||||||
@@ -393,6 +399,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/auth/users/<int:user_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
|
@http.route('/skeen/frontend/v1/auth/users/<int:user_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_perm('config.usuarios')
|
||||||
def auth_users_delete(self, user_id, **kw):
|
def auth_users_delete(self, user_id, **kw):
|
||||||
try:
|
try:
|
||||||
if not self._puede_gestionar_usuarios():
|
if not self._puede_gestionar_usuarios():
|
||||||
@@ -420,13 +427,18 @@ class SkeenFrontendController(http.Controller):
|
|||||||
return json_response({
|
return json_response({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'matrix': get_menu_roles(request.env),
|
'matrix': get_menu_roles(request.env),
|
||||||
'roles': [{'key': k, 'label': l} for k, l in ROLE_SELECTION],
|
'roles': [{'key': r.code, 'label': r.name, 'active': r.active, 'rank': r.rank}
|
||||||
|
for r in request.env['skeen.role'].sudo().with_context(
|
||||||
|
active_test=False).search([], order='sequence, name')] or
|
||||||
|
[{'key': k, 'label': l, 'active': True, 'rank': 0}
|
||||||
|
for k, l in ROLE_SELECTION],
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
@http.route('/skeen/frontend/v1/roles/permissions', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/roles/permissions', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_perm('config.roles')
|
||||||
def roles_permissions_put(self, **kw):
|
def roles_permissions_put(self, **kw):
|
||||||
try:
|
try:
|
||||||
if not self._puede_gestionar_roles():
|
if not self._puede_gestionar_roles():
|
||||||
@@ -435,11 +447,12 @@ class SkeenFrontendController(http.Controller):
|
|||||||
matrix = data.get('matrix')
|
matrix = data.get('matrix')
|
||||||
if not isinstance(matrix, dict):
|
if not isinstance(matrix, dict):
|
||||||
return json_response({'status': 'error', 'message': 'matrix debe ser un objeto {href: [roles]}'}, 400)
|
return json_response({'status': 'error', 'message': 'matrix debe ser un objeto {href: [roles]}'}, 400)
|
||||||
|
valid_roles = role_keys(request.env)
|
||||||
clean = {}
|
clean = {}
|
||||||
for href, roles in matrix.items():
|
for href, roles in matrix.items():
|
||||||
if not isinstance(href, str):
|
if not isinstance(href, str):
|
||||||
return json_response({'status': 'error', 'message': 'Las llaves de matrix deben ser strings (href)'}, 400)
|
return json_response({'status': 'error', 'message': 'Las llaves de matrix deben ser strings (href)'}, 400)
|
||||||
if not isinstance(roles, list) or any(not isinstance(r, str) or r not in ROLE_KEYS for r in roles):
|
if not isinstance(roles, list) or any(not isinstance(r, str) or r not in valid_roles for r in roles):
|
||||||
return json_response({'status': 'error', 'message': f'Roles inválidos para {href}'}, 400)
|
return json_response({'status': 'error', 'message': f'Roles inválidos para {href}'}, 400)
|
||||||
clean[href] = list(dict.fromkeys(roles))
|
clean[href] = list(dict.fromkeys(roles))
|
||||||
if 'admin' not in clean.get('/configuracion', []):
|
if 'admin' not in clean.get('/configuracion', []):
|
||||||
@@ -455,6 +468,264 @@ class SkeenFrontendController(http.Controller):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Roles configurables (skeen.role)
|
||||||
|
# ============================================================
|
||||||
|
# 'admin' y 'lectura' no se pueden desactivar: sin admin nadie administra y
|
||||||
|
# sin lectura no queda el rol base de solo consulta.
|
||||||
|
ROLES_PROTEGIDOS = ('admin', 'lectura')
|
||||||
|
|
||||||
|
def _role_to_dict(self, r):
|
||||||
|
return {
|
||||||
|
'id': r.id,
|
||||||
|
'code': r.code,
|
||||||
|
'name': r.name,
|
||||||
|
'rank': r.rank,
|
||||||
|
'sequence': r.sequence,
|
||||||
|
'active': r.active,
|
||||||
|
'is_system': r.is_system,
|
||||||
|
'description': r.description or '',
|
||||||
|
}
|
||||||
|
|
||||||
|
@http.route('/skeen/frontend/v1/roles', type='http', auth='none',
|
||||||
|
methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
def list_roles(self, **kw):
|
||||||
|
"""Roles configurados. ?include_inactive=1 trae también los inactivos."""
|
||||||
|
try:
|
||||||
|
incluir = kw.get('include_inactive') in ('1', 'true', 'True')
|
||||||
|
Role = request.env['skeen.role'].sudo()
|
||||||
|
if incluir:
|
||||||
|
Role = Role.with_context(active_test=False)
|
||||||
|
recs = Role.search([], order='sequence, name')
|
||||||
|
return json_response({'status': 'success',
|
||||||
|
'roles': [self._role_to_dict(r) for r in recs],
|
||||||
|
'total': len(recs)})
|
||||||
|
except Exception as e:
|
||||||
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/skeen/frontend/v1/roles', type='http', auth='none',
|
||||||
|
methods=['POST'], csrf=False)
|
||||||
|
@require_role('gerencia')
|
||||||
|
@require_perm('config.roles')
|
||||||
|
def create_role(self, **kw):
|
||||||
|
"""Crea un rol. El `code` se genera como slug único del nombre."""
|
||||||
|
try:
|
||||||
|
if not self._puede_gestionar_roles():
|
||||||
|
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||||
|
data = _parse_json_body()
|
||||||
|
name = (data.get('name') or '').strip()
|
||||||
|
if not name:
|
||||||
|
return json_response({'status': 'error',
|
||||||
|
'message': 'El nombre es obligatorio'}, 400)
|
||||||
|
Role = request.env['skeen.role'].sudo()
|
||||||
|
codigo = (data.get('code') or '').strip()
|
||||||
|
if codigo:
|
||||||
|
codigo = catalog_slugify(codigo) or codigo
|
||||||
|
if Role.with_context(active_test=False).search_count([('code', '=', codigo)]):
|
||||||
|
return json_response({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Ya existe un rol con el código %s' % codigo}, 409)
|
||||||
|
else:
|
||||||
|
codigo = catalog_unique_slug(Role, name)
|
||||||
|
try:
|
||||||
|
rank = int(data.get('rank', 50))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return json_response({'status': 'error',
|
||||||
|
'message': 'El rango debe ser numérico'}, 400)
|
||||||
|
try:
|
||||||
|
sequence = int(data.get('sequence') or 10)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
sequence = 10
|
||||||
|
rol = Role.create({
|
||||||
|
'name': name,
|
||||||
|
'code': codigo,
|
||||||
|
'rank': rank,
|
||||||
|
'sequence': sequence,
|
||||||
|
'active': bool(data.get('active', True)),
|
||||||
|
'description': data.get('description') or '',
|
||||||
|
'is_system': False,
|
||||||
|
})
|
||||||
|
self._auditar('roles', 'role:%s' % rol.id, 'crear',
|
||||||
|
new_value=json.dumps(self._role_to_dict(rol), ensure_ascii=False)[:500],
|
||||||
|
entity_label='Rol %s' % rol.name,
|
||||||
|
notes=(data.get('motivo') or 'creado').strip())
|
||||||
|
return json_response({'status': 'success',
|
||||||
|
'role': self._role_to_dict(rol)}, 201)
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/skeen/frontend/v1/roles/<int:role_id>', type='http', auth='none',
|
||||||
|
methods=['PUT', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('gerencia')
|
||||||
|
@require_perm('config.roles')
|
||||||
|
def update_role(self, role_id, **kw):
|
||||||
|
"""Renombra / ajusta rango, secuencia, estado y descripción de un rol.
|
||||||
|
|
||||||
|
El `code` NUNCA se cambia: es el valor guardado en los usuarios.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not self._puede_gestionar_roles():
|
||||||
|
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||||
|
rol = request.env['skeen.role'].sudo().with_context(
|
||||||
|
active_test=False).browse(role_id)
|
||||||
|
if not rol.exists():
|
||||||
|
return json_response({'status': 'error', 'message': 'Rol no encontrado'}, 404)
|
||||||
|
data = _parse_json_body()
|
||||||
|
vals = {}
|
||||||
|
if 'name' in data and str(data.get('name') or '').strip():
|
||||||
|
vals['name'] = str(data['name']).strip()
|
||||||
|
if 'description' in data:
|
||||||
|
vals['description'] = data.get('description') or ''
|
||||||
|
if 'rank' in data:
|
||||||
|
try:
|
||||||
|
vals['rank'] = int(data['rank'])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return json_response({'status': 'error',
|
||||||
|
'message': 'El rango debe ser numérico'}, 400)
|
||||||
|
if 'sequence' in data:
|
||||||
|
try:
|
||||||
|
vals['sequence'] = int(data['sequence'])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return json_response({'status': 'error',
|
||||||
|
'message': 'La secuencia debe ser numérica'}, 400)
|
||||||
|
if 'active' in data:
|
||||||
|
activo = bool(data['active'])
|
||||||
|
if not activo and rol.code in self.ROLES_PROTEGIDOS:
|
||||||
|
return json_response({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'El rol "%s" no se puede desactivar.' % rol.name}, 400)
|
||||||
|
vals['active'] = activo
|
||||||
|
if not vals:
|
||||||
|
return json_response({'status': 'error',
|
||||||
|
'message': 'Nada que actualizar'}, 400)
|
||||||
|
rol.write(vals)
|
||||||
|
self._auditar('roles', 'role:%s' % rol.id, 'editar',
|
||||||
|
new_value=json.dumps(self._role_to_dict(rol), ensure_ascii=False)[:500],
|
||||||
|
entity_label='Rol %s' % rol.name,
|
||||||
|
notes=(data.get('motivo') or 'actualizado').strip())
|
||||||
|
return json_response({'status': 'success',
|
||||||
|
'role': self._role_to_dict(rol)})
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/skeen/frontend/v1/roles/<int:role_id>', type='http', auth='none',
|
||||||
|
methods=['DELETE'], csrf=False)
|
||||||
|
@require_role('gerencia')
|
||||||
|
@require_perm('config.roles')
|
||||||
|
def delete_role(self, role_id, **kw):
|
||||||
|
"""Desactiva un rol (nunca se borra: los usuarios lo referencian).
|
||||||
|
|
||||||
|
Los roles de sistema no se pueden eliminar (400).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not self._puede_gestionar_roles():
|
||||||
|
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||||
|
rol = request.env['skeen.role'].sudo().with_context(
|
||||||
|
active_test=False).browse(role_id)
|
||||||
|
if not rol.exists():
|
||||||
|
return json_response({'status': 'error', 'message': 'Rol no encontrado'}, 404)
|
||||||
|
if rol.is_system:
|
||||||
|
return json_response({
|
||||||
|
'status': 'error',
|
||||||
|
'message': ('"%s" es un rol del sistema: no se puede eliminar, '
|
||||||
|
'solo desactivar (si aplica) o renombrar.' % rol.name)}, 400)
|
||||||
|
if rol.code in self.ROLES_PROTEGIDOS:
|
||||||
|
return json_response({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'El rol "%s" no se puede desactivar.' % rol.name}, 400)
|
||||||
|
en_uso = request.env['skeen.frontend.user'].sudo().search_count(
|
||||||
|
[('role', '=', rol.code)])
|
||||||
|
rol.write({'active': False})
|
||||||
|
self._auditar('roles', 'role:%s' % rol.id, 'desactivar',
|
||||||
|
old_value=json.dumps(self._role_to_dict(rol), ensure_ascii=False)[:500],
|
||||||
|
entity_label='Rol %s' % rol.name,
|
||||||
|
notes='desactivado (%d usuarios)' % en_uso)
|
||||||
|
mensaje = 'Rol desactivado.'
|
||||||
|
if en_uso:
|
||||||
|
mensaje = ('El rol tiene %d usuario(s); quedó desactivado y sus '
|
||||||
|
'usuarios conservan el rol.' % en_uso)
|
||||||
|
return json_response({'status': 'success',
|
||||||
|
'role': self._role_to_dict(rol),
|
||||||
|
'en_uso': en_uso, 'message': mensaje})
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Permisos por acción (catálogo + por rol)
|
||||||
|
# ============================================================
|
||||||
|
def _perm_catalogo(self):
|
||||||
|
return [{'grupo': g, 'label': label,
|
||||||
|
'permisos': [{'key': k, 'label': l} for k, l in perms]}
|
||||||
|
for g, label, perms in PERMISSION_GROUPS]
|
||||||
|
|
||||||
|
@http.route('/skeen/frontend/v1/permisos', type='http', auth='none',
|
||||||
|
methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('gerencia')
|
||||||
|
@require_perm('config.roles')
|
||||||
|
def permisos_get(self, **kw):
|
||||||
|
"""Catálogo de permisos + permisos vigentes por rol + defaults."""
|
||||||
|
try:
|
||||||
|
if not self._puede_gestionar_roles():
|
||||||
|
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||||
|
Role = request.env['skeen.role'].sudo().with_context(active_test=False)
|
||||||
|
return json_response({
|
||||||
|
'status': 'success',
|
||||||
|
'catalogo': self._perm_catalogo(),
|
||||||
|
'por_rol': all_roles_perms(request.env),
|
||||||
|
'defaults': default_role_perms(),
|
||||||
|
'roles': [{'code': r.code, 'name': r.name, 'rank': r.rank,
|
||||||
|
'active': r.active, 'is_system': r.is_system}
|
||||||
|
for r in Role.search([], order='sequence, name')],
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/skeen/frontend/v1/permisos', type='http', auth='none',
|
||||||
|
methods=['PUT', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('gerencia')
|
||||||
|
@require_perm('config.roles')
|
||||||
|
def permisos_put(self, **kw):
|
||||||
|
"""Guarda permisos de uno o varios roles.
|
||||||
|
|
||||||
|
Body: {role_code, permisos:{clave:bool}} o {por_rol:{role_code:{...}}}.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not self._puede_gestionar_roles():
|
||||||
|
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||||
|
data = _parse_json_body()
|
||||||
|
validos = role_keys(request.env)
|
||||||
|
guardados = 0
|
||||||
|
if isinstance(data.get('por_rol'), dict):
|
||||||
|
por_rol = data['por_rol']
|
||||||
|
elif data.get('role_code'):
|
||||||
|
por_rol = {data['role_code']: data.get('permisos') or {}}
|
||||||
|
else:
|
||||||
|
return json_response({'status': 'error',
|
||||||
|
'message': 'Se espera role_code+permisos o por_rol'}, 400)
|
||||||
|
for code, perms in por_rol.items():
|
||||||
|
if code not in validos:
|
||||||
|
return json_response({'status': 'error',
|
||||||
|
'message': 'Rol inválido: %s' % code}, 400)
|
||||||
|
if not isinstance(perms, dict):
|
||||||
|
return json_response({'status': 'error',
|
||||||
|
'message': 'permisos de %s debe ser un objeto' % code}, 400)
|
||||||
|
if set_role_perms(request.env, code, perms):
|
||||||
|
guardados += 1
|
||||||
|
self._auditar('roles', 'role_perms', 'permisos',
|
||||||
|
new_value=json.dumps(por_rol, ensure_ascii=False)[:2000],
|
||||||
|
entity_label='Permisos por rol',
|
||||||
|
notes='%d rol(es)' % guardados)
|
||||||
|
return json_response({'status': 'success',
|
||||||
|
'por_rol': all_roles_perms(request.env),
|
||||||
|
'guardados': guardados})
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Dashboard
|
# Dashboard
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -1932,6 +2203,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/patients/export', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/patients/export', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
@require_role('lectura')
|
@require_role('lectura')
|
||||||
|
@require_perm('pacientes.exportar')
|
||||||
def export_patients(self, **kw):
|
def export_patients(self, **kw):
|
||||||
"""Todos los pacientes activos que cumplen los filtros, SIN paginar.
|
"""Todos los pacientes activos que cumplen los filtros, SIN paginar.
|
||||||
|
|
||||||
@@ -1973,6 +2245,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/patients', type='http', auth='none', methods=['POST'], csrf=False)
|
@http.route('/skeen/frontend/v1/patients', type='http', auth='none', methods=['POST'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_perm('pacientes.crear')
|
||||||
def create_patient(self, **kw):
|
def create_patient(self, **kw):
|
||||||
try:
|
try:
|
||||||
data = _parse_json_body()
|
data = _parse_json_body()
|
||||||
@@ -2048,6 +2321,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/patients/<int:patient_id>', type='http', auth='none', methods=['PUT'], csrf=False)
|
@http.route('/skeen/frontend/v1/patients/<int:patient_id>', type='http', auth='none', methods=['PUT'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_perm('pacientes.editar')
|
||||||
def update_patient(self, patient_id, **kw):
|
def update_patient(self, patient_id, **kw):
|
||||||
try:
|
try:
|
||||||
patient = request.env['res.partner'].sudo().browse(patient_id)
|
patient = request.env['res.partner'].sudo().browse(patient_id)
|
||||||
@@ -2642,6 +2916,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/deposit', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/deposit', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_perm('ventas.cobrar')
|
||||||
@forbid_roles('marketing')
|
@forbid_roles('marketing')
|
||||||
def patient_deposit(self, patient_id, **kw):
|
def patient_deposit(self, patient_id, **kw):
|
||||||
"""Recarga de saldo a favor (anticipo): dinero a cuenta del paciente.
|
"""Recarga de saldo a favor (anticipo): dinero a cuenta del paciente.
|
||||||
@@ -2740,6 +3015,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/pay_adeudo', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/pay_adeudo', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_perm('ventas.cobrar')
|
||||||
@forbid_roles('marketing')
|
@forbid_roles('marketing')
|
||||||
def pay_patient_adeudo(self, patient_id, **kw):
|
def pay_patient_adeudo(self, patient_id, **kw):
|
||||||
"""Registra un pago del adeudo (parcial o total) y devuelve el recibo.
|
"""Registra un pago del adeudo (parcial o total) y devuelve el recibo.
|
||||||
@@ -3450,7 +3726,8 @@ class SkeenFrontendController(http.Controller):
|
|||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
@http.route('/skeen/frontend/v1/services', type='http', auth='none', methods=['POST'], csrf=False)
|
@http.route('/skeen/frontend/v1/services', type='http', auth='none', methods=['POST'], csrf=False)
|
||||||
@require_role('admin')
|
@require_role('gerencia')
|
||||||
|
@require_perm('servicios.editar')
|
||||||
def create_service(self, **kw):
|
def create_service(self, **kw):
|
||||||
try:
|
try:
|
||||||
data = _parse_json_body()
|
data = _parse_json_body()
|
||||||
@@ -3492,7 +3769,8 @@ class SkeenFrontendController(http.Controller):
|
|||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
@http.route('/skeen/frontend/v1/services/reorder', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/services/reorder', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
@require_role('admin')
|
@require_role('gerencia')
|
||||||
|
@require_perm('servicios.editar')
|
||||||
def reorder_services(self, **kw):
|
def reorder_services(self, **kw):
|
||||||
"""Reordena el catálogo y renumera las secuencias (10, 20, 30...).
|
"""Reordena el catálogo y renumera las secuencias (10, 20, 30...).
|
||||||
|
|
||||||
@@ -3533,7 +3811,8 @@ class SkeenFrontendController(http.Controller):
|
|||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
@http.route('/skeen/frontend/v1/services/<int:service_id>', type='http', auth='none', methods=['PUT'], csrf=False)
|
@http.route('/skeen/frontend/v1/services/<int:service_id>', type='http', auth='none', methods=['PUT'], csrf=False)
|
||||||
@require_role('admin')
|
@require_role('gerencia')
|
||||||
|
@require_perm('servicios.editar')
|
||||||
def update_service(self, service_id, **kw):
|
def update_service(self, service_id, **kw):
|
||||||
try:
|
try:
|
||||||
service = request.env['skeen.servicio'].sudo().browse(service_id)
|
service = request.env['skeen.servicio'].sudo().browse(service_id)
|
||||||
@@ -4345,6 +4624,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/payments', type='http', auth='none', methods=['POST'], csrf=False)
|
@http.route('/skeen/frontend/v1/payments', type='http', auth='none', methods=['POST'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_perm('ventas.cobrar')
|
||||||
@forbid_roles('marketing')
|
@forbid_roles('marketing')
|
||||||
def create_payment(self, **kw):
|
def create_payment(self, **kw):
|
||||||
try:
|
try:
|
||||||
@@ -4362,6 +4642,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/payments/<int:payment_id>/confirm', type='http', auth='none', methods=['POST'], csrf=False)
|
@http.route('/skeen/frontend/v1/payments/<int:payment_id>/confirm', type='http', auth='none', methods=['POST'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_perm('ventas.cobrar')
|
||||||
@forbid_roles('marketing')
|
@forbid_roles('marketing')
|
||||||
def confirm_payment(self, payment_id, **kw):
|
def confirm_payment(self, payment_id, **kw):
|
||||||
try:
|
try:
|
||||||
@@ -4530,6 +4811,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/sales/<int:sale_id>/pay', type='http', auth='none', methods=['POST'], csrf=False)
|
@http.route('/skeen/frontend/v1/sales/<int:sale_id>/pay', type='http', auth='none', methods=['POST'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_perm('ventas.cobrar')
|
||||||
@forbid_roles('marketing')
|
@forbid_roles('marketing')
|
||||||
def pay_sale(self, sale_id, **kw):
|
def pay_sale(self, sale_id, **kw):
|
||||||
try:
|
try:
|
||||||
@@ -4568,6 +4850,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/sales/<int:sale_id>/refund', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/sales/<int:sale_id>/refund', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_perm('ventas.devolver')
|
||||||
@forbid_roles('marketing')
|
@forbid_roles('marketing')
|
||||||
def refund_sale(self, sale_id, **kw):
|
def refund_sale(self, sale_id, **kw):
|
||||||
try:
|
try:
|
||||||
@@ -4584,6 +4867,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/sales/<int:sale_id>/cancel', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/sales/<int:sale_id>/cancel', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_perm('ventas.cancelar')
|
||||||
@forbid_roles('marketing')
|
@forbid_roles('marketing')
|
||||||
def cancel_sale(self, sale_id, **kw):
|
def cancel_sale(self, sale_id, **kw):
|
||||||
"""Cancelación de venta con nota de crédito (motivo obligatorio).
|
"""Cancelación de venta con nota de crédito (motivo obligatorio).
|
||||||
@@ -4747,6 +5031,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
@http.route('/skeen/frontend/v1/pos/checkout', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/pos/checkout', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_perm('ventas.vender')
|
||||||
@forbid_roles('marketing')
|
@forbid_roles('marketing')
|
||||||
def pos_checkout(self, **kw):
|
def pos_checkout(self, **kw):
|
||||||
"""Crea la venta confirmada, aplica canje de puntos y/o registra el pago.
|
"""Crea la venta confirmada, aplica canje de puntos y/o registra el pago.
|
||||||
@@ -4767,6 +5052,11 @@ class SkeenFrontendController(http.Controller):
|
|||||||
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
|
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
|
||||||
|
|
||||||
discount = float(data.get('discount', 0) or 0)
|
discount = float(data.get('discount', 0) or 0)
|
||||||
|
if discount > 0 and not has_perm(request.frontend_user, 'ventas.descuentos'):
|
||||||
|
return json_response({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Sin permiso: %s' % PERM_LABELS.get('ventas.descuentos', 'Aplicar descuentos'),
|
||||||
|
}, 403)
|
||||||
line_vals = []
|
line_vals = []
|
||||||
item_lines = [] # líneas de artículo: para el move 'venta' tras confirmar
|
item_lines = [] # líneas de artículo: para el move 'venta' tras confirmar
|
||||||
paquete_lines = [] # (paquete_id, qty, price): materializan el saldo del paciente
|
paquete_lines = [] # (paquete_id, qty, price): materializan el saldo del paciente
|
||||||
@@ -5016,6 +5306,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/cash_closings/<int:closing_id>/close', type='http', auth='none', methods=['POST'], csrf=False)
|
@http.route('/skeen/frontend/v1/cash_closings/<int:closing_id>/close', type='http', auth='none', methods=['POST'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_perm('caja.cerrar')
|
||||||
@forbid_roles('marketing')
|
@forbid_roles('marketing')
|
||||||
def close_cash_closing(self, closing_id, **kw):
|
def close_cash_closing(self, closing_id, **kw):
|
||||||
try:
|
try:
|
||||||
@@ -6181,6 +6472,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/exchange_rate', type='http', auth='none', methods=['POST'], csrf=False)
|
@http.route('/skeen/frontend/v1/exchange_rate', type='http', auth='none', methods=['POST'], csrf=False)
|
||||||
@require_role('admin')
|
@require_role('admin')
|
||||||
|
@require_perm('config.editar')
|
||||||
def set_exchange_rate(self, **kw):
|
def set_exchange_rate(self, **kw):
|
||||||
try:
|
try:
|
||||||
data = _parse_json_body()
|
data = _parse_json_body()
|
||||||
@@ -6238,6 +6530,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/sales_goal', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/sales_goal', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
@require_role('admin')
|
@require_role('admin')
|
||||||
|
@require_perm('config.editar')
|
||||||
def set_sales_goal(self, **kw):
|
def set_sales_goal(self, **kw):
|
||||||
try:
|
try:
|
||||||
data = _parse_json_body()
|
data = _parse_json_body()
|
||||||
@@ -6262,6 +6555,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/config/commission_default', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/config/commission_default', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
@require_role('admin')
|
@require_role('admin')
|
||||||
|
@require_perm('config.editar')
|
||||||
def set_commission_default(self, **kw):
|
def set_commission_default(self, **kw):
|
||||||
try:
|
try:
|
||||||
data = _parse_json_body()
|
data = _parse_json_body()
|
||||||
@@ -6285,6 +6579,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
@http.route('/skeen/frontend/v1/config/impact_simulation', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/config/impact_simulation', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
@require_role('admin')
|
@require_role('admin')
|
||||||
|
@require_perm('config.editar')
|
||||||
def config_impact_simulation(self, **kw):
|
def config_impact_simulation(self, **kw):
|
||||||
"""Qué habría pasado con los valores configurados (o unos propuestos).
|
"""Qué habría pasado con los valores configurados (o unos propuestos).
|
||||||
|
|
||||||
@@ -6381,6 +6676,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/config/audit', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/config/audit', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
@require_role('admin')
|
@require_role('admin')
|
||||||
|
@require_perm('config.auditoria')
|
||||||
def config_audit_list(self, **kw):
|
def config_audit_list(self, **kw):
|
||||||
"""Bitácora de cambios de configuración (filtrable y paginada)."""
|
"""Bitácora de cambios de configuración (filtrable y paginada)."""
|
||||||
try:
|
try:
|
||||||
@@ -6492,6 +6788,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
@http.route('/skeen/frontend/v1/brand', type='http',
|
@http.route('/skeen/frontend/v1/brand', type='http',
|
||||||
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||||||
@require_role('admin')
|
@require_role('admin')
|
||||||
|
@require_perm('config.editar')
|
||||||
def put_brand(self, **kw):
|
def put_brand(self, **kw):
|
||||||
"""Guarda el brand. Solo admin del sistema; queda en la bitácora."""
|
"""Guarda el brand. Solo admin del sistema; queda en la bitácora."""
|
||||||
try:
|
try:
|
||||||
@@ -6571,6 +6868,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
@http.route('/skeen/frontend/v1/glosario', type='http',
|
@http.route('/skeen/frontend/v1/glosario', type='http',
|
||||||
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||||||
@require_role('gerencia')
|
@require_role('gerencia')
|
||||||
|
@require_perm('config.editar')
|
||||||
def put_glosario(self, **kw):
|
def put_glosario(self, **kw):
|
||||||
"""Guarda pack y overrides. Los overrides pisan al pack.
|
"""Guarda pack y overrides. Los overrides pisan al pack.
|
||||||
|
|
||||||
@@ -6632,6 +6930,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
@http.route('/skeen/frontend/v1/clinic-settings', type='http',
|
@http.route('/skeen/frontend/v1/clinic-settings', type='http',
|
||||||
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||||||
@require_role('gerencia')
|
@require_role('gerencia')
|
||||||
|
@require_perm('config.editar')
|
||||||
def put_clinic_settings_api(self, **kw):
|
def put_clinic_settings_api(self, **kw):
|
||||||
"""Guarda los ajustes mergeando sobre lo actual.
|
"""Guarda los ajustes mergeando sobre lo actual.
|
||||||
|
|
||||||
@@ -6699,6 +6998,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
@http.route('/skeen/frontend/v1/sucursales', type='http',
|
@http.route('/skeen/frontend/v1/sucursales', type='http',
|
||||||
auth='none', methods=['POST'], csrf=False)
|
auth='none', methods=['POST'], csrf=False)
|
||||||
@require_role('gerencia')
|
@require_role('gerencia')
|
||||||
|
@require_perm('config.editar')
|
||||||
def create_sucursal(self, **kw):
|
def create_sucursal(self, **kw):
|
||||||
"""Crea una sucursal. Sin `code` se genera un slug del nombre."""
|
"""Crea una sucursal. Sin `code` se genera un slug del nombre."""
|
||||||
try:
|
try:
|
||||||
@@ -6750,6 +7050,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
@http.route('/skeen/frontend/v1/sucursales/<int:sucursal_id>', type='http',
|
@http.route('/skeen/frontend/v1/sucursales/<int:sucursal_id>', type='http',
|
||||||
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||||||
@require_role('gerencia')
|
@require_role('gerencia')
|
||||||
|
@require_perm('config.editar')
|
||||||
def update_sucursal(self, sucursal_id, **kw):
|
def update_sucursal(self, sucursal_id, **kw):
|
||||||
try:
|
try:
|
||||||
if not self._puede_configurar_catalogo():
|
if not self._puede_configurar_catalogo():
|
||||||
@@ -6806,6 +7107,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
@http.route('/skeen/frontend/v1/sucursales/<int:sucursal_id>', type='http',
|
@http.route('/skeen/frontend/v1/sucursales/<int:sucursal_id>', type='http',
|
||||||
auth='none', methods=['DELETE'], csrf=False)
|
auth='none', methods=['DELETE'], csrf=False)
|
||||||
@require_role('gerencia')
|
@require_role('gerencia')
|
||||||
|
@require_perm('config.editar')
|
||||||
def delete_sucursal(self, sucursal_id, **kw):
|
def delete_sucursal(self, sucursal_id, **kw):
|
||||||
"""Desactiva la sucursal (nunca se borra: las citas la referencian)."""
|
"""Desactiva la sucursal (nunca se borra: las citas la referencian)."""
|
||||||
try:
|
try:
|
||||||
@@ -6868,6 +7170,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
@http.route('/skeen/frontend/v1/service-categories', type='http',
|
@http.route('/skeen/frontend/v1/service-categories', type='http',
|
||||||
auth='none', methods=['POST'], csrf=False)
|
auth='none', methods=['POST'], csrf=False)
|
||||||
@require_role('gerencia')
|
@require_role('gerencia')
|
||||||
|
@require_perm('config.editar')
|
||||||
def create_service_category(self, **kw):
|
def create_service_category(self, **kw):
|
||||||
"""Crea una categoría (las creadas por API nunca son de sistema)."""
|
"""Crea una categoría (las creadas por API nunca son de sistema)."""
|
||||||
try:
|
try:
|
||||||
@@ -6914,6 +7217,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
@http.route('/skeen/frontend/v1/service-categories/<int:category_id>', type='http',
|
@http.route('/skeen/frontend/v1/service-categories/<int:category_id>', type='http',
|
||||||
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||||||
@require_role('gerencia')
|
@require_role('gerencia')
|
||||||
|
@require_perm('config.editar')
|
||||||
def update_service_category(self, category_id, **kw):
|
def update_service_category(self, category_id, **kw):
|
||||||
"""Edita una categoría. `is_system` no se puede cambiar."""
|
"""Edita una categoría. `is_system` no se puede cambiar."""
|
||||||
try:
|
try:
|
||||||
@@ -6964,6 +7268,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
@http.route('/skeen/frontend/v1/service-categories/<int:category_id>', type='http',
|
@http.route('/skeen/frontend/v1/service-categories/<int:category_id>', type='http',
|
||||||
auth='none', methods=['DELETE'], csrf=False)
|
auth='none', methods=['DELETE'], csrf=False)
|
||||||
@require_role('gerencia')
|
@require_role('gerencia')
|
||||||
|
@require_perm('config.editar')
|
||||||
def delete_service_category(self, category_id, **kw):
|
def delete_service_category(self, category_id, **kw):
|
||||||
"""Desactiva la categoría. Las de sistema no se pueden eliminar."""
|
"""Desactiva la categoría. Las de sistema no se pueden eliminar."""
|
||||||
try:
|
try:
|
||||||
@@ -7018,6 +7323,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
@http.route('/skeen/frontend/v1/ticket_config', type='http',
|
@http.route('/skeen/frontend/v1/ticket_config', type='http',
|
||||||
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||||||
@require_role('admin')
|
@require_role('admin')
|
||||||
|
@require_perm('config.editar')
|
||||||
def put_ticket_config(self, **kw):
|
def put_ticket_config(self, **kw):
|
||||||
"""Guarda el diseño del ticket. Requiere motivo para la bitácora."""
|
"""Guarda el diseño del ticket. Requiere motivo para la bitácora."""
|
||||||
try:
|
try:
|
||||||
@@ -7048,6 +7354,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
@http.route('/skeen/frontend/v1/ticket_config/clinica', type='http',
|
@http.route('/skeen/frontend/v1/ticket_config/clinica', type='http',
|
||||||
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||||||
@require_role('admin')
|
@require_role('admin')
|
||||||
|
@require_perm('config.editar')
|
||||||
def put_ticket_clinica(self, **kw):
|
def put_ticket_clinica(self, **kw):
|
||||||
"""Perfil de la clínica que sale en el encabezado del ticket (y logo)."""
|
"""Perfil de la clínica que sale en el encabezado del ticket (y logo)."""
|
||||||
try:
|
try:
|
||||||
@@ -7081,6 +7388,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
@http.route('/skeen/frontend/v1/ticket_config/reset', type='http',
|
@http.route('/skeen/frontend/v1/ticket_config/reset', type='http',
|
||||||
auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
@require_role('admin')
|
@require_role('admin')
|
||||||
|
@require_perm('config.editar')
|
||||||
def reset_ticket_config(self, **kw):
|
def reset_ticket_config(self, **kw):
|
||||||
"""Devuelve el diseño al formato original (el del ticket de siempre)."""
|
"""Devuelve el diseño al formato original (el del ticket de siempre)."""
|
||||||
try:
|
try:
|
||||||
@@ -7244,6 +7552,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/inventory/items', type='http', auth='none', methods=['POST'], csrf=False)
|
@http.route('/skeen/frontend/v1/inventory/items', type='http', auth='none', methods=['POST'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_perm('inventario.mover')
|
||||||
@forbid_roles('marketing')
|
@forbid_roles('marketing')
|
||||||
def inventory_create(self, **kw):
|
def inventory_create(self, **kw):
|
||||||
try:
|
try:
|
||||||
@@ -7271,6 +7580,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/inventory/items/<int:item_id>', type='http', auth='none', methods=['PUT'], csrf=False)
|
@http.route('/skeen/frontend/v1/inventory/items/<int:item_id>', type='http', auth='none', methods=['PUT'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_perm('inventario.ajustar')
|
||||||
@forbid_roles('marketing')
|
@forbid_roles('marketing')
|
||||||
def inventory_update(self, item_id, **kw):
|
def inventory_update(self, item_id, **kw):
|
||||||
try:
|
try:
|
||||||
@@ -7303,6 +7613,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/inventory/items/<int:item_id>/moves', type='http', auth='none', methods=['POST'], csrf=False)
|
@http.route('/skeen/frontend/v1/inventory/items/<int:item_id>/moves', type='http', auth='none', methods=['POST'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_perm('inventario.mover')
|
||||||
@forbid_roles('marketing')
|
@forbid_roles('marketing')
|
||||||
def inventory_move_create(self, item_id, **kw):
|
def inventory_move_create(self, item_id, **kw):
|
||||||
try:
|
try:
|
||||||
@@ -7366,6 +7677,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/inventario/compras', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/inventario/compras', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_perm('inventario.mover')
|
||||||
@forbid_roles('marketing')
|
@forbid_roles('marketing')
|
||||||
def inventario_compra(self, **kw):
|
def inventario_compra(self, **kw):
|
||||||
"""Registrar compra de consumible (sube stock; actualiza costo si viene)"""
|
"""Registrar compra de consumible (sube stock; actualiza costo si viene)"""
|
||||||
@@ -7455,6 +7767,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/inventario/bajas', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/inventario/bajas', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_perm('inventario.mover')
|
||||||
@forbid_roles('marketing')
|
@forbid_roles('marketing')
|
||||||
def inventario_baja(self, **kw):
|
def inventario_baja(self, **kw):
|
||||||
"""Registrar baja/merma de consumible (baja stock)"""
|
"""Registrar baja/merma de consumible (baja stock)"""
|
||||||
@@ -7532,6 +7845,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/inventario/conteos', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/inventario/conteos', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_perm('inventario.ajustar')
|
||||||
@forbid_roles('marketing')
|
@forbid_roles('marketing')
|
||||||
def create_conteo(self, **kw):
|
def create_conteo(self, **kw):
|
||||||
"""Registra un conteo físico y aplica los ajustes por diferencia.
|
"""Registra un conteo físico y aplica los ajustes por diferencia.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Expone endpoints REST para el frontend SKEEN.
|
|||||||
from odoo import http, fields
|
from odoo import http, fields
|
||||||
from odoo.http import request
|
from odoo.http import request
|
||||||
from .frontend import json_response, _parse_json_body
|
from .frontend import json_response, _parse_json_body
|
||||||
from .auth import require_role
|
from .auth import require_role, require_perm
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import requests
|
import requests
|
||||||
@@ -397,6 +397,8 @@ class SkeenWacrmProxyController(http.Controller):
|
|||||||
# Mensajes
|
# Mensajes
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@http.route('/skeen/frontend/v1/wacrm/messages', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/wacrm/messages', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('whatsapp.responder')
|
||||||
def send_message(self, **kw):
|
def send_message(self, **kw):
|
||||||
try:
|
try:
|
||||||
data = _parse_json_body()
|
data = _parse_json_body()
|
||||||
@@ -547,6 +549,8 @@ class SkeenWacrmProxyController(http.Controller):
|
|||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
@http.route('/skeen/frontend/v1/wacrm/conversations/<string:external_id>/assign', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/wacrm/conversations/<string:external_id>/assign', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('whatsapp.asignar')
|
||||||
def assign_conversation(self, external_id, **kw):
|
def assign_conversation(self, external_id, **kw):
|
||||||
try:
|
try:
|
||||||
data = _parse_json_body()
|
data = _parse_json_body()
|
||||||
@@ -641,6 +645,8 @@ class SkeenWacrmProxyController(http.Controller):
|
|||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
@http.route('/skeen/frontend/v1/wacrm/leads/<int:lead_id>/status', type='http', auth='none', methods=['PUT'], csrf=False)
|
@http.route('/skeen/frontend/v1/wacrm/leads/<int:lead_id>/status', type='http', auth='none', methods=['PUT'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('whatsapp.asignar')
|
||||||
def update_lead_status(self, lead_id, **kw):
|
def update_lead_status(self, lead_id, **kw):
|
||||||
try:
|
try:
|
||||||
deal = request.env['skeen.wacrm.deal'].sudo().browse(lead_id)
|
deal = request.env['skeen.wacrm.deal'].sudo().browse(lead_id)
|
||||||
@@ -679,6 +685,8 @@ class SkeenWacrmProxyController(http.Controller):
|
|||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
@http.route('/skeen/frontend/v1/wacrm/leads/<int:lead_id>/convert', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/wacrm/leads/<int:lead_id>/convert', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('whatsapp.asignar')
|
||||||
def convert_lead(self, lead_id, **kw):
|
def convert_lead(self, lead_id, **kw):
|
||||||
"""Convierte el lead en paciente SKEEN (crea o liga por teléfono) y lo marca ganado"""
|
"""Convierte el lead en paciente SKEEN (crea o liga por teléfono) y lo marca ganado"""
|
||||||
try:
|
try:
|
||||||
@@ -805,6 +813,7 @@ class SkeenWacrmProxyController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/wacrm/conversations/<string:external_id>/notes', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/wacrm/conversations/<string:external_id>/notes', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_perm('whatsapp.responder')
|
||||||
def create_note(self, external_id, **kw):
|
def create_note(self, external_id, **kw):
|
||||||
try:
|
try:
|
||||||
data = _parse_json_body()
|
data = _parse_json_body()
|
||||||
@@ -827,6 +836,7 @@ class SkeenWacrmProxyController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/wacrm/notes/<int:note_id>', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/wacrm/notes/<int:note_id>', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_perm('whatsapp.responder')
|
||||||
def update_note(self, note_id, **kw):
|
def update_note(self, note_id, **kw):
|
||||||
"""Marca/desmarca una nota como atendida, o edita su texto."""
|
"""Marca/desmarca una nota como atendida, o edita su texto."""
|
||||||
try:
|
try:
|
||||||
@@ -854,6 +864,7 @@ class SkeenWacrmProxyController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/wacrm/notes/<int:note_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
|
@http.route('/skeen/frontend/v1/wacrm/notes/<int:note_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_perm('whatsapp.responder')
|
||||||
def delete_note(self, note_id, **kw):
|
def delete_note(self, note_id, **kw):
|
||||||
try:
|
try:
|
||||||
note = request.env['skeen.wacrm.note'].sudo().browse(note_id)
|
note = request.env['skeen.wacrm.note'].sudo().browse(note_id)
|
||||||
@@ -876,6 +887,7 @@ class SkeenWacrmProxyController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/wacrm/tags', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/wacrm/tags', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_perm('whatsapp.responder')
|
||||||
def create_tag(self, **kw):
|
def create_tag(self, **kw):
|
||||||
try:
|
try:
|
||||||
data = _parse_json_body()
|
data = _parse_json_body()
|
||||||
@@ -906,6 +918,7 @@ class SkeenWacrmProxyController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/wacrm/tags/<int:tag_id>', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/wacrm/tags/<int:tag_id>', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_perm('whatsapp.responder')
|
||||||
def update_tag(self, tag_id, **kw):
|
def update_tag(self, tag_id, **kw):
|
||||||
try:
|
try:
|
||||||
tag = request.env['skeen.wacrm.tag'].sudo().browse(tag_id)
|
tag = request.env['skeen.wacrm.tag'].sudo().browse(tag_id)
|
||||||
@@ -936,6 +949,7 @@ class SkeenWacrmProxyController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/skeen/frontend/v1/wacrm/tags/<int:tag_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
|
@http.route('/skeen/frontend/v1/wacrm/tags/<int:tag_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
|
@require_perm('whatsapp.responder')
|
||||||
def delete_tag(self, tag_id, **kw):
|
def delete_tag(self, tag_id, **kw):
|
||||||
"""Desactiva la etiqueta (conserva el histórico de asignaciones)."""
|
"""Desactiva la etiqueta (conserva el histórico de asignaciones)."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
89
odoo-addons/skeen_whatsapp/data/roles.xml
Normal file
89
odoo-addons/skeen_whatsapp/data/roles.xml
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo noupdate="1">
|
||||||
|
<!-- Roles semilla del frontend React. Los `code` son EXACTAMENTE los que ya
|
||||||
|
estaban guardados en skeen.frontend.user.role: no cambiarlos o los
|
||||||
|
usuarios históricos quedarían huérfanos. is_system=True: se pueden
|
||||||
|
renombrar, ajustar de rango y desactivar (salvo admin/lectura), pero no
|
||||||
|
eliminar. Los permisos por acción viven en ir.config_parameter
|
||||||
|
'skeen.role_perms' (defaults calculados en permissions.py). -->
|
||||||
|
<record id="role_admin" model="skeen.role">
|
||||||
|
<field name="name">Administrador</field>
|
||||||
|
<field name="code">admin</field>
|
||||||
|
<field name="rank">100</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="is_system" eval="True"/>
|
||||||
|
<field name="description">Acceso total al sistema y a la configuración.</field>
|
||||||
|
</record>
|
||||||
|
<record id="role_medico" model="skeen.role">
|
||||||
|
<field name="name">Médico</field>
|
||||||
|
<field name="code">medico</field>
|
||||||
|
<field name="rank">50</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
<field name="is_system" eval="True"/>
|
||||||
|
<field name="description">Atención clínica; sin pantallas de dinero.</field>
|
||||||
|
</record>
|
||||||
|
<record id="role_recepcion" model="skeen.role">
|
||||||
|
<field name="name">Recepción</field>
|
||||||
|
<field name="code">recepcion</field>
|
||||||
|
<field name="rank">50</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
<field name="is_system" eval="True"/>
|
||||||
|
<field name="description">Agenda, pacientes, ventas y cobros.</field>
|
||||||
|
</record>
|
||||||
|
<record id="role_enfermeria" model="skeen.role">
|
||||||
|
<field name="name">Enfermería</field>
|
||||||
|
<field name="code">enfermeria</field>
|
||||||
|
<field name="rank">50</field>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
<field name="is_system" eval="True"/>
|
||||||
|
<field name="description">Apoyo clínico y visitas.</field>
|
||||||
|
</record>
|
||||||
|
<record id="role_cosmetologia" model="skeen.role">
|
||||||
|
<field name="name">Cosmetología</field>
|
||||||
|
<field name="code">cosmetologia</field>
|
||||||
|
<field name="rank">50</field>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
<field name="is_system" eval="True"/>
|
||||||
|
<field name="description">Servicios de cosmetología.</field>
|
||||||
|
</record>
|
||||||
|
<record id="role_psicologa" model="skeen.role">
|
||||||
|
<field name="name">Psicóloga</field>
|
||||||
|
<field name="code">psicologa</field>
|
||||||
|
<field name="rank">50</field>
|
||||||
|
<field name="sequence">60</field>
|
||||||
|
<field name="is_system" eval="True"/>
|
||||||
|
<field name="description">Servicios de psicología.</field>
|
||||||
|
</record>
|
||||||
|
<record id="role_farmacia" model="skeen.role">
|
||||||
|
<field name="name">Farmacia</field>
|
||||||
|
<field name="code">farmacia</field>
|
||||||
|
<field name="rank">50</field>
|
||||||
|
<field name="sequence">70</field>
|
||||||
|
<field name="is_system" eval="True"/>
|
||||||
|
<field name="description">Dispensación de productos y medicamentos.</field>
|
||||||
|
</record>
|
||||||
|
<record id="role_gerencia" model="skeen.role">
|
||||||
|
<field name="name">Gerencia</field>
|
||||||
|
<field name="code">gerencia</field>
|
||||||
|
<field name="rank">50</field>
|
||||||
|
<field name="sequence">80</field>
|
||||||
|
<field name="is_system" eval="True"/>
|
||||||
|
<field name="description">Administra usuarios, roles y catálogos.</field>
|
||||||
|
</record>
|
||||||
|
<record id="role_marketing" model="skeen.role">
|
||||||
|
<field name="name">Marketing</field>
|
||||||
|
<field name="code">marketing</field>
|
||||||
|
<field name="rank">50</field>
|
||||||
|
<field name="sequence">90</field>
|
||||||
|
<field name="is_system" eval="True"/>
|
||||||
|
<field name="description">Agenda, pacientes y WhatsApp; sin caja.</field>
|
||||||
|
</record>
|
||||||
|
<record id="role_lectura" model="skeen.role">
|
||||||
|
<field name="name">Lectura</field>
|
||||||
|
<field name="code">lectura</field>
|
||||||
|
<field name="rank">10</field>
|
||||||
|
<field name="sequence">100</field>
|
||||||
|
<field name="is_system" eval="True"/>
|
||||||
|
<field name="description">Solo consulta, sin escritura.</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from . import main
|
from . import main
|
||||||
from . import wacrm_sync
|
from . import wacrm_sync
|
||||||
|
from . import role
|
||||||
from . import frontend_user
|
from . import frontend_user
|
||||||
from . import config_audit
|
from . import config_audit
|
||||||
from . import wacrm_notes
|
from . import wacrm_notes
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import json
|
|||||||
from odoo import models, fields, api, _
|
from odoo import models, fields, api, _
|
||||||
from odoo.exceptions import ValidationError
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
from ..roles import ROLE_SELECTION, ROLE_RANKS, resolve_agenda_perms
|
from ..roles import ROLE_SELECTION, ROLE_RANKS, resolve_agenda_perms, role_rank_map
|
||||||
|
from ..permissions import resolve_role_perms_for_user
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from werkzeug.security import check_password_hash, generate_password_hash
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
@@ -23,7 +24,9 @@ class SkeenFrontendUser(models.Model):
|
|||||||
login = fields.Char(string='Usuario', required=True, index=True)
|
login = fields.Char(string='Usuario', required=True, index=True)
|
||||||
name = fields.Char(string='Nombre', required=True)
|
name = fields.Char(string='Nombre', required=True)
|
||||||
password_hash = fields.Char(string='Hash de contraseña', required=True)
|
password_hash = fields.Char(string='Hash de contraseña', required=True)
|
||||||
role = fields.Selection(ROLE_SELECTION, string='Rol', default='recepcion', required=True)
|
role = fields.Selection(
|
||||||
|
selection='_get_role_selection', string='Rol', default='recepcion',
|
||||||
|
required=True)
|
||||||
active = fields.Boolean(string='Activo', default=True)
|
active = fields.Boolean(string='Activo', default=True)
|
||||||
must_change_password = fields.Boolean(string='Debe cambiar contraseña', default=False)
|
must_change_password = fields.Boolean(string='Debe cambiar contraseña', default=False)
|
||||||
last_login = fields.Datetime(string='Último acceso', readonly=True)
|
last_login = fields.Datetime(string='Último acceso', readonly=True)
|
||||||
@@ -63,7 +66,14 @@ class SkeenFrontendUser(models.Model):
|
|||||||
def role_rank(self):
|
def role_rank(self):
|
||||||
"""Jerarquía simple para permisos: mayor número = más acceso."""
|
"""Jerarquía simple para permisos: mayor número = más acceso."""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
return ROLE_RANKS.get(self.role, 0)
|
return role_rank_map(self.env).get(self.role, ROLE_RANKS.get(self.role, 0))
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _get_role_selection(self):
|
||||||
|
"""Selección de roles configurados (incluye inactivos para no invalidar
|
||||||
|
usuarios históricos); si aún no hay roles usa la lista de siempre."""
|
||||||
|
from ..roles import role_selection
|
||||||
|
return role_selection(self.env)
|
||||||
|
|
||||||
def _allowed_menus_list(self):
|
def _allowed_menus_list(self):
|
||||||
"""Devuelve la lista de hrefs permitidos o None (usar defaults por rol)"""
|
"""Devuelve la lista de hrefs permitidos o None (usar defaults por rol)"""
|
||||||
@@ -99,6 +109,7 @@ class SkeenFrontendUser(models.Model):
|
|||||||
'login': self.login,
|
'login': self.login,
|
||||||
'name': self.name,
|
'name': self.name,
|
||||||
'role': self.role,
|
'role': self.role,
|
||||||
|
'role_name': self._role_name(),
|
||||||
'must_change_password': self.must_change_password,
|
'must_change_password': self.must_change_password,
|
||||||
'last_login': self.last_login.strftime('%Y-%m-%d %H:%M') if self.last_login else None,
|
'last_login': self.last_login.strftime('%Y-%m-%d %H:%M') if self.last_login else None,
|
||||||
'allowed_menus': self._allowed_menus_list(),
|
'allowed_menus': self._allowed_menus_list(),
|
||||||
@@ -106,4 +117,14 @@ class SkeenFrontendUser(models.Model):
|
|||||||
# cruda, para que el editor de usuarios sepa si está heredando.
|
# cruda, para que el editor de usuarios sepa si está heredando.
|
||||||
'agenda_perms': resolve_agenda_perms(self),
|
'agenda_perms': resolve_agenda_perms(self),
|
||||||
'agenda_perms_custom': self._agenda_perms_list(),
|
'agenda_perms_custom': self._agenda_perms_list(),
|
||||||
|
# Permisos por acción vigentes (rol + overrides), para que el
|
||||||
|
# frontend sepa qué ocultar sin consultar la configuración.
|
||||||
|
'perms': resolve_role_perms_for_user(self),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _role_name(self):
|
||||||
|
"""Nombre del rol configurado (o el código si no existe)."""
|
||||||
|
self.ensure_one()
|
||||||
|
rol = self.env['skeen.role'].sudo().with_context(active_test=False).search(
|
||||||
|
[('code', '=', self.role)], limit=1)
|
||||||
|
return rol.name if rol else self.role
|
||||||
|
|||||||
36
odoo-addons/skeen_whatsapp/models/role.py
Normal file
36
odoo-addons/skeen_whatsapp/models/role.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenRole(models.Model):
|
||||||
|
"""Rol del frontend React.
|
||||||
|
|
||||||
|
Antes los roles eran un Selection hardcodeado en skeen.frontend.user. Ahora
|
||||||
|
son datos: el `code` es el valor que se guarda en los usuarios, por eso los
|
||||||
|
roles semilla conservan los códigos actuales y ningún usuario histórico se
|
||||||
|
invalida. Los roles de sistema (is_system) se pueden renombrar pero no
|
||||||
|
eliminar.
|
||||||
|
"""
|
||||||
|
_name = 'skeen.role'
|
||||||
|
_description = 'Rol del frontend'
|
||||||
|
_order = 'sequence, name'
|
||||||
|
|
||||||
|
name = fields.Char(string='Nombre', required=True, translate=True)
|
||||||
|
code = fields.Char(
|
||||||
|
string='Código', required=True, copy=False,
|
||||||
|
help='Identificador estable que se guarda en los usuarios.')
|
||||||
|
rank = fields.Integer(
|
||||||
|
string='Rango', default=50,
|
||||||
|
help='Jerarquía: a mayor rango, más acceso de lectura/escritura. '
|
||||||
|
'Administrador=100, roles operativos=50, solo lectura=10.')
|
||||||
|
sequence = fields.Integer(string='Secuencia', default=10)
|
||||||
|
active = fields.Boolean(string='Activo', default=True)
|
||||||
|
is_system = fields.Boolean(
|
||||||
|
string='De sistema', default=False,
|
||||||
|
help='Los roles de sistema no se pueden eliminar, solo renombrar.')
|
||||||
|
description = fields.Text(string='Descripción')
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('unique_code', 'unique(code)', 'El código del rol debe ser único!')
|
||||||
|
]
|
||||||
296
odoo-addons/skeen_whatsapp/permissions.py
Normal file
296
odoo-addons/skeen_whatsapp/permissions.py
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Catálogo de permisos por ACCIÓN y defaults por rol.
|
||||||
|
|
||||||
|
A diferencia de la matriz de menús (qué secciones ve un rol) y de los permisos
|
||||||
|
de agenda por usuario, esto responde a "qué puede HACER" cada rol dentro de las
|
||||||
|
pantallas: cancelar un ticket, aplicar descuentos, cerrar caja, exportar,
|
||||||
|
administrar usuarios, etc.
|
||||||
|
|
||||||
|
Sin dependencias de Odoo para poder importarse desde modelos y controladores.
|
||||||
|
|
||||||
|
Persistencia: ir.config_parameter 'skeen.role_perms' = JSON
|
||||||
|
{role_code: {perm_key: true/false}}. Lo guardado manda sobre los defaults; no
|
||||||
|
hay que persistir los defaults (se calculan).
|
||||||
|
|
||||||
|
REGLA DE ORO: con la configuración por defecto TODO se comporta como antes.
|
||||||
|
Los defaults solo son restrictivos donde ya lo era el guard viejo
|
||||||
|
(@require_role) o donde el rol es claramente de solo lectura. Todo lo demás
|
||||||
|
queda permitido para los roles de escritura y son los guards existentes
|
||||||
|
(veto de 'medico' a dinero, forbid_roles('marketing'), _puede_gestionar_*)
|
||||||
|
los que siguen mandando. Así ningún usuario pierde acceso al desplegar.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
ROLE_PERMS_KEY = 'skeen.role_perms'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Catálogo: grupos -> [(clave, etiqueta)]. El orden es el de la UI.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
PERMISSION_GROUPS = [
|
||||||
|
('agenda', 'Agenda', [
|
||||||
|
('agenda.ver', 'Consultar la agenda'),
|
||||||
|
('agenda.agendar', 'Agendar citas'),
|
||||||
|
('agenda.editar', 'Editar / reprogramar citas'),
|
||||||
|
('agenda.eliminar', 'Eliminar o cancelar citas'),
|
||||||
|
('agenda.mover', 'Mover citas de horario'),
|
||||||
|
('agenda.visitas', 'Generar visitas (check-in)'),
|
||||||
|
('agenda.todas', 'Ver la agenda de todos (no solo la propia)'),
|
||||||
|
('agenda.bloquear', 'Bloquear / cerrar agenda'),
|
||||||
|
]),
|
||||||
|
('pacientes', 'Pacientes', [
|
||||||
|
('pacientes.ver', 'Ver pacientes'),
|
||||||
|
('pacientes.expediente', 'Ver expediente completo'),
|
||||||
|
('pacientes.crear', 'Crear pacientes'),
|
||||||
|
('pacientes.editar', 'Editar pacientes'),
|
||||||
|
('pacientes.eliminar', 'Eliminar pacientes'),
|
||||||
|
('pacientes.exportar', 'Exportar pacientes'),
|
||||||
|
('pacientes.saldos', 'Ver saldos y adeudos'),
|
||||||
|
]),
|
||||||
|
('visitas', 'Visitas y expedientes', [
|
||||||
|
('visitas.ver', 'Ver visitas'),
|
||||||
|
('visitas.crear', 'Crear visitas / consultas'),
|
||||||
|
('visitas.editar', 'Editar visitas'),
|
||||||
|
('visitas.eliminar', 'Eliminar visitas'),
|
||||||
|
('visitas.archivos', 'Subir archivos al expediente'),
|
||||||
|
]),
|
||||||
|
('ventas', 'Ventas y cobros', [
|
||||||
|
('ventas.vender', 'Vender (punto de venta)'),
|
||||||
|
('ventas.cobrar', 'Cobrar y registrar pagos'),
|
||||||
|
('ventas.descuentos', 'Aplicar descuentos'),
|
||||||
|
('ventas.cancelar', 'Cancelar tickets / ventas'),
|
||||||
|
('ventas.devolver', 'Hacer devoluciones'),
|
||||||
|
('ventas.reimprimir', 'Reimprimir tickets'),
|
||||||
|
]),
|
||||||
|
('inventario', 'Inventario', [
|
||||||
|
('inventario.ver', 'Ver inventario'),
|
||||||
|
('inventario.ajustar', 'Ajustar existencias'),
|
||||||
|
('inventario.mover', 'Registrar movimientos / compras / bajas'),
|
||||||
|
('inventario.costos', 'Editar costos'),
|
||||||
|
]),
|
||||||
|
('servicios', 'Servicios y catálogo', [
|
||||||
|
('servicios.ver', 'Ver servicios y catálogo'),
|
||||||
|
('servicios.editar', 'Editar servicios y precios'),
|
||||||
|
]),
|
||||||
|
('reportes', 'Reportes y caja', [
|
||||||
|
('reportes.ver', 'Ver reportes'),
|
||||||
|
('reportes.exportar', 'Exportar reportes'),
|
||||||
|
('caja.ver', 'Ver cortes de caja'),
|
||||||
|
('caja.cerrar', 'Cerrar caja'),
|
||||||
|
]),
|
||||||
|
('whatsapp', 'WhatsApp y leads', [
|
||||||
|
('whatsapp.ver', 'Ver conversaciones y leads'),
|
||||||
|
('whatsapp.responder', 'Responder conversaciones'),
|
||||||
|
('whatsapp.asignar', 'Asignar conversaciones y leads'),
|
||||||
|
]),
|
||||||
|
('config', 'Usuarios y configuración', [
|
||||||
|
('config.usuarios', 'Administrar usuarios'),
|
||||||
|
('config.roles', 'Administrar roles y menús'),
|
||||||
|
('config.editar', 'Editar la configuración de la clínica'),
|
||||||
|
('config.auditoria', 'Ver la auditoría'),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
|
||||||
|
PERM_KEYS = tuple(
|
||||||
|
key for _g, _label, perms in PERMISSION_GROUPS for key, _l in perms
|
||||||
|
)
|
||||||
|
|
||||||
|
PERM_LABELS = {
|
||||||
|
key: label for _g, _label, perms in PERMISSION_GROUPS for key, label in perms
|
||||||
|
}
|
||||||
|
|
||||||
|
# Permisos que sólo son lectura: son los que conserva el rol 'lectura'.
|
||||||
|
READ_PERM_KEYS = (
|
||||||
|
'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',
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Conjuntos de roles para los defaults.
|
||||||
|
# W = roles con rango de escritura (todo menos 'lectura').
|
||||||
|
# WN = W sin marketing (a quien forbid_roles cierra los módulos de dinero).
|
||||||
|
# MONEY = W sin medico ni marketing (veto de medico a las pantallas de dinero).
|
||||||
|
# AG = admin + gerencia (los que hoy administran usuarios/roles/catálogos).
|
||||||
|
# AD = admin (lo que hoy exige @require_role('admin')).
|
||||||
|
# ALL = W + lectura.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
W = ('admin', 'medico', 'recepcion', 'enfermeria', 'cosmetologia',
|
||||||
|
'psicologa', 'farmacia', 'gerencia', 'marketing')
|
||||||
|
WN = tuple(r for r in W if r != 'marketing')
|
||||||
|
MONEY = tuple(r for r in W if r not in ('medico', 'marketing'))
|
||||||
|
AG = ('admin', 'gerencia')
|
||||||
|
AD = ('admin',)
|
||||||
|
ALL = W + ('lectura',)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Defaults por permiso. Tabla explicable:
|
||||||
|
# - lectura -> solo los READ_PERM_KEYS.
|
||||||
|
# - W -> escritura general (agenda, pacientes, visitas, visitas...).
|
||||||
|
# - WN / MONEY -> bloques de dinero: coinciden con require_role('recepcion')
|
||||||
|
# + forbid_roles('marketing') y con el veto de 'medico'.
|
||||||
|
# - AG / AD -> administración (hoy restringida a admin/gerencia o admin).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
DEFAULT_PERM_ROLES = {
|
||||||
|
# Agenda (hoy: require_role('recepcion') para escribir)
|
||||||
|
'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
|
||||||
|
'pacientes.ver': ALL,
|
||||||
|
'pacientes.expediente': ALL,
|
||||||
|
'pacientes.crear': W,
|
||||||
|
'pacientes.editar': W,
|
||||||
|
'pacientes.eliminar': W,
|
||||||
|
'pacientes.exportar': ALL,
|
||||||
|
'pacientes.saldos': ALL,
|
||||||
|
# Visitas
|
||||||
|
'visitas.ver': ALL,
|
||||||
|
'visitas.crear': W,
|
||||||
|
'visitas.editar': W,
|
||||||
|
'visitas.eliminar': W,
|
||||||
|
'visitas.archivos': W,
|
||||||
|
# Ventas y cobros
|
||||||
|
'ventas.vender': MONEY,
|
||||||
|
'ventas.cobrar': WN, # medico también cobra depósitos de paciente hoy
|
||||||
|
'ventas.descuentos': MONEY,
|
||||||
|
'ventas.cancelar': AG, # hoy: admin/gerencia (chequeo explícito en el endpoint)
|
||||||
|
'ventas.devolver': MONEY,
|
||||||
|
'ventas.reimprimir': ALL,
|
||||||
|
# Inventario (hoy: require_role('recepcion') + forbid_roles('marketing'))
|
||||||
|
'inventario.ver': ALL,
|
||||||
|
'inventario.ajustar': WN,
|
||||||
|
'inventario.mover': WN,
|
||||||
|
'inventario.costos': WN,
|
||||||
|
# Servicios y catálogo (hoy services POST/PUT/DELETE exigen admin)
|
||||||
|
'servicios.ver': ALL,
|
||||||
|
'servicios.editar': AD,
|
||||||
|
# Reportes y caja
|
||||||
|
'reportes.ver': ALL,
|
||||||
|
'reportes.exportar': ALL,
|
||||||
|
'caja.ver': ALL,
|
||||||
|
'caja.cerrar': MONEY,
|
||||||
|
# WhatsApp y leads (menú WACRM: admin, recepcion, gerencia, marketing)
|
||||||
|
'whatsapp.ver': W,
|
||||||
|
'whatsapp.responder': W,
|
||||||
|
'whatsapp.asignar': W,
|
||||||
|
# Usuarios y configuración
|
||||||
|
'config.usuarios': AG,
|
||||||
|
'config.roles': AG,
|
||||||
|
'config.editar': AG,
|
||||||
|
'config.auditoria': AD,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Los roles nuevos (creados desde la UI) arrancan como 'lectura': el admin
|
||||||
|
# decide después qué permisos les da. Si no, cualquier rol nuevo tendría acceso
|
||||||
|
# de escritura sin que nadie lo pidiera.
|
||||||
|
NEW_ROLE_BASE = 'lectura'
|
||||||
|
|
||||||
|
|
||||||
|
def default_perms_for(role_code):
|
||||||
|
"""Dict {perm_key: bool} por defecto para un rol (base por rango)."""
|
||||||
|
base_role = role_code if role_code in W or role_code == 'lectura' else NEW_ROLE_BASE
|
||||||
|
perms = {}
|
||||||
|
for key in PERM_KEYS:
|
||||||
|
if base_role == 'lectura':
|
||||||
|
perms[key] = key in READ_PERM_KEYS
|
||||||
|
else:
|
||||||
|
perms[key] = base_role in (DEFAULT_PERM_ROLES.get(key) or ())
|
||||||
|
return perms
|
||||||
|
|
||||||
|
|
||||||
|
def default_role_perms():
|
||||||
|
"""Defaults completos {role_code: {perm_key: bool}} para los 10 roles base.
|
||||||
|
|
||||||
|
No incluye roles personalizados (no se conocen sin consultar Odoo); el
|
||||||
|
frontend usa su propio espejo para eso.
|
||||||
|
"""
|
||||||
|
return {code: default_perms_for(code) for code in
|
||||||
|
('admin', 'medico', 'recepcion', 'enfermeria', 'cosmetologia',
|
||||||
|
'psicologa', 'farmacia', 'gerencia', 'marketing', 'lectura')}
|
||||||
|
|
||||||
|
|
||||||
|
def _stored(env):
|
||||||
|
"""Overrides guardados {role_code: {perm_key: bool}} (o {})."""
|
||||||
|
raw = env['ir.config_parameter'].sudo().get_param(ROLE_PERMS_KEY)
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
val = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
if not isinstance(val, dict):
|
||||||
|
return {}
|
||||||
|
clean = {}
|
||||||
|
for role, perms in val.items():
|
||||||
|
if isinstance(perms, dict):
|
||||||
|
clean[role] = {k: bool(v) for k, v in perms.items() if k in PERM_KEYS}
|
||||||
|
return clean
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_role_perms(env, role_code):
|
||||||
|
"""Permisos vigentes de un rol: defaults + overrides guardados."""
|
||||||
|
perms = default_perms_for(role_code)
|
||||||
|
perms.update(_stored(env).get(role_code) or {})
|
||||||
|
return perms
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_role_perms_for_user(user):
|
||||||
|
"""Permisos vigentes del usuario (por su rol). None -> todo False."""
|
||||||
|
if user is None:
|
||||||
|
return {k: False for k in PERM_KEYS}
|
||||||
|
return resolve_role_perms(user.env, user.role)
|
||||||
|
|
||||||
|
|
||||||
|
def has_perm(user, perm_key):
|
||||||
|
"""True si el usuario puede ejecutar la acción. Admin siempre puede."""
|
||||||
|
if user is None:
|
||||||
|
return False
|
||||||
|
if user.role == 'admin':
|
||||||
|
return True
|
||||||
|
return bool(resolve_role_perms_for_user(user).get(perm_key))
|
||||||
|
|
||||||
|
|
||||||
|
def all_roles_perms(env):
|
||||||
|
"""{role_code: {perm_key: bool}} para todos los roles en skeen.role."""
|
||||||
|
roles = env['skeen.role'].sudo().with_context(active_test=False).search([])
|
||||||
|
codes = [r.code for r in roles if r.code]
|
||||||
|
if not codes:
|
||||||
|
return default_role_perms()
|
||||||
|
return {code: resolve_role_perms(env, code) for code in codes}
|
||||||
|
|
||||||
|
|
||||||
|
def set_role_perms(env, role_code, perms):
|
||||||
|
"""Guarda permisos de un rol (merge con lo ya guardado). False si no aplica."""
|
||||||
|
rol = env['skeen.role'].sudo().with_context(active_test=False).search(
|
||||||
|
[('code', '=', role_code)], limit=1)
|
||||||
|
if not rol:
|
||||||
|
return False
|
||||||
|
clean = {k: bool(v) for k, v in (perms or {}).items() if k in PERM_KEYS}
|
||||||
|
if not clean:
|
||||||
|
return False
|
||||||
|
stored = _stored(env)
|
||||||
|
current = dict(stored.get(role_code) or {})
|
||||||
|
current.update(clean)
|
||||||
|
stored[role_code] = current
|
||||||
|
env['ir.config_parameter'].sudo().set_param(ROLE_PERMS_KEY, json.dumps(stored))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def reset_role_perms(env, role_code):
|
||||||
|
"""Quita los overrides de un rol (vuelve a los defaults)."""
|
||||||
|
stored = _stored(env)
|
||||||
|
if role_code in stored:
|
||||||
|
stored.pop(role_code)
|
||||||
|
env['ir.config_parameter'].sudo().set_param(ROLE_PERMS_KEY, json.dumps(stored))
|
||||||
|
return True
|
||||||
@@ -42,6 +42,42 @@ ROLE_RANKS = {
|
|||||||
'lectura': 10,
|
'lectura': 10,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Roles dinámicos (modelo skeen.role). ROLE_SELECTION/ROLE_RANKS quedan como
|
||||||
|
# respaldo para el arranque (antes de que la semilla XML esté disponible) y para
|
||||||
|
# no romper instalaciones viejas.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def role_records(env):
|
||||||
|
"""Registros de skeen.role (incluye inactivos) o lista vacía si no existe."""
|
||||||
|
try:
|
||||||
|
return env['skeen.role'].sudo().with_context(active_test=False).search([])
|
||||||
|
except KeyError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def role_selection(env):
|
||||||
|
"""[(code, name)] de los roles configurados, con respaldo estático."""
|
||||||
|
seleccion = [(r.code, r.name) for r in role_records(env) if r.code]
|
||||||
|
return seleccion or ROLE_SELECTION
|
||||||
|
|
||||||
|
|
||||||
|
def role_keys(env):
|
||||||
|
"""Códigos de rol válidos (incluye inactivos, para no invalidar históricos)."""
|
||||||
|
return tuple(k for k, _ in role_selection(env))
|
||||||
|
|
||||||
|
|
||||||
|
def role_rank_map(env):
|
||||||
|
"""{code: rank} de los roles configurados, con respaldo estático."""
|
||||||
|
mapa = {r.code: r.rank for r in role_records(env) if r.code}
|
||||||
|
return mapa or dict(ROLE_RANKS)
|
||||||
|
|
||||||
|
|
||||||
|
def min_rank(env, code):
|
||||||
|
"""Rango mínimo exigido por un código ('recepcion' -> 50 por defecto)."""
|
||||||
|
return role_rank_map(env).get(code, ROLE_RANKS.get(code, 10))
|
||||||
|
|
||||||
MENU_ROLES_KEY = 'skeen.menu_roles'
|
MENU_ROLES_KEY = 'skeen.menu_roles'
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -69,16 +105,30 @@ WRITE_RANK_MIN = 'recepcion'
|
|||||||
# rango, no por nombre, para no duplicar la lista de roles.
|
# rango, no por nombre, para no duplicar la lista de roles.
|
||||||
|
|
||||||
|
|
||||||
|
# Mapa de cada acción de agenda al permiso por rol equivalente. El permiso de
|
||||||
|
# rol es el default; la lista propia del usuario (agenda_perms) lo pisa.
|
||||||
|
AGENDA_ROLE_PERM = {
|
||||||
|
'ver': 'agenda.ver',
|
||||||
|
'agendar': 'agenda.agendar',
|
||||||
|
'editar': 'agenda.editar',
|
||||||
|
'eliminar': 'agenda.eliminar',
|
||||||
|
'visitas': 'agenda.visitas',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def agenda_defaults_for(user):
|
def agenda_defaults_for(user):
|
||||||
"""Permisos de agenda por defecto de un usuario (sin lista propia)."""
|
"""Permisos de agenda por defecto de un usuario (sin lista propia).
|
||||||
ver = ['ver']
|
|
||||||
|
Se derivan de los permisos por acción del rol; 'ver' siempre se conserva.
|
||||||
|
"""
|
||||||
if user is None:
|
if user is None:
|
||||||
return list(ver)
|
return ['ver']
|
||||||
if user.role == 'admin':
|
from .permissions import resolve_role_perms_for_user
|
||||||
return list(AGENDA_PERM_KEYS)
|
role_perms = resolve_role_perms_for_user(user)
|
||||||
if user.role_rank() >= ROLE_RANKS.get(WRITE_RANK_MIN, 50):
|
acciones = [a for a, key in AGENDA_ROLE_PERM.items() if role_perms.get(key)]
|
||||||
return list(AGENDA_PERM_KEYS)
|
if 'ver' not in acciones:
|
||||||
return list(ver)
|
acciones.insert(0, 'ver')
|
||||||
|
return acciones
|
||||||
|
|
||||||
|
|
||||||
def resolve_agenda_perms(user):
|
def resolve_agenda_perms(user):
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ access_skeen_wacrm_pipeline,skeen.wacrm.pipeline,model_skeen_wacrm_pipeline,base
|
|||||||
access_skeen_wacrm_stage,skeen.wacrm.stage,model_skeen_wacrm_stage,base.group_user,1,1,1,1
|
access_skeen_wacrm_stage,skeen.wacrm.stage,model_skeen_wacrm_stage,base.group_user,1,1,1,1
|
||||||
access_skeen_wacrm_deal,skeen.wacrm.deal,model_skeen_wacrm_deal,base.group_user,1,1,1,1
|
access_skeen_wacrm_deal,skeen.wacrm.deal,model_skeen_wacrm_deal,base.group_user,1,1,1,1
|
||||||
access_skeen_frontend_user,skeen.frontend.user,model_skeen_frontend_user,base.group_user,1,1,1,1
|
access_skeen_frontend_user,skeen.frontend.user,model_skeen_frontend_user,base.group_user,1,1,1,1
|
||||||
|
access_skeen_role,Roles del frontend,model_skeen_role,base.group_user,1,1,1,1
|
||||||
access_skeen_config_audit,skeen.config.audit,model_skeen_config_audit,base.group_user,1,1,1,1
|
access_skeen_config_audit,skeen.config.audit,model_skeen_config_audit,base.group_user,1,1,1,1
|
||||||
access_skeen_wacrm_note,Notas internas WACRM,model_skeen_wacrm_note,base.group_user,1,1,1,1
|
access_skeen_wacrm_note,Notas internas WACRM,model_skeen_wacrm_note,base.group_user,1,1,1,1
|
||||||
access_skeen_wacrm_tag,Etiquetas WACRM,model_skeen_wacrm_tag,base.group_user,1,1,1,1
|
access_skeen_wacrm_tag,Etiquetas WACRM,model_skeen_wacrm_tag,base.group_user,1,1,1,1
|
||||||
|
|||||||
|
Reference in New Issue
Block a user