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:
@@ -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>} />
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user