diff --git a/docs/producto/configuracion-cliente.md b/docs/producto/configuracion-cliente.md index 8fbc67d..8708121 100644 --- a/docs/producto/configuracion-cliente.md +++ b/docs/producto/configuracion-cliente.md @@ -144,6 +144,58 @@ Dentro del mismo bloque, la llave `reservas_online.anticipo`. Con `activo = true pagado, el pago se marca como reembolsado y la cita como "Anticipo reembolsado". Con `false`, el anticipo se retiene. +## 7 bis. Cuenta del cliente (portal de autoservicio) + +**Configuración → Portal del cliente** (apagado por defecto) + +Hoy el cliente final solo llega por enlaces con token (reserva, pago, +formulario). Con este bloque se enciende una **cuenta propia** en `/mi-cuenta` +donde entra con su teléfono o correo, **sin contraseña**, y ve su información. +Apagado, nada cambia: el portal responde como deshabilitado y no aparece ninguna +ruta ni enlace nuevo (ni para el cliente ni para el personal). + +- **Acceso sin contraseña**: el cliente escribe su teléfono (o correo) y recibe + por el motor de notificaciones un **código de 6 dígitos** por WhatsApp o un + **enlace de acceso** por correo, según `metodo` (`otp_whatsapp` | `enlace_correo` + | `ambos`). El código se guarda **con hash**, caduca en 10 minutos, admite como + máximo 5 intentos y tiene límite de peticiones por IP y por teléfono/correo + (nadie puede probar códigos ni molestar a un cliente). El enlace de correo es + de un solo uso y caduca en 30 minutos. +- **Sesión propia del cliente**, separada de la del personal: token firmado con + alcance limitado al paciente identificado y revocable (cerrar sesión lo + invalida de inmediato; el personal puede cerrar las sesiones de un paciente). + Un token de cliente **no** funciona en los endpoints del personal, y uno del + personal **no** funciona en el portal. El portal nunca confía en un + identificador enviado por el navegador: el paciente siempre sale de la sesión. +- **Opciones** (defaults entre paréntesis): `activo` (no), `metodo` + (`otp_whatsapp`), `requerir_correo_verificado` (no), `permitir_contrasena` (no; + permite fijar contraseña tras entrar), `sesion_horas` (24), `permitir_reagendar` + (sí), `horas_minimas_cancelar` (24), `mostrar_precios` (sí), `mostrar_recetas` + (sí), `permitir_descargar_estado_cuenta` (sí) y `permitir_actualizar_datos` (sí). +- **Qué ve el cliente**: Mis citas (próximas/pasadas; cancelar respetando + `horas_minimas_cancelar` y reagendar reutilizando la disponibilidad real), + Mis pagos (historial, saldo pendiente y estado de cuenta imprimible), Mi + monedero (puntos y su equivalente en pesos y dólares), Mis paquetes (sesiones + incluidas/usadas/restantes y vigencia), Mis membresías (plan, vigencia, próximo + cobro, sesiones y adeudo, con botón para pagar el periodo pendiente por el + enlace de cobro existente), Mis formularios (pendientes y respondidos con su + firma), Mis documentos (recetas y estado de cuenta) y Mi perfil (datos de + contacto y fotografías; edición solo si `permitir_actualizar_datos`, y nunca + datos clínicos). +- **Para el personal**: en la ficha del paciente (expediente) hay un bloque para + **invitar al portal**, ver **si ya entró** (última entrada y sesiones abiertas) + y **cerrar sus sesiones**; en la pestaña de configuración hay un listado de + quién tiene acceso activo. Requiere los permisos nuevos `portal.invitar` y + `portal.sesiones` (por defecto: administración, gerencia y recepción). +- **Notificaciones**: usa dos plantillas nuevas del bloque de notificaciones, + `plantilla_codigo_acceso` y `plantilla_enlace_acceso` (con advertencia de no + compartir el código/enlace). En modo prueba del motor, la pantalla de acceso + muestra el código o el enlace en pantalla para poder probar sin enviar nada + real; con el modo prueba apagado nunca se expone. +- **Enlaces cruzados**: el portal de reservas (`/reservar`) enlaza a la cuenta y + la pantalla de acceso del personal muestra un aviso para clientes que llegan + ahí por error. + ## 8. Recursos (cabinas, salas y equipos) **Configuración → Recursos** (apagado por defecto) @@ -465,6 +517,7 @@ El panel es una **aplicación web instalable** (PWA), sin tienda de aplicaciones - [ ] Comisiones (reglas, metas y liquidaciones). - [ ] Formularios y consentimientos. - [ ] Reservas en línea (activar y difundir el enlace). +- [ ] Portal del cliente (si se ofrece cuenta): activar y difundir `/mi-cuenta`. - [ ] Notificaciones en modo prueba → revisar textos → encender. - [ ] Tickets: formato y secciones. - [ ] Licencia: aplicar la clave del plan contratado. @@ -490,6 +543,7 @@ Todas las fases están **entregadas**. | 3.4 | PWA instalable y branding por instancia | Entregado | | 3.5 | Licenciamiento por plan, API keys gestionables y webhooks | Entregado | | 3.6 | i18n español/inglés | Entregado | +| 3.7 | Cuenta del cliente (portal de autoservicio) | Entregado | | Operación | Backups automáticos verificados y panel/CLI multi-instancia | Entregado | Los pendientes y limitaciones conocidos (los que aún no están cerrados) viven en diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index efa9476..f0c814e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -44,6 +44,7 @@ import Reservar from './pages/Reservar'; import ReservarCita from './pages/ReservarCita'; import Pagar from './pages/Pagar'; import FormularioPublico from './pages/FormularioPublico'; +import MiCuenta from './pages/MiCuenta'; import './App.css'; const FullScreenLoader: FC = () => ( @@ -185,6 +186,10 @@ const App: FC = () => { } /> {/* Portal público de formularios: token HMAC como credencial. */} } /> + {/* Cuenta del cliente (portal de autoservicio): ruta pública, fuera + del gate del personal. Apagada por defecto en el backend. */} + } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/PatientDrawer.tsx b/frontend/src/components/PatientDrawer.tsx index d9fa17d..4c98bad 100644 --- a/frontend/src/components/PatientDrawer.tsx +++ b/frontend/src/components/PatientDrawer.tsx @@ -10,6 +10,7 @@ import { Badge, Button, Skeleton } from './ui'; import RecargarSaldoModal from './RecargarSaldoModal'; import ReciboPrint from './ReciboPrint'; import MembresiasPaciente from './MembresiasPaciente'; +import PortalPacienteAcceso from './PortalPacienteAcceso'; import { waLink } from '../lib/whatsapp'; import { badgeForAppointmentState, badgeForSaleState } from './ui'; @@ -320,6 +321,8 @@ const PatientDrawerPanel: FC<{ patientId: number; onClose: () => void }> = ({ pa <> {/* Membresías activas (solo si la clínica las tiene encendidas) */} + {/* Acceso al portal del cliente (solo si está activo) */} + {/* Contacto */}
diff --git a/frontend/src/components/PortalClientePanel.tsx b/frontend/src/components/PortalClientePanel.tsx new file mode 100644 index 0000000..9a05aa1 --- /dev/null +++ b/frontend/src/components/PortalClientePanel.tsx @@ -0,0 +1,213 @@ +import type { FC, ReactNode } from 'react'; +import { useEffect, useState } from 'react'; +import { Copy, ExternalLink, Save, UserCheck } from 'lucide-react'; +import { Button, Input, Select, toast } from './ui'; +import { + odooApi, PORTAL_CLIENTE_DEFAULT, + type ClinicSettings, type PortalAcceso, type PortalClienteSettings, +} from '../services/odoo'; +import { urlBasePublica } from '../lib/publicBooking'; +import { useI18n } from '../lib/i18n'; + +// Editor del bloque `portal_cliente` (cuenta de autoservicio del paciente) y +// listado de quién tiene acceso activo. Apagado por defecto: apagado, el portal +// responde deshabilitado y no aparece ninguna ruta ni enlace nuevo. + +interface Props { + settings: ClinicSettings; + onSaved: (settings: ClinicSettings) => void; +} + +const PortalClientePanel: FC = ({ settings, onSaved }) => { + const { t } = useI18n(); + const [form, setForm] = useState( + settings.portal_cliente || PORTAL_CLIENTE_DEFAULT); + const [saving, setSaving] = useState(false); + const [copiado, setCopiado] = useState(false); + const [accesos, setAccesos] = useState([]); + const [cargandoAccesos, setCargandoAccesos] = useState(true); + + useEffect(() => { + setForm(settings.portal_cliente || PORTAL_CLIENTE_DEFAULT); + }, [settings.portal_cliente]); + + useEffect(() => { + let alive = true; + odooApi.getPortalAccesos() + .then((res) => { if (alive && res.status === 'success') setAccesos(res.accesos || []); }) + .catch(() => { /* sin permiso o portal apagado: no se muestra la lista */ }) + .finally(() => { if (alive) setCargandoAccesos(false); }); + return () => { alive = false; }; + }, []); + + const set = (k: K, v: PortalClienteSettings[K]) => + setForm((prev) => ({ ...prev, [k]: v })); + + const enlace = `${urlBasePublica(settings.url_publica)}/mi-cuenta`; + + const guardar = async () => { + setSaving(true); + try { + const res = await odooApi.putClinicSettings({ + portal_cliente: form, + motivo: 'Portal del cliente desde Configuración', + }); + if (res.status === 'success') { + onSaved(res.settings); + toast.success(t('config.portal.saved')); + } + } catch (err) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || t('config.portal.saveError')); + } finally { + setSaving(false); + } + }; + + const copiar = async () => { + try { + await navigator.clipboard.writeText(enlace); + setCopiado(true); + setTimeout(() => setCopiado(false), 2000); + } catch { + toast.error(t('config.portal.saveError')); + } + }; + + const cerrarSesiones = async (partnerId: number) => { + try { + await odooApi.cerrarPortalSesiones(partnerId); + toast.success(t('config.portal.sessionsClosed')); + const res = await odooApi.getPortalAccesos(); + if (res.status === 'success') setAccesos(res.accesos || []); + } catch { + toast.error(t('config.portal.saveError')); + } + }; + + return ( +
+
+ +
+

{t('config.portal.title')}

+

{t('config.portal.help')}

+
+
+ + set('activo', v)} ayuda={t('config.portal.enableHelp')} /> + +
+ set('sesion_horas', Number(e.target.value) || 1)} /> +
+ +
+ set('requerir_correo_verificado', v)} + ayuda={t('config.portal.requireEmailHelp')} /> + set('permitir_contrasena', v)} + ayuda={t('config.portal.passwordHelp')} /> + set('permitir_reagendar', v)} /> + set('horas_minimas_cancelar', Number(e.target.value) || 0)} /> + set('mostrar_precios', v)} /> + set('mostrar_recetas', v)} /> + set('permitir_descargar_estado_cuenta', v)} /> + set('permitir_actualizar_datos', v)} /> +
+ +
+

{t('config.portal.linkTitle')}

+
+ + + +
+

{t('config.portal.linkHelp')}

+
+ +
+ +
+ +
+

{t('config.portal.accessTitle')}

+

{t('config.portal.accessHelp')}

+ {cargandoAccesos ? null : accesos.length === 0 ? ( +

{t('config.portal.accessNone')}

+ ) : ( +
+ + + + + + + + + + {accesos.map((a) => ( + + + + + + + ))} + +
{t('config.portal.colPatient')}{t('config.portal.colLast')}{t('config.portal.colSessions')} +
+ {a.paciente} + {a.telefono || a.email || ''} + {a.ultimo_acceso ? String(a.ultimo_acceso).slice(0, 16) : '—'}{a.sesiones_abiertas} + {a.sesiones_abiertas > 0 && ( + + )} +
+
+ )} +
+
+ ); +}; + +const Toggle: FC<{ label: string; checked: boolean; onChange: (v: boolean) => void; ayuda?: ReactNode }> = ({ + label, checked, onChange, ayuda, +}) => ( + +); + +export default PortalClientePanel; diff --git a/frontend/src/components/PortalPacienteAcceso.tsx b/frontend/src/components/PortalPacienteAcceso.tsx new file mode 100644 index 0000000..bf4ec37 --- /dev/null +++ b/frontend/src/components/PortalPacienteAcceso.tsx @@ -0,0 +1,97 @@ +import type { FC } from 'react'; +import { useEffect, useState } from 'react'; +import { UserCheck } from 'lucide-react'; +import { Button, toast } from './ui'; +import { odooApi, type PortalAcceso } from '../services/odoo'; +import { useAuth } from '../lib/auth'; +import { useI18n } from '../lib/i18n'; + +// Bloque de la ficha del paciente (expediente): invitar al portal, ver si ya +// entró y cerrar sus sesiones. Solo se pinta si el portal está activo y el +// usuario tiene el permiso `portal.invitar`. + +const PortalPacienteAcceso: FC<{ partnerId: number }> = ({ partnerId }) => { + const { t } = useI18n(); + const { can } = useAuth(); + const [activo, setActivo] = useState(false); + const [estado, setEstado] = useState(null); + const [busy, setBusy] = useState(false); + + const cargar = () => { + odooApi.getPortalPaciente(partnerId) + .then((res) => { + if (res.status === 'success') { setActivo(res.activo); setEstado(res.estado); } + }) + .catch(() => { /* sin permiso o portal apagado */ }); + }; + + useEffect(() => { cargar(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [partnerId]); + + if (!activo || !can('portal.invitar')) return null; + + const invitar = async () => { + setBusy(true); + try { + const res = await odooApi.invitarPortal(partnerId); + if (res.status === 'success') { + toast.success(t('portal.patient.invited')); + if (res.codigo_prueba) toast.info(t('portal.patient.testCode', { code: res.codigo_prueba })); + else if (res.enlace_prueba) toast.info(t('portal.patient.testLink', { url: res.enlace_prueba })); + cargar(); + } else { + toast.error(res.message || t('portal.patient.noPermission')); + } + } catch (err) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || t('portal.patient.noPermission')); + } finally { setBusy(false); } + }; + + const cerrar = async () => { + setBusy(true); + try { await odooApi.cerrarPortalSesiones(partnerId); toast.success(t('config.portal.sessionsClosed')); cargar(); } + catch { toast.error(t('portal.patient.noPermission')); } + finally { setBusy(false); } + }; + + const cambiar = async (accion: 'activar' | 'desactivar') => { + setBusy(true); + try { await odooApi.portalAccesoEstado(partnerId, accion); toast.success(t('portal.patient.updated')); cargar(); } + catch { toast.error(t('portal.patient.noPermission')); } + finally { setBusy(false); } + }; + + const tiene = !!estado?.tiene_acceso; + return ( +
+
+

+ {t('portal.patient.title')} +

+ + {tiene ? (estado?.activo ? t('config.portal.on') : t('config.portal.off')) : t('portal.patient.noAccess')} + +
+ {tiene && ( +
+ {t('portal.patient.lastAccess')}: {estado?.ultimo_acceso ? String(estado.ultimo_acceso).slice(0, 16) : '—'} + {t('portal.patient.sessions')}: {estado?.sesiones_abiertas ?? 0} +
+ )} +
+ + {tiene && estado?.sesiones_abiertas ? ( + + ) : null} + {tiene && ( + + )} +
+
+ ); +}; + +export default PortalPacienteAcceso; diff --git a/frontend/src/lib/clinic-settings.ts b/frontend/src/lib/clinic-settings.ts index 81e7a95..a7b08b9 100644 --- a/frontend/src/lib/clinic-settings.ts +++ b/frontend/src/lib/clinic-settings.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, COBRO_AUTOMATICO_DEFAULT, RESERVAS_ONLINE_DEFAULT, ANTICIPO_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT, COMISIONES_DEFAULT, LICENCIA_DEFAULT, LICENCIA_LIMITES_DEFAULT } from '../services/odoo'; +import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, COBRO_AUTOMATICO_DEFAULT, RESERVAS_ONLINE_DEFAULT, ANTICIPO_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT, COMISIONES_DEFAULT, LICENCIA_DEFAULT, LICENCIA_LIMITES_DEFAULT, PORTAL_CLIENTE_DEFAULT } from '../services/odoo'; // Ajustes de la clínica compartidos por la sesión (nombre, moneda e impuestos). // Se leen una sola vez del servidor y se avisa a quien los use cuando cambian. @@ -28,6 +28,7 @@ const normalizar = (settings?: Partial | null): ClinicSettings = ...(settings?.licencia || {}), limites: { ...LICENCIA_LIMITES_DEFAULT, ...((settings?.licencia || {}).limites || {}) }, }, + portal_cliente: { ...PORTAL_CLIENTE_DEFAULT, ...(settings?.portal_cliente || {}) }, }); let actual: ClinicSettings = normalizar(null); diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 1350484..82a3ecf 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -670,6 +670,173 @@ export const es: Dict = { 'config.cobroAuto.cancelHelp': 'Al agotar los reintentos, marca la membresía vencida. Apagado, sigue activa hasta el proceso normal de vencimiento.', 'config.cobroAuto.simulated': 'Permitir pago simulado', 'config.cobroAuto.simulatedHelp': 'Permite probar el flujo sin pasarela ni dinero real. Déjalo apagado en producción.', + // --- Portal del cliente (cuenta de autoservicio) --- + 'config.tabs.portal': 'Portal del cliente', + 'config.portal.title': 'Portal del cliente', + 'config.portal.help': 'Cuenta de autoservicio donde el cliente entra con su teléfono o correo, sin contraseña, y ve sus citas, pagos, monedero, paquetes, membresías, formularios y documentos. Apagado, nada cambia en el sistema.', + 'config.portal.enable': 'Activar el portal del cliente', + 'config.portal.enableHelp': 'Mientras esté apagado, el portal responde deshabilitado y no aparece ninguna ruta ni enlace nuevo.', + 'config.portal.method': 'Método de acceso', + 'config.portal.methodWhatsapp': 'Código por WhatsApp', + 'config.portal.methodEmail': 'Enlace por correo', + 'config.portal.methodBoth': 'Código o enlace (según el dato)', + 'config.portal.requireEmail': 'Exigir correo registrado', + 'config.portal.requireEmailHelp': 'Si el acceso es por WhatsApp, requiere que el paciente tenga correo.', + 'config.portal.password': 'Permitir contraseña', + 'config.portal.passwordHelp': 'El cliente puede fijar una contraseña después de entrar la primera vez.', + 'config.portal.sessionHours': 'Duración de la sesión (horas)', + 'config.portal.allowReschedule': 'Permitir reagendar citas', + 'config.portal.cancelHours': 'Horas mínimas para cancelar', + 'config.portal.showPrices': 'Mostrar importes', + 'config.portal.showRecipes': 'Mostrar recetas', + 'config.portal.allowStatement': 'Permitir descargar estado de cuenta', + 'config.portal.allowUpdate': 'Permitir actualizar sus datos', + 'config.portal.save': 'Guardar portal del cliente', + 'config.portal.on': 'Activo', + 'config.portal.off': 'Inactivo', + 'config.portal.saved': 'Portal del cliente guardado', + 'config.portal.saveError': 'No se pudo guardar el portal del cliente', + 'config.portal.linkTitle': 'Enlace del portal', + 'config.portal.copy': 'Copiar', + 'config.portal.copied': 'Copiado', + 'config.portal.open': 'Abrir', + 'config.portal.linkHelp': 'El enlace usa la URL pública configurada en «Clínica»; si está vacía, la dirección actual.', + 'config.portal.accessTitle': 'Accesos al portal', + 'config.portal.accessHelp': 'Pacientes con acceso, última entrada y sesiones abiertas.', + 'config.portal.accessNone': 'Aún no hay accesos al portal.', + 'config.portal.colPatient': 'Paciente', + 'config.portal.colLast': 'Última entrada', + 'config.portal.colSessions': 'Sesiones abiertas', + 'config.portal.closeSessions': 'Cerrar sesiones', + 'config.portal.sessionsClosed': 'Sesiones cerradas', + 'portal.patient.title': 'Portal del cliente', + 'portal.patient.invite': 'Invitar al portal', + 'portal.patient.invited': 'Invitación enviada', + 'portal.patient.noAccess': 'Sin acceso al portal', + 'portal.patient.lastAccess': 'Última entrada', + 'portal.patient.sessions': 'Sesiones abiertas', + 'portal.patient.activate': 'Reactivar acceso', + 'portal.patient.deactivate': 'Desactivar acceso', + 'portal.patient.updated': 'Acceso actualizado', + 'portal.patient.noPermission': 'No tienes permiso para gestionar el portal.', + 'portal.patient.testCode': 'Código de prueba: {code}', + 'portal.patient.testLink': 'Enlace de prueba: {url}', + 'portal.loading': 'Cargando…', + 'login.isClient': '¿Eres cliente? Entra a tu cuenta', + 'portal.unavailableTitle': 'Cuenta no disponible', + 'portal.unavailableBody': 'El portal de clientes no está activo. Si necesitas algo, comunícate con la clínica.', + 'portal.title': 'Mi cuenta', + 'portal.logout': 'Cerrar sesión', + 'portal.login.intro': 'Entra con tu teléfono o correo. Te enviamos un acceso, sin contraseña.', + 'portal.login.identifier': 'Teléfono o correo', + 'portal.login.identifierPh': 'Ej. 664 123 4567 o tu correo', + 'portal.login.send': 'Enviar acceso', + 'portal.login.sent': 'Si los datos están registrados, te enviamos tu acceso.', + 'portal.login.code': 'Código de 6 dígitos', + 'portal.login.codePh': '000000', + 'portal.login.enter': 'Entrar', + 'portal.login.usePassword': 'Entrar con contraseña', + 'portal.login.useCode': 'Prefiero recibir un código', + 'portal.login.password': 'Contraseña', + 'portal.login.enterPw': 'Entrar', + 'portal.login.wrongCode': 'El código no es válido o ya expiró.', + 'portal.login.genericError': 'No se pudo completar. Intenta de nuevo.', + 'portal.login.testHint': 'Modo de prueba: tu código es {code}', + 'portal.login.testLink': 'Modo de prueba: abre este enlace para entrar: {url}', + 'portal.tabs.resumen': 'Resumen', + 'portal.tabs.citas': 'Mis citas', + 'portal.tabs.pagos': 'Mis pagos', + 'portal.tabs.monedero': 'Mi monedero', + 'portal.tabs.paquetes': 'Mis paquetes', + 'portal.tabs.membresias': 'Mis membresías', + 'portal.tabs.formularios': 'Mis formularios', + 'portal.tabs.documentos': 'Mis documentos', + 'portal.tabs.perfil': 'Mi perfil', + 'portal.resumen.hello': 'Hola, {name}', + 'portal.resumen.upcoming': 'Próximas citas', + 'portal.resumen.balance': 'Saldo pendiente', + 'portal.resumen.points': 'Puntos', + 'portal.resumen.forms': 'Formularios pendientes', + 'portal.citas.upcoming': 'Próximas', + 'portal.citas.past': 'Pasadas', + 'portal.citas.none': 'No tienes citas.', + 'portal.citas.cancel': 'Cancelar', + 'portal.citas.reschedule': 'Reagendar', + 'portal.citas.confirmCancel': '¿Cancelar esta cita?', + 'portal.citas.cancelled': 'Cita cancelada.', + 'portal.citas.cancelError': 'No se pudo cancelar la cita.', + 'portal.citas.rescheduled': 'Cita reagendada.', + 'portal.citas.rescheduleError': 'No se pudo reagendar la cita.', + 'portal.citas.date': 'Elige la nueva fecha', + 'portal.citas.slot': 'Elige un horario disponible', + 'portal.citas.noSlots': 'No hay horarios disponibles ese día.', + 'portal.citas.service': 'Servicio', + 'portal.citas.doctor': 'Especialista', + 'portal.citas.branch': 'Sucursal', + 'portal.citas.duration': 'Duración', + 'portal.citas.amount': 'Importe', + 'portal.citas.minutes': 'min', + 'portal.citas.anticipo': 'Anticipo pendiente', + 'portal.pagos.payments': 'Pagos', + 'portal.pagos.sales': 'Compras (notas)', + 'portal.pagos.balance': 'Saldo pendiente', + 'portal.pagos.download': 'Descargar estado de cuenta', + 'portal.pagos.none': 'No hay movimientos.', + 'portal.monedero.points': 'Puntos disponibles', + 'portal.monedero.mxn': 'Equivalente en pesos', + 'portal.monedero.usd': 'Equivalente en dólares', + 'portal.monedero.none': 'Aún no tienes movimientos en tu monedero.', + 'portal.paquetes.total': 'Incluidas', + 'portal.paquetes.used': 'Usadas', + 'portal.paquetes.left': 'Restantes', + 'portal.paquetes.valid': 'Vigencia', + 'portal.paquetes.none': 'No tienes paquetes.', + 'portal.membresias.plan': 'Plan', + 'portal.membresias.valid': 'Vigencia', + 'portal.membresias.next': 'Próximo cobro', + 'portal.membresias.sessions': 'Sesiones restantes', + 'portal.membresias.unlimited': 'Ilimitadas', + 'portal.membresias.due': 'Periodo pendiente', + 'portal.membresias.pay': 'Pagar periodo', + 'portal.membresias.none': 'No tienes membresías.', + 'portal.membresias.payError': 'No se pudo generar el pago.', + 'portal.formularios.pending': 'Pendientes', + 'portal.formularios.done': 'Respondidos', + 'portal.formularios.open': 'Llenar', + 'portal.formularios.none': 'No tienes formularios.', + 'portal.formularios.signed': 'Con firma', + 'portal.documentos.recetas': 'Recetas', + 'portal.documentos.estado': 'Estado de cuenta', + 'portal.documentos.none': 'No hay documentos.', + 'portal.documentos.noAccess': 'La descarga del estado de cuenta no está disponible.', + 'portal.documentos.download': 'Descargar', + 'portal.perfil.name': 'Nombre', + 'portal.perfil.phone': 'Teléfono', + 'portal.perfil.email': 'Correo', + 'portal.perfil.birth': 'Fecha de nacimiento', + 'portal.perfil.occupation': 'Ocupación', + 'portal.perfil.address': 'Dirección', + 'portal.perfil.emergency': 'Contacto de emergencia', + 'portal.perfil.emergencyPhone': 'Teléfono de emergencia', + 'portal.perfil.save': 'Guardar cambios', + 'portal.perfil.saved': 'Datos actualizados.', + 'portal.perfil.error': 'No se pudieron guardar los datos.', + 'portal.perfil.photos': 'Mis fotografías', + 'portal.perfil.noPhotos': 'Sin fotografías.', + 'portal.perfil.readonly': 'La actualización de datos está desactivada. Pide cambios a la clínica.', + 'portal.perfil.passwordTitle': 'Contraseña', + 'portal.perfil.setPassword': 'Establecer contraseña', + 'portal.perfil.passwordSaved': 'Contraseña guardada.', + 'portal.estado.title': 'Estado de cuenta de {name}', + 'portal.estado.generated': 'Generado el {date}', + 'portal.estado.points': 'Puntos monedero', + 'portal.estado.spent': 'Total gastado', + 'portal.estado.due': 'Adeudo', + 'portal.estado.visits': 'Visitas', + 'portal.estado.print': 'Imprimir', + 'portal.common.currency': 'Moneda', + 'portal.common.status': 'Estado', + 'portal.common.date': 'Fecha', }; // ------------------------------------------------------------ @@ -1295,6 +1462,173 @@ export const en: Dict = { 'config.cobroAuto.cancelHelp': 'When retries are exhausted, marks the membership as expired. Off, it stays active until the normal expiration process.', 'config.cobroAuto.simulated': 'Allow simulated payment', 'config.cobroAuto.simulatedHelp': 'Lets you test the flow with no gateway and no real money. Keep it off in production.', + // --- Customer portal (self-service account) --- + 'config.tabs.portal': 'Customer portal', + 'config.portal.title': 'Customer portal', + 'config.portal.help': 'Self-service account where the customer signs in with their phone or email, no password, and sees their appointments, payments, wallet, packages, memberships, forms and documents. When off, nothing changes in the system.', + 'config.portal.enable': 'Enable the customer portal', + 'config.portal.enableHelp': 'While off, the portal responds as disabled and no new route or link appears.', + 'config.portal.method': 'Access method', + 'config.portal.methodWhatsapp': 'Code via WhatsApp', + 'config.portal.methodEmail': 'Link via email', + 'config.portal.methodBoth': 'Code or link (based on the detail)', + 'config.portal.requireEmail': 'Require a registered email', + 'config.portal.requireEmailHelp': 'For WhatsApp access, requires the patient to have an email.', + 'config.portal.password': 'Allow password', + 'config.portal.passwordHelp': 'The customer may set a password after their first sign-in.', + 'config.portal.sessionHours': 'Session length (hours)', + 'config.portal.allowReschedule': 'Allow rescheduling', + 'config.portal.cancelHours': 'Minimum hours to cancel', + 'config.portal.showPrices': 'Show amounts', + 'config.portal.showRecipes': 'Show prescriptions', + 'config.portal.allowStatement': 'Allow statement download', + 'config.portal.allowUpdate': 'Allow editing their details', + 'config.portal.save': 'Save customer portal', + 'config.portal.on': 'Active', + 'config.portal.off': 'Inactive', + 'config.portal.saved': 'Customer portal saved', + 'config.portal.saveError': 'The customer portal could not be saved', + 'config.portal.linkTitle': 'Portal link', + 'config.portal.copy': 'Copy', + 'config.portal.copied': 'Copied', + 'config.portal.open': 'Open', + 'config.portal.linkHelp': 'The link uses the public URL set in “Clinic”; if empty, the current address.', + 'config.portal.accessTitle': 'Portal access', + 'config.portal.accessHelp': 'Patients with access, last sign-in and open sessions.', + 'config.portal.accessNone': 'No portal access yet.', + 'config.portal.colPatient': 'Patient', + 'config.portal.colLast': 'Last sign-in', + 'config.portal.colSessions': 'Open sessions', + 'config.portal.closeSessions': 'Close sessions', + 'config.portal.sessionsClosed': 'Sessions closed', + 'portal.patient.title': 'Customer portal', + 'portal.patient.invite': 'Invite to portal', + 'portal.patient.invited': 'Invitation sent', + 'portal.patient.noAccess': 'No portal access', + 'portal.patient.lastAccess': 'Last sign-in', + 'portal.patient.sessions': 'Open sessions', + 'portal.patient.activate': 'Reactivate access', + 'portal.patient.deactivate': 'Disable access', + 'portal.patient.updated': 'Access updated', + 'portal.patient.noPermission': 'You do not have permission to manage the portal.', + 'portal.patient.testCode': 'Test code: {code}', + 'portal.patient.testLink': 'Test link: {url}', + 'portal.loading': 'Loading…', + 'login.isClient': 'Are you a customer? Go to your account', + 'portal.unavailableTitle': 'Account unavailable', + 'portal.unavailableBody': 'The customer portal is not active. If you need anything, please contact the clinic.', + 'portal.title': 'My account', + 'portal.logout': 'Sign out', + 'portal.login.intro': 'Sign in with your phone or email. We will send you an access link or code — no password needed.', + 'portal.login.identifier': 'Phone or email', + 'portal.login.identifierPh': 'e.g. 664 123 4567 or your email', + 'portal.login.send': 'Send access', + 'portal.login.sent': 'If your details are registered, we sent you access.', + 'portal.login.code': '6-digit code', + 'portal.login.codePh': '000000', + 'portal.login.enter': 'Sign in', + 'portal.login.usePassword': 'Sign in with password', + 'portal.login.useCode': 'I prefer a code', + 'portal.login.password': 'Password', + 'portal.login.enterPw': 'Sign in', + 'portal.login.wrongCode': 'The code is invalid or has expired.', + 'portal.login.genericError': 'Could not complete. Please try again.', + 'portal.login.testHint': 'Test mode: your code is {code}', + 'portal.login.testLink': 'Test mode: open this link to sign in: {url}', + 'portal.tabs.resumen': 'Overview', + 'portal.tabs.citas': 'My appointments', + 'portal.tabs.pagos': 'My payments', + 'portal.tabs.monedero': 'My wallet', + 'portal.tabs.paquetes': 'My packages', + 'portal.tabs.membresias': 'My memberships', + 'portal.tabs.formularios': 'My forms', + 'portal.tabs.documentos': 'My documents', + 'portal.tabs.perfil': 'My profile', + 'portal.resumen.hello': 'Hi, {name}', + 'portal.resumen.upcoming': 'Upcoming appointments', + 'portal.resumen.balance': 'Outstanding balance', + 'portal.resumen.points': 'Points', + 'portal.resumen.forms': 'Pending forms', + 'portal.citas.upcoming': 'Upcoming', + 'portal.citas.past': 'Past', + 'portal.citas.none': 'You have no appointments.', + 'portal.citas.cancel': 'Cancel', + 'portal.citas.reschedule': 'Reschedule', + 'portal.citas.confirmCancel': 'Cancel this appointment?', + 'portal.citas.cancelled': 'Appointment cancelled.', + 'portal.citas.cancelError': 'The appointment could not be cancelled.', + 'portal.citas.rescheduled': 'Appointment rescheduled.', + 'portal.citas.rescheduleError': 'The appointment could not be rescheduled.', + 'portal.citas.date': 'Choose the new date', + 'portal.citas.slot': 'Choose an available time', + 'portal.citas.noSlots': 'No times available that day.', + 'portal.citas.service': 'Service', + 'portal.citas.doctor': 'Specialist', + 'portal.citas.branch': 'Branch', + 'portal.citas.duration': 'Duration', + 'portal.citas.amount': 'Amount', + 'portal.citas.minutes': 'min', + 'portal.citas.anticipo': 'Deposit pending', + 'portal.pagos.payments': 'Payments', + 'portal.pagos.sales': 'Sales (notes)', + 'portal.pagos.balance': 'Outstanding balance', + 'portal.pagos.download': 'Download statement', + 'portal.pagos.none': 'No transactions.', + 'portal.monedero.points': 'Available points', + 'portal.monedero.mxn': 'Equivalent in pesos', + 'portal.monedero.usd': 'Equivalent in dollars', + 'portal.monedero.none': 'You have no wallet activity yet.', + 'portal.paquetes.total': 'Included', + 'portal.paquetes.used': 'Used', + 'portal.paquetes.left': 'Remaining', + 'portal.paquetes.valid': 'Valid until', + 'portal.paquetes.none': 'You have no packages.', + 'portal.membresias.plan': 'Plan', + 'portal.membresias.valid': 'Term', + 'portal.membresias.next': 'Next charge', + 'portal.membresias.sessions': 'Remaining sessions', + 'portal.membresias.unlimited': 'Unlimited', + 'portal.membresias.due': 'Pending period', + 'portal.membresias.pay': 'Pay period', + 'portal.membresias.none': 'You have no memberships.', + 'portal.membresias.payError': 'The payment could not be generated.', + 'portal.formularios.pending': 'Pending', + 'portal.formularios.done': 'Answered', + 'portal.formularios.open': 'Fill out', + 'portal.formularios.none': 'You have no forms.', + 'portal.formularios.signed': 'Signed', + 'portal.documentos.recetas': 'Prescriptions', + 'portal.documentos.estado': 'Account statement', + 'portal.documentos.none': 'No documents.', + 'portal.documentos.noAccess': 'Statement download is not available.', + 'portal.documentos.download': 'Download', + 'portal.perfil.name': 'Name', + 'portal.perfil.phone': 'Phone', + 'portal.perfil.email': 'Email', + 'portal.perfil.birth': 'Date of birth', + 'portal.perfil.occupation': 'Occupation', + 'portal.perfil.address': 'Address', + 'portal.perfil.emergency': 'Emergency contact', + 'portal.perfil.emergencyPhone': 'Emergency phone', + 'portal.perfil.save': 'Save changes', + 'portal.perfil.saved': 'Details updated.', + 'portal.perfil.error': 'The details could not be saved.', + 'portal.perfil.photos': 'My photos', + 'portal.perfil.noPhotos': 'No photos.', + 'portal.perfil.readonly': 'Editing is disabled. Ask the clinic for changes.', + 'portal.perfil.passwordTitle': 'Password', + 'portal.perfil.setPassword': 'Set password', + 'portal.perfil.passwordSaved': 'Password saved.', + 'portal.estado.title': 'Account statement for {name}', + 'portal.estado.generated': 'Generated on {date}', + 'portal.estado.points': 'Wallet points', + 'portal.estado.spent': 'Total spent', + 'portal.estado.due': 'Amount due', + 'portal.estado.visits': 'Visits', + 'portal.estado.print': 'Print', + 'portal.common.currency': 'Currency', + 'portal.common.status': 'Status', + 'portal.common.date': 'Date', }; export const DICT: Record = { es, en }; diff --git a/frontend/src/lib/permissions.ts b/frontend/src/lib/permissions.ts index 98b6959..e5bebfe 100644 --- a/frontend/src/lib/permissions.ts +++ b/frontend/src/lib/permissions.ts @@ -9,6 +9,7 @@ export const PERM_KEYS: string[] = [ 'recursos.ver', 'recursos.editar', 'pacientes.ver', 'pacientes.expediente', 'pacientes.crear', 'pacientes.editar', 'pacientes.eliminar', 'pacientes.exportar', 'pacientes.saldos', + 'portal.invitar', 'portal.sesiones', 'visitas.ver', 'visitas.crear', 'visitas.editar', 'visitas.eliminar', 'visitas.archivos', 'ventas.vender', 'ventas.cobrar', 'ventas.descuentos', 'ventas.impuestos', 'ventas.cancelar', 'ventas.devolver', 'ventas.reimprimir', @@ -64,6 +65,8 @@ const DEFAULT_PERM_ROLES: Record = { 'pacientes.ver': ALL, 'pacientes.expediente': ALL, 'pacientes.crear': W, 'pacientes.editar': W, 'pacientes.eliminar': W, 'pacientes.exportar': ALL, 'pacientes.saldos': ALL, + 'portal.invitar': ['admin', 'recepcion', 'gerencia'], + 'portal.sesiones': ['admin', 'recepcion', 'gerencia'], 'visitas.ver': ALL, 'visitas.crear': W, 'visitas.editar': W, 'visitas.eliminar': W, 'visitas.archivos': W, 'ventas.vender': MONEY, 'ventas.cobrar': WN, 'ventas.descuentos': MONEY, diff --git a/frontend/src/pages/Configuracion.tsx b/frontend/src/pages/Configuracion.tsx index 3ef1a44..dd14ae3 100644 --- a/frontend/src/pages/Configuracion.tsx +++ b/frontend/src/pages/Configuracion.tsx @@ -15,6 +15,7 @@ import CategoriasPanel from '../components/CategoriasPanel'; import RecursosPanel from '../components/RecursosPanel'; import TerminologiaPanel from '../components/TerminologiaPanel'; import ReservasOnlinePanel from '../components/ReservasOnlinePanel'; +import PortalClientePanel from '../components/PortalClientePanel'; import NotificacionesPanel from '../components/NotificacionesPanel'; import NotificacionesEnviadasPanel from '../components/NotificacionesEnviadasPanel'; import FormulariosPanel from '../components/FormulariosPanel'; @@ -34,6 +35,7 @@ import { LanguageSelect } from '../components/LanguageSelect'; const TABS = [ { key: 'clinica', label: 'Clínica' }, { key: 'reservas', label: 'Reservas en línea' }, + { key: 'portal', label: 'Portal del cliente' }, { key: 'notificaciones', label: 'Notificaciones' }, { key: 'notif_enviadas', label: 'Notificaciones enviadas' }, { key: 'formularios', label: 'Formularios' }, @@ -56,7 +58,7 @@ const TABS = [ ] as const; // Secciones que ve gerencia (admin del sistema ve todas). -const TABS_GERENCIA = ['reservas', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'comisiones', 'terminologia', 'usuarios', 'roles', 'tickets', 'licencia', 'webhooks']; +const TABS_GERENCIA = ['reservas', 'portal', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'comisiones', 'terminologia', 'usuarios', 'roles', 'tickets', 'licencia', 'webhooks']; const SUBCATALOGOS = [ { key: 'diagnosticos', label: 'Diagnósticos' }, @@ -450,6 +452,12 @@ const Configuracion: FC = () => { + ) : tab === 'portal' ? ( + + + + + ) : tab === 'notificaciones' ? ( diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index febed93..3287f5e 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -8,6 +8,7 @@ import { useAuth } from '../lib/auth'; import { useTheme } from '../lib/theme'; import { useI18n } from '../lib/i18n'; import { useBookingConfig } from '../lib/publicBooking'; +import { usePortalConfig } from '../services/portal'; const Login: FC = () => { const { login, brand, setBrand } = useAuth(); @@ -17,6 +18,8 @@ const Login: FC = () => { // Enlace público de reservas: solo se muestra si el portal está activo // (con reservas_online.activo=false el endpoint responde 404 y no aparece). const { active: reservasActivas } = useBookingConfig(); + // Aviso a clientes que caen en el acceso del personal: enlace a su cuenta. + const { active: portalActivo } = usePortalConfig(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [showPassword, setShowPassword] = useState(false); @@ -154,6 +157,18 @@ const Login: FC = () => {

)} + {portalActivo && ( +

+ + {t('login.isClient')} + +

+ )} +

{brand.pie}

diff --git a/frontend/src/pages/MiCuenta.tsx b/frontend/src/pages/MiCuenta.tsx new file mode 100644 index 0000000..1dab57f --- /dev/null +++ b/frontend/src/pages/MiCuenta.tsx @@ -0,0 +1,768 @@ +import type { FC, ReactNode } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { + AlertTriangle, Calendar, CreditCard, FileSignature, FileText, KeyRound, + Loader2, LogOut, Package, Printer, Sparkles, User, Wallet, +} from 'lucide-react'; +import { Button, Input, toast } from '../components/ui'; +import { useBusinessTerm, useI18n } from '../lib/i18n'; +import { + portalApi, portalToken, setPortalToken, usePortalConfig, usePortalLogout, + type PortalCita, type PortalConfig, type PortalPerfil, +} from '../services/portal'; + +const money = (n: number | null | undefined, moneda = 'MXN') => + n === null || n === undefined ? '—' : `${moneda} ${Number(n).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + +const fecha = (iso: string | null | undefined) => { + if (!iso) return '—'; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return String(iso).slice(0, 10); + return d.toLocaleDateString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' }); +}; + +// ============================================================ +// Página pública: cuenta del cliente (portal de autoservicio). +// Fuera del gate del personal. La sesión es propia (token del portal). +// ============================================================ + +const Card: FC<{ children: ReactNode; className?: string }> = ({ children, className = '' }) => ( +
{children}
+); + +const Centro: FC<{ children: ReactNode }> = ({ children }) => ( +
{children}
+); + +const MiCuenta: FC = () => { + const { t } = useI18n(); + const { config, loading, active } = usePortalConfig(); + const { token: linkToken } = useParams(); + const navigate = useNavigate(); + const [nombre, setNombre] = useState(null); + const [checking, setChecking] = useState(true); + const [bootingLink, setBootingLink] = useState(false); + + // Entrada por enlace de correo (/mi-cuenta/acceso/:token). + useEffect(() => { + if (!active || !linkToken) return; + let alive = true; + setBootingLink(true); + portalApi.entrarEnlace(linkToken) + .then((res) => { + if (!alive) return; + if (res.token) { + setPortalToken(res.token); + setNombre(res.paciente?.nombre || ''); + navigate('/mi-cuenta', { replace: true }); + } + }) + .catch(() => { /* enlace inválido: se cae al login */ }) + .finally(() => { if (alive) setBootingLink(false); }); + return () => { alive = false; }; + }, [active, linkToken, navigate]); + + // Sesión existente guardada en el navegador. + useEffect(() => { + if (!active || linkToken) { setChecking(false); return; } + if (!portalToken()) { setChecking(false); return; } + let alive = true; + portalApi.sesion() + .then((res) => { + if (!alive) return; + if (res.status === 'success') setNombre(res.paciente?.nombre || ''); + else setPortalToken(null); + }) + .catch(() => { if (alive) setPortalToken(null); }) + .finally(() => { if (alive) setChecking(false); }); + return () => { alive = false; }; + }, [active, linkToken]); + + usePortalLogout(() => setNombre(null)); + + const salir = useCallback(async () => { + await portalApi.cerrarSesion(); + setPortalToken(null); + setNombre(null); + }, []); + + if (loading || checking || bootingLink) { + return ; + } + + if (!active || !config) { + return ( + +
+ +

{t('portal.unavailableTitle')}

+

{t('portal.unavailableBody')}

+
+
+ ); + } + + if (nombre === null) { + return setNombre(n)} />; + } + + return ; +}; + +// ------------------------------------------------------------ +// Acceso (código o enlace, o contraseña) +// ------------------------------------------------------------ +const PortalLogin: FC<{ config: PortalConfig; onLoggedIn: (nombre: string) => void }> = ({ config, onLoggedIn }) => { + const { t } = useI18n(); + const [identificador, setIdentificador] = useState(''); + const [codigo, setCodigo] = useState(''); + const [password, setPassword] = useState(''); + const [modoPw, setModoPw] = useState(false); + const [enviado, setEnviado] = useState(false); + const [hint, setHint] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const ingresar = (res: { token?: string; paciente?: { nombre?: string } }) => { + if (res.token) { + setPortalToken(res.token); + onLoggedIn(res.paciente?.nombre || ''); + } else { + setError(t('portal.login.genericError')); + } + }; + + const enviar = async () => { + setError(null); + if (!identificador.trim()) { setError(t('portal.login.genericError')); return; } + setBusy(true); + try { + const res = await portalApi.solicitar(identificador.trim()); + setEnviado(true); + setHint(null); + if (res.codigo_prueba) setHint(t('portal.login.testHint', { code: res.codigo_prueba })); + else if (res.enlace_prueba) setHint(t('portal.login.testLink', { url: res.enlace_prueba })); + else setHint(res.message || t('portal.login.sent')); + } catch { + setError(t('portal.login.genericError')); + } finally { + setBusy(false); + } + }; + + const verificar = async () => { + setError(null); + setBusy(true); + try { + ingresar(await portalApi.verificar(identificador.trim(), codigo.trim())); + } catch { + setError(t('portal.login.wrongCode')); + } finally { + setBusy(false); + } + }; + + const entrarPw = async () => { + setError(null); + setBusy(true); + try { + ingresar(await portalApi.entrarPassword(identificador.trim(), password)); + } catch { + setError(t('portal.login.genericError')); + } finally { + setBusy(false); + } + }; + + const usaCodigo = config.config.metodo !== 'enlace_correo'; + + return ( + +
+
+ {config.brand?.logo_oscuro && ( + {config.brand.nombre} + )} +

