bloque 2: cuenta del cliente (modulo skeen_portal_cliente: acceso con codigo por WhatsApp o enlace por correo, sesion propia separada del personal, mis citas con cancelar y reagendar, pagos, monedero, paquetes, membresias con pago del periodo, formularios, documentos y perfil; invitacion y control de sesiones desde el expediente); apagado por defecto

This commit is contained in:
2026-10-12 00:33:32 +00:00
parent 6a6c2a928b
commit 1bd4cad6a0
26 changed files with 3981 additions and 5 deletions

View File

@@ -22,7 +22,7 @@ axios.interceptors.response.use(
// Rutas públicas (sin sesión): un 401 de un endpoint autenticado no debe
// expulsar al visitante a /login. Cubre el portal de reservas, la página
// de pago y el portal de formularios.
const PUBLICAS = ['/login', '/reservar', '/pagar', '/formulario'];
const PUBLICAS = ['/login', '/reservar', '/pagar', '/formulario', '/mi-cuenta'];
const onPublica = PUBLICAS.some((p) => path === p || path.startsWith(p + '/'));
const isLoginCall = String(error.config?.url || '').includes('/auth/login');
if (!onPublica && !isLoginCall) {
@@ -1088,6 +1088,7 @@ export interface ClinicSettings {
compras: ComprasSettings;
comisiones: ComisionesSettings;
licencia: LicenciaSettings;
portal_cliente: PortalClienteSettings;
}
// ---- Licencia por plan (bloque `licencia` de clinic-settings) ----
@@ -1281,6 +1282,63 @@ export const RESERVAS_ONLINE_DEFAULT: ReservasOnlineSettings = {
anticipo: { ...ANTICIPO_DEFAULT },
};
// ---- Portal del cliente (cuenta de autoservicio del paciente) ----
export interface PortalClienteSettings {
/** Enciende TODO el portal del cliente. Apagado = nada cambia. */
activo: boolean;
/** Cómo entra el cliente: código por WhatsApp, enlace por correo o ambos. */
metodo: 'otp_whatsapp' | 'enlace_correo' | 'ambos';
/** Exige tener correo registrado para acceder. */
requerir_correo_verificado: boolean;
/** Permite al cliente fijar una contraseña tras entrar la primera vez. */
permitir_contrasena: boolean;
/** Vigencia de la sesión del cliente (horas). */
sesion_horas: number;
/** Permite reagendar citas en línea. */
permitir_reagendar: boolean;
/** Horas mínimas de antelación para cancelar en línea. */
horas_minimas_cancelar: number;
/** Muestra importes en citas y ventas. */
mostrar_precios: boolean;
/** Muestra recetas en el portal. */
mostrar_recetas: boolean;
/** Permite descargar el estado de cuenta. */
permitir_descargar_estado_cuenta: boolean;
/** Permite al cliente actualizar sus datos de contacto. */
permitir_actualizar_datos: boolean;
}
export const PORTAL_CLIENTE_DEFAULT: PortalClienteSettings = {
activo: false,
metodo: 'otp_whatsapp',
requerir_correo_verificado: false,
permitir_contrasena: false,
sesion_horas: 24,
permitir_reagendar: true,
horas_minimas_cancelar: 24,
mostrar_precios: true,
mostrar_recetas: true,
permitir_descargar_estado_cuenta: true,
permitir_actualizar_datos: true,
};
// Estado del acceso al portal de un paciente (personal).
export interface PortalAcceso {
partner_id: number;
paciente?: string;
patient_id?: string;
telefono?: string;
email?: string;
tiene_acceso?: boolean;
activo: boolean;
ultimo_acceso: string | null;
total_accesos: number;
sesiones_abiertas: number;
metodo: string | null;
invitado_por?: string;
invitado_at?: string | null;
}
// ---- Notificaciones y recordatorios de citas ----
// ---- Recursos físicos de la agenda ----
export interface RecursosSettings {
@@ -2367,6 +2425,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
compras: COMPRAS_DEFAULT,
comisiones: COMISIONES_DEFAULT,
licencia: LICENCIA_DEFAULT,
portal_cliente: PORTAL_CLIENTE_DEFAULT,
};
export interface FrontendUser {
@@ -3061,6 +3120,32 @@ export const odooApi = {
return data;
},
// ---- Portal del cliente (personal: invitar, accesos, sesiones) ----
async getPortalAccesos(): Promise<{ status: string; activo: boolean; accesos: PortalAcceso[]; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/portal/accesos`);
return data;
},
async getPortalPaciente(partnerId: number): Promise<{ status: string; activo: boolean; estado: PortalAcceso; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/portal/paciente/${partnerId}`);
return data;
},
async invitarPortal(partnerId: number, canal?: 'whatsapp' | 'email'): Promise<{ status: string; ok?: boolean; canal?: string; modo_prueba?: boolean; codigo_prueba?: string; enlace_prueba?: string; acceso?: PortalAcceso; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/portal/invitar`, { partner_id: partnerId, canal });
return data;
},
async cerrarPortalSesiones(partnerId: number): Promise<{ status: string; ok?: boolean; acceso?: PortalAcceso; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/portal/sesiones/cerrar`, { partner_id: partnerId });
return data;
},
async portalAccesoEstado(partnerId: number, accion: 'activar' | 'desactivar'): Promise<{ status: string; ok?: boolean; acceso?: PortalAcceso; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/portal/acceso/${partnerId}/${accion}`);
return data;
},
// ---- Licencia por plan ----
async getLicencia(): Promise<LicenciaEstadoResponse> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/licencia`);

View File

@@ -0,0 +1,233 @@
import { useCallback, useEffect, useState } from 'react';
import axios from 'axios';
import type { ClinicBrand, GlossaryTerms } from './odoo';
// Cliente del portal del cliente (cuenta de autoservicio). Es ANÓNIMO salvo las
// peticiones de datos, que llevan su propio token (Bearer), distinto del token
// del personal. La sesión vive en localStorage y no toca la del personal.
const ODOO_BASE = import.meta.env.VITE_API_BASE_URL || '/api/odoo';
const BASE = `${ODOO_BASE}/skeen/frontend/v1/public/portal`;
export const PORTAL_TOKEN_KEY = 'skeen_portal_token';
export const portalToken = (): string | null => localStorage.getItem(PORTAL_TOKEN_KEY);
export const setPortalToken = (t: string | null) => {
if (t) localStorage.setItem(PORTAL_TOKEN_KEY, t);
else localStorage.removeItem(PORTAL_TOKEN_KEY);
};
export const portalHttp = axios.create({ baseURL: BASE });
portalHttp.interceptors.request.use((config) => {
const t = portalToken();
if (t) {
config.headers = config.headers || {};
(config.headers as Record<string, string>).Authorization = `Bearer ${t}`;
}
return config;
});
portalHttp.interceptors.response.use(
(res) => res,
(error) => {
if (error?.response?.status === 401) {
setPortalToken(null);
window.dispatchEvent(new Event('portal:logout'));
}
return Promise.reject(error);
},
);
export interface PortalConfig {
active: boolean;
brand: ClinicBrand;
terms: GlossaryTerms;
config: {
metodo: 'otp_whatsapp' | 'enlace_correo' | 'ambos';
requerir_correo_verificado: boolean;
permitir_contrasena: boolean;
sesion_horas: number;
permitir_reagendar: boolean;
horas_minimas_cancelar: number;
mostrar_precios: boolean;
mostrar_recetas: boolean;
permitir_descargar_estado_cuenta: boolean;
permitir_actualizar_datos: boolean;
reservas_online: boolean;
};
zona_horaria?: string;
moneda?: string;
}
export interface PortalCita {
id: number;
folio: string;
fecha: string | null;
hora: string;
inicio: string | null;
servicio_id: number | null;
servicio: string;
doctor_id: number | null;
doctor: string;
sucursal: string;
duracion_min: number;
importe: number | null;
moneda: string;
estado: string;
estado_label: string;
notas: string;
puede_cancelar: boolean;
puede_reagendar: boolean;
anticipo_estado: string;
}
export interface PortalPerfil {
nombre: string;
patient_id: string;
telefono: string;
mobile: string;
email: string;
birth_date: string | null;
gender: string;
occupation: string;
marital_status: string;
birthplace: string;
address_notes: string;
emergency_contact: string;
emergency_phone: string;
tiene_foto: boolean;
puede_editar: boolean;
fotografias: { id: number; nombre: string; clasificacion: string; fecha: string | null }[];
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Json = any;
export const portalApi = {
async config(): Promise<PortalConfig | null> {
const { data } = await portalHttp.get('/config');
if (!data || !data.activo) return null;
return {
active: true, brand: data.brand, terms: data.terms,
config: data.config, zona_horaria: data.zona_horaria, moneda: data.moneda,
};
},
async solicitar(identificador: string): Promise<Json> {
const { data } = await portalHttp.post('/acceso/solicitar', { identificador });
return data;
},
async verificar(identificador: string, codigo: string): Promise<Json> {
const { data } = await portalHttp.post('/acceso/verificar', { identificador, codigo });
return data;
},
async entrarEnlace(token: string): Promise<Json> {
const { data } = await portalHttp.post('/acceso/enlace', { token });
return data;
},
async entrarPassword(identificador: string, password: string): Promise<Json> {
const { data } = await portalHttp.post('/acceso/password', { identificador, password });
return data;
},
async sesion(): Promise<Json> {
const { data } = await portalHttp.get('/sesion');
return data;
},
async cerrarSesion(): Promise<Json> {
try {
const { data } = await portalHttp.post('/sesion/cerrar');
return data;
} catch {
return { status: 'success' };
}
},
async resumen(): Promise<Json> {
const { data } = await portalHttp.get('/resumen');
return data;
},
async citas(): Promise<{ proximas: PortalCita[]; pasadas: PortalCita[] }> {
const { data } = await portalHttp.get('/citas');
return { proximas: data.proximas || [], pasadas: data.pasadas || [] };
},
async disponibilidad(citaId: number, fecha: string): Promise<string[]> {
const { data } = await portalHttp.get('/citas/disponibilidad', {
params: { cita_id: citaId, fecha },
});
return data.slots || [];
},
async cancelarCita(citaId: number, motivo?: string): Promise<Json> {
const { data } = await portalHttp.post('/citas/cancelar', { cita_id: citaId, motivo });
return data;
},
async reagendar(citaId: number, fecha: string, hora: string): Promise<Json> {
const { data } = await portalHttp.post('/citas/reagendar', { cita_id: citaId, fecha, hora });
return data;
},
async pagos(): Promise<Json> {
const { data } = await portalHttp.get('/pagos');
return data;
},
async estadoCuenta(): Promise<Json> {
const { data } = await portalHttp.get('/estado-cuenta');
return data;
},
async monedero(): Promise<Json> {
const { data } = await portalHttp.get('/monedero');
return data;
},
async paquetes(): Promise<Json> {
const { data } = await portalHttp.get('/paquetes');
return data;
},
async membresias(): Promise<Json> {
const { data } = await portalHttp.get('/membresias');
return data;
},
async pagarMembresia(membresiaId: number, periodoId?: number): Promise<Json> {
const { data } = await portalHttp.post('/membresias/pagar', {
membresia_id: membresiaId, periodo_id: periodoId,
});
return data;
},
async formularios(): Promise<Json> {
const { data } = await portalHttp.get('/formularios');
return data;
},
async documentos(): Promise<Json> {
const { data } = await portalHttp.get('/documentos');
return data;
},
async perfil(): Promise<PortalPerfil> {
const { data } = await portalHttp.get('/perfil');
return data.perfil;
},
async guardarPerfil(vals: Partial<PortalPerfil>): Promise<Json> {
const { data } = await portalHttp.put('/perfil', vals);
return data;
},
async setPassword(password: string): Promise<Json> {
const { data } = await portalHttp.post('/perfil/password', { password });
return data;
},
};
/** Config del portal. `active=false` cuando está apagado o no responde. */
export const usePortalConfig = () => {
const [config, setConfig] = useState<PortalConfig | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let alive = true;
portalApi.config()
.then((res) => { if (alive) setConfig(res); })
.catch(() => { if (alive) setConfig(null); })
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, []);
return { config, loading, active: !!config };
};
/** Escucha el evento de cierre de sesión del portal (401 global). */
export const usePortalLogout = (onLogout: () => void) => {
const handler = useCallback(() => onLogout(), [onLogout]);
useEffect(() => {
window.addEventListener('portal:logout', handler);
return () => window.removeEventListener('portal:logout', handler);
}, [handler]);
};