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:
@@ -43,17 +43,24 @@ const SUBCATALOGOS = [
|
||||
const LEGACY_STORAGE_KEY = 'skeen_clinic_settings';
|
||||
|
||||
const Configuracion: FC = () => {
|
||||
const { user, brand, setBrand } = useAuth();
|
||||
const { user, brand, setBrand, can } = useAuth();
|
||||
// Gerencia gestiona catálogos, usuarios y roles; la sección "Clínica" (datos
|
||||
// de contacto, tipo de cambio y comisiones) queda para administración del sistema.
|
||||
const esAdmin = user?.role === 'admin';
|
||||
// Cada pestaña requiere su permiso por acción (Configuración › Roles › Permisos).
|
||||
const TABS_PERM: Record<string, string> = {
|
||||
usuarios: 'config.usuarios',
|
||||
roles: 'config.roles',
|
||||
auditoria: 'config.auditoria',
|
||||
};
|
||||
const tabsVisibles = useMemo(
|
||||
() => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key))),
|
||||
[esAdmin],
|
||||
() => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key)))
|
||||
.filter((t) => can(TABS_PERM[t.key] || 'config.editar')),
|
||||
[esAdmin, can],
|
||||
);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tabPedida = searchParams.get('tab') || 'clinica';
|
||||
const tab = tabsVisibles.some((t) => t.key === tabPedida) ? tabPedida : tabsVisibles[0].key;
|
||||
const tab = tabsVisibles.some((t) => t.key === tabPedida) ? tabPedida : (tabsVisibles[0]?.key || 'clinica');
|
||||
const setTab = (t: string) => setSearchParams({ tab: t });
|
||||
const [subcatalogo, setSubcatalogo] = useState<string>(() => {
|
||||
const sub = searchParams.get('sub');
|
||||
@@ -284,7 +291,13 @@ const Configuracion: FC = () => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'usuarios' ? (
|
||||
{tabsVisibles.length === 0 ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<p className="text-sm text-theme-muted">No tienes permiso para ninguna sección de Configuración.</p>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : tab === 'usuarios' ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<UsuariosPanel />
|
||||
|
||||
@@ -34,7 +34,7 @@ const PASOS = [
|
||||
] as const;
|
||||
|
||||
const Cortes: FC = () => {
|
||||
const { brand } = useAuth();
|
||||
const { brand, can } = useAuth();
|
||||
const [closings, setClosings] = useState<CashClosing[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -479,7 +479,8 @@ const Cortes: FC = () => {
|
||||
<ChevronRight size={16} className="ml-1" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={cerrarCaja} loading={submitting}>
|
||||
<Button onClick={cerrarCaja} loading={submitting} disabled={!can('caja.cerrar')}
|
||||
title={can('caja.cerrar') ? undefined : 'Sin permiso para cerrar caja'}>
|
||||
<Lock size={16} className="mr-1.5" />
|
||||
Cerrar caja
|
||||
</Button>
|
||||
|
||||
@@ -122,7 +122,7 @@ const CRITICAL_ALERTS: { key: keyof Patient; label: string }[] = [
|
||||
];
|
||||
|
||||
const Pacientes: FC = () => {
|
||||
const { brand } = useAuth();
|
||||
const { brand, can } = useAuth();
|
||||
const { term } = useGlosario();
|
||||
const { labelFor: branchLabel } = useSucursales();
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
@@ -525,7 +525,8 @@ const Pacientes: FC = () => {
|
||||
return (
|
||||
<Layout title={term('patient_plural')} subtitle="Directorio de pacientes">
|
||||
<PageHeader title={term('patient_plural')} subtitle="Busca, crea y gestiona pacientes">
|
||||
<Button variant="outline" onClick={exportPatients} loading={exporting} disabled={patients.length === 0}>
|
||||
<Button variant="outline" onClick={exportPatients} loading={exporting} disabled={patients.length === 0 || !can('pacientes.exportar')}
|
||||
title={can('pacientes.exportar') ? undefined : 'Sin permiso para exportar'}>
|
||||
<Download size={16} className="mr-2" />
|
||||
Exportar CSV
|
||||
</Button>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '../components/ui';
|
||||
import { odooApi, type Service, type Patient, type InventoryItem, type PosCheckoutResult, type Doctor, type Paquete } from '../services/odoo';
|
||||
import type { PatientPackage } from '../services/odoo';
|
||||
import { useAuth } from '../lib/auth';
|
||||
|
||||
const fmtMoney = (n: number) =>
|
||||
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 });
|
||||
@@ -52,6 +53,7 @@ interface TicketLine {
|
||||
|
||||
const Pos: FC = () => {
|
||||
const { openPatient } = usePatientDrawer();
|
||||
const { can } = useAuth();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// Catálogo
|
||||
const [catMode, setCatMode] = useState<'servicios' | 'paquetes' | 'articulos'>('servicios');
|
||||
@@ -818,7 +820,9 @@ const Pos: FC = () => {
|
||||
value={discount}
|
||||
onChange={(e) => setDiscount(e.target.value)}
|
||||
placeholder="0"
|
||||
className="w-28 border border-theme-border-strong rounded-lg px-2 py-1 text-sm text-right"
|
||||
disabled={!can('ventas.descuentos')}
|
||||
title={can('ventas.descuentos') ? undefined : 'Sin permiso para aplicar descuentos'}
|
||||
className="w-28 border border-theme-border-strong rounded-lg px-2 py-1 text-sm text-right disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
@@ -840,8 +844,8 @@ const Pos: FC = () => {
|
||||
<Button
|
||||
className="w-full mt-1 !py-3 text-base"
|
||||
onClick={abrirCobro}
|
||||
disabled={lines.length === 0 || !paciente}
|
||||
title="Cobrar (F9)"
|
||||
disabled={lines.length === 0 || !paciente || !can('ventas.vender')}
|
||||
title={can('ventas.vender') ? 'Cobrar (F9)' : 'Sin permiso para vender'}
|
||||
>
|
||||
<ShoppingBag size={18} className="mr-2" />
|
||||
Cobrar {fmtMoney(total)}
|
||||
|
||||
@@ -142,7 +142,7 @@ interface CashReport {
|
||||
|
||||
const ReportesContent: FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { hasRole, canSee, brand } = useAuth();
|
||||
const { hasRole, canSee, can, brand } = useAuth();
|
||||
const esAdmin = hasRole('admin');
|
||||
// PuedeVer una pestana = canSee('/reportes?tab=x'): la matriz guarda claves
|
||||
// por pestana y, cuando no existe la del tab, canSee cae al prefijo
|
||||
@@ -287,13 +287,13 @@ const ReportesContent: FC = () => {
|
||||
return (
|
||||
<Layout title="Reportes" subtitle="Análisis del mes y día">
|
||||
<PageHeader title="Reportes" subtitle={tabInfo?.desc || 'Análisis de la operación'}>
|
||||
{tab === 'general' && (
|
||||
{tab === 'general' && can('reportes.exportar') && (
|
||||
<Button variant="outline" onClick={exportExcel} disabled={loading || !salesReport} loading={exporting}>
|
||||
<Download size={16} className="mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
)}
|
||||
{puedeTab('exportar') && (
|
||||
{puedeTab('exportar') && can('reportes.exportar') && (
|
||||
<Button
|
||||
variant={tab === 'exportar' ? 'primary' : 'outline'}
|
||||
onClick={() => setTab('exportar')}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { odooApi, type Service } from '../services/odoo';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useServiceCategories } from '../lib/service-categories';
|
||||
|
||||
interface ServiceForm {
|
||||
@@ -49,6 +50,8 @@ const emptyForm: ServiceForm = {
|
||||
|
||||
const Servicios: FC = () => {
|
||||
const { term } = useGlosario();
|
||||
const { can } = useAuth();
|
||||
const puedeEditar = can('servicios.editar');
|
||||
const { categories, labelFor } = useServiceCategories();
|
||||
const categoryOptions = useMemo(
|
||||
() => [{ value: '', label: 'Todas las categorías' }, ...categories.map((c) => ({ value: c.code, label: c.name }))],
|
||||
@@ -286,10 +289,12 @@ const Servicios: FC = () => {
|
||||
return (
|
||||
<Layout title={term('service_plural')} subtitle="Catálogo de tratamientos">
|
||||
<PageHeader title={term('service_plural')} subtitle="Gestiona precios, paquetes y favoritos">
|
||||
<Button onClick={openCreate}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Nuevo {term('service_singular').toLowerCase()}
|
||||
</Button>
|
||||
{puedeEditar && (
|
||||
<Button onClick={openCreate}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Nuevo {term('service_singular').toLowerCase()}
|
||||
</Button>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4 sm:mb-6">
|
||||
@@ -404,12 +409,16 @@ const Servicios: FC = () => {
|
||||
>
|
||||
<ChevronDown size={16} className="text-theme-muted" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => openDuplicate(s)} title="Duplicar servicio">
|
||||
<Copy size={15} className="text-theme-muted" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(s)} title="Editar">
|
||||
<Edit2 size={16} className="text-theme-muted" />
|
||||
</Button>
|
||||
{puedeEditar && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={() => openDuplicate(s)} title="Duplicar servicio">
|
||||
<Copy size={15} className="text-theme-muted" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(s)} title="Editar">
|
||||
<Edit2 size={16} className="text-theme-muted" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -47,8 +47,9 @@ const PAGE_SIZE = 50;
|
||||
* Ventas. Permiso de cancelación: admin y gerencia (el API lo valida).
|
||||
*/
|
||||
const Tickets: FC = () => {
|
||||
const { user } = useAuth();
|
||||
const { user, can } = useAuth();
|
||||
const puedeCorregir = user?.role === 'admin' || user?.role === 'gerencia';
|
||||
const puedeCancelar = can('ventas.cancelar');
|
||||
const prefs = readDateFilterPrefs(PREF_KEY);
|
||||
const [preset, setPreset] = useState<DatePreset>(prefs?.preset || 'hoy');
|
||||
const [desde, setDesde] = useState(prefs?.start || rangeForPreset('hoy').start);
|
||||
@@ -304,7 +305,7 @@ const Tickets: FC = () => {
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
)}
|
||||
{s.state !== 'cancelled' && (
|
||||
{s.state !== 'cancelled' && puedeCancelar && (
|
||||
<Button size="sm" variant="ghost" onClick={() => abrirCancelar(s)} title="Cancelar ticket (nota de crédito)">
|
||||
<XCircle size={14} className="text-rose-600" />
|
||||
</Button>
|
||||
@@ -350,7 +351,7 @@ const Tickets: FC = () => {
|
||||
<Pencil size={14} className="mr-1.5" /> Corregir
|
||||
</Button>
|
||||
)}
|
||||
{detalle && detalle.state !== 'cancelled' && (
|
||||
{detalle && detalle.state !== 'cancelled' && puedeCancelar && (
|
||||
<Button variant="danger" onClick={() => { const d = detalle; setDetalle(null); abrirCancelar(d); }}>
|
||||
<XCircle size={14} className="mr-1.5" /> Cancelar ticket
|
||||
</Button>
|
||||
|
||||
@@ -27,7 +27,6 @@ import { DATE_PRESETS, rangeForPreset, readDateFilterPrefs, writeDateFilterPrefs
|
||||
|
||||
// Rango de fechas recordado entre visitas (igual que los presets de la agenda)
|
||||
const FILTERS_KEY = 'skeen_ventas_filtros';
|
||||
const ROLES_CANCELAN = ['admin', 'gerencia'];
|
||||
|
||||
interface LineForm {
|
||||
service_id: string;
|
||||
@@ -47,8 +46,10 @@ const paymentMethods = ['Efectivo', 'Tarjeta', 'Transferencia', 'MercadoPago'];
|
||||
|
||||
const Ventas: FC = () => {
|
||||
const { openPatient } = usePatientDrawer();
|
||||
const { user, brand } = useAuth();
|
||||
const puedeCancelar = ROLES_CANCELAN.includes(user?.role || '');
|
||||
const { brand, can } = useAuth();
|
||||
const puedeCancelar = can('ventas.cancelar');
|
||||
const puedeCobrar = can('ventas.cobrar');
|
||||
const puedeDevolver = can('ventas.devolver');
|
||||
const [sales, setSales] = useState<Sale[]>([]);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
@@ -491,13 +492,13 @@ const Ventas: FC = () => {
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setPrintSale(s); }} title="Reimprimir ticket">
|
||||
<Printer size={16} className="text-theme-heading" />
|
||||
</Button>
|
||||
{s.amount_due > 0 && !cancelada && (
|
||||
{s.amount_due > 0 && !cancelada && puedeCobrar && (
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); openPay(s); }} title="Pagar">
|
||||
<CreditCard size={16} className="text-theme-heading mr-1.5" />
|
||||
Pagar
|
||||
</Button>
|
||||
)}
|
||||
{s.amount_paid > 0 && !cancelada && (
|
||||
{s.amount_paid > 0 && !cancelada && puedeDevolver && (
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); openRefund(s); }} title="Devolución">
|
||||
<Undo2 size={16} className="text-amber-600 mr-1.5" />
|
||||
Devolución
|
||||
@@ -611,12 +612,12 @@ const Ventas: FC = () => {
|
||||
<Button variant="ghost" size="sm" onClick={() => setPrintSale(s)} title="Reimprimir ticket">
|
||||
<Printer size={16} className="text-theme-heading" />
|
||||
</Button>
|
||||
{s.amount_due > 0 && s.state !== 'cancelled' && (
|
||||
{s.amount_due > 0 && s.state !== 'cancelled' && puedeCobrar && (
|
||||
<Button variant="ghost" size="sm" onClick={() => openPay(s)}>
|
||||
<CreditCard size={16} className="text-theme-heading" />
|
||||
</Button>
|
||||
)}
|
||||
{s.amount_paid > 0 && s.state !== 'cancelled' && (
|
||||
{s.amount_paid > 0 && s.state !== 'cancelled' && puedeDevolver && (
|
||||
<Button variant="ghost" size="sm" onClick={() => openRefund(s)} title="Devolución">
|
||||
<Undo2 size={16} className="text-amber-600" />
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user