{config.brand?.nombre || t('portal.title')}

+

{t('portal.title')}

+
+ +

{t('portal.login.intro')}

+ setIdentificador(e.target.value)} autoFocus /> + + {!modoPw && usaCodigo && ( +
+ +
+ )} + {!modoPw && !usaCodigo && ( + + )} + + {hint &&

{hint}

} + + {!modoPw && usaCodigo && enviado && ( +
+ setCodigo(e.target.value.replace(/\D/g, '').slice(0, 6))} /> + +
+ )} + + {modoPw && ( +
+ setPassword(e.target.value)} autoComplete="current-password" /> + +
+ )} + + {error &&

{error}

} + + {config.config.permitir_contrasena && ( + + )} +
+
+
+ ); +}; + +// ------------------------------------------------------------ +// Panel del cliente +// ------------------------------------------------------------ +type TabKey = 'resumen' | 'citas' | 'pagos' | 'monedero' | 'paquetes' | 'membresias' | 'formularios' | 'documentos' | 'perfil'; + +const PortalPanel: FC<{ config: PortalConfig; nombre: string; onLogout: () => void }> = ({ config, nombre, onLogout }) => { + const { t } = useI18n(); + const trm = useBusinessTerm(); + const [tab, setTab] = useState('resumen'); + + const TABS: { key: TabKey; label: string; icon: ReactNode }[] = [ + { key: 'resumen', label: t('portal.tabs.resumen'), icon: }, + { key: 'citas', label: t('portal.tabs.citas'), icon: }, + { key: 'pagos', label: t('portal.tabs.pagos'), icon: }, + { key: 'monedero', label: t('portal.tabs.monedero'), icon: }, + { key: 'paquetes', label: t('portal.tabs.paquetes'), icon: }, + { key: 'membresias', label: t('portal.tabs.membresias'), icon: }, + { key: 'formularios', label: t('portal.tabs.formularios'), icon: }, + { key: 'documentos', label: t('portal.tabs.documentos'), icon: }, + { key: 'perfil', label: t('portal.tabs.perfil'), icon: }, + ]; + + return ( +
+
+
+
+ {config.brand?.logo_oscuro && ( + + )} + {nombre} +
+ +
+ +
+
+ {tab === 'resumen' && } + {tab === 'citas' && } + {tab === 'pagos' && } + {tab === 'monedero' && } + {tab === 'paquetes' && } + {tab === 'membresias' && } + {tab === 'formularios' && } + {tab === 'documentos' && } + {tab === 'perfil' && } + {config.config.reservas_online && ( +

+ + {t('login.bookOnline')} + +

+ )} +
+
+ ); +}; + +const Loading: FC = () =>

