import { createContext, useContext, useEffect, useState, useCallback } from 'react'; import type { FC, ReactNode } from 'react'; import { odooApi, type FrontendUser, type FrontendRole, type AgendaPerm, type ClinicBrand, BRAND_DEFAULT } from '../services/odoo'; import { applyBrandColors } from './brand-colors'; import { defaultPermFor } from './permissions'; const TOKEN_KEY = 'skeen_token'; const USER_KEY = 'skeen_user'; const MENU_ROLES_KEY = 'skeen_menu_roles'; const BRAND_KEY_LS = 'skeen_clinic_brand'; const ROLE_RANK: Record = { admin: 100, medico: 50, recepcion: 50, enfermeria: 50, cosmetologia: 50, psicologa: 50, farmacia: 50, gerencia: 50, // marketing pasó a 50: puede agendar citas y escribir en el WACRM marketing: 50, lectura: 10, }; // Visibilidad del menú por ruta. Si una ruta no está listada, se muestra a todos los roles. // 'gerencia' (gerencia de la clínica) ve toda la operación excepto // /configuracion (solo admin del sistema), /mis-ventas, /cumpleanos y /wacrm/*. const MENU_ROLES: Record = { '/' : ['admin', 'medico', 'recepcion', 'enfermeria', 'cosmetologia', 'psicologa', 'farmacia', 'gerencia', 'marketing', 'lectura'], '/agenda': ['admin', 'recepcion', 'medico', 'gerencia', 'cosmetologia', 'marketing'], '/mis-ventas': ['medico'], '/pacientes': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia', 'marketing'], '/pos': ['admin', 'recepcion', 'gerencia'], '/visitas': ['admin', 'recepcion', 'medico', 'gerencia', 'marketing'], '/consultas': ['admin', 'recepcion', 'medico', 'gerencia'], '/medicos': ['admin', 'recepcion', 'gerencia'], '/servicios': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'], '/paquetes': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia', 'cosmetologia'], // Membresías (apagado por defecto: la sección solo se pinta con // membresias.activo=true). Se lista aquí para que canSee la reconozca. '/membresias': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'], // Liquidaciones de comisiones (solo se pinta con comisiones.activo=true). '/comisiones/liquidaciones': ['admin', 'recepcion', 'gerencia', 'medico'], '/productos': ['admin', 'recepcion', 'lectura', 'gerencia'], // deprecated: redirige a /inventario '/ventas': ['admin', 'recepcion', 'gerencia'], '/tickets': ['admin', 'gerencia'], '/pagos': ['admin', 'recepcion', 'gerencia'], '/monedero': ['admin', 'recepcion', 'lectura', 'gerencia'], '/inventario': ['admin', 'recepcion', 'lectura', 'gerencia'], // Compras (apagado por defecto: la sección solo se pinta con // compras.activo=true). Se lista aquí para que canSee la reconozca. '/compras': ['admin', 'recepcion', 'lectura', 'gerencia'], '/cortes': ['admin', 'recepcion', 'gerencia'], '/reportes': ['admin', 'recepcion', 'lectura', 'gerencia'], // Tablero de dirección: solo dueño/administración y marketing (el médico no). '/direccion': ['admin', 'gerencia', 'marketing'], '/reportes?tab=adeudos': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=comisiones': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=horas-agenda': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=paquetes': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=membresias': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=vendedores': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=concentrado': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=recomendaciones': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=compras': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=margen': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=exportar': ['admin', 'recepcion', 'lectura', 'gerencia'], '/configuracion': ['admin', 'gerencia'], '/cumpleanos': ['admin', 'recepcion', 'gerencia', 'marketing'], // Campañas y satisfacción (apagadas por defecto: solo se pintan con // campanas.activo / encuestas.activo). Se listan aquí para canSee. '/campanas': ['admin', 'recepcion', 'gerencia', 'marketing'], '/satisfaccion': ['admin', 'recepcion', 'gerencia', 'medico'], '/wacrm/messages': ['admin', 'recepcion', 'gerencia', 'marketing'], '/wacrm/leads': ['admin', 'recepcion', 'gerencia', 'marketing'], // El chat se lista explícitamente para dejar fuera a marketing (antes, al no // estar listado, lo veía cualquier rol). '/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; canSee: (href: string) => boolean; canAgenda: (perm: AgendaPerm) => boolean; // Permiso por acción (catálogo skeen_whatsapp/permissions.py). La UI oculta // o deshabilita según esto; el backend sigue siendo la autoridad. can: (permKey: string) => boolean; // Brand de la clínica (white-label). BRAND_DEFAULT hasta que /brand responde. brand: ClinicBrand; setBrand: (b: ClinicBrand) => void; } const AuthContext = createContext(undefined); 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 { const cached = localStorage.getItem(MENU_ROLES_KEY); return cached ? JSON.parse(cached) : null; } catch { return null; } }); // Brand de la clínica: se hidrata del caché para no parpadear y se refresca // con /brand en el arranque (mismo patrón que menuRoles). const [brand, setBrandState] = useState(() => { try { const cached = localStorage.getItem(BRAND_KEY_LS); return cached ? { ...BRAND_DEFAULT, ...JSON.parse(cached) } : BRAND_DEFAULT; } catch { return BRAND_DEFAULT; } }); const setBrand = useCallback((b: ClinicBrand) => { setBrandState(b); localStorage.setItem(BRAND_KEY_LS, JSON.stringify(b)); // Título de la pestaña sigue al brand configurado. if (typeof document !== 'undefined' && b.nombre) { document.title = b.nombre; } }, []); // Colores de marca en runtime: solo pisan el tema si la clínica los configuró. useEffect(() => { applyBrandColors(brand); }, [brand]); const refreshBrand = useCallback(async () => { try { const res = await odooApi.getBrand(); if (res.status === 'success' && res.brand) setBrand(res.brand); } catch { // fallback: BRAND_DEFAULT / caché } }, [setBrand]); const refreshMenuRoles = useCallback(async () => { try { const res = await odooApi.getRolePermissions(); if (res.status === 'success' && res.matrix) { setMenuRoles(res.matrix); localStorage.setItem(MENU_ROLES_KEY, JSON.stringify(res.matrix)); } } catch { // fallback: MENU_ROLES hardcodeado } }, []); const clear = useCallback(() => { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); localStorage.removeItem(MENU_ROLES_KEY); setToken(null); setUser(null); setMenuRoles(null); }, []); // Validar token al arrancar useEffect(() => { let alive = true; let timer: number | undefined; const t = localStorage.getItem(TOKEN_KEY); if (!t) { setLoading(false); return; } // Hidratar usuario cacheado para evitar parpadeo const cached = localStorage.getItem(USER_KEY); 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; if (res.status === 'success') { setUser(res.user); localStorage.setItem(USER_KEY, JSON.stringify(res.user)); refreshMenuRoles(); refreshBrand(); } else { clear(); } setBootError(null); }) .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); if (res.status !== 'success' || !res.token) { throw new Error(res.message || 'Credenciales inválidas'); } localStorage.setItem(TOKEN_KEY, res.token); localStorage.setItem(USER_KEY, JSON.stringify(res.user)); setToken(res.token); setUser(res.user); refreshMenuRoles(); refreshBrand(); return res.user; }; const logout = async () => { try { await odooApi.logout(); } catch { /* ignore */ } clear(); }; const hasRole = (min: FrontendRole): boolean => { if (!user) return false; return (ROLE_RANK[user.role] ?? 0) >= (ROLE_RANK[min] ?? 0); }; const canSee = (href: string): boolean => { if (!user) return false; // Resolución de permiso: primero href exacto (con query), luego por // prefijo recortando el último segmento (/pacientes/PAT-123 -> /pacientes). // '/' no matchea subrutas: solo aterriza por canSee('/'). Rutas que no // matchean nada = visibles para todos. const segments = href.split('?')[0].split('/').filter(Boolean); const candidates = [href, ...segments.map((_, i) => '/' + segments.slice(0, segments.length - i).join('/'))].filter((c) => c !== '/' || href === '/'); // Permisos personalizados: lista no vacía manda sobre la matriz por rol if (user.allowed_menus && user.allowed_menus.length > 0) { for (const cand of candidates) { if (user.allowed_menus.includes(cand)) return true; } return false; } const matrix = menuRoles || MENU_ROLES; for (const cand of candidates) { const allowed = matrix[cand]; if (allowed) return allowed.includes(user.role); } return true; }; // Acciones dentro de la Agenda. El backend resuelve la herencia del rol y // manda la lista efectiva en user.agenda_perms; el fallback por rango cubre // la sesión cacheada anterior al despliegue. const canAgenda = (perm: AgendaPerm): boolean => { if (!user) return false; const todas: AgendaPerm[] = ['ver', 'agendar', 'editar', 'eliminar', 'visitas']; const perms = user.agenda_perms ?? (ROLE_RANK[user.role] >= ROLE_RANK.recepcion ? todas : ['ver']); return perms.includes(perm); }; // Permisos por acción. El backend manda user.perms en /auth/me y /auth/login; // si no viniera (sesión cacheada previa al despliegue) se usa el espejo de // defaults de lib/permissions.ts. El admin siempre puede. const can = (permKey: string): boolean => { if (!user) return false; if (user.role === 'admin') return true; if (user.perms && typeof user.perms[permKey] === 'boolean') return user.perms[permKey]; return defaultPermFor(user.role, permKey); }; return ( {children} ); }; export const useAuth = (): AuthContextValue => { const ctx = useContext(AuthContext); if (!ctx) throw new Error('useAuth debe usarse dentro de AuthProvider'); return ctx; }; export const ROLE_LABELS: Record = { admin: 'Administrador', medico: 'Médico', recepcion: 'Recepción', enfermeria: 'Enfermería', cosmetologia: 'Cosmetología', psicologa: 'Psicóloga', farmacia: 'Farmacia', gerencia: 'Gerencia', marketing: 'Marketing', lectura: 'Lectura', }; /** Nombre visible de un rol: el configurado (si se conoce) o el código. */ export const roleLabel = (role: string, name?: string): string => name || ROLE_LABELS[role] || role;