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:
2026-10-11 07:32:29 +00:00
parent 75736967ef
commit 28a690aa0f
24 changed files with 1637 additions and 157 deletions

View File

@@ -43,17 +43,24 @@ const SUBCATALOGOS = [
const LEGACY_STORAGE_KEY = 'skeen_clinic_settings';
const Configuracion: FC = () => {
const { user, brand, setBrand } = useAuth();
const { user, brand, setBrand, can } = useAuth();
// Gerencia gestiona catálogos, usuarios y roles; la sección "Clínica" (datos
// de contacto, tipo de cambio y comisiones) queda para administración del sistema.
const esAdmin = user?.role === 'admin';
// Cada pestaña requiere su permiso por acción (Configuración › Roles › Permisos).
const TABS_PERM: Record<string, string> = {
usuarios: 'config.usuarios',
roles: 'config.roles',
auditoria: 'config.auditoria',
};
const tabsVisibles = useMemo(
() => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key))),
[esAdmin],
() => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key)))
.filter((t) => can(TABS_PERM[t.key] || 'config.editar')),
[esAdmin, can],
);
const [searchParams, setSearchParams] = useSearchParams();
const tabPedida = searchParams.get('tab') || 'clinica';
const tab = tabsVisibles.some((t) => t.key === tabPedida) ? tabPedida : tabsVisibles[0].key;
const tab = tabsVisibles.some((t) => t.key === tabPedida) ? tabPedida : (tabsVisibles[0]?.key || 'clinica');
const setTab = (t: string) => setSearchParams({ tab: t });
const [subcatalogo, setSubcatalogo] = useState<string>(() => {
const sub = searchParams.get('sub');
@@ -284,7 +291,13 @@ const Configuracion: FC = () => {
))}
</div>
{tab === 'usuarios' ? (
{tabsVisibles.length === 0 ? (
<Card>
<Card.Body>
<p className="text-sm text-theme-muted">No tienes permiso para ninguna sección de Configuración.</p>
</Card.Body>
</Card>
) : tab === 'usuarios' ? (
<Card>
<Card.Body>
<UsuariosPanel />

View File

@@ -34,7 +34,7 @@ const PASOS = [
] as const;
const Cortes: FC = () => {
const { brand } = useAuth();
const { brand, can } = useAuth();
const [closings, setClosings] = useState<CashClosing[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -479,7 +479,8 @@ const Cortes: FC = () => {
<ChevronRight size={16} className="ml-1" />
</Button>
) : (
<Button onClick={cerrarCaja} loading={submitting}>
<Button onClick={cerrarCaja} loading={submitting} disabled={!can('caja.cerrar')}
title={can('caja.cerrar') ? undefined : 'Sin permiso para cerrar caja'}>
<Lock size={16} className="mr-1.5" />
Cerrar caja
</Button>

View File

@@ -122,7 +122,7 @@ const CRITICAL_ALERTS: { key: keyof Patient; label: string }[] = [
];
const Pacientes: FC = () => {
const { brand } = useAuth();
const { brand, can } = useAuth();
const { term } = useGlosario();
const { labelFor: branchLabel } = useSucursales();
const [patients, setPatients] = useState<Patient[]>([]);
@@ -525,7 +525,8 @@ const Pacientes: FC = () => {
return (
<Layout title={term('patient_plural')} subtitle="Directorio de pacientes">
<PageHeader title={term('patient_plural')} subtitle="Busca, crea y gestiona pacientes">
<Button variant="outline" onClick={exportPatients} loading={exporting} disabled={patients.length === 0}>
<Button variant="outline" onClick={exportPatients} loading={exporting} disabled={patients.length === 0 || !can('pacientes.exportar')}
title={can('pacientes.exportar') ? undefined : 'Sin permiso para exportar'}>
<Download size={16} className="mr-2" />
Exportar CSV
</Button>

View File

@@ -20,6 +20,7 @@ import {
} from '../components/ui';
import { odooApi, type Service, type Patient, type InventoryItem, type PosCheckoutResult, type Doctor, type Paquete } from '../services/odoo';
import type { PatientPackage } from '../services/odoo';
import { useAuth } from '../lib/auth';
const fmtMoney = (n: number) =>
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 });
@@ -52,6 +53,7 @@ interface TicketLine {
const Pos: FC = () => {
const { openPatient } = usePatientDrawer();
const { can } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
// Catálogo
const [catMode, setCatMode] = useState<'servicios' | 'paquetes' | 'articulos'>('servicios');
@@ -818,7 +820,9 @@ const Pos: FC = () => {
value={discount}
onChange={(e) => setDiscount(e.target.value)}
placeholder="0"
className="w-28 border border-theme-border-strong rounded-lg px-2 py-1 text-sm text-right"
disabled={!can('ventas.descuentos')}
title={can('ventas.descuentos') ? undefined : 'Sin permiso para aplicar descuentos'}
className="w-28 border border-theme-border-strong rounded-lg px-2 py-1 text-sm text-right disabled:opacity-50 disabled:cursor-not-allowed"
/>
</span>
</div>
@@ -840,8 +844,8 @@ const Pos: FC = () => {
<Button
className="w-full mt-1 !py-3 text-base"
onClick={abrirCobro}
disabled={lines.length === 0 || !paciente}
title="Cobrar (F9)"
disabled={lines.length === 0 || !paciente || !can('ventas.vender')}
title={can('ventas.vender') ? 'Cobrar (F9)' : 'Sin permiso para vender'}
>
<ShoppingBag size={18} className="mr-2" />
Cobrar {fmtMoney(total)}

View File

@@ -142,7 +142,7 @@ interface CashReport {
const ReportesContent: FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const { hasRole, canSee, brand } = useAuth();
const { hasRole, canSee, can, brand } = useAuth();
const esAdmin = hasRole('admin');
// PuedeVer una pestana = canSee('/reportes?tab=x'): la matriz guarda claves
// por pestana y, cuando no existe la del tab, canSee cae al prefijo
@@ -287,13 +287,13 @@ const ReportesContent: FC = () => {
return (
<Layout title="Reportes" subtitle="Análisis del mes y día">
<PageHeader title="Reportes" subtitle={tabInfo?.desc || 'Análisis de la operación'}>
{tab === 'general' && (
{tab === 'general' && can('reportes.exportar') && (
<Button variant="outline" onClick={exportExcel} disabled={loading || !salesReport} loading={exporting}>
<Download size={16} className="mr-2" />
Exportar
</Button>
)}
{puedeTab('exportar') && (
{puedeTab('exportar') && can('reportes.exportar') && (
<Button
variant={tab === 'exportar' ? 'primary' : 'outline'}
onClick={() => setTab('exportar')}

View File

@@ -17,6 +17,7 @@ import {
import { useSearchParams } from 'react-router-dom';
import { odooApi, type Service } from '../services/odoo';
import { useGlosario } from '../lib/glosario';
import { useAuth } from '../lib/auth';
import { useServiceCategories } from '../lib/service-categories';
interface ServiceForm {
@@ -49,6 +50,8 @@ const emptyForm: ServiceForm = {
const Servicios: FC = () => {
const { term } = useGlosario();
const { can } = useAuth();
const puedeEditar = can('servicios.editar');
const { categories, labelFor } = useServiceCategories();
const categoryOptions = useMemo(
() => [{ value: '', label: 'Todas las categorías' }, ...categories.map((c) => ({ value: c.code, label: c.name }))],
@@ -286,10 +289,12 @@ const Servicios: FC = () => {
return (
<Layout title={term('service_plural')} subtitle="Catálogo de tratamientos">
<PageHeader title={term('service_plural')} subtitle="Gestiona precios, paquetes y favoritos">
<Button onClick={openCreate}>
<Plus size={16} className="mr-2" />
Nuevo {term('service_singular').toLowerCase()}
</Button>
{puedeEditar && (
<Button onClick={openCreate}>
<Plus size={16} className="mr-2" />
Nuevo {term('service_singular').toLowerCase()}
</Button>
)}
</PageHeader>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4 sm:mb-6">
@@ -404,12 +409,16 @@ const Servicios: FC = () => {
>
<ChevronDown size={16} className="text-theme-muted" />
</Button>
<Button variant="ghost" size="sm" onClick={() => openDuplicate(s)} title="Duplicar servicio">
<Copy size={15} className="text-theme-muted" />
</Button>
<Button variant="ghost" size="sm" onClick={() => openEdit(s)} title="Editar">
<Edit2 size={16} className="text-theme-muted" />
</Button>
{puedeEditar && (
<>
<Button variant="ghost" size="sm" onClick={() => openDuplicate(s)} title="Duplicar servicio">
<Copy size={15} className="text-theme-muted" />
</Button>
<Button variant="ghost" size="sm" onClick={() => openEdit(s)} title="Editar">
<Edit2 size={16} className="text-theme-muted" />
</Button>
</>
)}
</div>
</div>
<div className="flex items-center gap-2">

View File

@@ -47,8 +47,9 @@ const PAGE_SIZE = 50;
* Ventas. Permiso de cancelación: admin y gerencia (el API lo valida).
*/
const Tickets: FC = () => {
const { user } = useAuth();
const { user, can } = useAuth();
const puedeCorregir = user?.role === 'admin' || user?.role === 'gerencia';
const puedeCancelar = can('ventas.cancelar');
const prefs = readDateFilterPrefs(PREF_KEY);
const [preset, setPreset] = useState<DatePreset>(prefs?.preset || 'hoy');
const [desde, setDesde] = useState(prefs?.start || rangeForPreset('hoy').start);
@@ -304,7 +305,7 @@ const Tickets: FC = () => {
<Pencil size={14} />
</Button>
)}
{s.state !== 'cancelled' && (
{s.state !== 'cancelled' && puedeCancelar && (
<Button size="sm" variant="ghost" onClick={() => abrirCancelar(s)} title="Cancelar ticket (nota de crédito)">
<XCircle size={14} className="text-rose-600" />
</Button>
@@ -350,7 +351,7 @@ const Tickets: FC = () => {
<Pencil size={14} className="mr-1.5" /> Corregir
</Button>
)}
{detalle && detalle.state !== 'cancelled' && (
{detalle && detalle.state !== 'cancelled' && puedeCancelar && (
<Button variant="danger" onClick={() => { const d = detalle; setDetalle(null); abrirCancelar(d); }}>
<XCircle size={14} className="mr-1.5" /> Cancelar ticket
</Button>

View File

@@ -27,7 +27,6 @@ import { DATE_PRESETS, rangeForPreset, readDateFilterPrefs, writeDateFilterPrefs
// Rango de fechas recordado entre visitas (igual que los presets de la agenda)
const FILTERS_KEY = 'skeen_ventas_filtros';
const ROLES_CANCELAN = ['admin', 'gerencia'];
interface LineForm {
service_id: string;
@@ -47,8 +46,10 @@ const paymentMethods = ['Efectivo', 'Tarjeta', 'Transferencia', 'MercadoPago'];
const Ventas: FC = () => {
const { openPatient } = usePatientDrawer();
const { user, brand } = useAuth();
const puedeCancelar = ROLES_CANCELAN.includes(user?.role || '');
const { brand, can } = useAuth();
const puedeCancelar = can('ventas.cancelar');
const puedeCobrar = can('ventas.cobrar');
const puedeDevolver = can('ventas.devolver');
const [sales, setSales] = useState<Sale[]>([]);
const [services, setServices] = useState<Service[]>([]);
const [doctors, setDoctors] = useState<Doctor[]>([]);
@@ -491,13 +492,13 @@ const Ventas: FC = () => {
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setPrintSale(s); }} title="Reimprimir ticket">
<Printer size={16} className="text-theme-heading" />
</Button>
{s.amount_due > 0 && !cancelada && (
{s.amount_due > 0 && !cancelada && puedeCobrar && (
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); openPay(s); }} title="Pagar">
<CreditCard size={16} className="text-theme-heading mr-1.5" />
Pagar
</Button>
)}
{s.amount_paid > 0 && !cancelada && (
{s.amount_paid > 0 && !cancelada && puedeDevolver && (
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); openRefund(s); }} title="Devolución">
<Undo2 size={16} className="text-amber-600 mr-1.5" />
Devolución
@@ -611,12 +612,12 @@ const Ventas: FC = () => {
<Button variant="ghost" size="sm" onClick={() => setPrintSale(s)} title="Reimprimir ticket">
<Printer size={16} className="text-theme-heading" />
</Button>
{s.amount_due > 0 && s.state !== 'cancelled' && (
{s.amount_due > 0 && s.state !== 'cancelled' && puedeCobrar && (
<Button variant="ghost" size="sm" onClick={() => openPay(s)}>
<CreditCard size={16} className="text-theme-heading" />
</Button>
)}
{s.amount_paid > 0 && s.state !== 'cancelled' && (
{s.amount_paid > 0 && s.state !== 'cancelled' && puedeDevolver && (
<Button variant="ghost" size="sm" onClick={() => openRefund(s)} title="Devolución">
<Undo2 size={16} className="text-amber-600" />
</Button>