{}

; +const Empty: FC<{ text: string }> = ({ text }) =>

{text}

; + +const useFetch = (fn: () => Promise) => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const reload = useCallback(() => { + setLoading(true); + fn().then(setData).catch(() => setData(null)).finally(() => setLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + useEffect(() => { reload(); }, [reload]); + return { data, loading, reload }; +}; + +const ResumenTab: FC = () => { + const { t } = useI18n(); + const { data, loading } = useFetch(() => portalApi.resumen()); + if (loading || !data) return ; + const items = [ + { label: t('portal.resumen.upcoming'), value: String(data.proximas_citas ?? 0) }, + { label: t('portal.resumen.balance'), value: money(data.adeudo) }, + { label: t('portal.resumen.points'), value: String(data.puntos ?? 0) }, + { label: t('portal.resumen.forms'), value: String(data.formularios_pendientes ?? 0) }, + ]; + return ( + <> +

{t('portal.resumen.hello', { name: data?.paciente?.nombre || '' })}

+
+ {items.map((it) => ( + +

{it.label}

+

{it.value}

+
+ ))} +
+ + ); +}; + +const CitasTab: FC<{ config: PortalConfig; trm: (k: 'appointment_singular' | 'doctor_singular' | 'branch_singular') => string }> = ({ config, trm }) => { + const { t } = useI18n(); + const { data, loading, reload } = useFetch(() => portalApi.citas()); + const [reagendando, setReagendando] = useState(null); + const [fechaNueva, setFechaNueva] = useState(''); + const [slots, setSlots] = useState([]); + const [hora, setHora] = useState(''); + const [busy, setBusy] = useState(false); + + const cancelar = async (c: PortalCita) => { + if (!window.confirm(t('portal.citas.confirmCancel'))) return; + setBusy(true); + try { + await portalApi.cancelarCita(c.id); + toast.success(t('portal.citas.cancelled')); + reload(); + } catch { + toast.error(t('portal.citas.cancelError')); + } finally { setBusy(false); } + }; + + const abrirReagenda = (c: PortalCita) => { + setReagendando(c); setFechaNueva(''); setSlots([]); setHora(''); + }; + + const cargarSlots = async (f: string) => { + setFechaNueva(f); setHora(''); + if (!f || !reagendando) return; + try { setSlots(await portalApi.disponibilidad(reagendando.id, f)); } + catch { setSlots([]); } + }; + + const guardar = async () => { + if (!reagendando || !fechaNueva || !hora) return; + setBusy(true); + try { + await portalApi.reagendar(reagendando.id, fechaNueva, hora); + toast.success(t('portal.citas.rescheduled')); + setReagendando(null); reload(); + } catch { + toast.error(t('portal.citas.rescheduleError')); + } finally { setBusy(false); } + }; + + if (loading || !data) return ; + + const CitaCard: FC<{ c: PortalCita }> = ({ c }) => ( + +
+
+

{c.servicio || trm('appointment_singular')}

+

{fecha(c.fecha)} · {c.hora} · {c.estado_label}

+
+ {c.folio} +
+
+ {c.doctor && {trm('doctor_singular')}: {c.doctor}} + {c.sucursal && {trm('branch_singular')}: {c.sucursal}} + {c.duracion_min > 0 && {t('portal.citas.duration')}: {c.duracion_min} {t('portal.citas.minutes')}} + {c.importe !== null && {t('portal.citas.amount')}: {money(c.importe, c.moneda)}} +
+ {c.anticipo_estado === 'pendiente' && ( +

{t('portal.citas.anticipo')}

+ )} + {(c.puede_cancelar || c.puede_reagendar) && ( +
+ {c.puede_reagendar && config.config.permitir_reagendar && ( + + )} + {c.puede_cancelar && ( + + )} +
+ )} + {reagendando?.id === c.id && ( +
+

{t('portal.citas.date')}

+ cargarSlots(e.target.value)} + className="w-full rounded-xl border border-theme-border bg-theme-bg px-3 py-2 text-sm text-theme-heading" /> + {fechaNueva && ( + slots.length === 0 ?

{t('portal.citas.noSlots')}

: ( +
+

{t('portal.citas.slot')}

+
+ {slots.map((s) => ( + + ))} +
+
+ ) + )} +
+ + +
+
+ )} +
+ ); + + return ( +
+

{t('portal.citas.upcoming')}

+ {data.proximas.length === 0 ? : data.proximas.map((c) => )} + {data.pasadas.length > 0 && ( + <> +

{t('portal.citas.past')}

+ {data.pasadas.map((c) => )} + + )} +
+ ); +}; + +const PagosTab: FC<{ config: PortalConfig }> = ({ config }) => { + const { t } = useI18n(); + const { data, loading } = useFetch(() => portalApi.pagos()); + if (loading || !data) return ; + return ( +
+ +

{t('portal.pagos.balance')}

+

{money(data.saldo_pendiente, data.moneda)}

+ {config.config.permitir_descargar_estado_cuenta && ( + + )} +
+

{t('portal.pagos.payments')}

+ {(data.pagos || []).length === 0 ? : (data.pagos || []).map((p: { id: number; folio: string; fecha: string; monto: number; moneda: string; metodo_label: string; estado: string }) => ( + +
+
+

{p.folio}

+

{fecha(p.fecha)} · {p.metodo_label}

+
+

{money(p.monto, p.moneda)}

+
+
+ ))} +

{t('portal.pagos.sales')}

+ {(data.ventas || []).length === 0 ? : (data.ventas || []).map((v: { id: number; folio: string; fecha: string; total: number | null; moneda: string; estado: string }) => ( + +
+
+

{v.folio}

+

{fecha(v.fecha)} · {v.estado}

+
+

{money(v.total, v.moneda)}

+
+
+ ))} +
+ ); +}; + +const MonederoTab: FC = () => { + const { t } = useI18n(); + const { data, loading } = useFetch(() => portalApi.monedero()); + if (loading || !data) return ; + return ( +
+
+ +

{t('portal.monedero.points')}

+

{data.points}

+
+ +

{t('portal.monedero.mxn')}

+

{money(data.equivalent_mxn)}

+
+ +

{t('portal.monedero.usd')}

+

{data.equivalent_usd === null ? '—' : `USD ${data.equivalent_usd}`}

+
+
+ {(data.movimientos || []).length === 0 ? : (data.movimientos || []).map((m: { id: number; fecha: string; puntos: number; descripcion: string }) => ( + +
+
+

{m.descripcion || '—'}

+

{fecha(m.fecha)}

+
+

= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>{m.puntos > 0 ? '+' : ''}{m.puntos}

+
+
+ ))} +
+ ); +}; + +const PaquetesTab: FC = () => { + const { t } = useI18n(); + const { data, loading } = useFetch(() => portalApi.paquetes()); + if (loading || !data) return ; + if ((data.paquetes || []).length === 0) return ; + return ( +
+ {(data.paquetes || []).map((p: { id: number; folio: string; paquete: string; servicio: string; sesiones_total: number; sesiones_usadas: number; sesiones_restantes: number; vigencia: string | null; estado: string }) => ( + +
+

{p.paquete || p.servicio}

+ {p.estado} +
+
+ {t('portal.paquetes.total')}: {p.sesiones_total} + {t('portal.paquetes.used')}: {p.sesiones_usadas} + {t('portal.paquetes.left')}: {p.sesiones_restantes} + {t('portal.paquetes.valid')}: {fecha(p.vigencia)} +
+
+ ))} +
+ ); +}; + +const MembresiasTab: FC = () => { + const { t } = useI18n(); + const { data, loading, reload } = useFetch(() => portalApi.membresias()); + const [busy, setBusy] = useState(null); + const pagar = async (id: number) => { + setBusy(id); + try { + const res = await portalApi.pagarMembresia(id); + if (res.url) window.location.href = res.url; + else toast.error(t('portal.membresias.payError')); + } catch { toast.error(t('portal.membresias.payError')); } + finally { setBusy(null); reload(); } + }; + if (loading || !data) return ; + if ((data.membresias || []).length === 0) return ; + return ( +
+ {(data.membresias || []).map((m: { + id: number; folio: string; plan: string; estado: string; proximo_cobro: string | null; + sesiones_restantes: number; ilimitadas: boolean; adeudo: number; moneda: string; + periodos_pendientes: { id: number; monto: number; moneda: string; estado: string }[]; + }) => ( + +
+

{m.plan}

+ {m.estado} +
+
+ {t('portal.membresias.next')}: {fecha(m.proximo_cobro)} + {t('portal.membresias.sessions')}: {m.ilimitadas ? t('portal.membresias.unlimited') : m.sesiones_restantes} + {t('portal.membresias.due')}: {money(m.adeudo, m.moneda)} +
+ {m.periodos_pendientes.length > 0 && ( + + )} +
+ ))} +
+ ); +}; + +const FormulariosTab: FC = () => { + const { t } = useI18n(); + const { data, loading } = useFetch(() => portalApi.formularios()); + if (loading || !data) return ; + return ( +
+

{t('portal.formularios.pending')}

+ {(data.pendientes || []).length === 0 ? : (data.pendientes || []).map((f: { id: number; folio: string; plantilla: string; enlace: string }) => ( + +
+
+

{f.plantilla}

+

{f.folio}

+
+ {t('portal.formularios.open')} +
+
+ ))} +

{t('portal.formularios.done')}

+ {(data.respondidos || []).length === 0 ? : (data.respondidos || []).map((f: { id: number; folio: string; plantilla: string; llenado_at: string; tiene_firma: boolean }) => ( + +

{f.plantilla}

+

{f.folio} · {fecha(f.llenado_at)}{f.tiene_firma ? ` · ${t('portal.formularios.signed')}` : ''}

+
+ ))} +
+ ); +}; + +const DocumentosTab: FC = () => { + const { t } = useI18n(); + const { data, loading } = useFetch(() => portalApi.documentos()); + if (loading || !data) return ; + return ( +
+

{t('portal.documentos.recetas')}

+ {(data.recetas || []).length === 0 ? : (data.recetas || []).map((r: { id: number; folio: string; fecha: string; contenido: string; servicio: string }) => ( + +

{r.servicio || r.folio}

+

{fecha(r.fecha)}

+

{r.contenido}

+
+ ))} +

{t('portal.documentos.estado')}

+ {data.estado_cuenta_disponible ? ( + + + + ) : } +
+ ); +}; + +const PerfilTab: FC = () => { + const { t } = useI18n(); + const { data, loading, reload } = useFetch(() => portalApi.perfil()); + const [form, setForm] = useState>({}); + const [busy, setBusy] = useState(false); + const [pw, setPw] = useState(''); + useEffect(() => { if (data) setForm(data); }, [data]); + if (loading || !data) return ; + const editable = data.puede_editar; + const set = (k: keyof PortalPerfil, v: string) => setForm((f) => ({ ...f, [k]: v })); + const guardar = async () => { + setBusy(true); + try { + await portalApi.guardarPerfil({ + telefono: form.telefono, email: form.email, birth_date: form.birth_date, + occupation: form.occupation, address_notes: form.address_notes, + emergency_contact: form.emergency_contact, emergency_phone: form.emergency_phone, + }); + toast.success(t('portal.perfil.saved')); reload(); + } catch { toast.error(t('portal.perfil.error')); } + finally { setBusy(false); } + }; + const guardarPw = async () => { + try { await portalApi.setPassword(pw); toast.success(t('portal.perfil.passwordSaved')); setPw(''); } + catch { toast.error(t('portal.perfil.error')); } + }; + return ( +
+ {!editable &&

{t('portal.perfil.readonly')}

} + +
+ + set('telefono', e.target.value)} /> + set('email', e.target.value)} /> + set('birth_date', e.target.value)} /> + set('occupation', e.target.value)} /> + set('emergency_contact', e.target.value)} /> + set('emergency_phone', e.target.value)} /> + set('address_notes', e.target.value)} /> +
+ {editable && } +
+ +

