diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bc8e3ba..2f6f04e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ; + if (bootError) return ; + if (!user) return ; + if (canSee('/reportes')) return ; + const tab = sp.get('tab'); + if (tab && canSee(`/reportes?tab=${tab}`)) return ; + // 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 ; + return ; +}; + const LoginRoute: FC = () => { const { user, loading } = useAuth(); if (loading) return ; @@ -124,7 +147,7 @@ const App: FC = () => { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/HomeNestLayout.tsx b/frontend/src/components/HomeNestLayout.tsx index 440bed0..507f0aa 100644 --- a/frontend/src/components/HomeNestLayout.tsx +++ b/frontend/src/components/HomeNestLayout.tsx @@ -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' }, diff --git a/frontend/src/components/SkeenLayout.tsx b/frontend/src/components/SkeenLayout.tsx index ea65541..f68fca5 100644 --- a/frontend/src/components/SkeenLayout.tsx +++ b/frontend/src/components/SkeenLayout.tsx @@ -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' }, ], }, diff --git a/frontend/src/pages/Reportes.tsx b/frontend/src/pages/Reportes.tsx index b98a9bf..bd34797 100644 --- a/frontend/src/pages/Reportes.tsx +++ b/frontend/src/pages/Reportes.tsx @@ -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 = 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 )} - + {puedeTab('exportar') && ( + + )} {/* Rango de fecha global (aplica a todas las pestañas) */} @@ -318,14 +349,14 @@ const ReportesContent: FC = () => { {/* Favoritos por usuario */} - {favs.length > 0 && ( + {favsVisibles.length > 0 && (

Favoritos

- {favs.map((k) => ( + {favsVisibles.map((k) => ( { {/* Familias */}
- {FAMILIAS.map((f) => { + {familiasVisibles.map((f) => { const Icono = f.icon; const activa = f.key === familiaKey; return ( diff --git a/odoo-addons/skeen_chat/controllers/chat.py b/odoo-addons/skeen_chat/controllers/chat.py index fed1a1a..b74ef10 100644 --- a/odoo-addons/skeen_chat/controllers/chat.py +++ b/odoo-addons/skeen_chat/controllers/chat.py @@ -27,17 +27,20 @@ class SkeenChatController(http.Controller): def _touch(self, me): """Marca actividad del usuario (presencia). Nunca debe romper la petición. - Se hace con SQL directo dentro de un savepoint: el frontend consulta - varios endpoints de chat en paralelo (bandeja, badge, heartbeat) y dos - UPDATE concurrentes sobre la misma fila abortan la transacción en - REPEATABLE READ ("could not serialize access due to concurrent update"). - Con el savepoint ese fallo se descarta y la petición sigue; además sólo - se escribe si el último registro tiene más de TOUCH_INTERVAL segundos, - que es lo que evita la mayor parte de los choques. + El frontend consulta varios endpoints de chat en paralelo (bandeja, + badge, heartbeat) y todos llaman aquí. Por eso se corta antes de tocar + la fila cuando el registro es reciente (TOUCH_INTERVAL): el UPDATE se + ejecutaba aunque no aplicara ningún cambio y abortaba la transacción de + otra petición concurrente ("could not serialize access due to concurrent + update", 211 veces en un barrido de 260 visitas). El savepoint sigue + ahí por si dos peticiones pasan el corte en el mismo instante; su fallo + se descarta y la petición continúa. """ try: ahora = fields.Datetime.now() limite = fields.Datetime.subtract(ahora, seconds=TOUCH_INTERVAL) + if me.last_seen and me.last_seen >= limite: + return cr = request.env.cr with cr.savepoint(): cr.execute( diff --git a/odoo-addons/skeen_whatsapp/controllers/auth.py b/odoo-addons/skeen_whatsapp/controllers/auth.py index d67f5d6..578a02b 100644 --- a/odoo-addons/skeen_whatsapp/controllers/auth.py +++ b/odoo-addons/skeen_whatsapp/controllers/auth.py @@ -15,7 +15,7 @@ import time from odoo.http import request, Response -from ..roles import ROLE_RANKS +from ..roles import ROLE_RANKS, get_menu_roles TTL_SECONDS = 60 * 60 * 12 # 12 horas SECRET_KEY = 'skeen.frontend.secret' @@ -36,6 +36,26 @@ MEDICO_BLOCKED_PREFIXES = ( ) +def _medico_reporte_permitido(env, path): + """True si la matriz de menus concede al medico ESA pestana de reportes. + + El veto de 'medico' sobre /reports/ es por diseno (no ve dinero), pero la + matriz permite habilitar pestañas puntuales (/reportes?tab=horas-agenda). + Sin este puente el menu mostraria la opcion y la API la rechazaria con 403. + Las demas rutas vetadas (sales, payments, wallets...) siguen cerradas. + """ + prefijo = '/skeen/frontend/v1/reports/' + if not path.startswith(prefijo): + return False + tab = path[len(prefijo):].strip('/') + if not tab or '/' in tab: + return False + matriz = get_menu_roles(env) + if 'medico' in (matriz.get('/reportes') or []): + return True + return 'medico' in (matriz.get('/reportes?tab=%s' % tab) or []) + + def _cors_headers(): return { 'Access-Control-Allow-Origin': '*', @@ -156,7 +176,7 @@ def require_role(min_role='lectura'): request.httprequest.path == p or request.httprequest.path.startswith( p if p.endswith('/') else p + '/') for p in MEDICO_BLOCKED_PREFIXES - ): + ) and not _medico_reporte_permitido(request.env, request.httprequest.path): return _json({'status': 'error', 'message': 'Sin permiso'}, 403) request.frontend_user = user return func(self, *args, **kw)