334 lines
13 KiB
TypeScript
334 lines
13 KiB
TypeScript
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<string, number> = {
|
|
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<string, FrontendRole[]> = {
|
|
'/' : ['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<FrontendUser>;
|
|
logout: () => Promise<void>;
|
|
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<AuthContextValue | undefined>(undefined);
|
|
|
|
export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
|
|
const [user, setUser] = useState<FrontendUser | null>(null);
|
|
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
|
|
const [loading, setLoading] = useState(true);
|
|
const [bootError, setBootError] = useState<string | null>(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<Record<string, FrontendRole[]> | 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<ClinicBrand>(() => {
|
|
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<FrontendUser> => {
|
|
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 (
|
|
<AuthContext.Provider value={{ user, token, loading, bootError, retryBoot, login, logout, hasRole, canSee, canAgenda, can, brand, setBrand }}>
|
|
{children}
|
|
</AuthContext.Provider>
|
|
);
|
|
};
|
|
|
|
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<string, string> = {
|
|
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;
|