{t('portal.perfil.photos')}

+ {data.fotografias.length === 0 ?

{t('portal.perfil.noPhotos')}

: ( +
+ {data.fotografias.map((f) => ( + {f.nombre || f.clasificacion} + ))} +
+ )} +
+ +

{t('portal.perfil.passwordTitle')}

+
+ setPw(e.target.value)} /> + +
+
+
+ ); +}; + +// Estado de cuenta imprimible (sin dependencias: abre una ventana y llama print). +const imprimirEstadoCuenta = (data: { + status: string; paciente: { nombre: string; patient_id: string; telefono: string; email: string }; + generado: string; moneda: string; + estado_cuenta: { puntos_monedero: number; total_gastado: number; adeudo: number; visitas_total: number }; + pagos: { folio: string; fecha: string; monto: number; moneda: string; metodo_label: string }[]; +}) => { + if (!data || data.status !== 'success') return; + const ec = data.estado_cuenta; + const filas = (data.pagos || []).map((p) => + `${p.fecha ? String(p.fecha).slice(0, 10) : ''}${p.folio}${p.metodo_label}${p.moneda} ${Number(p.monto).toFixed(2)}`).join(''); + const html = `Estado de cuenta + +

Estado de cuenta

+

${data.paciente.nombre} · ${data.paciente.patient_id || ''}
${data.paciente.telefono || ''} · ${data.paciente.email || ''}

+

Generado: ${String(data.generado).slice(0, 16)}

