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}
);