diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 675dfc8..bc8e3ba 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import type { FC, ReactNode } from 'react'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; -import { ToastProvider } from './components/ui'; +import { ToastProvider, Button } from './components/ui'; +import ErrorBoundary from './components/ErrorBoundary'; import { AuthProvider, useAuth } from './lib/auth'; import { QuickLinksProvider } from './lib/quicklinks'; import { PatientDrawerProvider } from './components/PatientDrawer'; @@ -36,9 +37,24 @@ const FullScreenLoader: FC = () => ( ); +// Pantalla cuando el servidor no responde al arrancar (red lenta o caída): +// en lugar de quedarse cargando para siempre, ofrece reintentar. +const BootErrorScreen: FC<{ onRetry: () => void }> = ({ onRetry }) => ( +
+
+

No se pudo conectar

+

+ El servidor no responde en este momento. Revisa tu conexión a internet e intenta de nuevo. +

+ +
+
+); + const RequireAuth: FC<{ children: ReactNode; href: string }> = ({ children, href }) => { - const { user, loading, canSee } = useAuth(); + const { user, loading, bootError, retryBoot, canSee } = useAuth(); if (loading) return ; + if (bootError) return ; if (!user) return ; // La ruta "/" se maneja en HomeRoute (calcula landing por rol) para evitar // el bucle de redirecciones cuando el rol no tiene '/' en la matriz. @@ -81,6 +97,7 @@ const HomeRoute: FC = () => { const App: FC = () => { return ( + @@ -121,6 +138,7 @@ const App: FC = () => { + ); }; diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..9c90760 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,48 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; +import { Button } from './ui'; + +interface Props { + children: ReactNode; +} + +interface State { + error: Error | null; +} + +/** + * Barrera de errores global: si un módulo lanza una excepción al renderizar, + * en lugar de la pantalla en blanco se muestra un mensaje amable con la + * opción de recargar. No sustituye corregir el error, pero evita que un bug + * puntual deje a recepción sin sistema. + */ +export class ErrorBoundary extends Component { + state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + // Log técnico en consola para depuración; al usuario solo se le muestra + // el mensaje amable de abajo. + console.error('Error de render capturado:', error, info.componentStack); + } + + render() { + if (!this.state.error) return this.props.children; + return ( +
+
+

Algo salió mal

+

+ Ocurrió un error inesperado en la aplicación. Recarga la página; si el problema + continúa, cierra sesión y vuelve a entrar. +

+ +
+
+ ); + } +} + +export default ErrorBoundary; diff --git a/frontend/src/lib/auth.tsx b/frontend/src/lib/auth.tsx index 4401cd9..fac3c1a 100644 --- a/frontend/src/lib/auth.tsx +++ b/frontend/src/lib/auth.tsx @@ -59,10 +59,18 @@ const MENU_ROLES: Record = { '/chat': ['admin', 'medico', 'recepcion', 'enfermeria', 'cosmetologia', 'psicologa', 'farmacia', 'gerencia', 'lectura'], }; +// Si /auth/me no responde en este tiempo (red lenta o caída), se muestra la +// pantalla de reintento en lugar del spinner eterno ("pantalla en blanco"). +const BOOT_TIMEOUT_MS = 20000; + interface AuthContextValue { user: FrontendUser | null; token: string | null; loading: boolean; + // Error de arranque (servidor no responde): la app muestra Reintentar + // en lugar de quedarse cargando para siempre. + bootError: string | null; + retryBoot: () => void; login: (login: string, password: string) => Promise; logout: () => Promise; hasRole: (min: FrontendRole) => boolean; @@ -75,6 +83,9 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => { const [user, setUser] = useState(null); const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY)); const [loading, setLoading] = useState(true); + const [bootError, setBootError] = useState(null); + // Cada reintento de arranque re-ejecuta la validación del token. + const [bootAttempt, setBootAttempt] = useState(0); // Matriz dinámica de permisos por rol (cache en localStorage; fallback a MENU_ROLES) const [menuRoles, setMenuRoles] = useState | null>(() => { try { @@ -109,6 +120,7 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => { // Validar token al arrancar useEffect(() => { let alive = true; + let timer: number | undefined; const t = localStorage.getItem(TOKEN_KEY); if (!t) { setLoading(false); @@ -119,6 +131,13 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => { if (cached) { try { setUser(JSON.parse(cached)); } catch { /* ignore */ } } + // Red de seguridad: si el servidor no responde (red lenta/caída del + // túnel), mostrar pantalla de reintento en vez del spinner eterno. + timer = window.setTimeout(() => { + if (!alive) return; + setBootError('El servidor no responde. Revisa tu conexión e intenta de nuevo.'); + setLoading(false); + }, BOOT_TIMEOUT_MS); odooApi.me() .then((res) => { if (!alive) return; @@ -129,11 +148,30 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => { } else { clear(); } + setBootError(null); }) - .catch(() => { if (alive) clear(); }) - .finally(() => { if (alive) setLoading(false); }); - return () => { alive = false; }; - }, [clear, refreshMenuRoles]); + .catch((err) => { + if (!alive) return; + // Solo se descarta la sesión si el servidor contestó (token inválido, + // 401). Si la red falló se conserva el token para el reintento. + if (err?.response) clear(); + else setBootError('No se pudo conectar con el servidor. Revisa tu conexión e intenta de nuevo.'); + }) + .finally(() => { + if (timer) window.clearTimeout(timer); + if (alive) setLoading(false); + }); + return () => { + alive = false; + if (timer) window.clearTimeout(timer); + }; + }, [clear, refreshMenuRoles, bootAttempt]); + + const retryBoot = useCallback(() => { + setBootError(null); + setLoading(true); + setBootAttempt((n) => n + 1); + }, []); const login = async (login: string, password: string): Promise => { const res = await odooApi.login(login, password); @@ -182,7 +220,7 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => { }; return ( - + {children} );