+
Puntos monedero${ec.puntos_monedero}
+
Total gastado${data.moneda} ${Number(ec.total_gastado).toFixed(2)}
+
Visitas${ec.visitas_total}
+
Adeudo${data.moneda} ${Number(ec.adeudo).toFixed(2)}
+ ${filas}
FechaFolioMétodoMonto
+ `; + const w = window.open('', '_blank'); + if (w) { w.document.write(html); w.document.close(); } +}; + +export default MiCuenta; diff --git a/frontend/src/pages/Reservar.tsx b/frontend/src/pages/Reservar.tsx index 0543af7..54a3df0 100644 --- a/frontend/src/pages/Reservar.tsx +++ b/frontend/src/pages/Reservar.tsx @@ -11,6 +11,7 @@ import { descargarIcs, normalizarTelefono, telefonoValido, } from '../lib/publicBooking'; import { useI18n, GLOSARIO_EN } from '../lib/i18n'; +import { usePortalConfig } from '../services/portal'; // Portal público de reservas en línea (sin sesión). Mobile-first: la mayoría // de las clientas reservan del celular. Respeta brand y glosario del backend. @@ -21,6 +22,7 @@ type GrupoServicios = { code: string; nombre: string; servicios: ServicioPortal[ const Reservar: FC = () => { const navigate = useNavigate(); const { config, loading } = useBookingConfig(); + const { active: portalActivo } = usePortalConfig(); const { t, locale } = useI18n(); const [paso, setPaso] = useState(0); @@ -452,6 +454,13 @@ const Reservar: FC = () => {

{config.brand.nombre}

+ {portalActivo && ( +

+ + {t('login.isClient')} + +

+ )}
); diff --git a/frontend/src/services/odoo.ts b/frontend/src/services/odoo.ts index 4ca1d43..779525d 100644 --- a/frontend/src/services/odoo.ts +++ b/frontend/src/services/odoo.ts @@ -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 { const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/licencia`); diff --git a/frontend/src/services/portal.ts b/frontend/src/services/portal.ts new file mode 100644 index 0000000..e0aaa5c --- /dev/null +++ b/frontend/src/services/portal.ts @@ -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).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 { + 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 { + const { data } = await portalHttp.post('/acceso/solicitar', { identificador }); + return data; + }, + async verificar(identificador: string, codigo: string): Promise { + const { data } = await portalHttp.post('/acceso/verificar', { identificador, codigo }); + return data; + }, + async entrarEnlace(token: string): Promise { + const { data } = await portalHttp.post('/acceso/enlace', { token }); + return data; + }, + async entrarPassword(identificador: string, password: string): Promise { + const { data } = await portalHttp.post('/acceso/password', { identificador, password }); + return data; + }, + async sesion(): Promise { + const { data } = await portalHttp.get('/sesion'); + return data; + }, + async cerrarSesion(): Promise { + try { + const { data } = await portalHttp.post('/sesion/cerrar'); + return data; + } catch { + return { status: 'success' }; + } + }, + async resumen(): Promise { + 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 { + const { data } = await portalHttp.get('/citas/disponibilidad', { + params: { cita_id: citaId, fecha }, + }); + return data.slots || []; + }, + async cancelarCita(citaId: number, motivo?: string): Promise { + const { data } = await portalHttp.post('/citas/cancelar', { cita_id: citaId, motivo }); + return data; + }, + async reagendar(citaId: number, fecha: string, hora: string): Promise { + const { data } = await portalHttp.post('/citas/reagendar', { cita_id: citaId, fecha, hora }); + return data; + }, + async pagos(): Promise { + const { data } = await portalHttp.get('/pagos'); + return data; + }, + async estadoCuenta(): Promise { + const { data } = await portalHttp.get('/estado-cuenta'); + return data; + }, + async monedero(): Promise { + const { data } = await portalHttp.get('/monedero'); + return data; + }, + async paquetes(): Promise { + const { data } = await portalHttp.get('/paquetes'); + return data; + }, + async membresias(): Promise { + const { data } = await portalHttp.get('/membresias'); + return data; + }, + async pagarMembresia(membresiaId: number, periodoId?: number): Promise { + const { data } = await portalHttp.post('/membresias/pagar', { + membresia_id: membresiaId, periodo_id: periodoId, + }); + return data; + }, + async formularios(): Promise { + const { data } = await portalHttp.get('/formularios'); + return data; + }, + async documentos(): Promise { + const { data } = await portalHttp.get('/documentos'); + return data; + }, + async perfil(): Promise { + const { data } = await portalHttp.get('/perfil'); + return data.perfil; + }, + async guardarPerfil(vals: Partial): Promise { + const { data } = await portalHttp.put('/perfil', vals); + return data; + }, + async setPassword(password: string): Promise { + 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(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]); +}; diff --git a/odoo-addons/skeen_citas/clinic_settings.py b/odoo-addons/skeen_citas/clinic_settings.py index 9590ab0..ed7092f 100644 --- a/odoo-addons/skeen_citas/clinic_settings.py +++ b/odoo-addons/skeen_citas/clinic_settings.py @@ -362,6 +362,111 @@ def get_anticipo(env): """Sub-bloque `anticipo` vigente (copia). Apagado por defecto.""" return get_reservas_online(env)['anticipo'] +# --------------------------------------------------------------------------- +# Portal del cliente (cuenta de autoservicio del paciente). +# +# Bloque anidado del mismo JSON de ajustes, igual que `impuestos` y +# `reservas_online`. La regla de oro: con `activo=False` (default) NADA cambia +# en la instancia — el portal público responde `activo=false`/404, el frontend +# no muestra ninguna ruta ni enlace nuevo y las tablas propias quedan en cero. +# Por eso el default es apagado. +# +# activo: enciende TODO el portal del cliente +# metodo: otp_whatsapp | enlace_correo | ambos +# requerir_correo_verificado: exige correo registrado para acceder +# permitir_contrasena: permite fijar contraseña tras entrar +# sesion_horas: vigencia de la sesión del cliente (horas) +# permitir_reagendar: permite reagendar citas en línea +# horas_minimas_cancelar: horas de antelación para cancelar en línea +# mostrar_precios: muestra importes en citas y ventas +# mostrar_recetas: muestra recetas en el portal +# permitir_descargar_estado_cuenta: permite descargar el estado de cuenta +# permitir_actualizar_datos: permite al cliente editar sus datos +# --------------------------------------------------------------------------- +PORTAL_CLIENTE_DEFAULT = { + '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, +} + +PORTAL_METODOS = ('otp_whatsapp', 'enlace_correo', 'ambos') +PORTAL_SESION_HORAS_MIN, PORTAL_SESION_HORAS_MAX = 1, 720 +PORTAL_CANCELAR_HORAS_MAX = 168 # 1 semana +PORTAL_BOOLEANOS = ( + 'activo', 'requerir_correo_verificado', 'permitir_contrasena', + 'permitir_reagendar', 'mostrar_precios', 'mostrar_recetas', + 'permitir_descargar_estado_cuenta', 'permitir_actualizar_datos', +) + + +def normalizar_portal_cliente(raw): + """Bloque `portal_cliente` completo, con defaults. Tolerante: nunca lanza.""" + out = dict(PORTAL_CLIENTE_DEFAULT) + if not isinstance(raw, dict): + return out + for k in PORTAL_BOOLEANOS: + out[k] = _bool(raw.get(k), PORTAL_CLIENTE_DEFAULT[k]) + metodo = str(raw.get('metodo') or '').strip() + out['metodo'] = metodo if metodo in PORTAL_METODOS else PORTAL_CLIENTE_DEFAULT['metodo'] + out['sesion_horas'] = min(max( + _int(raw.get('sesion_horas'), PORTAL_CLIENTE_DEFAULT['sesion_horas']), + PORTAL_SESION_HORAS_MIN), PORTAL_SESION_HORAS_MAX) + out['horas_minimas_cancelar'] = min(max( + _int(raw.get('horas_minimas_cancelar'), + PORTAL_CLIENTE_DEFAULT['horas_minimas_cancelar']), + 0), PORTAL_CANCELAR_HORAS_MAX) + return out + + +def validar_portal_cliente(raw): + """Valida el bloque `portal_cliente` de un PUT. Lanza ValueError si algo no + cuadra (se traduce a 400). Devuelve SOLO las claves presentes y normalizadas + para que el merge anidado no reinicie lo que el PUT no tocó.""" + if raw is None: + return None + if not isinstance(raw, dict): + raise ValueError('El bloque del portal del cliente debe ser un objeto') + out = {} + for k in PORTAL_BOOLEANOS: + if k in raw: + if not isinstance(raw[k], bool): + raise ValueError('El campo %s debe ser verdadero o falso' % k) + out[k] = raw[k] + if 'metodo' in raw: + metodo = str(raw['metodo'] or '').strip() + if metodo not in PORTAL_METODOS: + raise ValueError("El método debe ser 'otp_whatsapp', 'enlace_correo' o 'ambos'") + out['metodo'] = metodo + if 'sesion_horas' in raw: + if isinstance(raw['sesion_horas'], bool) or not isinstance(raw['sesion_horas'], int): + raise ValueError('Las horas de sesión deben ser un número entero') + if not (PORTAL_SESION_HORAS_MIN <= raw['sesion_horas'] <= PORTAL_SESION_HORAS_MAX): + raise ValueError('Las horas de sesión deben estar entre %d y %d' + % (PORTAL_SESION_HORAS_MIN, PORTAL_SESION_HORAS_MAX)) + out['sesion_horas'] = raw['sesion_horas'] + if 'horas_minimas_cancelar' in raw: + if isinstance(raw['horas_minimas_cancelar'], bool) or \ + not isinstance(raw['horas_minimas_cancelar'], int): + raise ValueError('Las horas mínimas para cancelar deben ser un número entero') + if not (0 <= raw['horas_minimas_cancelar'] <= PORTAL_CANCELAR_HORAS_MAX): + raise ValueError('Las horas mínimas para cancelar deben estar entre 0 y %d' + % PORTAL_CANCELAR_HORAS_MAX) + out['horas_minimas_cancelar'] = raw['horas_minimas_cancelar'] + return out + + +def get_portal_cliente(env): + """Bloque del portal del cliente vigente (copia). Apagado por defecto.""" + return normalizar_portal_cliente(get_settings(env).get('portal_cliente')) + # --------------------------------------------------------------------------- # Recordatorios y notificaciones automáticas de citas. # @@ -427,6 +532,16 @@ NOTIFICACIONES_DEFAULT = { 'Hola {nombre}, tu plan {plan} ({periodo}) está por renovarse. ' 'Paga {monto} desde este enlace: {enlace}' ), + # Acceso a la cuenta del cliente (portal). El código y el enlace caducan: + # se advierte de no compartirlos. Ver `skeen_portal_cliente`. + 'plantilla_codigo_acceso': ( + 'Hola {nombre}, tu código para entrar a tu cuenta es {codigo}. Vence en ' + '{minutos} minutos. Por tu seguridad, no compartas este código con nadie.' + ), + 'plantilla_enlace_acceso': ( + 'Hola {nombre}, entra a tu cuenta desde este enlace: {enlace}. ' + 'No compartas este enlace con nadie.' + ), 'whatsapp_numero': '', 'email_remitente': '', } @@ -435,11 +550,14 @@ NOTIF_CANALES = ('whatsapp', 'email', 'ambos') NOTIF_VARIABLES = ('nombre', 'cita', 'servicio', 'fecha', 'hora', 'doctor', 'sucursal', 'telefono', 'enlace', 'formulario', # Variables del aviso de membresías. - 'plan', 'membresia', 'monto', 'sesiones', 'periodo') + 'plan', 'membresia', 'monto', 'sesiones', 'periodo', + # Variables del acceso a la cuenta del cliente (portal). + 'codigo', 'minutos') NOTIF_PLANTILLAS = ('plantilla_recordatorio', 'plantilla_confirmacion', 'plantilla_cumpleanos', 'plantilla_post_visita', 'plantilla_formulario', 'plantilla_renovacion_membresia', - 'plantilla_cobro_membresia') + 'plantilla_cobro_membresia', 'plantilla_codigo_acceso', + 'plantilla_enlace_acceso') NOTIF_HORAS_MAX = 168 # 1 semana NOTIF_PLANTILLA_MAX = 1000 # caracteres por plantilla NOTIF_TEXTO_MAX = 120 # whatsapp_numero / email_remitente @@ -1498,6 +1616,7 @@ def get_settings(env, aplicar_licencia=True): 'compras': dict(COMPRAS_DEFAULT), 'comisiones': dict(COMISIONES_DEFAULT), 'licencia': dict(LICENCIA_DEFAULT, limites=dict(LICENCIA_DEFAULT['limites'])), + 'portal_cliente': normalizar_portal_cliente(None), } raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY) if raw: @@ -1517,6 +1636,8 @@ def get_settings(env, aplicar_licencia=True): out['compras'] = normalizar_compras(guardado.get('compras')) out['comisiones'] = normalizar_comisiones(guardado.get('comisiones')) out['licencia'] = normalizar_licencia(guardado.get('licencia')) + out['portal_cliente'] = normalizar_portal_cliente( + guardado.get('portal_cliente')) if aplicar_licencia: _aplicar_licencia(out) return out @@ -1570,6 +1691,11 @@ def set_settings(env, vals): if bloque is not None: # Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto. actual['comisiones'] = {**actual['comisiones'], **bloque} + if 'portal_cliente' in vals: + bloque = validar_portal_cliente(vals['portal_cliente']) + if bloque is not None: + # Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto. + actual['portal_cliente'] = {**actual['portal_cliente'], **bloque} if 'licencia' in vals: bloque = validar_licencia(vals['licencia']) if bloque is not None: diff --git a/odoo-addons/skeen_portal_cliente/__init__.py b/odoo-addons/skeen_portal_cliente/__init__.py new file mode 100644 index 0000000..3b38916 --- /dev/null +++ b/odoo-addons/skeen_portal_cliente/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +from . import models +from . import controllers diff --git a/odoo-addons/skeen_portal_cliente/__manifest__.py b/odoo-addons/skeen_portal_cliente/__manifest__.py new file mode 100644 index 0000000..7ca818f --- /dev/null +++ b/odoo-addons/skeen_portal_cliente/__manifest__.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'SKEEN Portal del cliente (cuenta de autoservicio)', + 'version': '1.0.0', + 'category': 'Healthcare', + 'summary': 'Cuenta del cliente: acceso sin contraseña, citas, pagos, monedero, paquetes, membresías, formularios y documentos', + 'description': """ + Portal de autoservicio del paciente final, APAGADO por defecto. + + - Acceso sin contraseña: código de 6 dígitos por WhatsApp o enlace de + acceso por correo, usando el motor de notificaciones (modo prueba). + - Sesión propia del cliente (token HMAC revocable), separada de la del + personal: un token de cliente no sirve en los endpoints del personal y + viceversa. El alcance queda fijado al paciente identificado. + - El cliente ve sus citas (cancelar/reagendar), pagos y estado de cuenta, + monedero, paquetes, membresías, formularios y documentos. + - Para el personal: invitar al portal, ver accesos activos y cerrar + sesiones, con permiso propio (`portal.invitar` / `portal.sesiones`). + + La regla de oro: con `portal_cliente.activo=False` (default) TODO + responde 404/activo=false y no aparece ninguna ruta ni enlace nuevo. + """, + 'author': 'Consultoria Alcaraz Salazar, S.A.S.', + 'website': 'https://skeen.mx', + 'depends': [ + 'base', 'skeen_citas', 'skeen_pacientes', 'skeen_whatsapp', + 'skeen_cobros', 'skeen_membresias', 'skeen_monedero', 'skeen_ventas', + 'skeen_pagos', 'skeen_formularios', 'skeen_visitas', + ], + 'data': [ + 'security/ir.model.access.csv', + 'data/portal_cron.xml', + ], + 'installable': True, + 'application': False, + 'auto_install': False, + 'license': 'LGPL-3', +} diff --git a/odoo-addons/skeen_portal_cliente/controllers/__init__.py b/odoo-addons/skeen_portal_cliente/controllers/__init__.py new file mode 100644 index 0000000..9312447 --- /dev/null +++ b/odoo-addons/skeen_portal_cliente/controllers/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +from . import public_portal +from . import staff_portal diff --git a/odoo-addons/skeen_portal_cliente/controllers/public_portal.py b/odoo-addons/skeen_portal_cliente/controllers/public_portal.py new file mode 100644 index 0000000..ead8a7a --- /dev/null +++ b/odoo-addons/skeen_portal_cliente/controllers/public_portal.py @@ -0,0 +1,1225 @@ +# -*- coding: utf-8 -*- +"""Portal público de la cuenta del cliente (autoservicio del paciente). + +Endpoints anónimos bajo `/skeen/frontend/v1/public/portal`. Sirven al portal +React de `/mi-cuenta`, que es una ruta PÚBLICA (fuera del gate del personal). + +Reglas de oro: + - Con `portal_cliente.activo=False` (default) TODO responde `activo=false` + (config) o 404 (resto): nada cambia en la instancia ni se expone información. + - La sesión del cliente es un token HMAC revocable (ver `skeen.portal.service`). + El paciente SIEMPRE sale de la sesión: ningún endpoint acepta un id de + paciente enviado por el navegador para leer datos ajenos. + - Límite de peticiones por IP y por teléfono/correo, y máximo de códigos por + paciente/hora, para que nadie pruebe códigos ni moleste a un cliente. +""" + +import json +import logging +import re +import threading +import time +from datetime import datetime, time as dtime, timedelta + +import pytz + +from odoo import fields, http +from odoo.http import request, Response + +from odoo.addons.skeen_citas.clinic_settings import ( + get_settings as get_clinic_settings, + get_reservas_online, + brand_dict, +) +from odoo.addons.skeen_citas.glosario import resolve as resolver_glosario +from odoo.addons.skeen_whatsapp.controllers import public_booking as _pb + +_logger = logging.getLogger(__name__) + +PREFIX = '/skeen/frontend/v1/public/portal' + +# Buckets propios dentro del rate limiter compartido del portal público. +_pb.RATE_LIMITS.update({ + 'portal_config': (60, 60), + 'portal_solicitar': (10, 60), + 'portal_verificar': (20, 60), + 'portal_enlace': (20, 60), + 'portal_datos': (180, 60), + 'portal_accion': (30, 60), +}) + +# Límite por identificador (teléfono/correo), independiente de la IP: evita que +# alguien moleste a un mismo cliente desde varias IP. +_ID_LOCK = threading.Lock() +_ID_HITS = {} +_ID_LIMITS = {'solicitar': (5, 3600), 'verificar': (15, 3600)} + +_ERROR_INTERNO = 'No se pudo procesar la solicitud. Intenta de nuevo.' +_MSG_GENERICO = 'Si los datos están registrados, te enviamos tu acceso.' +_MSG_CODIGO = 'El código no es válido o ya expiró.' +_MSG_SESION = 'Tu sesión no es válida o expiró. Vuelve a entrar.' + + +def _json(data, status=200): + return Response( + json.dumps(data, default=str), + status=status, + mimetype='application/json', + headers={ + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + }, + ) + + +def _client_ip(): + xff = request.httprequest.headers.get('X-Forwarded-For') or '' + if xff: + return xff.split(',')[0].strip() + return request.httprequest.remote_addr or 'desconocida' + + +def _ident_limited(bucket, identificador): + """True si el identificador agotó su cupo. Registra el hit.""" + limite, ventana = _ID_LIMITS.get(bucket, (10, 3600)) + clave = _pb._normaliza_telefono(identificador) or str(identificador or '').strip().lower() + if not clave: + return False + ahora = time.time() + with _ID_LOCK: + hits = [t for t in _ID_HITS.get((bucket, clave), []) if ahora - t < ventana] + if len(hits) >= limite: + _ID_HITS[(bucket, clave)] = hits + return True + hits.append(ahora) + _ID_HITS[(bucket, clave)] = hits + return False + + +def _body(): + try: + return json.loads(request.httprequest.get_data(as_text=True) or '{}') + except Exception: + return {} + + +def _tz(settings): + nombre = (settings.get('zona_horaria') or 'America/Tijuana').strip() + try: + return pytz.timezone(nombre) + except Exception: + return pytz.timezone('America/Tijuana') + + +def _iso(valor): + return valor.isoformat() if valor else None + + +class SkeenPortalPublicoController(http.Controller): + """Cuenta del cliente: acceso y datos del paciente con sesión propia.""" + + # ------------------------------------------------------------------ + # Infra interna + # ------------------------------------------------------------------ + def _svc(self): + return request.env['skeen.portal.service'].sudo() + + def _cfg(self): + return self._svc().cfg() + + def _activo(self): + try: + return self._svc().activo() + except Exception: + return False + + def _deshabilitado(self): + return _json({'status': 'error', 'message': 'Portal no disponible'}, 404) + + def _settings(self): + return get_clinic_settings(request.env) + + def _brand(self): + guardado = brand_dict(request.env) + out = { + 'nombre': self._settings().get('nombre') or 'Clínica', + 'logo_claro': '/brand/logo-blanco.svg', + 'logo_oscuro': '/brand/logo-negro.svg', + 'colores': {'primario': '', 'acento': ''}, + } + if isinstance(guardado, dict): + if guardado.get('nombre'): + out['nombre'] = str(guardado['nombre']) + for k in ('logo_claro', 'logo_oscuro'): + if guardado.get(k): + out[k] = str(guardado[k]) + colores = guardado.get('colores') + if isinstance(colores, dict): + for c in ('primario', 'acento'): + if colores.get(c): + out['colores'][c] = str(colores[c]) + return out + + def _token_request(self): + auth = request.httprequest.headers.get('Authorization', '') or '' + if auth.lower().startswith('bearer '): + return auth[7:].strip() + return request.httprequest.args.get('token') or '' + + def _auth(self): + """Devuelve (partner, sesion, error_response).""" + if not self._activo(): + return None, None, self._deshabilitado() + partner, sesion, error = self._svc().verificar_sesion(self._token_request()) + if error: + return None, None, _json({'status': 'error', 'message': _MSG_SESION}, 401) + return partner, sesion, None + + def _config_publica(self, cfg): + return { + 'metodo': cfg['metodo'], + 'requerir_correo_verificado': cfg['requerir_correo_verificado'], + 'permitir_contrasena': cfg['permitir_contrasena'], + 'sesion_horas': cfg['sesion_horas'], + 'permitir_reagendar': cfg['permitir_reagendar'], + 'horas_minimas_cancelar': cfg['horas_minimas_cancelar'], + 'mostrar_precios': cfg['mostrar_precios'], + 'mostrar_recetas': cfg['mostrar_recetas'], + 'permitir_descargar_estado_cuenta': cfg['permitir_descargar_estado_cuenta'], + 'permitir_actualizar_datos': cfg['permitir_actualizar_datos'], + # Permite enlazar del portal hacia el portal de reservas (si está activo). + 'reservas_online': bool(get_reservas_online(request.env).get('activo')), + } + + # ------------------------------------------------------------------ + # 1. Configuración pública + # ------------------------------------------------------------------ + @http.route(PREFIX + '/config', type='http', auth='none', + methods=['GET', 'OPTIONS'], csrf=False) + def portal_config(self, **kw): + if request.httprequest.method == 'OPTIONS': + return _json({}, 200) + cfg = self._cfg() + if not cfg['activo']: + return _json({'status': 'success', 'activo': False}) + _pack, terms = resolver_glosario(request.env) + settings = self._settings() + return _json({ + 'status': 'success', + 'activo': True, + 'brand': self._brand(), + 'terms': terms, + 'config': self._config_publica(cfg), + 'zona_horaria': settings.get('zona_horaria') or 'America/Tijuana', + 'moneda': settings.get('moneda_base') or 'MXN', + }) + + # ------------------------------------------------------------------ + # 2. Solicitar acceso (código o enlace) + # ------------------------------------------------------------------ + @http.route(PREFIX + '/acceso/solicitar', type='http', auth='none', + methods=['POST', 'OPTIONS'], csrf=False) + def portal_solicitar(self, **kw): + if request.httprequest.method == 'OPTIONS': + return _json({}, 200) + if not self._activo(): + return self._deshabilitado() + if _pb._rate_limited('portal_solicitar') or \ + _ident_limited('solicitar', (_body().get('identificador') or '')): + return _json({'status': 'error', + 'message': 'Demasiadas solicitudes. Intenta más tarde.'}, 429) + data = _body() + identificador = str(data.get('identificador') or '').strip() + if not identificador: + return _json({'status': 'error', 'message': 'Escribe tu teléfono o correo.'}, 400) + respuesta = {'status': 'success', 'ok': True, 'message': _MSG_GENERICO} + try: + cfg = self._cfg() + svc = self._svc() + partner = svc.buscar_paciente(identificador) + if not partner: + return _json(respuesta) + acceso = svc.acceso_de(partner, crear=False) + if acceso and not acceso.activo: + return _json(respuesta) + canal = svc.canal_para(cfg, partner, identificador) + if cfg['requerir_correo_verificado'] and canal == 'whatsapp' and not partner.email: + return _json(respuesta) + if not svc.destino_disponible(partner, canal): + return _json(respuesta) + if canal == 'whatsapp': + if svc.codigos_recientes(partner, canal='whatsapp', horas=1) >= 5: + return _json(respuesta) + codigo = svc.enviar_codigo(partner, ip=_client_ip()) + if svc.modo_prueba(): + respuesta['modo_prueba'] = True + respuesta['codigo_prueba'] = codigo + else: + url = svc.enviar_enlace(partner, ip=_client_ip()) + if svc.modo_prueba(): + respuesta['modo_prueba'] = True + respuesta['enlace_prueba'] = url + return _json(respuesta) + except Exception as exc: + _logger.exception('Error solicitando acceso al portal: %s', exc) + return _json({'status': 'error', 'message': _ERROR_INTERNO}, 500) + + # ------------------------------------------------------------------ + # 3. Verificar código + # ------------------------------------------------------------------ + @http.route(PREFIX + '/acceso/verificar', type='http', auth='none', + methods=['POST', 'OPTIONS'], csrf=False) + def portal_verificar(self, **kw): + if request.httprequest.method == 'OPTIONS': + return _json({}, 200) + if not self._activo(): + return self._deshabilitado() + if _pb._rate_limited('portal_verificar') or \ + _ident_limited('verificar', (_body().get('identificador') or '')): + return _json({'status': 'error', + 'message': 'Demasiadas solicitudes. Intenta más tarde.'}, 429) + data = _body() + svc = self._svc() + partner = svc.buscar_paciente(data.get('identificador')) + if not partner: + return _json({'status': 'error', 'message': _MSG_CODIGO}, 401) + try: + ok, mensaje = svc.verificar_codigo(partner, data.get('codigo')) + if not ok: + return _json({'status': 'error', 'message': mensaje or _MSG_CODIGO}, 401) + return self._respuesta_sesion(partner) + except Exception as exc: + _logger.exception('Error verificando código del portal: %s', exc) + return _json({'status': 'error', 'message': _ERROR_INTERNO}, 500) + + # ------------------------------------------------------------------ + # 4. Verificar enlace de correo + # ------------------------------------------------------------------ + @http.route(PREFIX + '/acceso/enlace', type='http', auth='none', + methods=['POST', 'OPTIONS'], csrf=False) + def portal_enlace(self, **kw): + if request.httprequest.method == 'OPTIONS': + return _json({}, 200) + if not self._activo(): + return self._deshabilitado() + if _pb._rate_limited('portal_enlace'): + return _json({'status': 'error', + 'message': 'Demasiadas solicitudes. Intenta más tarde.'}, 429) + data = _body() + token = data.get('token') or kw.get('token') + try: + partner, error = self._svc().verificar_enlace(token) + if not partner: + return _json({'status': 'error', + 'message': error or 'El enlace no es válido o ya expiró.'}, 401) + return self._respuesta_sesion(partner) + except Exception as exc: + _logger.exception('Error verificando enlace del portal: %s', exc) + return _json({'status': 'error', 'message': _ERROR_INTERNO}, 500) + + # ------------------------------------------------------------------ + # 5. Entrar con contraseña (solo si permitir_contrasena) + # ------------------------------------------------------------------ + @http.route(PREFIX + '/acceso/password', type='http', auth='none', + methods=['POST', 'OPTIONS'], csrf=False) + def portal_password(self, **kw): + if request.httprequest.method == 'OPTIONS': + return _json({}, 200) + if not self._activo(): + return self._deshabilitado() + if _pb._rate_limited('portal_verificar') or \ + _ident_limited('verificar', (_body().get('identificador') or '')): + return _json({'status': 'error', + 'message': 'Demasiadas solicitudes. Intenta más tarde.'}, 429) + cfg = self._cfg() + if not cfg['permitir_contrasena']: + return _json({'status': 'error', + 'message': 'El acceso con contraseña no está disponible.'}, 403) + data = _body() + svc = self._svc() + partner = svc.buscar_paciente(data.get('identificador')) + acceso = svc.acceso_de(partner, crear=False) if partner else None + if not acceso or not acceso.check_password(str(data.get('password') or '')): + return _json({'status': 'error', + 'message': 'Teléfono/correo o contraseña incorrectos.'}, 401) + return self._respuesta_sesion(partner) + + def _respuesta_sesion(self, partner): + token, sesion = self._svc().crear_sesion( + partner, ip=_client_ip(), + user_agent=request.httprequest.headers.get('User-Agent') or '') + if not token: + return _json({'status': 'error', + 'message': 'El acceso al portal está desactivado para tu cuenta.'}, 403) + cfg = self._cfg() + return _json({ + 'status': 'success', + 'token': token, + 'expira': _iso(sesion.expira), + 'paciente': {'nombre': partner.name, 'partner_id': partner.id}, + 'config': self._config_publica(cfg), + }) + + # ------------------------------------------------------------------ + # 6. Sesión actual / cerrar sesión + # ------------------------------------------------------------------ + @http.route(PREFIX + '/sesion', type='http', auth='none', + methods=['GET', 'OPTIONS'], csrf=False) + def portal_sesion(self, **kw): + if request.httprequest.method == 'OPTIONS': + return _json({}, 200) + partner, sesion, error = self._auth() + if error: + return error + return _json({ + 'status': 'success', + 'paciente': {'nombre': partner.name, 'partner_id': partner.id, + 'patient_id': partner.patient_id or ''}, + 'expira': _iso(sesion.expira), + 'config': self._config_publica(self._cfg()), + 'brand': self._brand(), + }) + + @http.route(PREFIX + '/sesion/cerrar', type='http', auth='none', + methods=['POST', 'OPTIONS'], csrf=False) + def portal_cerrar_sesion(self, **kw): + if request.httprequest.method == 'OPTIONS': + return _json({}, 200) + if not self._activo(): + return self._deshabilitado() + partner, sesion, error = self._auth() + if error: + return error + self._svc().cerrar_sesion(sesion) + return _json({'status': 'success', 'ok': True}) + + # ------------------------------------------------------------------ + # 7. Resumen + # ------------------------------------------------------------------ + @http.route(PREFIX + '/resumen', type='http', auth='none', + methods=['GET', 'OPTIONS'], csrf=False) + def portal_resumen(self, **kw): + if request.httprequest.method == 'OPTIONS': + return _json({}, 200) + if _pb._rate_limited('portal_datos'): + return _json({'status': 'error', 'message': 'Demasiadas solicitudes.'}, 429) + partner, sesion, error = self._auth() + if error: + return error + try: + cfg = self._cfg() + tz = _tz(self._settings()) + ahora = datetime.now(tz) + proximas = request.env['skeen.cita'].sudo().search([ + ('partner_id', '=', partner.id), + ('state', 'in', ('pending', 'confirmed', 'arrived', 'in_progress')), + ('datetime_start', '>=', ahora.astimezone(pytz.utc).replace(tzinfo=None)), + ], order='datetime_start asc') + adeudo = request.env['skeen.venta'].sudo().read_group( + [('partner_id', '=', partner.id), ('state', '!=', 'cancelled'), + ('amount_due', '>', 0)], ['amount_due:sum'], []) + monedero = request.env['skeen.monedero'].sudo().search( + [('partner_id', '=', partner.id)], limit=1) + pendientes = request.env['skeen.formulario.respuesta'].sudo().search_count([ + ('partner_id', '=', partner.id), + ('estado', 'in', ('borrador', 'enviado')), + ('token', '!=', False)]) + return _json({ + 'status': 'success', + 'paciente': {'nombre': partner.name, 'patient_id': partner.patient_id or ''}, + 'proximas_citas': len(proximas), + 'adeudo': round(adeudo[0]['amount_due'], 2) if adeudo else 0.0, + 'puntos': monedero.points if monedero else 0, + 'formularios_pendientes': pendientes, + 'config': self._config_publica(cfg), + }) + except Exception as exc: + _logger.exception('Error en el resumen del portal: %s', exc) + return _json({'status': 'error', 'message': _ERROR_INTERNO}, 500) + + # ------------------------------------------------------------------ + # 8. Citas + # ------------------------------------------------------------------ + def _inicio_cita(self, cita, tz): + hh = int(cita.time) + mm = int(round((cita.time - hh) * 60)) + return tz.localize(datetime.combine(cita.date, dtime(hh, mm))) + + def _cita_dict(self, cita, cfg, ahora, tz): + inicio = self._inicio_cita(cita, tz) + cancelable = bool( + cita.state in ('pending', 'confirmed') + and inicio - ahora > timedelta(hours=cfg['horas_minimas_cancelar'])) + reagendable = bool( + cfg['permitir_reagendar'] and cita.state in ('pending', 'confirmed') + and inicio > ahora) + duracion = 0 + try: + duracion = cita._duracion() + except Exception: + duracion = cita.duration or cita.servicio_duration or 30 + return { + 'id': cita.id, + 'folio': cita.name, + 'fecha': cita.date.strftime('%Y-%m-%d') if cita.date else None, + 'hora': cita.time_str, + 'inicio': _iso(cita.datetime_start), + 'servicio_id': cita.servicio_id.id if cita.servicio_id else None, + 'servicio': cita.servicio_id.name if cita.servicio_id else '', + 'doctor_id': cita.doctor_id.id if cita.doctor_id else None, + 'doctor': cita.doctor_id.name if cita.doctor_id else '', + 'sucursal': _pb._selection_label(request.env, 'skeen.cita', 'branch', cita.branch), + 'duracion_min': duracion, + 'importe': round(cita.servicio_price or 0.0, 2) if cfg['mostrar_precios'] else None, + 'moneda': self._settings().get('moneda_base') or 'MXN', + 'estado': cita.state, + 'estado_label': _pb._selection_label(request.env, 'skeen.cita', 'state', cita.state), + 'notas': cita.notes or '', + 'puede_cancelar': cancelable, + 'puede_reagendar': reagendable, + 'anticipo_estado': cita.anticipo_estado or 'ninguno', + } + + @http.route(PREFIX + '/citas', type='http', auth='none', + methods=['GET', 'OPTIONS'], csrf=False) + def portal_citas(self, **kw): + if request.httprequest.method == 'OPTIONS': + return _json({}, 200) + if _pb._rate_limited('portal_datos'): + return _json({'status': 'error', 'message': 'Demasiadas solicitudes.'}, 429) + partner, sesion, error = self._auth() + if error: + return error + try: + cfg = self._cfg() + tz = _tz(self._settings()) + ahora = datetime.now(tz) + citas = request.env['skeen.cita'].sudo().search([ + ('partner_id', '=', partner.id), + ('state', '!=', 'waitlist'), + ], order='datetime_start desc') + proximas, pasadas = [], [] + for cita in citas: + item = self._cita_dict(cita, cfg, ahora, tz) + inicio = self._inicio_cita(cita, tz) + if inicio >= ahora and cita.state in ('pending', 'confirmed', 'arrived', 'in_progress'): + proximas.append(item) + else: + pasadas.append(item) + proximas.sort(key=lambda c: c['inicio'] or '') + return _json({'status': 'success', 'proximas': proximas, 'pasadas': pasadas}) + except Exception as exc: + _logger.exception('Error listando citas del portal: %s', exc) + return _json({'status': 'error', 'message': _ERROR_INTERNO}, 500) + + @http.route(PREFIX + '/citas/disponibilidad', type='http', auth='none', + methods=['GET', 'OPTIONS'], csrf=False) + def portal_disponibilidad(self, **kw): + if request.httprequest.method == 'OPTIONS': + return _json({}, 200) + if not self._activo(): + return self._deshabilitado() + if _pb._rate_limited('portal_datos'): + return _json({'status': 'error', 'message': 'Demasiadas solicitudes.'}, 429) + partner, sesion, error = self._auth() + if error: + return error + try: + fecha = _pb._parse_fecha(kw.get('fecha')) + cita = request.env['skeen.cita'].sudo().browse(int(kw.get('cita_id') or 0)) + if not cita.exists() or cita.partner_id.id != partner.id: + return _json({'status': 'error', 'message': 'Cita no encontrada'}, 404) + if not fecha: + return _json({'status': 'error', 'message': 'Falta la fecha'}, 400) + slots = request.env['skeen.cita'].sudo().get_available_slots( + fecha, cita.servicio_id.id, doctor_id=cita.doctor_id.id or None) + return _json({'status': 'success', + 'slots': [s['time_str'] for s in slots], + 'duracion_min': cita.servicio_duration or 30}) + except Exception as exc: + _logger.exception('Error en disponibilidad del portal: %s', exc) + return _json({'status': 'error', 'message': _ERROR_INTERNO}, 500) + + def _cita_de_sesion(self, partner, cita_id): + cita = request.env['skeen.cita'].sudo().browse(int(cita_id or 0)) + if not cita.exists() or cita.partner_id.id != partner.id: + return None + return cita + + @http.route(PREFIX + '/citas/cancelar', type='http', auth='none', + methods=['POST', 'OPTIONS'], csrf=False) + def portal_cancelar_cita(self, **kw): + if request.httprequest.method == 'OPTIONS': + return _json({}, 200) + if not self._activo(): + return self._deshabilitado() + if _pb._rate_limited('portal_accion'): + return _json({'status': 'error', 'message': 'Demasiadas solicitudes.'}, 429) + partner, sesion, error = self._auth() + if error: + return error + data = _body() + try: + cfg = self._cfg() + cita = self._cita_de_sesion(partner, data.get('cita_id')) + if not cita: + return _json({'status': 'error', 'message': 'Cita no encontrada'}, 404) + if cita.state not in ('pending', 'confirmed'): + return _json({'status': 'error', + 'message': 'Esta cita ya no se puede cancelar en línea.'}, 409) + tz = _tz(self._settings()) + ahora = datetime.now(tz) + inicio = self._inicio_cita(cita, tz) + if inicio - ahora <= timedelta(hours=cfg['horas_minimas_cancelar']): + return _json({'status': 'error', + 'message': 'Ya no es posible cancelar en línea: faltan menos de ' + '%d horas. Contacta a la clínica.' + % cfg['horas_minimas_cancelar']}, 409) + motivo = str(data.get('motivo') or '').strip()[:300] + cita.write({'cancellation_reason': motivo or 'Cancelada por el cliente en el portal'}) + cita.set_state('cancelled', user='Portal del cliente', + note=motivo or 'Cancelada en la cuenta del cliente') + try: + svc = request.env['skeen.cobro.service'].sudo() + svc.reembolsar_anticipo(cita) + svc.cancelar_enlaces_de_cita(cita, 'Cita cancelada en el portal') + except Exception: + _logger.exception('No se pudo resolver el anticipo al cancelar') + try: + cita.sudo().message_post(body='Cancelada desde la cuenta del cliente. IP: %s' + % _client_ip(), message_type='comment') + except Exception: + pass + return _json({'status': 'success', 'ok': True, + 'folio': cita.name, 'estado': 'cancelled'}) + except Exception as exc: + request.env.cr.rollback() + _logger.exception('Error cancelando cita del portal: %s', exc) + return _json({'status': 'error', 'message': _ERROR_INTERNO}, 500) + + @http.route(PREFIX + '/citas/reagendar', type='http', auth='none', + methods=['POST', 'OPTIONS'], csrf=False) + def portal_reagendar_cita(self, **kw): + if request.httprequest.method == 'OPTIONS': + return _json({}, 200) + if not self._activo(): + return self._deshabilitado() + if _pb._rate_limited('portal_accion'): + return _json({'status': 'error', 'message': 'Demasiadas solicitudes.'}, 429) + partner, sesion, error = self._auth() + if error: + return error + data = _body() + try: + cfg = self._cfg() + if not cfg['permitir_reagendar']: + return _json({'status': 'error', + 'message': 'El reagendado en línea no está disponible.'}, 403) + cita = self._cita_de_sesion(partner, data.get('cita_id')) + if not cita: + return _json({'status': 'error', 'message': 'Cita no encontrada'}, 404) + if cita.state not in ('pending', 'confirmed'): + return _json({'status': 'error', + 'message': 'Esta cita ya no se puede reagendar.'}, 409) + fecha = _pb._parse_fecha(data.get('fecha')) + hora = _pb._parse_hora(data.get('hora')) + if not fecha or hora is None: + return _json({'status': 'error', 'message': 'Elige fecha y hora.'}, 400) + tz = _tz(self._settings()) + ahora = datetime.now(tz) + hh, mm = int(hora), int(round((hora - int(hora)) * 60)) + inicio = tz.localize(datetime.combine(fecha, dtime(hh, mm))) + if inicio <= ahora: + return _json({'status': 'error', + 'message': 'Elige un horario futuro.'}, 400) + slots = request.env['skeen.cita'].sudo().get_available_slots( + fecha, cita.servicio_id.id, doctor_id=cita.doctor_id.id or None) + if not any(abs(hora - s['time']) < 1e-6 for s in slots): + return _json({'status': 'error', + 'message': 'Ese horario ya no está disponible.'}, 409) + anterior = '%s %s' % (cita.date, cita.time_str) + cita.write({'date': fecha.strftime('%Y-%m-%d'), 'time': hora}) + try: + cita.sudo().message_post( + body='Reagendada desde la cuenta del cliente: antes %s, ahora %s %s.' + % (anterior, fecha, cita.time_str), message_type='comment') + except Exception: + pass + return _json({'status': 'success', 'ok': True, + 'fecha': cita.date.strftime('%Y-%m-%d'), 'hora': cita.time_str}) + except Exception as exc: + request.env.cr.rollback() + _logger.exception('Error reagendando cita del portal: %s', exc) + return _json({'status': 'error', 'message': _ERROR_INTERNO}, 500) + + # ------------------------------------------------------------------ + # 9. Pagos y estado de cuenta + # ------------------------------------------------------------------ + def _pagos_dicts(self, partner, cfg): + pagos = request.env['skeen.pago'].sudo().search([ + ('partner_id', '=', partner.id), + ('state', 'in', ('completed', 'refunded')), + ], order='payment_date desc, id desc', limit=200) + metodo_label = dict( + request.env['skeen.pago']._fields['payment_method'].selection) + out = [] + for p in pagos: + out.append({ + 'id': p.id, + 'folio': p.name, + 'fecha': _iso(p.payment_date or p.create_date), + 'monto': round(p.amount or 0.0, 2), + 'moneda': p.currency_id.name or 'MXN', + 'metodo': p.payment_method, + 'metodo_label': metodo_label.get(p.payment_method, p.payment_method), + 'estado': p.state, + }) + return out + + def _ventas_dicts(self, partner, cfg): + ventas = request.env['skeen.venta'].sudo().search([ + ('partner_id', '=', partner.id), + ], order='date desc, id desc', limit=200) + out = [] + for v in ventas: + lineas = [] + if cfg['mostrar_precios']: + for ln in v.line_ids: + lineas.append({ + 'descripcion': ln.description or '', + 'cantidad': ln.quantity, + 'precio': round(ln.price_unit or 0.0, 2), + 'subtotal': round(ln.subtotal or 0.0, 2), + }) + out.append({ + 'id': v.id, + 'folio': v.name, + 'fecha': v.date.strftime('%Y-%m-%d') if v.date else None, + 'total': round(v.total or 0.0, 2) if cfg['mostrar_precios'] else None, + 'pagado': round(v.amount_paid or 0.0, 2) if cfg['mostrar_precios'] else None, + 'adeudo': round(v.amount_due or 0.0, 2) if cfg['mostrar_precios'] else None, + 'moneda': self._settings().get('moneda_base') or 'MXN', + 'estado': v.state, + 'lineas': lineas, + }) + return out + + @http.route(PREFIX + '/pagos', type='http', auth='none', + methods=['GET', 'OPTIONS'], csrf=False) + def portal_pagos(self, **kw): + if request.httprequest.method == 'OPTIONS': + return _json({}, 200) + if _pb._rate_limited('portal_datos'): + return _json({'status': 'error', 'message': 'Demasiadas solicitudes.'}, 429) + partner, sesion, error = self._auth() + if error: + return error + try: + cfg = self._cfg() + adeudo = request.env['skeen.venta'].sudo().read_group( + [('partner_id', '=', partner.id), ('state', '!=', 'cancelled'), + ('amount_due', '>', 0)], ['amount_due:sum'], []) + enlaces = request.env['skeen.cobro.enlace'].sudo().search([ + ('partner_id', '=', partner.id), ('estado', '=', 'pendiente')]) + return _json({ + 'status': 'success', + 'pagos': self._pagos_dicts(partner, cfg), + 'ventas': self._ventas_dicts(partner, cfg), + 'saldo_pendiente': round(adeudo[0]['amount_due'], 2) if adeudo else 0.0, + 'moneda': self._settings().get('moneda_base') or 'MXN', + 'enlaces_pendientes': [{ + 'folio': e.name, 'concepto': e.concepto, + 'monto': round(e.monto or 0.0, 2), + 'moneda': e.currency_id.name or 'MXN', + 'expira': _iso(e.token_expira), + 'url': e.url or '', + } for e in enlaces], + }) + except Exception as exc: + _logger.exception('Error listando pagos del portal: %s', exc) + return _json({'status': 'error', 'message': _ERROR_INTERNO}, 500) + + @http.route(PREFIX + '/estado-cuenta', type='http', auth='none', + methods=['GET', 'OPTIONS'], csrf=False) + def portal_estado_cuenta(self, **kw): + if request.httprequest.method == 'OPTIONS': + return _json({}, 200) + if _pb._rate_limited('portal_datos'): + return _json({'status': 'error', 'message': 'Demasiadas solicitudes.'}, 429) + partner, sesion, error = self._auth() + if error: + return error + cfg = self._cfg() + if not cfg['permitir_descargar_estado_cuenta']: + return _json({'status': 'error', + 'message': 'La descarga del estado de cuenta no está disponible.'}, 403) + try: + monedero = request.env['skeen.monedero'].sudo().search( + [('partner_id', '=', partner.id)], limit=1) + adeudo = request.env['skeen.venta'].sudo().read_group( + [('partner_id', '=', partner.id), ('state', '!=', 'cancelled'), + ('amount_due', '>', 0)], ['amount_due:sum'], []) + return _json({ + 'status': 'success', + 'paciente': {'nombre': partner.name, 'patient_id': partner.patient_id or '', + 'telefono': partner.phone or '', 'email': partner.email or ''}, + 'generado': fields.Datetime.now(), + 'estado_cuenta': { + 'puntos_monedero': monedero.points if monedero else 0, + 'total_gastado': round(partner.total_spent or 0.0, 2), + 'adeudo': round(adeudo[0]['amount_due'], 2) if adeudo else 0.0, + 'visitas_total': partner.total_visits or 0, + }, + 'moneda': self._settings().get('moneda_base') or 'MXN', + 'sucursal': self._settings().get('nombre') or '', + 'pagos': self._pagos_dicts(partner, cfg), + 'ventas': self._ventas_dicts(partner, cfg), + }) + except Exception as exc: + _logger.exception('Error en estado de cuenta del portal: %s', exc) + return _json({'status': 'error', 'message': _ERROR_INTERNO}, 500) + + # ------------------------------------------------------------------ + # 10. Monedero + # ------------------------------------------------------------------ + @http.route(PREFIX + '/monedero', type='http', auth='none', + methods=['GET', 'OPTIONS'], csrf=False) + def portal_monedero(self, **kw): + if request.httprequest.method == 'OPTIONS': + return _json({}, 200) + if _pb._rate_limited('portal_datos'): + return _json({'status': 'error', 'message': 'Demasiadas solicitudes.'}, 429) + partner, sesion, error = self._auth() + if error: + return error + try: + monedero = request.env['skeen.monedero'].sudo().search( + [('partner_id', '=', partner.id)], limit=1) + points = monedero.points if monedero else 0 + mxn = float(points) * 1.0 # 1 punto = $1 MXN + rate = 0.0 + try: + rate = request.env['skeen.tipo.cambio'].sudo().get_current_rate() + except Exception: + rate = 0.0 + usd = round(mxn / rate, 2) if rate else None + movimientos = [] + if monedero: + for tx in monedero.transaction_ids[:100]: + movimientos.append({ + 'id': tx.id, + 'fecha': _iso(tx.create_date), + 'tipo': tx.type, + 'puntos': tx.points, + 'descripcion': tx.description or '', + 'referencia': tx.reference or '', + }) + return _json({ + 'status': 'success', + 'points': points, + 'equivalent_mxn': round(mxn, 2), + 'equivalent_usd': usd, + 'rate': rate or None, + 'movimientos': movimientos, + }) + except Exception as exc: + _logger.exception('Error en monedero del portal: %s', exc) + return _json({'status': 'error', 'message': _ERROR_INTERNO}, 500) + + # ------------------------------------------------------------------ + # 11. Paquetes + # ------------------------------------------------------------------ + @http.route(PREFIX + '/paquetes', type='http', auth='none', + methods=['GET', 'OPTIONS'], csrf=False) + def portal_paquetes(self, **kw): + if request.httprequest.method == 'OPTIONS': + return _json({}, 200) + if _pb._rate_limited('portal_datos'): + return _json({'status': 'error', 'message': 'Demasiadas solicitudes.'}, 429) + partner, sesion, error = self._auth() + if error: + return error + try: + compras = request.env['skeen.paquete.compra'].sudo().search([ + ('partner_id', '=', partner.id), + ], order='date desc, id desc') + out = [] + for c in compras: + out.append({ + 'id': c.id, + 'folio': 'PAQ-%05d' % c.id, + 'paquete': c.paquete_id.name if c.paquete_id else '', + 'servicio': c.service_id.name if c.service_id else '', + 'sesiones_total': c.sessions_total, + 'sesiones_usadas': c.sessions_used, + 'sesiones_restantes': c.sessions_remaining, + 'fecha': c.date.strftime('%Y-%m-%d') if c.date else None, + 'vigencia': c.expiry_date.strftime('%Y-%m-%d') if c.expiry_date else None, + 'estado': c.state, + }) + return _json({'status': 'success', 'paquetes': out}) + except Exception as exc: + _logger.exception('Error listando paquetes del portal: %s', exc) + return _json({'status': 'error', 'message': _ERROR_INTERNO}, 500) + + # ------------------------------------------------------------------ + # 12. Membresías + # ------------------------------------------------------------------ + @http.route(PREFIX + '/membresias', type='http', auth='none', + methods=['GET', 'OPTIONS'], csrf=False) + def portal_membresias(self, **kw): + if request.httprequest.method == 'OPTIONS': + return _json({}, 200) + if _pb._rate_limited('portal_datos'): + return _json({'status': 'error', 'message': 'Demasiadas solicitudes.'}, 429) + partner, sesion, error = self._auth() + if error: + return error + try: + membresias = request.env['skeen.membresia'].sudo().search([ + ('partner_id', '=', partner.id), + ], order='id desc') + out = [] + for m in membresias: + pendientes = m.periodo_ids.filtered(lambda p: p.estado in ('pendiente', 'vencido')) + out.append({ + 'id': m.id, + 'folio': m.folio, + 'plan': m.plan_id.name if m.plan_id else '', + 'estado': m.estado, + 'fecha_inicio': m.fecha_inicio.strftime('%Y-%m-%d') if m.fecha_inicio else None, + 'fecha_fin': m.fecha_fin.strftime('%Y-%m-%d') if m.fecha_fin else None, + 'proximo_cobro': m.proximo_cobro.strftime('%Y-%m-%d') if m.proximo_cobro else None, + 'sesiones_restantes': m.sesiones_restantes, + 'ilimitadas': m.ilimitadas, + 'adeudo': round(m.adeudo or 0.0, 2), + 'moneda': m.plan_id.moneda if m.plan_id else 'MXN', + 'periodos_pendientes': [{ + 'id': p.id, + 'inicio': p.periodo_inicio.strftime('%Y-%m-%d') if p.periodo_inicio else None, + 'fin': p.periodo_fin.strftime('%Y-%m-%d') if p.periodo_fin else None, + 'monto': round(p.monto or 0.0, 2), + 'moneda': p.moneda or 'MXN', + 'estado': p.estado, + } for p in pendientes], + }) + return _json({'status': 'success', 'membresias': out}) + except Exception as exc: + _logger.exception('Error listando membresías del portal: %s', exc) + return _json({'status': 'error', 'message': _ERROR_INTERNO}, 500) + + @http.route(PREFIX + '/membresias/pagar', type='http', auth='none', + methods=['POST', 'OPTIONS'], csrf=False) + def portal_pagar_membresia(self, **kw): + if request.httprequest.method == 'OPTIONS': + return _json({}, 200) + if not self._activo(): + return self._deshabilitado() + if _pb._rate_limited('portal_accion'): + return _json({'status': 'error', 'message': 'Demasiadas solicitudes.'}, 429) + partner, sesion, error = self._auth() + if error: + return error + data = _body() + try: + membresia = request.env['skeen.membresia'].sudo().browse( + int(data.get('membresia_id') or 0)) + if not membresia.exists() or membresia.partner_id.id != partner.id: + return _json({'status': 'error', 'message': 'Membresía no encontrada'}, 404) + periodo = None + periodo_id = int(data.get('periodo_id') or 0) + if periodo_id: + periodo = membresia.periodo_ids.filtered(lambda p: p.id == periodo_id)[:1] + if not periodo: + pendientes = membresia.periodo_ids.filtered( + lambda p: p.estado in ('pendiente', 'vencido')).sorted('periodo_inicio') + periodo = pendientes[:1] + if not periodo: + return _json({'status': 'error', + 'message': 'No tienes periodos pendientes de pago.'}, 409) + Cobro = request.env['skeen.cobro.enlace'].sudo() + enlace = Cobro.search([ + ('origen', '=', 'membresia'), ('periodo_id', '=', periodo.id), + ('estado', '=', 'pendiente')], limit=1) + if not enlace: + svc = request.env['skeen.cobro.service'].sudo() + enlace = svc.crear( + 'membresia', periodo.monto, + 'Membresía %s (%s a %s)' % ( + membresia.plan_id.name or '', periodo.periodo_inicio, periodo.periodo_fin), + ttl_horas=72, partner=partner, moneda=periodo.moneda or 'MXN') + enlace.write({ + 'periodo_id': periodo.id, + 'venta_id': periodo.venta_id.id if periodo.venta_id else False, + }) + svc.intentar_checkout(enlace) + return _json({'status': 'success', 'ok': True, + 'folio': enlace.name, + 'monto': round(enlace.monto or 0.0, 2), + 'moneda': enlace.currency_id.name or 'MXN', + 'url': enlace.url or '', + 'expira': _iso(enlace.token_expira), + 'proveedor': enlace.proveedor}) + except Exception as exc: + request.env.cr.rollback() + _logger.exception('Error pagando membresía del portal: %s', exc) + return _json({'status': 'error', 'message': _ERROR_INTERNO}, 500) + + # ------------------------------------------------------------------ + # 13. Formularios + # ------------------------------------------------------------------ + @http.route(PREFIX + '/formularios', type='http', auth='none', + methods=['GET', 'OPTIONS'], csrf=False) + def portal_formularios(self, **kw): + if request.httprequest.method == 'OPTIONS': + return _json({}, 200) + if _pb._rate_limited('portal_datos'): + return _json({'status': 'error', 'message': 'Demasiadas solicitudes.'}, 429) + partner, sesion, error = self._auth() + if error: + return error + try: + base = request.env['ir.config_parameter'].sudo().get_param('web.base.url') or '' + respuestas = request.env['skeen.formulario.respuesta'].sudo().search([ + ('partner_id', '=', partner.id), + ], order='create_date desc, id desc') + pendientes, respondidos = [], [] + for r in respuestas: + item = { + 'id': r.id, + 'folio': r.name, + 'plantilla': r.plantilla_id.name if r.plantilla_id else '', + 'estado': r.estado, + 'enviado_at': _iso(r.enviado_at), + 'llenado_at': _iso(r.llenado_at), + 'tiene_firma': bool(r.firma_imagen), + 'fotos_count': len(r.foto_ids), + } + if r.estado in ('borrador', 'enviado') and r.token: + item['enlace'] = '/formulario/%s' % r.token + pendientes.append(item) + elif r.estado == 'llenado': + respondidos.append(item) + return _json({'status': 'success', 'pendientes': pendientes, + 'respondidos': respondidos}) + except Exception as exc: + _logger.exception('Error listando formularios del portal: %s', exc) + return _json({'status': 'error', 'message': _ERROR_INTERNO}, 500) + + # ------------------------------------------------------------------ + # 14. Documentos (recetas y estado de cuenta) + # ------------------------------------------------------------------ + @http.route(PREFIX + '/documentos', type='http', auth='none', + methods=['GET', 'OPTIONS'], csrf=False) + def portal_documentos(self, **kw): + if request.httprequest.method == 'OPTIONS': + return _json({}, 200) + if _pb._rate_limited('portal_datos'): + return _json({'status': 'error', 'message': 'Demasiadas solicitudes.'}, 429) + partner, sesion, error = self._auth() + if error: + return error + try: + cfg = self._cfg() + recetas = [] + if cfg['mostrar_recetas']: + visitas = request.env['skeen.visita'].sudo().search([ + ('partner_id', '=', partner.id), + ('receta_text', '!=', False), + ], order='date_start desc, id desc', limit=100) + for v in visitas: + recetas.append({ + 'id': v.id, + 'folio': v.name, + 'fecha': _iso(v.date_start), + 'contenido': v.receta_text or '', + 'servicio': v.servicio_id.name if v.servicio_id else '', + }) + return _json({ + 'status': 'success', + 'recetas': recetas, + 'estado_cuenta_disponible': cfg['permitir_descargar_estado_cuenta'], + }) + except Exception as exc: + _logger.exception('Error listando documentos del portal: %s', exc) + return _json({'status': 'error', 'message': _ERROR_INTERNO}, 500) + + # ------------------------------------------------------------------ + # 15. Perfil + # ------------------------------------------------------------------ + def _perfil_dict(self, partner, cfg): + adjuntos = request.env['skeen.patient.adjunto'].sudo().search([ + ('partner_id', '=', partner.id), ('kind', '=', 'imagen'), + ], order='create_date desc', limit=60) + return { + 'nombre': partner.name, + 'patient_id': partner.patient_id or '', + 'telefono': partner.phone or '', + 'mobile': partner.mobile or '', + 'email': partner.email or '', + 'birth_date': partner.birth_date.strftime('%Y-%m-%d') if partner.birth_date else None, + 'gender': partner.gender or '', + 'occupation': partner.occupation or '', + 'marital_status': partner.marital_status or '', + 'birthplace': partner.birthplace or '', + 'address_notes': partner.address_notes or '', + 'emergency_contact': partner.emergency_contact or '', + 'emergency_phone': partner.emergency_phone or '', + 'tiene_foto': bool(partner.image_1920), + 'puede_editar': bool(cfg['permitir_actualizar_datos']), + 'fotografias': [{ + 'id': a.id, + 'nombre': a.name or '', + 'clasificacion': a.antes_despues or 'sin_clasificar', + 'fecha': _iso(a.create_date), + } for a in adjuntos], + } + + @http.route(PREFIX + '/perfil', type='http', auth='none', + methods=['GET', 'OPTIONS'], csrf=False) + def portal_perfil(self, **kw): + if request.httprequest.method == 'OPTIONS': + return _json({}, 200) + if _pb._rate_limited('portal_datos'): + return _json({'status': 'error', 'message': 'Demasiadas solicitudes.'}, 429) + partner, sesion, error = self._auth() + if error: + return error + try: + return _json({'status': 'success', + 'perfil': self._perfil_dict(partner, self._cfg())}) + except Exception as exc: + _logger.exception('Error leyendo perfil del portal: %s', exc) + return _json({'status': 'error', 'message': _ERROR_INTERNO}, 500) + + _GENEROS = ('male', 'female', 'other') + _ESTADOS_CIVILES = ('single', 'married', 'divorced', 'widowed', 'union', 'other') + + @http.route(PREFIX + '/perfil', type='http', auth='none', + methods=['PUT', 'OPTIONS'], csrf=False) + def portal_perfil_guardar(self, **kw): + if request.httprequest.method == 'OPTIONS': + return _json({}, 200) + if not self._activo(): + return self._deshabilitado() + if _pb._rate_limited('portal_accion'): + return _json({'status': 'error', 'message': 'Demasiadas solicitudes.'}, 429) + partner, sesion, error = self._auth() + if error: + return error + cfg = self._cfg() + if not cfg['permitir_actualizar_datos']: + return _json({'status': 'error', + 'message': 'La actualización de datos está desactivada.'}, 403) + data = _body() + vals = {} + if 'telefono' in data: + tel = _pb._normaliza_telefono(data.get('telefono')) + if tel and not (10 <= len(re.sub(r'\D', '', tel)) <= 15): + return _json({'status': 'error', 'message': 'Teléfono no válido.'}, 400) + vals['phone'] = tel + if 'email' in data: + email = str(data.get('email') or '').strip() + if email and not _pb._EMAIL_RE.match(email): + return _json({'status': 'error', 'message': 'Correo no válido.'}, 400) + vals['email'] = email + for campo in ('occupation', 'birthplace', 'address_notes', 'emergency_contact'): + if campo in data: + vals[campo] = str(data.get(campo) or '').strip()[:500] + if 'emergency_phone' in data: + vals['emergency_phone'] = str(data.get('emergency_phone') or '').strip()[:40] + if 'birth_date' in data: + fecha = _pb._parse_fecha(data.get('birth_date')) + if data.get('birth_date') and not fecha: + return _json({'status': 'error', 'message': 'Fecha de nacimiento no válida.'}, 400) + vals['birth_date'] = fecha.strftime('%Y-%m-%d') if fecha else False + if 'gender' in data: + genero = str(data.get('gender') or '').strip() + if genero and genero not in self._GENEROS: + return _json({'status': 'error', 'message': 'Género no válido.'}, 400) + vals['gender'] = genero or False + if 'marital_status' in data: + estado = str(data.get('marital_status') or '').strip() + if estado and estado not in self._ESTADOS_CIVILES: + return _json({'status': 'error', 'message': 'Estado civil no válido.'}, 400) + vals['marital_status'] = estado or False + if not vals: + return _json({'status': 'error', 'message': 'No hay datos por actualizar.'}, 400) + try: + partner.sudo().write(vals) + return _json({'status': 'success', 'ok': True, + 'perfil': self._perfil_dict(partner, cfg)}) + except Exception as exc: + request.env.cr.rollback() + _logger.exception('Error guardando perfil del portal: %s', exc) + return _json({'status': 'error', 'message': _ERROR_INTERNO}, 500) + + @http.route(PREFIX + '/perfil/password', type='http', auth='none', + methods=['POST', 'OPTIONS'], csrf=False) + def portal_perfil_password(self, **kw): + if request.httprequest.method == 'OPTIONS': + return _json({}, 200) + if not self._activo(): + return self._deshabilitado() + if _pb._rate_limited('portal_accion'): + return _json({'status': 'error', 'message': 'Demasiadas solicitudes.'}, 429) + partner, sesion, error = self._auth() + if error: + return error + cfg = self._cfg() + if not cfg['permitir_contrasena']: + return _json({'status': 'error', + 'message': 'El acceso con contraseña no está disponible.'}, 403) + password = str(_body().get('password') or '') + if len(password) < 6: + return _json({'status': 'error', + 'message': 'La contraseña debe tener al menos 6 caracteres.'}, 400) + try: + acceso = self._svc().acceso_de(partner, crear=True, metodo=cfg.get('metodo')) + acceso.set_password(password) + return _json({'status': 'success', 'ok': True}) + except Exception as exc: + request.env.cr.rollback() + _logger.exception('Error guardando contraseña del portal: %s', exc) + return _json({'status': 'error', 'message': _ERROR_INTERNO}, 500) + + # ------------------------------------------------------------------ + # 16. Fotografías del perfil (solo las del propio paciente) + # ------------------------------------------------------------------ + @http.route(PREFIX + '/perfil/foto', type='http', auth='none', + methods=['GET'], csrf=False) + def portal_perfil_foto(self, **kw): + if not self._activo(): + return self._deshabilitado() + partner, sesion, error = self._auth() + if error: + return error + if not partner.image_1920: + return _json({'status': 'error', 'message': 'Sin foto'}, 404) + import base64 + raw = base64.b64decode(partner.image_1920) + mimetype = 'image/png' if raw[:8] == b'\x89PNG\r\n\x1a\n' else 'image/jpeg' + return Response(raw, status=200, mimetype=mimetype, headers={ + 'Cache-Control': 'private, max-age=60', + 'Access-Control-Allow-Origin': '*', + }) + + @http.route(PREFIX + '/perfil/adjunto/', type='http', + auth='none', methods=['GET'], csrf=False) + def portal_perfil_adjunto(self, adjunto_id, **kw): + if not self._activo(): + return self._deshabilitado() + partner, sesion, error = self._auth() + if error: + return error + adjunto = request.env['skeen.patient.adjunto'].sudo().browse(adjunto_id) + if not adjunto.exists() or adjunto.partner_id.id != partner.id or not adjunto.file: + return _json({'status': 'error', 'message': 'No encontrado'}, 404) + import base64 + raw = base64.b64decode(adjunto.file) + mimetype = adjunto.mimetype or 'image/jpeg' + return Response(raw, status=200, mimetype=mimetype, headers={ + 'Cache-Control': 'private, max-age=60', + 'Access-Control-Allow-Origin': '*', + }) diff --git a/odoo-addons/skeen_portal_cliente/controllers/staff_portal.py b/odoo-addons/skeen_portal_cliente/controllers/staff_portal.py new file mode 100644 index 0000000..4d3f0fc --- /dev/null +++ b/odoo-addons/skeen_portal_cliente/controllers/staff_portal.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- +"""Portal del cliente: endpoints del PERSONAL (invitar, accesos, sesiones). + +Bajo `/skeen/frontend/v1/portal`, autenticados con la sesión del personal +(`require_perm`). Un token del portal del cliente NO sirve aquí (no lleva `uid`). + +Permiso nuevo: `portal.invitar` (invitar y cerrar sesiones) y `portal.sesiones` +(ver el listado de accesos). Por defecto: admin, gerencia y recepción. +""" + +import json +import logging + +from odoo import fields, http +from odoo.http import request + +from odoo.addons.skeen_whatsapp.controllers.auth import ( + _json, require_perm, require_role, +) +from odoo.addons.skeen_whatsapp.controllers import public_booking as _pb + +_logger = logging.getLogger(__name__) + +PREFIX = '/skeen/frontend/v1/portal' + + +def _body(): + try: + return json.loads(request.httprequest.get_data(as_text=True) or '{}') + except Exception: + return {} + + +class SkeenPortalStaffController(http.Controller): + """Gestión del portal del cliente desde la interfaz del personal.""" + + def _svc(self): + return request.env['skeen.portal.service'].sudo() + + # ------------------------------------------------------------------ + # Invitar al portal (envía el código o el enlace) + # ------------------------------------------------------------------ + @http.route(PREFIX + '/invitar', type='http', auth='none', + methods=['POST', 'OPTIONS'], csrf=False) + @require_role('recepcion') + @require_perm('portal.invitar') + def portal_invitar(self, **kw): + data = _body() + try: + partner = request.env['res.partner'].sudo().browse( + int(data.get('partner_id') or 0)) + if not partner.exists() or not partner.is_patient: + return _json({'status': 'error', 'message': 'Paciente no encontrado'}, 404) + svc = self._svc() + cfg = svc.cfg() + if not cfg.get('activo'): + return _json({'status': 'error', + 'message': 'El portal del cliente está apagado. ' + 'Actívalo en Configuración.'}, 403) + canal = str(data.get('canal') or '').strip() + if canal not in ('whatsapp', 'email'): + canal = svc.canal_para(cfg, partner, partner.email or partner.phone or '') + if not svc.destino_disponible(partner, canal): + canal = 'whatsapp' if svc.destino_disponible(partner, 'whatsapp') else 'email' + if not svc.destino_disponible(partner, canal): + return _json({'status': 'error', + 'message': 'El paciente no tiene teléfono ni correo.'}, 400) + usuario = getattr(request, 'frontend_user', None) + acceso = svc.acceso_de(partner, crear=True, metodo=cfg.get('metodo'), + invitado_por=usuario.login if usuario else '') + acceso.write({'activo': True, 'invitado_at': fields.Datetime.now()}) + salida = {'status': 'success', 'ok': True, 'canal': canal, + 'modo_prueba': svc.modo_prueba(), + 'acceso': svc.estado_paciente(partner)} + if canal == 'whatsapp': + codigo = svc.enviar_codigo(partner, ip=_pb._client_ip()) + if svc.modo_prueba(): + salida['codigo_prueba'] = codigo + else: + url = svc.enviar_enlace(partner, ip=_pb._client_ip()) + if svc.modo_prueba(): + salida['enlace_prueba'] = url + return _json(salida) + except Exception as exc: + request.env.cr.rollback() + _logger.exception('Error invitando al portal: %s', exc) + return _json({'status': 'error', + 'message': 'No se pudo invitar al portal.'}, 500) + + # ------------------------------------------------------------------ + # Estado del acceso de un paciente (ficha de expediente) + # ------------------------------------------------------------------ + @http.route(PREFIX + '/paciente/', type='http', auth='none', + methods=['GET', 'OPTIONS'], csrf=False) + @require_role('lectura') + @require_perm('portal.invitar') + def portal_paciente_estado(self, partner_id, **kw): + try: + partner = request.env['res.partner'].sudo().browse(partner_id) + if not partner.exists() or not partner.is_patient: + return _json({'status': 'error', 'message': 'Paciente no encontrado'}, 404) + return _json({'status': 'success', + 'activo': self._svc().activo(), + 'estado': self._svc().estado_paciente(partner)}) + except Exception as exc: + _logger.exception('Error consultando acceso del portal: %s', exc) + return _json({'status': 'error', 'message': 'No se pudo consultar.'}, 500) + + # ------------------------------------------------------------------ + # Listado de accesos activos + # ------------------------------------------------------------------ + @http.route(PREFIX + '/accesos', type='http', auth='none', + methods=['GET', 'OPTIONS'], csrf=False) + @require_role('lectura') + @require_perm('portal.sesiones') + def portal_accesos(self, **kw): + try: + svc = self._svc() + return _json({'status': 'success', + 'activo': svc.activo(), + 'accesos': svc.lista_accesos()}) + except Exception as exc: + _logger.exception('Error listando accesos del portal: %s', exc) + return _json({'status': 'error', 'message': 'No se pudo consultar.'}, 500) + + # ------------------------------------------------------------------ + # Cerrar sesiones de un paciente + # ------------------------------------------------------------------ + @http.route(PREFIX + '/sesiones/cerrar', type='http', auth='none', + methods=['POST', 'OPTIONS'], csrf=False) + @require_role('recepcion') + @require_perm('portal.invitar') + def portal_cerrar_sesiones(self, **kw): + data = _body() + try: + partner = request.env['res.partner'].sudo().browse( + int(data.get('partner_id') or 0)) + if not partner.exists() or not partner.is_patient: + return _json({'status': 'error', 'message': 'Paciente no encontrado'}, 404) + svc = self._svc() + acceso = svc.acceso_de(partner, crear=False) + if not acceso: + return _json({'status': 'error', + 'message': 'El paciente no tiene acceso al portal.'}, 404) + acceso.action_cerrar_sesiones() + return _json({'status': 'success', 'ok': True, + 'acceso': svc.estado_paciente(partner)}) + except Exception as exc: + request.env.cr.rollback() + _logger.exception('Error cerrando sesiones del portal: %s', exc) + return _json({'status': 'error', 'message': 'No se pudo cerrar sesiones.'}, 500) + + # ------------------------------------------------------------------ + # Activar / desactivar el acceso de un paciente + # ------------------------------------------------------------------ + @http.route(PREFIX + '/acceso//', type='http', + auth='none', methods=['POST', 'OPTIONS'], csrf=False) + @require_role('recepcion') + @require_perm('portal.invitar') + def portal_acceso_estado(self, partner_id, accion, **kw): + try: + partner = request.env['res.partner'].sudo().browse(partner_id) + if not partner.exists() or not partner.is_patient: + return _json({'status': 'error', 'message': 'Paciente no encontrado'}, 404) + if accion not in ('activar', 'desactivar'): + return _json({'status': 'error', 'message': 'Acción no válida'}, 400) + svc = self._svc() + acceso = svc.acceso_de(partner, crear=(accion == 'activar')) + if not acceso: + return _json({'status': 'error', + 'message': 'El paciente no tiene acceso al portal.'}, 404) + activo = accion == 'activar' + acceso.write({'activo': activo}) + if not activo: + acceso.action_cerrar_sesiones() + return _json({'status': 'success', 'ok': True, + 'acceso': svc.estado_paciente(partner)}) + except Exception as exc: + request.env.cr.rollback() + _logger.exception('Error cambiando el acceso al portal: %s', exc) + return _json({'status': 'error', 'message': 'No se pudo actualizar.'}, 500) diff --git a/odoo-addons/skeen_portal_cliente/data/portal_cron.xml b/odoo-addons/skeen_portal_cliente/data/portal_cron.xml new file mode 100644 index 0000000..280e0df --- /dev/null +++ b/odoo-addons/skeen_portal_cliente/data/portal_cron.xml @@ -0,0 +1,17 @@ + + + + + + Portal del cliente: limpiar códigos y sesiones + + code + model._cron_limpiar() + 1 + days + -1 + + + + diff --git a/odoo-addons/skeen_portal_cliente/models/__init__.py b/odoo-addons/skeen_portal_cliente/models/__init__.py new file mode 100644 index 0000000..811abc3 --- /dev/null +++ b/odoo-addons/skeen_portal_cliente/models/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import portal diff --git a/odoo-addons/skeen_portal_cliente/models/portal.py b/odoo-addons/skeen_portal_cliente/models/portal.py new file mode 100644 index 0000000..2d7db8e --- /dev/null +++ b/odoo-addons/skeen_portal_cliente/models/portal.py @@ -0,0 +1,538 @@ +# -*- coding: utf-8 -*- +"""Cuenta del cliente (portal de autoservicio del paciente). + +Tres modelos de soporte, todos gobernados por el bloque `portal_cliente` de los +ajustes de la clínica: + + skeen.portal.acceso un registro por paciente (activo, método, clave) + skeen.portal.codigo código OTP o enlace de acceso (hash, caducidad, intentos) + skeen.portal.sesion sesiones del portal (token revocable) + +La sesión del cliente es un token HMAC firmado con `skeen.frontend.secret`, con +alcance limitado al paciente identificado (`pid`) y ligado a una fila de +`skeen.portal.sesion` para poder cerrarla de forma explícita. Es independiente +de la sesión del personal: un token de cliente no lleva `uid` (el personal lo +rechaza con 401) y un token de personal no lleva `pid`/`kind='portal'` (el +portal lo rechaza). El portal nunca confía en un identificador enviado por el +navegador: siempre resuelve el paciente desde la sesión. + +Toda la lógica de tokens, códigos y sesiones vive en `skeen.portal.service`, +para no duplicarla entre el controlador público y el del personal. +""" + +import hashlib +import hmac +import json +import logging +import re +import secrets +import time +from base64 import urlsafe_b64decode, urlsafe_b64encode +from datetime import timedelta + +from odoo import api, fields, models +from odoo.exceptions import ValidationError + +from odoo.addons.skeen_citas.clinic_settings import ( + get_notificaciones, + get_portal_cliente, + url_publica, +) + +_logger = logging.getLogger(__name__) + +METODOS = [ + ('otp_whatsapp', 'Código por WhatsApp'), + ('enlace_correo', 'Enlace por correo'), + ('ambos', 'Código o enlace'), +] +CANALES = [('whatsapp', 'WhatsApp'), ('email', 'Correo')] + +TOKEN_KIND_SESION = 'portal' +TOKEN_KIND_ENLACE = 'portal_link' + +CODIGO_MINUTOS = 10 # caducidad del código de 6 dígitos +ENLACE_MINUTOS = 30 # caducidad del enlace de acceso por correo +CODIGO_MAX_INTENTOS = 5 +CODIGOS_POR_HORA = 5 # máximo de códigos solicitados por paciente/hora +_EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$') + + +def _b64(raw): + return urlsafe_b64encode(raw).rstrip(b'=').decode('ascii') + + +def _b64d(texto): + return urlsafe_b64decode(texto + '=' * (-len(texto) % 4)) + + +class SkeenPortalAcceso(models.Model): + _name = 'skeen.portal.acceso' + _description = 'Acceso al portal del cliente' + _order = 'id desc' + + partner_id = fields.Many2one('res.partner', string='Paciente', required=True, + ondelete='cascade', index=True, + domain=[('is_patient', '=', True)]) + activo = fields.Boolean(string='Activo', default=True) + metodo = fields.Selection(METODOS, string='Método de acceso') + password_hash = fields.Char(string='Contraseña (hash)', copy=False) + password_salt = fields.Char(string='Sal', copy=False) + ultimo_acceso = fields.Datetime(string='Última entrada') + total_accesos = fields.Integer(string='Accesos', default=0) + invitado_por = fields.Char(string='Invitado por') + invitado_at = fields.Datetime(string='Invitado el') + notas = fields.Text(string='Notas') + + sesion_ids = fields.One2many('skeen.portal.sesion', 'acceso_id', + string='Sesiones') + sesiones_abiertas = fields.Integer(string='Sesiones abiertas', + compute='_compute_sesiones_abiertas') + + _sql_constraints = [ + ('partner_uniq', 'unique(partner_id)', + 'Ya existe un acceso al portal para ese paciente.'), + ] + + @api.depends('sesion_ids.cerrada', 'sesion_ids.expira') + def _compute_sesiones_abiertas(self): + ahora = fields.Datetime.now() + for acceso in self: + acceso.sesiones_abiertas = len(acceso.sesion_ids.filtered( + lambda s: not s.cerrada and (not s.expira or s.expira > ahora))) + + def set_password(self, password): + self.ensure_one() + salt = secrets.token_hex(16) + raw = ('%s%s' % (salt, password)).encode('utf-8') + self.write({'password_hash': hashlib.sha256(raw).hexdigest(), + 'password_salt': salt}) + + def check_password(self, password): + self.ensure_one() + if not self.password_hash or not self.password_salt: + return False + raw = ('%s%s' % (self.password_salt, password)).encode('utf-8') + return hmac.compare_digest(hashlib.sha256(raw).hexdigest(), + self.password_hash) + + def _sesiones_vigentes(self): + ahora = fields.Datetime.now() + return self.sesion_ids.filtered( + lambda s: not s.cerrada and (not s.expira or s.expira > ahora)) + + def action_cerrar_sesiones(self): + ahora = fields.Datetime.now() + for acceso in self: + vigentes = acceso._sesiones_vigentes() + vigentes.write({'cerrada': True, 'cerrada_at': ahora}) + return True + + @api.model + def _cron_limpiar(self): + """Borra códigos y sesiones caducados (inocuo con el portal apagado).""" + ahora = fields.Datetime.now() + self.env['skeen.portal.codigo'].sudo().search( + [('expira', '<', ahora)]).unlink() + self.env['skeen.portal.sesion'].sudo().search( + [('expira', '<', ahora)]).unlink() + return True + + +class SkeenPortalCodigo(models.Model): + _name = 'skeen.portal.codigo' + _description = 'Código de acceso al portal' + _order = 'id desc' + + partner_id = fields.Many2one('res.partner', string='Paciente', required=True, + ondelete='cascade', index=True) + canal = fields.Selection(CANALES, string='Canal', required=True) + codigo_hash = fields.Char(string='Código (hash)', required=True, copy=False) + expira = fields.Datetime(string='Vence el', required=True, index=True) + intentos = fields.Integer(string='Intentos', default=0) + max_intentos = fields.Integer(string='Intentos máximos', + default=CODIGO_MAX_INTENTOS) + usado = fields.Boolean(string='Usado', default=False, index=True) + ip = fields.Char(string='IP') + create_date = fields.Datetime(string='Creado', readonly=True) + + +class SkeenPortalSesion(models.Model): + _name = 'skeen.portal.sesion' + _description = 'Sesión del portal del cliente' + _order = 'id desc' + + acceso_id = fields.Many2one('skeen.portal.acceso', string='Acceso', + required=True, ondelete='cascade', index=True) + partner_id = fields.Many2one(related='acceso_id.partner_id', string='Paciente', + store=True, index=True) + token_id = fields.Char(string='Identificador de sesión', required=True, + index=True, copy=False) + expira = fields.Datetime(string='Vence el', index=True) + cerrada = fields.Boolean(string='Cerrada', default=False, index=True) + cerrada_at = fields.Datetime(string='Cerrada el') + ip = fields.Char(string='IP') + user_agent = fields.Char(string='Navegador') + ultimo_uso = fields.Datetime(string='Último uso') + create_date = fields.Datetime(string='Creada', readonly=True) + + _sql_constraints = [ + ('token_id_uniq', 'unique(token_id)', + 'El identificador de sesión del portal debe ser único.'), + ] + + +class ResPartner(models.Model): + _inherit = 'res.partner' + + portal_acceso_ids = fields.One2many('skeen.portal.acceso', 'partner_id', + string='Accesos al portal') + + +class SkeenPortalService(models.AbstractModel): + """Servicio del portal: tokens, códigos, sesiones y datos del paciente.""" + + _name = 'skeen.portal.service' + _description = 'Servicio del portal del cliente' + + # ------------------------------------------------------------------ + # Configuración y secreto + # ------------------------------------------------------------------ + @api.model + def cfg(self): + return get_portal_cliente(self.env) + + @api.model + def activo(self): + return bool(self.cfg().get('activo')) + + @api.model + def _secreto(self): + # Mismo secreto de la instancia que firma la sesión del personal y los + # tokens de reservas/cobros/formularios. + from odoo.addons.skeen_whatsapp.controllers.auth import _get_secret + return _get_secret(self.env) + + # ------------------------------------------------------------------ + # Tokens firmados (HMAC) + # ------------------------------------------------------------------ + @api.model + def _firmar(self, payload): + body = _b64(json.dumps(payload, separators=(',', ':')).encode('utf-8')) + sig = hmac.new(self._secreto().encode('utf-8'), body.encode('ascii'), + hashlib.sha256).hexdigest() + return '%s.%s' % (body, sig) + + @api.model + def _verificar(self, token, kind): + if not token or '.' not in str(token): + return None + try: + body, sig = str(token).rsplit('.', 1) + esperada = hmac.new(self._secreto().encode('utf-8'), + body.encode('ascii'), hashlib.sha256).hexdigest() + if not hmac.compare_digest(sig, esperada): + return None + payload = json.loads(_b64d(body).decode('utf-8')) + except Exception: + return None + if payload.get('kind') != kind: + return None + if int(payload.get('exp', 0)) < int(time.time()): + return None + return payload + + @api.model + def hash_codigo(self, valor): + return hmac.new(self._secreto().encode('utf-8'), + str(valor or '').encode('utf-8'), hashlib.sha256).hexdigest() + + # ------------------------------------------------------------------ + # Paciente + # ------------------------------------------------------------------ + @api.model + def buscar_paciente(self, identificador): + """Paciente (is_patient) por teléfono o correo. None si no existe. + + La búsqueda es tolerante al formato del teléfono, igual que el portal de + reservas (variantes con/sin +52 y búsqueda por últimos dígitos). + """ + ident = str(identificador or '').strip() + if not ident: + return None + Partner = self.env['res.partner'].sudo() + if '@' in ident: + if not _EMAIL_RE.match(ident): + return None + return Partner.search([ + ('is_patient', '=', True), ('email', '=ilike', ident)], limit=1) + digitos = re.sub(r'\D', '', ident) + if len(digitos) < 10: + return None + variantes = {digitos, '+%s' % digitos, '52%s' % digitos, '+52%s' % digitos} + partner = Partner.search([ + ('is_patient', '=', True), + '|', ('phone', 'in', list(variantes)), ('mobile', 'in', list(variantes)), + ], limit=1) + if not partner: + partner = Partner.search([ + ('is_patient', '=', True), + '|', ('phone', 'ilike', digitos[-10:]), ('mobile', 'ilike', digitos[-10:]), + ], limit=1) + return partner + + @api.model + def acceso_de(self, partner, crear=False, metodo=None, invitado_por=''): + Acceso = self.env['skeen.portal.acceso'].sudo() + acceso = Acceso.search([('partner_id', '=', partner.id)], limit=1) + if not acceso and crear: + acceso = Acceso.create({ + 'partner_id': partner.id, + 'activo': True, + 'metodo': metodo or self.cfg().get('metodo'), + 'invitado_por': invitado_por or '', + 'invitado_at': fields.Datetime.now(), + }) + return acceso + + # ------------------------------------------------------------------ + # Códigos y enlaces + # ------------------------------------------------------------------ + @api.model + def crear_codigo(self, partner, canal, valor, minutos, ip=''): + return self.env['skeen.portal.codigo'].sudo().create({ + 'partner_id': partner.id, + 'canal': canal, + 'codigo_hash': self.hash_codigo(valor), + 'expira': fields.Datetime.now() + timedelta(minutes=int(minutos)), + 'max_intentos': CODIGO_MAX_INTENTOS, + 'ip': ip or '', + }) + + @api.model + def codigos_recientes(self, partner, canal=None, horas=1): + desde = fields.Datetime.now() - timedelta(hours=int(horas)) + dom = [('partner_id', '=', partner.id), ('create_date', '>=', desde)] + if canal: + dom.append(('canal', '=', canal)) + return self.env['skeen.portal.codigo'].sudo().search_count(dom) + + @api.model + def destino_disponible(self, partner, canal): + if not partner: + return False + if canal == 'whatsapp': + return bool((partner.whatsapp or partner.phone or '').strip()) + return bool((partner.email or '').strip()) + + @api.model + def canal_para(self, cfg, partner, identificador): + """Canal que corresponde al identificador y al método configurado.""" + metodo = cfg.get('metodo') or 'otp_whatsapp' + es_email = '@' in str(identificador or '') + if metodo == 'enlace_correo': + return 'email' + if metodo == 'otp_whatsapp': + return 'whatsapp' + return 'email' if (es_email and (partner.email or '')) else 'whatsapp' + + @api.model + def enviar_codigo(self, partner, ip=''): + """Genera, guarda y encola el código de 6 dígitos. Devuelve el código.""" + codigo = '%06d' % secrets.randbelow(1000000) + self.crear_codigo(partner, 'whatsapp', codigo, CODIGO_MINUTOS, ip=ip) + self._encolar_acceso(partner, 'acceso_codigo', 'whatsapp', + {'codigo': codigo, 'minutos': CODIGO_MINUTOS}) + return codigo + + @api.model + def enviar_enlace(self, partner, ip=''): + """Genera, guarda y encola el enlace de acceso por correo. Devuelve la URL.""" + token = self._firmar({'pid': partner.id, 'kind': TOKEN_KIND_ENLACE, + 'exp': int(time.time()) + ENLACE_MINUTOS * 60, + 'n': secrets.token_urlsafe(8)}) + self.crear_codigo(partner, 'email', token, ENLACE_MINUTOS, ip=ip) + base = url_publica(self.env, fallback='') + url = '%s/mi-cuenta/acceso/%s' % (base, token) + self._encolar_acceso(partner, 'acceso_enlace', 'email', {'enlace': url}) + return url + + @api.model + def _encolar_acceso(self, partner, tipo, canal, extra): + """Encola y dispara el aviso por el motor de notificaciones. + + Con `notificaciones.activo=False` no se envía nada (el motor lo decide). + Nunca debe tumbar el acceso: cualquier fallo se registra y se ignora. + """ + try: + svc = self.env['skeen.notificacion.service'].sudo() + ncfg = get_notificaciones(self.env) + if not ncfg.get('activo'): + return False + clave = '%s:%s:%s' % (tipo, partner.id, secrets.token_hex(6)) + notifs = svc.encolar(partner=partner, tipo=tipo, canal=canal, + cuando=fields.Datetime.now(), clave_unica=clave, + contexto_extra=extra) + for notif in notifs: + svc._intentar(notif, ncfg) + return bool(notifs) + except Exception: + _logger.exception('No se pudo enviar el acceso al portal') + return False + + @api.model + def modo_prueba(self): + return bool(get_notificaciones(self.env).get('modo_prueba')) + + @api.model + def verificar_codigo(self, partner, codigo): + """Valida el código. Devuelve (ok, mensaje). Consume intentos.""" + codigo = re.sub(r'\D', '', str(codigo or '')) + if len(codigo) != 6: + return False, 'Escribe el código de 6 dígitos.' + Codigo = self.env['skeen.portal.codigo'].sudo() + registros = Codigo.search([ + ('partner_id', '=', partner.id), ('canal', '=', 'whatsapp'), + ('usado', '=', False)], order='id desc', limit=1) + if not registros: + return False, 'El código no es válido o ya expiró.' + registro = registros[0] + if registro.expira and registro.expira < fields.Datetime.now(): + return False, 'El código expiró. Solicita uno nuevo.' + if registro.intentos >= registro.max_intentos: + registro.write({'usado': True}) + return False, 'Se agotaron los intentos. Solicita uno nuevo.' + if not hmac.compare_digest(self.hash_codigo(codigo), registro.codigo_hash): + intentos = registro.intentos + 1 + registro.write({'intentos': intentos, + 'usado': intentos >= registro.max_intentos}) + return False, 'El código no es válido o ya expiró.' + registro.write({'usado': True}) + return True, '' + + @api.model + def verificar_enlace(self, token): + """Valida el enlace de acceso. Devuelve (partner, error). Consume el token.""" + payload = self._verificar(token, TOKEN_KIND_ENLACE) + if not payload: + return None, 'El enlace no es válido o ya expiró.' + partner = self.env['res.partner'].sudo().browse(int(payload.get('pid') or 0)) + if not partner.exists() or not partner.is_patient: + return None, 'El enlace no es válido o ya expiró.' + Codigo = self.env['skeen.portal.codigo'].sudo() + registro = Codigo.search([ + ('partner_id', '=', partner.id), ('canal', '=', 'email'), + ('codigo_hash', '=', self.hash_codigo(token)), + ('usado', '=', False)], limit=1) + if not registro: + return None, 'El enlace no es válido o ya expiró.' + if registro.expira and registro.expira < fields.Datetime.now(): + return None, 'El enlace no es válido o ya expiró.' + registro.write({'usado': True}) + return partner, '' + + # ------------------------------------------------------------------ + # Sesiones + # ------------------------------------------------------------------ + @api.model + def crear_sesion(self, partner, ip='', user_agent=''): + """Crea la sesión y devuelve (token, sesion).""" + cfg = self.cfg() + horas = max(1, int(cfg.get('sesion_horas') or 24)) + acceso = self.acceso_de(partner, crear=True, metodo=cfg.get('metodo')) + if not acceso.activo: + return None, None + sesion = self.env['skeen.portal.sesion'].sudo().create({ + 'acceso_id': acceso.id, + 'token_id': secrets.token_urlsafe(24), + 'expira': fields.Datetime.now() + timedelta(hours=horas), + 'ip': ip or '', + 'user_agent': (user_agent or '')[:200], + 'ultimo_uso': fields.Datetime.now(), + }) + token = self._firmar({ + 'pid': partner.id, 'sid': sesion.id, 'jti': sesion.token_id, + 'kind': TOKEN_KIND_SESION, + 'exp': int(time.time()) + horas * 3600, + }) + acceso.write({ + 'ultimo_acceso': fields.Datetime.now(), + 'total_accesos': (acceso.total_accesos or 0) + 1, + }) + return token, sesion + + @api.model + def verificar_sesion(self, token): + """Devuelve (partner, sesion, error). El paciente SIEMPRE sale del token.""" + payload = self._verificar(token, TOKEN_KIND_SESION) + if not payload: + return None, None, 'Sesión no válida o expirada.' + sesion = self.env['skeen.portal.sesion'].sudo().browse(int(payload.get('sid') or 0)) + if not sesion.exists() or sesion.cerrada: + return None, None, 'Sesión no válida o expirada.' + if not hmac.compare_digest(str(sesion.token_id or ''), str(payload.get('jti') or '')): + return None, None, 'Sesión no válida o expirada.' + if sesion.expira and sesion.expira < fields.Datetime.now(): + return None, None, 'Sesión no válida o expirada.' + partner = self.env['res.partner'].sudo().browse(int(payload.get('pid') or 0)) + if not partner.exists() or not partner.is_patient: + return None, None, 'Sesión no válida o expirada.' + if sesion.partner_id.id != partner.id: + return None, None, 'Sesión no válida o expirada.' + sesion.write({'ultimo_uso': fields.Datetime.now()}) + return partner, sesion, '' + + @api.model + def cerrar_sesion(self, sesion): + if sesion and sesion.exists(): + sesion.write({'cerrada': True, 'cerrada_at': fields.Datetime.now()}) + return True + + # ------------------------------------------------------------------ + # Datos para el staff + # ------------------------------------------------------------------ + @api.model + def estado_paciente(self, partner): + """Resumen del acceso de un paciente para la ficha de expediente.""" + acceso = self.acceso_de(partner, crear=False) + if not acceso: + return { + 'partner_id': partner.id, + 'tiene_acceso': False, + 'activo': False, + 'ultimo_acceso': None, + 'total_accesos': 0, + 'sesiones_abiertas': 0, + 'metodo': None, + } + return { + 'partner_id': partner.id, + 'tiene_acceso': True, + 'activo': bool(acceso.activo), + 'ultimo_acceso': acceso.ultimo_acceso, + 'total_accesos': acceso.total_accesos or 0, + 'sesiones_abiertas': len(acceso._sesiones_vigentes()), + 'metodo': acceso.metodo, + 'invitado_por': acceso.invitado_por or '', + 'invitado_at': acceso.invitado_at, + } + + @api.model + def lista_accesos(self, limite=200): + Acceso = self.env['skeen.portal.acceso'].sudo() + accesos = Acceso.search([], order='ultimo_acceso desc, id desc', limit=limite) + out = [] + for acceso in accesos: + out.append({ + 'partner_id': acceso.partner_id.id, + 'paciente': acceso.partner_id.name, + 'patient_id': acceso.partner_id.patient_id or '', + 'telefono': acceso.partner_id.phone or '', + 'email': acceso.partner_id.email or '', + 'activo': bool(acceso.activo), + 'metodo': acceso.metodo, + 'ultimo_acceso': acceso.ultimo_acceso, + 'total_accesos': acceso.total_accesos or 0, + 'sesiones_abiertas': len(acceso._sesiones_vigentes()), + }) + return out diff --git a/odoo-addons/skeen_portal_cliente/security/ir.model.access.csv b/odoo-addons/skeen_portal_cliente/security/ir.model.access.csv new file mode 100644 index 0000000..1d4ec3c --- /dev/null +++ b/odoo-addons/skeen_portal_cliente/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_skeen_portal_acceso_user,Acceso al portal del cliente,model_skeen_portal_acceso,base.group_user,1,1,1,1 +access_skeen_portal_codigo_user,Código de acceso al portal,model_skeen_portal_codigo,base.group_user,1,1,1,1 +access_skeen_portal_sesion_user,Sesión del portal del cliente,model_skeen_portal_sesion,base.group_user,1,1,1,1 diff --git a/odoo-addons/skeen_whatsapp/models/notificacion.py b/odoo-addons/skeen_whatsapp/models/notificacion.py index 00e9b76..3e5f1c7 100644 --- a/odoo-addons/skeen_whatsapp/models/notificacion.py +++ b/odoo-addons/skeen_whatsapp/models/notificacion.py @@ -46,6 +46,8 @@ TIPOS = [ ('formulario', 'Formulario'), ('renovacion_membresia', 'Renovación de membresía'), ('cobro_membresia', 'Cobro de membresía'), + ('acceso_codigo', 'Código de acceso al portal'), + ('acceso_enlace', 'Enlace de acceso al portal'), ('manual', 'Manual'), ] CANALES = [('whatsapp', 'WhatsApp'), ('email', 'Correo')] @@ -73,6 +75,9 @@ PLANTILLA_POR_TIPO = { 'renovacion_membresia': 'plantilla_renovacion_membresia', # Aviso con enlace de pago del periodo (cobro automático de membresías). 'cobro_membresia': 'plantilla_cobro_membresia', + # Acceso a la cuenta del cliente (portal): código de 6 dígitos o enlace. + 'acceso_codigo': 'plantilla_codigo_acceso', + 'acceso_enlace': 'plantilla_enlace_acceso', 'manual': 'plantilla_recordatorio', } diff --git a/odoo-addons/skeen_whatsapp/permissions.py b/odoo-addons/skeen_whatsapp/permissions.py index 425ae88..6090cc4 100644 --- a/odoo-addons/skeen_whatsapp/permissions.py +++ b/odoo-addons/skeen_whatsapp/permissions.py @@ -50,6 +50,8 @@ PERMISSION_GROUPS = [ ('pacientes.eliminar', 'Eliminar pacientes'), ('pacientes.exportar', 'Exportar pacientes'), ('pacientes.saldos', 'Ver saldos y adeudos'), + ('portal.invitar', 'Invitar al portal del cliente y cerrar sus sesiones'), + ('portal.sesiones', 'Ver quién tiene acceso al portal del cliente'), ]), ('visitas', 'Visitas y expedientes', [ ('visitas.ver', 'Ver visitas'), @@ -213,6 +215,10 @@ DEFAULT_PERM_ROLES = { 'pacientes.eliminar': W, 'pacientes.exportar': ALL, 'pacientes.saldos': ALL, + # Portal del cliente: invitar y cerrar sesiones, y ver el listado de + # accesos, es de recepción/gerencia/admin (dato de contacto del paciente). + 'portal.invitar': ('admin', 'recepcion', 'gerencia'), + 'portal.sesiones': ('admin', 'recepcion', 'gerencia'), # Visitas 'visitas.ver': ALL, 'visitas.crear': W,