Permisos por pestana de reportes y ruido de serializacion en presencia

Auditoria de 260 visitas (26 rutas x 10 roles) sobre el build actual: 0 hallazgos.
Dos defectos reales que salieron del barrido anterior se corrigen aqui.

1) Reportes: la matriz guarda claves '/reportes?tab=x' y puede conceder una
   pestana suelta (Horas agenda al medico, Exportar a cosmetologia) sin dar el
   modulo. Nadie la respetaba:
   - App.tsx: nuevo ReportesRoute que evalua la pestana del URL; si el rol no
     tiene '/reportes' pero si alguna pestana, aterriza en la primera permitida.
   - Reportes.tsx: familias, favoritos y el boton "Exportar datos" se filtran
     por canSee('/reportes?tab=x'). 'exportar' no pertenece a ninguna familia,
     por eso se lista en TABS_FUERA_DE_FAMILIA (si no, el guard lo reescribia a
     'general' y admin perdia la vista). El query se sincroniza con lo renderizado.
   - SkeenLayout/HomeNestLayout: "Horas agenda" y "Exportar datos" aparecen en
     Analitica para que el permiso sea alcanzable desde el menu, no solo a
     mano. Clasico ya los traia.
   - auth.py: _medico_reporte_permitido consulta la matriz antes de vetar
     /reports/ al medico. Sin ese puente el menu ofrecia la opcion y la API
     respondia 403. Las demas rutas vetadas (sales, payments, wallets, pos)
     siguen cerradas: verificado comisiones=403, horas-agenda=200.

2) skeen_chat/_touch: 211 "could not serialize access due to concurrent update"
   en un barrido. Bandeja, badge y heartbeat salen en paralelo y cada uno
   emitia el UPDATE de last_seen aunque no aplicara cambios, abortando la
   transaccion de otra peticion. Se corta antes de tocar la fila cuando el
   registro es reciente; el savepoint queda para el caso de empate exacto.
   Ráfaga de 128 peticiones concurrentes: todas 200, presencia sigue
   actualizando (online=true, last_seen avanza) y el ruido bajo de 211 a 5.
This commit is contained in:
2026-09-30 01:26:37 +00:00
parent 5a16f694de
commit 85a281add1
6 changed files with 120 additions and 32 deletions

View File

