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:
@@ -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`);
|
||||
|
||||
233
frontend/src/services/portal.ts
Normal file
233
frontend/src/services/portal.ts
Normal 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]);
|
||||
};
|
||||
Reference in New Issue
Block a user