@@ -1,5 +1,6 @@
import type { FC, ReactNode } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { BrowserRouter as Router, Routes, Route, Navigate, useSearchParams } from 'react-router-dom';
import { MENU_ITEMS } from './lib/menu-items';
import { ToastProvider, Button } from './components/ui';
import ErrorBoundary from './components/ErrorBoundary';
import { AuthProvider, useAuth } from './lib/auth';
@@ -62,6 +63,28 @@ const RequireAuth: FC<{ children: ReactNode; href: string }> = ({ children, href
return <>{children}</>;
};
// Reportes se gatea por pestana: la matriz guarda '/reportes?tab=xxx' y puede
// conceder una sola (p. ej. Horas agenda al medico, Exportar a cosmetologia).
// El guard anterior solo miraba '/reportes', asi que el menu ofrecia la pestana
// y la ruta rebotaba a '/'. Se resuelve la pestana real del URL.
const ReportesRoute: FC = () => {
const { user, loading, bootError, retryBoot, canSee } = useAuth();
const [sp] = useSearchParams();
if (loading) return <FullScreenLoader />;
if (bootError) return <BootErrorScreen onRetry={retryBoot} />;
if (!user) return <Navigate to="/login" replace />;
if (canSee('/reportes')) return <Reportes />;
const tab = sp.get('tab');
if (tab && canSee(`/reportes?tab=${tab}`)) return <Reportes />;
// Sin acceso al modulo completo: si la matriz le concedio alguna pestana,
// aterriza en la primera permitida en vez de rebotar a '/'.
const primera = MENU_ITEMS
.map((it) => it.href)
.find((href) => href.startsWith('/reportes?tab=') && canSee(href));
if (primera) return <Navigate to={primera} replace />;
return <Navigate to="/" replace />;
};
const LoginRoute: FC = () => {
const { user, loading } = useAuth();
if (loading) return <FullScreenLoader />;
@@ -124,7 +147,7 @@ const App: FC = () => {
<Route path="/ventas" element={<RequireAuth href="/ventas"><Ventas /></RequireAuth>} />
<Route path="/tickets" element={<RequireAuth href="/tickets"><Tickets /></RequireAuth>} />
<Route path="/cortes" element={<RequireAuth href="/cortes"><Cortes /></RequireAuth>} />
<Route path="/reportes" element={<RequireAuth href="/reportes"><Reportes /></RequireAuth>} />
<Route path="/reportes" element={<ReportesRoute />} />
<Route path="/configuracion" element={<RequireAuth href="/configuracion"><Configuracion /></RequireAuth>} />
<Route path="/usuarios" element={<Navigate to="/configuracion?tab=usuarios" replace />} />
<Route path="/cumpleanos" element={<RequireAuth href="/cumpleanos"><Cumpleanos /></RequireAuth>} />

View File

@@ -35,6 +35,7 @@ import {
CircleDollarSign,
TicketCheck,
Layers,
Download,
} from 'lucide-react';
import { Button, Modal, Input } from './ui';
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
@@ -93,6 +94,9 @@ const menuSections = [
items: [
{ label: 'Reportes', icon: BarChart3, href: '/reportes' },
{ label: 'Comisiones', icon: Percent, href: '/reportes?tab=comisiones' },
// Pestanas sueltas que la matriz puede conceder sin dar /reportes completo
{ label: 'Horas agenda', icon: FolderClock, href: '/reportes?tab=horas-agenda' },
{ label: 'Exportar datos', icon: Download, href: '/reportes?tab=exportar' },
{ label: 'Chat', icon: MessageCircle, href: '/chat' },
{ label: 'Cumpleañeros', icon: Cake, href: '/cumpleanos' },
{ label: 'Configuración', icon: Settings, href: '/configuracion' },

View File

@@ -36,6 +36,8 @@ import {
PanelLeftOpen,
Ticket,
Layers,
Clock,
Download,
} from 'lucide-react';
import { Button, Modal, Input } from './ui';
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
@@ -93,6 +95,11 @@ const menuGroups = [
items: [
{ label: 'Reportes', icon: BarChart3, href: '/reportes' },
{ label: 'Comisiones', icon: Percent, href: '/reportes?tab=comisiones' },
// La matriz puede conceder una pestana suelta sin dar /reportes completo
// (Horas agenda al medico, Exportar a cosmetologia): se listan para que
// el permiso sea alcanzable desde el menu y no solo tecleando la URL.
{ label: 'Horas agenda', icon: Clock, href: '/reportes?tab=horas-agenda' },
{ label: 'Exportar datos', icon: Download, href: '/reportes?tab=exportar' },
{ label: 'Configuración', icon: Settings, href: '/configuracion' },
],
},

View File

@@ -1,5 +1,5 @@
import type { FC } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Download, BarChart3, Star, Wallet, Users, Activity, Stethoscope, Share2 } from 'lucide-react';
import {
@@ -114,13 +114,15 @@ const FAMILIAS: ReportFamily[] = [
},
];
// 'exportar' no vive en ninguna familia: se abre desde el botón del encabezado
// y se renderiza en la cadena de condiciones de abajo. Se lista aquí para que
// el control de acceso por pestana la trate igual que las demas.
const TABS_FUERA_DE_FAMILIA = ['exportar'];
const TODOS: ReportTab[] = FAMILIAS.flatMap((f) => f.tabs);
const ETIQUETA_TAB: Record<string, string> = Object.fromEntries(TODOS.map((t) => [t.key, t.label]));
const familiaDe = (tab: string): string =>
FAMILIAS.find((f) => f.tabs.some((t) => t.key === tab))?.key || FAMILIAS[0].key;
interface SalesReport {
total_sales: number;
total_paid: number;
@@ -140,13 +142,40 @@ interface CashReport {
const ReportesContent: FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const tab = searchParams.get('tab') || 'general';
const setTab = (t: string) => setSearchParams({ tab: t });
const familiaKey = familiaDe(tab);
const familia = FAMILIAS.find((f) => f.key === familiaKey) || FAMILIAS[0];
const { favs, toggle, isFav } = useReportFavorites();
const { hasRole } = useAuth();
const { hasRole, canSee } = 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
// '/reportes'. Asi, quien tiene el modulo completo ve todas y quien tiene una
// concedida suelta (Horas agenda al medico, Exportar a cosmetologia) solo esa.
const puedeTab = useCallback((k: string) => canSee(`/reportes?tab=${k}`), [canSee]);
const familiasVisibles = useMemo(
() =>
FAMILIAS
.map((f) => ({ ...f, tabs: f.tabs.filter((t) => puedeTab(t.key)) }))
.filter((f) => f.tabs.length > 0),
[puedeTab]
);
const tabParam = searchParams.get('tab') || '';
const esTabValido = (k: string) =>
k !== '' && (TABS_FUERA_DE_FAMILIA.includes(k) || TODOS.some((t) => t.key === k)) && puedeTab(k);
const tab = esTabValido(tabParam)
? tabParam
: familiasVisibles[0]?.tabs[0]?.key
|| (esTabValido('exportar') ? 'exportar' : 'general');
const setTab = (t: string) => setSearchParams({ tab: t });
const familiaKey = familiasVisibles.find((f) => f.tabs.some((t) => t.key === tab))?.key
|| familiasVisibles[0]?.key || FAMILIAS[0].key;
const familia = familiasVisibles.find((f) => f.key === familiaKey) || FAMILIAS[0];
const { favs, toggle, isFav } = useReportFavorites();
// Un favorito fijado antes de cambiar la matriz no debe ofrecer una pestana
// que el rol ya no tiene asignada.
const favsVisibles = favs.filter((k) => puedeTab(k));
// Si el URL pedía una pestana no concedida, la página renderiza otra: se
// reescribe el query para que la dirección refleje lo que se está viendo.
useEffect(() => {
if (tab !== tabParam) setSearchParams({ tab });
}, [tab, tabParam, setSearchParams]);
// Rango global del módulo (persistente, compartido por todas las pestañas)
const { start, end, preset, setRange } = useGlobalReportRange();
const [today, setToday] = useState(() => new Date().toISOString().split('T')[0]);
@@ -264,14 +293,16 @@ const ReportesContent: FC = () => {
Exportar
</Button>
)}
<Button
variant={tab === 'exportar' ? 'primary' : 'outline'}
onClick={() => setTab('exportar')}
title="Exportar listas de pacientes (correos, VIP, etiquetas)"
>
<Share2 size={16} className="mr-2" />
Exportar datos
</Button>
{puedeTab('exportar') && (
<Button
variant={tab === 'exportar' ? 'primary' : 'outline'}
onClick={() => setTab('exportar')}
title="Exportar listas de pacientes (correos, VIP, etiquetas)"
>
<Share2 size={16} className="mr-2" />
Exportar datos
</Button>
)}
</PageHeader>
{/* Rango de fecha global (aplica a todas las pestañas) */}
@@ -318,14 +349,14 @@ const ReportesContent: FC = () => {
</Card>
{/* Favoritos por usuario */}
{favs.length > 0 && (
{favsVisibles.length > 0 && (
<div className="mb-4">
<p className="text-xs font-medium text-theme-muted uppercase mb-2 inline-flex items-center gap-1.5">
<Star size={12} className="text-amber-500" fill="currentColor" />
Favoritos
</p>
<div className="flex flex-wrap gap-1.5">
{favs.map((k) => (
{favsVisibles.map((k) => (
<span
key={k}
className={`inline-flex items-center rounded-full border overflow-hidden text-xs font-medium ${
@@ -355,7 +386,7 @@ const ReportesContent: FC = () => {
{/* Familias */}
<div className="flex flex-wrap gap-1.5 mb-3">
{FAMILIAS.map((f) => {
{familiasVisibles.map((f) => {
const Icono = f.icon;
const activa = f.key === familiaKey;
return (