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

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

View File

@@ -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 pagado, el pago se marca como reembolsado y la cita como "Anticipo
reembolsado". Con `false`, el anticipo se retiene. 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) ## 8. Recursos (cabinas, salas y equipos)
**Configuración → Recursos** (apagado por defecto) **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). - [ ] Comisiones (reglas, metas y liquidaciones).
- [ ] Formularios y consentimientos. - [ ] Formularios y consentimientos.
- [ ] Reservas en línea (activar y difundir el enlace). - [ ] 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. - [ ] Notificaciones en modo prueba → revisar textos → encender.
- [ ] Tickets: formato y secciones. - [ ] Tickets: formato y secciones.
- [ ] Licencia: aplicar la clave del plan contratado. - [ ] 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.4 | PWA instalable y branding por instancia | Entregado |
| 3.5 | Licenciamiento por plan, API keys gestionables y webhooks | Entregado | | 3.5 | Licenciamiento por plan, API keys gestionables y webhooks | Entregado |
| 3.6 | i18n español/inglés | 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 | | 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 Los pendientes y limitaciones conocidos (los que aún no están cerrados) viven en

View File

@@ -44,6 +44,7 @@ import Reservar from './pages/Reservar';
import ReservarCita from './pages/ReservarCita'; import ReservarCita from './pages/ReservarCita';
import Pagar from './pages/Pagar'; import Pagar from './pages/Pagar';
import FormularioPublico from './pages/FormularioPublico'; import FormularioPublico from './pages/FormularioPublico';
import MiCuenta from './pages/MiCuenta';
import './App.css'; import './App.css';
const FullScreenLoader: FC = () => ( const FullScreenLoader: FC = () => (
@@ -185,6 +186,10 @@ const App: FC = () => {
<Route path="/pagar/:token" element={<Pagar />} /> <Route path="/pagar/:token" element={<Pagar />} />
{/* Portal público de formularios: token HMAC como credencial. */} {/* Portal público de formularios: token HMAC como credencial. */}
<Route path="/formulario/:token" element={<FormularioPublico />} /> <Route path="/formulario/:token" element={<FormularioPublico />} />
{/* Cuenta del cliente (portal de autoservicio): ruta pública, fuera
del gate del personal. Apagada por defecto en el backend. */}
<Route path="/mi-cuenta" element={<MiCuenta />} />
<Route path="/mi-cuenta/acceso/:token" element={<MiCuenta />} />
<Route path="/" element={<RequireAuth href="/"><HomeRoute /></RequireAuth>} /> <Route path="/" element={<RequireAuth href="/"><HomeRoute /></RequireAuth>} />
<Route path="/agenda" element={<RequireAuth href="/agenda"><Agenda /></RequireAuth>} /> <Route path="/agenda" element={<RequireAuth href="/agenda"><Agenda /></RequireAuth>} />
<Route path="/mis-ventas" element={<RequireAuth href="/mis-ventas"><MisVentas /></RequireAuth>} /> <Route path="/mis-ventas" element={<RequireAuth href="/mis-ventas"><MisVentas /></RequireAuth>} />

View File

@@ -10,6 +10,7 @@ import { Badge, Button, Skeleton } from './ui';
import RecargarSaldoModal from './RecargarSaldoModal'; import RecargarSaldoModal from './RecargarSaldoModal';
import ReciboPrint from './ReciboPrint'; import ReciboPrint from './ReciboPrint';
import MembresiasPaciente from './MembresiasPaciente'; import MembresiasPaciente from './MembresiasPaciente';
import PortalPacienteAcceso from './PortalPacienteAcceso';
import { waLink } from '../lib/whatsapp'; import { waLink } from '../lib/whatsapp';
import { badgeForAppointmentState, badgeForSaleState } from './ui'; 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) */} {/* Membresías activas (solo si la clínica las tiene encendidas) */}
<MembresiasPaciente partnerId={patient.id} /> <MembresiasPaciente partnerId={patient.id} />
{/* Acceso al portal del cliente (solo si está activo) */}
<PortalPacienteAcceso partnerId={patient.id} />
{/* Contacto */} {/* Contacto */}
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div className="p-3 bg-theme-bg rounded-xl"> <div className="p-3 bg-theme-bg rounded-xl">

View File

@@ -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<Props> = ({ settings, onSaved }) => {
const { t } = useI18n();
const [form, setForm] = useState<PortalClienteSettings>(
settings.portal_cliente || PORTAL_CLIENTE_DEFAULT);
const [saving, setSaving] = useState(false);
const [copiado, setCopiado] = useState(false);
const [accesos, setAccesos] = useState<PortalAcceso[]>([]);
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 extends keyof PortalClienteSettings>(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 (
<div className="space-y-6">
<div className="flex items-start gap-3">
<UserCheck size={20} className="mt-0.5 shrink-0 text-theme-accent" />
<div>
<h3 className="font-heading text-xl text-theme-heading">{t('config.portal.title')}</h3>
<p className="text-sm text-theme-muted">{t('config.portal.help')}</p>
</div>
</div>
<Toggle label={t('config.portal.enable')} checked={form.activo}
onChange={(v) => set('activo', v)} ayuda={t('config.portal.enableHelp')} />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Select label={t('config.portal.method')} value={form.metodo}
onChange={(e) => set('metodo', e.target.value as PortalClienteSettings['metodo'])}
options={[
{ value: 'otp_whatsapp', label: t('config.portal.methodWhatsapp') },
{ value: 'enlace_correo', label: t('config.portal.methodEmail') },
{ value: 'ambos', label: t('config.portal.methodBoth') },
]} />
<Input label={t('config.portal.sessionHours')} type="number" min={1} max={720}
value={String(form.sesion_horas)}
onChange={(e) => set('sesion_horas', Number(e.target.value) || 1)} />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Toggle label={t('config.portal.requireEmail')} checked={form.requerir_correo_verificado}
onChange={(v) => set('requerir_correo_verificado', v)}
ayuda={t('config.portal.requireEmailHelp')} />
<Toggle label={t('config.portal.password')} checked={form.permitir_contrasena}
onChange={(v) => set('permitir_contrasena', v)}
ayuda={t('config.portal.passwordHelp')} />
<Toggle label={t('config.portal.allowReschedule')} checked={form.permitir_reagendar}
onChange={(v) => set('permitir_reagendar', v)} />
<Input label={t('config.portal.cancelHours')} type="number" min={0} max={168}
value={String(form.horas_minimas_cancelar)}
onChange={(e) => set('horas_minimas_cancelar', Number(e.target.value) || 0)} />
<Toggle label={t('config.portal.showPrices')} checked={form.mostrar_precios}
onChange={(v) => set('mostrar_precios', v)} />
<Toggle label={t('config.portal.showRecipes')} checked={form.mostrar_recetas}
onChange={(v) => set('mostrar_recetas', v)} />
<Toggle label={t('config.portal.allowStatement')} checked={form.permitir_descargar_estado_cuenta}
onChange={(v) => set('permitir_descargar_estado_cuenta', v)} />
<Toggle label={t('config.portal.allowUpdate')} checked={form.permitir_actualizar_datos}
onChange={(v) => set('permitir_actualizar_datos', v)} />
</div>
<div className="rounded-2xl border border-theme-border bg-theme-bg p-4">
<p className="text-xs font-medium text-theme-muted">{t('config.portal.linkTitle')}</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<input readOnly value={enlace}
className="min-w-0 flex-1 rounded-xl border border-theme-border bg-theme-surface px-3 py-2 text-xs text-theme-heading" />
<Button variant="ghost" onClick={copiar}>
<Copy size={14} className="mr-1" /> {copiado ? t('config.portal.copied') : t('config.portal.copy')}
</Button>
<Button variant="ghost" onClick={() => window.open('/mi-cuenta', '_blank', 'noopener')}>
<ExternalLink size={14} className="mr-1" /> {t('config.portal.open')}
</Button>
</div>
<p className="mt-2 text-[11px] text-theme-muted">{t('config.portal.linkHelp')}</p>
</div>
<div className="flex justify-end">
<Button loading={saving} onClick={guardar}>
<Save size={16} className="mr-2" /> {t('config.portal.save')}
</Button>
</div>
<div className="rounded-2xl border border-theme-border p-4">
<h4 className="font-heading text-base text-theme-heading">{t('config.portal.accessTitle')}</h4>
<p className="mt-1 text-sm text-theme-muted">{t('config.portal.accessHelp')}</p>
{cargandoAccesos ? null : accesos.length === 0 ? (
<p className="mt-3 text-sm text-theme-muted">{t('config.portal.accessNone')}</p>
) : (
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-theme-muted">
<th className="py-2">{t('config.portal.colPatient')}</th>
<th className="py-2">{t('config.portal.colLast')}</th>
<th className="py-2">{t('config.portal.colSessions')}</th>
<th className="py-2" />
</tr>
</thead>
<tbody>
{accesos.map((a) => (
<tr key={a.partner_id} className="border-t border-theme-border">
<td className="py-2 text-theme-heading">
{a.paciente}
<span className="block text-xs text-theme-muted">{a.telefono || a.email || ''}</span>
</td>
<td className="py-2 text-theme-muted">{a.ultimo_acceso ? String(a.ultimo_acceso).slice(0, 16) : '—'}</td>
<td className="py-2 text-theme-muted">{a.sesiones_abiertas}</td>
<td className="py-2 text-right">
{a.sesiones_abiertas > 0 && (
<Button size="sm" variant="ghost" onClick={() => cerrarSesiones(a.partner_id)}>
{t('config.portal.closeSessions')}
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
};
const Toggle: FC<{ label: string; checked: boolean; onChange: (v: boolean) => void; ayuda?: ReactNode }> = ({
label, checked, onChange, ayuda,
}) => (
<label className="flex cursor-pointer items-start gap-3 rounded-2xl border border-theme-border px-4 py-3">
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)}
className="mt-0.5 h-4 w-4 accent-theme-accent" />
<span>
<span className="block text-sm font-medium text-theme-heading">{label}</span>
{ayuda && <span className="mt-0.5 block text-xs text-theme-muted">{ayuda}</span>}
</span>
</label>
);
export default PortalClientePanel;

View File

@@ -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<PortalAcceso | null>(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 (
<div className="rounded-2xl border border-theme-border p-4">
<div className="flex items-center justify-between gap-2">
<p className="flex items-center gap-1.5 text-sm font-medium text-theme-heading">
<UserCheck size={15} /> {t('portal.patient.title')}
</p>
<span className={`rounded-full px-2.5 py-0.5 text-xs ${estado?.activo ? 'bg-emerald-100 text-emerald-700' : 'bg-theme-bg text-theme-muted'}`}>
{tiene ? (estado?.activo ? t('config.portal.on') : t('config.portal.off')) : t('portal.patient.noAccess')}
</span>
</div>
{tiene && (
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-theme-muted">
<span>{t('portal.patient.lastAccess')}: {estado?.ultimo_acceso ? String(estado.ultimo_acceso).slice(0, 16) : '—'}</span>
<span>{t('portal.patient.sessions')}: {estado?.sesiones_abiertas ?? 0}</span>
</div>
)}
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" loading={busy} onClick={invitar}>{t('portal.patient.invite')}</Button>
{tiene && estado?.sesiones_abiertas ? (
<Button size="sm" variant="ghost" disabled={busy} onClick={cerrar}>{t('config.portal.closeSessions')}</Button>
) : null}
{tiene && (
<Button size="sm" variant="ghost" disabled={busy}
onClick={() => cambiar(estado?.activo ? 'desactivar' : 'activar')}>
{estado?.activo ? t('portal.patient.deactivate') : t('portal.patient.activate')}
</Button>
)}
</div>
</div>
);
};
export default PortalPacienteAcceso;

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; 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). // 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. // Se leen una sola vez del servidor y se avisa a quien los use cuando cambian.
@@ -28,6 +28,7 @@ const normalizar = (settings?: Partial<ClinicSettings> | null): ClinicSettings =
...(settings?.licencia || {}), ...(settings?.licencia || {}),
limites: { ...LICENCIA_LIMITES_DEFAULT, ...((settings?.licencia || {}).limites || {}) }, limites: { ...LICENCIA_LIMITES_DEFAULT, ...((settings?.licencia || {}).limites || {}) },
}, },
portal_cliente: { ...PORTAL_CLIENTE_DEFAULT, ...(settings?.portal_cliente || {}) },
}); });
let actual: ClinicSettings = normalizar(null); let actual: ClinicSettings = normalizar(null);

View File

@@ -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.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.simulated': 'Permitir pago simulado',
'config.cobroAuto.simulatedHelp': 'Permite probar el flujo sin pasarela ni dinero real. Déjalo apagado en producción.', '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.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.simulated': 'Allow simulated payment',
'config.cobroAuto.simulatedHelp': 'Lets you test the flow with no gateway and no real money. Keep it off in production.', '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<Locale, Dict> = { es, en }; export const DICT: Record<Locale, Dict> = { es, en };

View File

@@ -9,6 +9,7 @@ export const PERM_KEYS: string[] = [
'recursos.ver', 'recursos.editar', 'recursos.ver', 'recursos.editar',
'pacientes.ver', 'pacientes.expediente', 'pacientes.crear', 'pacientes.editar', 'pacientes.ver', 'pacientes.expediente', 'pacientes.crear', 'pacientes.editar',
'pacientes.eliminar', 'pacientes.exportar', 'pacientes.saldos', 'pacientes.eliminar', 'pacientes.exportar', 'pacientes.saldos',
'portal.invitar', 'portal.sesiones',
'visitas.ver', 'visitas.crear', 'visitas.editar', 'visitas.eliminar', 'visitas.archivos', 'visitas.ver', 'visitas.crear', 'visitas.editar', 'visitas.eliminar', 'visitas.archivos',
'ventas.vender', 'ventas.cobrar', 'ventas.descuentos', 'ventas.impuestos', 'ventas.cancelar', 'ventas.vender', 'ventas.cobrar', 'ventas.descuentos', 'ventas.impuestos', 'ventas.cancelar',
'ventas.devolver', 'ventas.reimprimir', 'ventas.devolver', 'ventas.reimprimir',
@@ -64,6 +65,8 @@ const DEFAULT_PERM_ROLES: Record<string, string[]> = {
'pacientes.ver': ALL, 'pacientes.expediente': ALL, 'pacientes.crear': W, 'pacientes.ver': ALL, 'pacientes.expediente': ALL, 'pacientes.crear': W,
'pacientes.editar': W, 'pacientes.eliminar': W, 'pacientes.exportar': ALL, 'pacientes.editar': W, 'pacientes.eliminar': W, 'pacientes.exportar': ALL,
'pacientes.saldos': ALL, 'pacientes.saldos': ALL,
'portal.invitar': ['admin', 'recepcion', 'gerencia'],
'portal.sesiones': ['admin', 'recepcion', 'gerencia'],
'visitas.ver': ALL, 'visitas.crear': W, 'visitas.editar': W, 'visitas.ver': ALL, 'visitas.crear': W, 'visitas.editar': W,
'visitas.eliminar': W, 'visitas.archivos': W, 'visitas.eliminar': W, 'visitas.archivos': W,
'ventas.vender': MONEY, 'ventas.cobrar': WN, 'ventas.descuentos': MONEY, 'ventas.vender': MONEY, 'ventas.cobrar': WN, 'ventas.descuentos': MONEY,

View File

@@ -15,6 +15,7 @@ import CategoriasPanel from '../components/CategoriasPanel';
import RecursosPanel from '../components/RecursosPanel'; import RecursosPanel from '../components/RecursosPanel';
import TerminologiaPanel from '../components/TerminologiaPanel'; import TerminologiaPanel from '../components/TerminologiaPanel';
import ReservasOnlinePanel from '../components/ReservasOnlinePanel'; import ReservasOnlinePanel from '../components/ReservasOnlinePanel';
import PortalClientePanel from '../components/PortalClientePanel';
import NotificacionesPanel from '../components/NotificacionesPanel'; import NotificacionesPanel from '../components/NotificacionesPanel';
import NotificacionesEnviadasPanel from '../components/NotificacionesEnviadasPanel'; import NotificacionesEnviadasPanel from '../components/NotificacionesEnviadasPanel';
import FormulariosPanel from '../components/FormulariosPanel'; import FormulariosPanel from '../components/FormulariosPanel';
@@ -34,6 +35,7 @@ import { LanguageSelect } from '../components/LanguageSelect';
const TABS = [ const TABS = [
{ key: 'clinica', label: 'Clínica' }, { key: 'clinica', label: 'Clínica' },
{ key: 'reservas', label: 'Reservas en línea' }, { key: 'reservas', label: 'Reservas en línea' },
{ key: 'portal', label: 'Portal del cliente' },
{ key: 'notificaciones', label: 'Notificaciones' }, { key: 'notificaciones', label: 'Notificaciones' },
{ key: 'notif_enviadas', label: 'Notificaciones enviadas' }, { key: 'notif_enviadas', label: 'Notificaciones enviadas' },
{ key: 'formularios', label: 'Formularios' }, { key: 'formularios', label: 'Formularios' },
@@ -56,7 +58,7 @@ const TABS = [
] as const; ] as const;
// Secciones que ve gerencia (admin del sistema ve todas). // 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 = [ const SUBCATALOGOS = [
{ key: 'diagnosticos', label: 'Diagnósticos' }, { key: 'diagnosticos', label: 'Diagnósticos' },
@@ -450,6 +452,12 @@ const Configuracion: FC = () => {
<ReservasOnlinePanel settings={settings} onSaved={setSettings} /> <ReservasOnlinePanel settings={settings} onSaved={setSettings} />
</Card.Body> </Card.Body>
</Card> </Card>
) : tab === 'portal' ? (
<Card>
<Card.Body>
<PortalClientePanel settings={settings} onSaved={setSettings} />
</Card.Body>
</Card>
) : tab === 'notificaciones' ? ( ) : tab === 'notificaciones' ? (
<Card> <Card>
<Card.Body> <Card.Body>

View File

@@ -8,6 +8,7 @@ import { useAuth } from '../lib/auth';
import { useTheme } from '../lib/theme'; import { useTheme } from '../lib/theme';
import { useI18n } from '../lib/i18n'; import { useI18n } from '../lib/i18n';
import { useBookingConfig } from '../lib/publicBooking'; import { useBookingConfig } from '../lib/publicBooking';
import { usePortalConfig } from '../services/portal';
const Login: FC = () => { const Login: FC = () => {
const { login, brand, setBrand } = useAuth(); 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 // 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). // (con reservas_online.activo=false el endpoint responde 404 y no aparece).
const { active: reservasActivas } = useBookingConfig(); 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 [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
@@ -154,6 +157,18 @@ const Login: FC = () => {
</p> </p>
)} )}
{portalActivo && (
<p className="text-center text-xs mt-3">
<a
href="/mi-cuenta"
className="inline-flex items-center gap-1 underline underline-offset-2 hover:opacity-80"
style={{ color: isHomeNest ? 'var(--text-muted)' : 'rgba(255,255,255,0.6)' }}
>
{t('login.isClient')}
</a>
</p>
)}
<p className="text-center text-xs mt-6" style={{ color: isHomeNest ? 'var(--text-muted)' : 'rgba(255,255,255,0.3)' }}> <p className="text-center text-xs mt-6" style={{ color: isHomeNest ? 'var(--text-muted)' : 'rgba(255,255,255,0.3)' }}>
{brand.pie} {brand.pie}
</p> </p>

View File

@@ -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 = '' }) => (
<div className={`rounded-3xl border border-theme-border bg-theme-surface p-5 shadow-soft ${className}`}>{children}</div>
);
const Centro: FC<{ children: ReactNode }> = ({ children }) => (
<div className="flex min-h-screen items-start justify-center bg-theme-bg px-4 py-8 sm:items-center">{children}</div>
);
const MiCuenta: FC = () => {
const { t } = useI18n();
const { config, loading, active } = usePortalConfig();
const { token: linkToken } = useParams();
const navigate = useNavigate();
const [nombre, setNombre] = useState<string | null>(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 <Centro><Loader2 className="h-6 w-6 animate-spin text-theme-muted" /></Centro>;
}
if (!active || !config) {
return (
<Centro>
<div className="w-full max-w-sm rounded-3xl border border-theme-border bg-theme-surface p-8 text-center shadow-soft">
<AlertTriangle className="mx-auto mb-3 h-8 w-8 text-amber-500" />
<h1 className="font-heading text-lg text-theme-heading">{t('portal.unavailableTitle')}</h1>
<p className="mt-2 text-sm text-theme-muted">{t('portal.unavailableBody')}</p>
</div>
</Centro>
);
}
if (nombre === null) {
return <PortalLogin config={config} onLoggedIn={(n) => setNombre(n)} />;
}
return <PortalPanel config={config} nombre={nombre} onLogout={salir} />;
};
// ------------------------------------------------------------
// 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<string | null>(null);
const [error, setError] = useState<string | null>(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 (
<Centro>
<div className="w-full max-w-sm space-y-4">
<header className="text-center">
{config.brand?.logo_oscuro && (
<img src={config.brand.logo_oscuro} alt={config.brand.nombre}
className="mx-auto mb-3 h-9 w-auto object-contain" />
)}
<h1 className="font-heading text-lg text-theme-heading">{config.brand?.nombre || t('portal.title')}</h1>
<p className="mt-1 text-sm text-theme-muted">{t('portal.title')}</p>
</header>
<Card>
<p className="mb-4 text-sm text-theme-muted">{t('portal.login.intro')}</p>
<Input label={t('portal.login.identifier')} value={identificador}
placeholder={t('portal.login.identifierPh')}
onChange={(e) => setIdentificador(e.target.value)} autoFocus />
{!modoPw && usaCodigo && (
<div className="mt-3 flex items-end gap-2">
<Button className="flex-1" loading={busy} onClick={enviar}>
<KeyRound size={16} className="mr-2" />{t('portal.login.send')}
</Button>
</div>
)}
{!modoPw && !usaCodigo && (
<Button className="mt-3 w-full" loading={busy} onClick={enviar}>
<KeyRound size={16} className="mr-2" />{t('portal.login.send')}
</Button>
)}
{hint && <p className="mt-3 rounded-xl bg-theme-bg p-3 text-xs text-theme-muted break-all">{hint}</p>}
{!modoPw && usaCodigo && enviado && (
<div className="mt-3">
<Input label={t('portal.login.code')} value={codigo}
placeholder={t('portal.login.codePh')} inputMode="numeric"
onChange={(e) => setCodigo(e.target.value.replace(/\D/g, '').slice(0, 6))} />
<Button className="mt-3 w-full" loading={busy} onClick={verificar} disabled={codigo.length !== 6}>
{t('portal.login.enter')}
</Button>
</div>
)}
{modoPw && (
<div className="mt-3">
<Input label={t('portal.login.password')} type="password" value={password}
onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
<Button className="mt-3 w-full" loading={busy} onClick={entrarPw}>
{t('portal.login.enterPw')}
</Button>
</div>
)}
{error && <p className="mt-3 text-sm text-rose-600">{error}</p>}
{config.config.permitir_contrasena && (
<button type="button" onClick={() => { setModoPw((v) => !v); setError(null); }}
className="mt-4 w-full text-center text-xs text-theme-muted underline underline-offset-2 hover:opacity-80">
{modoPw ? t('portal.login.useCode') : t('portal.login.usePassword')}
</button>
)}
</Card>
</div>
</Centro>
);
};
// ------------------------------------------------------------
// 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<TabKey>('resumen');
const TABS: { key: TabKey; label: string; icon: ReactNode }[] = [
{ key: 'resumen', label: t('portal.tabs.resumen'), icon: <Sparkles size={15} /> },
{ key: 'citas', label: t('portal.tabs.citas'), icon: <Calendar size={15} /> },
{ key: 'pagos', label: t('portal.tabs.pagos'), icon: <CreditCard size={15} /> },
{ key: 'monedero', label: t('portal.tabs.monedero'), icon: <Wallet size={15} /> },
{ key: 'paquetes', label: t('portal.tabs.paquetes'), icon: <Package size={15} /> },
{ key: 'membresias', label: t('portal.tabs.membresias'), icon: <Sparkles size={15} /> },
{ key: 'formularios', label: t('portal.tabs.formularios'), icon: <FileSignature size={15} /> },
{ key: 'documentos', label: t('portal.tabs.documentos'), icon: <FileText size={15} /> },
{ key: 'perfil', label: t('portal.tabs.perfil'), icon: <User size={15} /> },
];
return (
<div className="min-h-screen bg-theme-bg">
<header className="sticky top-0 z-10 border-b border-theme-border bg-theme-surface/95 backdrop-blur">
<div className="mx-auto flex max-w-4xl items-center justify-between gap-3 px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
{config.brand?.logo_oscuro && (
<img src={config.brand.logo_oscuro} alt="" className="h-7 w-auto object-contain" />
)}
<span className="truncate font-heading text-sm text-theme-heading">{nombre}</span>
</div>
<Button variant="ghost" size="sm" onClick={onLogout}>
<LogOut size={15} className="mr-1.5" />{t('portal.logout')}
</Button>
</div>
<nav className="mx-auto flex max-w-4xl gap-1.5 overflow-x-auto px-4 pb-3">
{TABS.map((it) => (
<button key={it.key} type="button" onClick={() => setTab(it.key)}
className={`inline-flex shrink-0 items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
tab === it.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'}`}>
{it.icon}{it.label}
</button>
))}
</nav>
</header>
<main className="mx-auto max-w-4xl space-y-4 px-4 py-5">
{tab === 'resumen' && <ResumenTab />}
{tab === 'citas' && <CitasTab config={config} trm={trm} />}
{tab === 'pagos' && <PagosTab config={config} />}
{tab === 'monedero' && <MonederoTab />}
{tab === 'paquetes' && <PaquetesTab />}
{tab === 'membresias' && <MembresiasTab />}
{tab === 'formularios' && <FormulariosTab />}
{tab === 'documentos' && <DocumentosTab />}
{tab === 'perfil' && <PerfilTab />}
{config.config.reservas_online && (
<p className="pt-2 text-center text-xs">
<a href="/reservar" className="text-theme-muted underline underline-offset-2 hover:opacity-80">
{t('login.bookOnline')}
</a>
</p>
)}
</main>
</div>
);
};
const Loading: FC = () => <p className="text-sm text-theme-muted">{<Loader2 className="inline h-4 w-4 animate-spin" />}</p>;
const Empty: FC<{ text: string }> = ({ text }) => <Card><p className="text-sm text-theme-muted">{text}</p></Card>;
const useFetch = <T,>(fn: () => Promise<T>) => {
const [data, setData] = useState<T | null>(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 <Loading />;
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 (
<>
<h2 className="font-heading text-lg text-theme-heading">{t('portal.resumen.hello', { name: data?.paciente?.nombre || '' })}</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{items.map((it) => (
<Card key={it.label}>
<p className="text-xs uppercase tracking-wide text-theme-muted">{it.label}</p>
<p className="mt-1 font-heading text-lg text-theme-heading">{it.value}</p>
</Card>
))}
</div>
</>
);
};
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<PortalCita | null>(null);
const [fechaNueva, setFechaNueva] = useState('');
const [slots, setSlots] = useState<string[]>([]);
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 <Loading />;
const CitaCard: FC<{ c: PortalCita }> = ({ c }) => (
<Card>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-heading text-sm text-theme-heading">{c.servicio || trm('appointment_singular')}</p>
<p className="text-xs text-theme-muted">{fecha(c.fecha)} · {c.hora} · {c.estado_label}</p>
</div>
<span className="shrink-0 rounded-full bg-theme-bg px-2.5 py-1 text-xs text-theme-muted">{c.folio}</span>
</div>
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-theme-muted sm:grid-cols-4">
{c.doctor && <span>{trm('doctor_singular')}: {c.doctor}</span>}
{c.sucursal && <span>{trm('branch_singular')}: {c.sucursal}</span>}
{c.duracion_min > 0 && <span>{t('portal.citas.duration')}: {c.duracion_min} {t('portal.citas.minutes')}</span>}
{c.importe !== null && <span>{t('portal.citas.amount')}: {money(c.importe, c.moneda)}</span>}
</div>
{c.anticipo_estado === 'pendiente' && (
<p className="mt-2 rounded-lg bg-amber-50 px-2 py-1 text-xs text-amber-800">{t('portal.citas.anticipo')}</p>
)}
{(c.puede_cancelar || c.puede_reagendar) && (
<div className="mt-3 flex flex-wrap gap-2">
{c.puede_reagendar && config.config.permitir_reagendar && (
<Button size="sm" variant="outline" disabled={busy} onClick={() => abrirReagenda(c)}>
<Calendar size={14} className="mr-1.5" />{t('portal.citas.reschedule')}
</Button>
)}
{c.puede_cancelar && (
<Button size="sm" variant="ghost" disabled={busy} onClick={() => cancelar(c)}>
{t('portal.citas.cancel')}
</Button>
)}
</div>
)}
{reagendando?.id === c.id && (
<div className="mt-3 rounded-2xl border border-theme-border p-3">
<p className="mb-2 text-xs font-medium text-theme-heading">{t('portal.citas.date')}</p>
<input type="date" value={fechaNueva} onChange={(e) => 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 ? <p className="mt-2 text-xs text-theme-muted">{t('portal.citas.noSlots')}</p> : (
<div className="mt-2">
<p className="mb-1 text-xs font-medium text-theme-heading">{t('portal.citas.slot')}</p>
<div className="flex flex-wrap gap-1.5">
{slots.map((s) => (
<button key={s} type="button" onClick={() => setHora(s)}
className={`rounded-full px-2.5 py-1 text-xs ${hora === s ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-bg text-theme-heading'}`}>
{s}
</button>
))}
</div>
</div>
)
)}
<div className="mt-3 flex gap-2">
<Button size="sm" loading={busy} disabled={!hora} onClick={guardar}>{t('common.save')}</Button>
<Button size="sm" variant="ghost" onClick={() => setReagendando(null)}>{t('common.cancel')}</Button>
</div>
</div>
)}
</Card>
);
return (
<div className="space-y-4">
<h2 className="font-heading text-base text-theme-heading">{t('portal.citas.upcoming')}</h2>
{data.proximas.length === 0 ? <Empty text={t('portal.citas.none')} /> : data.proximas.map((c) => <CitaCard key={c.id} c={c} />)}
{data.pasadas.length > 0 && (
<>
<h2 className="pt-2 font-heading text-base text-theme-heading">{t('portal.citas.past')}</h2>
{data.pasadas.map((c) => <CitaCard key={c.id} c={c} />)}
</>
)}
</div>
);
};
const PagosTab: FC<{ config: PortalConfig }> = ({ config }) => {
const { t } = useI18n();
const { data, loading } = useFetch(() => portalApi.pagos());
if (loading || !data) return <Loading />;
return (
<div className="space-y-4">
<Card>
<p className="text-xs uppercase tracking-wide text-theme-muted">{t('portal.pagos.balance')}</p>
<p className="mt-1 font-heading text-xl text-theme-heading">{money(data.saldo_pendiente, data.moneda)}</p>
{config.config.permitir_descargar_estado_cuenta && (
<Button size="sm" variant="outline" className="mt-3" onClick={() => portalApi.estadoCuenta().then(imprimirEstadoCuenta)}>
<Printer size={14} className="mr-1.5" />{t('portal.pagos.download')}
</Button>
)}
</Card>
<h2 className="font-heading text-base text-theme-heading">{t('portal.pagos.payments')}</h2>
{(data.pagos || []).length === 0 ? <Empty text={t('portal.pagos.none')} /> : (data.pagos || []).map((p: { id: number; folio: string; fecha: string; monto: number; moneda: string; metodo_label: string; estado: string }) => (
<Card key={p.id}>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm text-theme-heading">{p.folio}</p>
<p className="text-xs text-theme-muted">{fecha(p.fecha)} · {p.metodo_label}</p>
</div>
<p className="font-heading text-sm text-theme-heading">{money(p.monto, p.moneda)}</p>
</div>
</Card>
))}
<h2 className="font-heading text-base text-theme-heading">{t('portal.pagos.sales')}</h2>
{(data.ventas || []).length === 0 ? <Empty text={t('portal.pagos.none')} /> : (data.ventas || []).map((v: { id: number; folio: string; fecha: string; total: number | null; moneda: string; estado: string }) => (
<Card key={v.id}>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm text-theme-heading">{v.folio}</p>
<p className="text-xs text-theme-muted">{fecha(v.fecha)} · {v.estado}</p>
</div>
<p className="font-heading text-sm text-theme-heading">{money(v.total, v.moneda)}</p>
</div>
</Card>
))}
</div>
);
};
const MonederoTab: FC = () => {
const { t } = useI18n();
const { data, loading } = useFetch(() => portalApi.monedero());
if (loading || !data) return <Loading />;
return (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<Card>
<p className="text-xs uppercase tracking-wide text-theme-muted">{t('portal.monedero.points')}</p>
<p className="mt-1 font-heading text-xl text-theme-heading">{data.points}</p>
</Card>
<Card>
<p className="text-xs uppercase tracking-wide text-theme-muted">{t('portal.monedero.mxn')}</p>
<p className="mt-1 font-heading text-xl text-theme-heading">{money(data.equivalent_mxn)}</p>
</Card>
<Card>
<p className="text-xs uppercase tracking-wide text-theme-muted">{t('portal.monedero.usd')}</p>
<p className="mt-1 font-heading text-xl text-theme-heading">{data.equivalent_usd === null ? '—' : `USD ${data.equivalent_usd}`}</p>
</Card>
</div>
{(data.movimientos || []).length === 0 ? <Empty text={t('portal.monedero.none')} /> : (data.movimientos || []).map((m: { id: number; fecha: string; puntos: number; descripcion: string }) => (
<Card key={m.id}>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm text-theme-heading">{m.descripcion || '—'}</p>
<p className="text-xs text-theme-muted">{fecha(m.fecha)}</p>
</div>
<p className={`font-heading text-sm ${m.puntos >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>{m.puntos > 0 ? '+' : ''}{m.puntos}</p>
</div>
</Card>
))}
</div>
);
};
const PaquetesTab: FC = () => {
const { t } = useI18n();
const { data, loading } = useFetch(() => portalApi.paquetes());
if (loading || !data) return <Loading />;
if ((data.paquetes || []).length === 0) return <Empty text={t('portal.paquetes.none')} />;
return (
<div className="space-y-3">
{(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 }) => (
<Card key={p.id}>
<div className="flex items-center justify-between gap-3">
<p className="font-heading text-sm text-theme-heading">{p.paquete || p.servicio}</p>
<span className="rounded-full bg-theme-bg px-2.5 py-1 text-xs text-theme-muted">{p.estado}</span>
</div>
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-theme-muted sm:grid-cols-4">
<span>{t('portal.paquetes.total')}: {p.sesiones_total}</span>
<span>{t('portal.paquetes.used')}: {p.sesiones_usadas}</span>
<span>{t('portal.paquetes.left')}: {p.sesiones_restantes}</span>
<span>{t('portal.paquetes.valid')}: {fecha(p.vigencia)}</span>
</div>
</Card>
))}
</div>
);
};
const MembresiasTab: FC = () => {
const { t } = useI18n();
const { data, loading, reload } = useFetch(() => portalApi.membresias());
const [busy, setBusy] = useState<number | null>(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 <Loading />;
if ((data.membresias || []).length === 0) return <Empty text={t('portal.membresias.none')} />;
return (
<div className="space-y-3">
{(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 }[];
}) => (
<Card key={m.id}>
<div className="flex items-center justify-between gap-3">
<p className="font-heading text-sm text-theme-heading">{m.plan}</p>
<span className="rounded-full bg-theme-bg px-2.5 py-1 text-xs text-theme-muted">{m.estado}</span>
</div>
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-theme-muted sm:grid-cols-3">
<span>{t('portal.membresias.next')}: {fecha(m.proximo_cobro)}</span>
<span>{t('portal.membresias.sessions')}: {m.ilimitadas ? t('portal.membresias.unlimited') : m.sesiones_restantes}</span>
<span>{t('portal.membresias.due')}: {money(m.adeudo, m.moneda)}</span>
</div>
{m.periodos_pendientes.length > 0 && (
<Button size="sm" className="mt-3" loading={busy === m.id} onClick={() => pagar(m.id)}>
<CreditCard size={14} className="mr-1.5" />{t('portal.membresias.pay')}
</Button>
)}
</Card>
))}
</div>
);
};
const FormulariosTab: FC = () => {
const { t } = useI18n();
const { data, loading } = useFetch(() => portalApi.formularios());
if (loading || !data) return <Loading />;
return (
<div className="space-y-4">
<h2 className="font-heading text-base text-theme-heading">{t('portal.formularios.pending')}</h2>
{(data.pendientes || []).length === 0 ? <Empty text={t('portal.formularios.none')} /> : (data.pendientes || []).map((f: { id: number; folio: string; plantilla: string; enlace: string }) => (
<Card key={f.id}>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm text-theme-heading">{f.plantilla}</p>
<p className="text-xs text-theme-muted">{f.folio}</p>
</div>
<a href={f.enlace} className="rounded-full bg-theme-accent px-3 py-1.5 text-xs font-medium text-theme-inverse">{t('portal.formularios.open')}</a>
</div>
</Card>
))}
<h2 className="font-heading text-base text-theme-heading">{t('portal.formularios.done')}</h2>
{(data.respondidos || []).length === 0 ? <Empty text={t('portal.formularios.none')} /> : (data.respondidos || []).map((f: { id: number; folio: string; plantilla: string; llenado_at: string; tiene_firma: boolean }) => (
<Card key={f.id}>
<p className="text-sm text-theme-heading">{f.plantilla}</p>
<p className="text-xs text-theme-muted">{f.folio} · {fecha(f.llenado_at)}{f.tiene_firma ? ` · ${t('portal.formularios.signed')}` : ''}</p>
</Card>
))}
</div>
);
};
const DocumentosTab: FC = () => {
const { t } = useI18n();
const { data, loading } = useFetch(() => portalApi.documentos());
if (loading || !data) return <Loading />;
return (
<div className="space-y-4">
<h2 className="font-heading text-base text-theme-heading">{t('portal.documentos.recetas')}</h2>
{(data.recetas || []).length === 0 ? <Empty text={t('portal.documentos.none')} /> : (data.recetas || []).map((r: { id: number; folio: string; fecha: string; contenido: string; servicio: string }) => (
<Card key={r.id}>
<p className="text-sm text-theme-heading">{r.servicio || r.folio}</p>
<p className="text-xs text-theme-muted">{fecha(r.fecha)}</p>
<p className="mt-2 whitespace-pre-wrap text-sm text-theme-muted">{r.contenido}</p>
</Card>
))}
<h2 className="font-heading text-base text-theme-heading">{t('portal.documentos.estado')}</h2>
{data.estado_cuenta_disponible ? (
<Card>
<Button size="sm" variant="outline" onClick={() => portalApi.estadoCuenta().then(imprimirEstadoCuenta)}>
<Printer size={14} className="mr-1.5" />{t('portal.documentos.download')}
</Button>
</Card>
) : <Empty text={t('portal.documentos.noAccess')} />}
</div>
);
};
const PerfilTab: FC = () => {
const { t } = useI18n();
const { data, loading, reload } = useFetch(() => portalApi.perfil());
const [form, setForm] = useState<Partial<PortalPerfil>>({});
const [busy, setBusy] = useState(false);
const [pw, setPw] = useState('');
useEffect(() => { if (data) setForm(data); }, [data]);
if (loading || !data) return <Loading />;
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 (
<div className="space-y-4">
{!editable && <p className="rounded-xl bg-theme-bg p-3 text-xs text-theme-muted">{t('portal.perfil.readonly')}</p>}
<Card>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Input label={t('portal.perfil.name')} value={form.nombre || ''} disabled />
<Input label={t('portal.perfil.phone')} value={form.telefono || ''} disabled={!editable} onChange={(e) => set('telefono', e.target.value)} />
<Input label={t('portal.perfil.email')} value={form.email || ''} disabled={!editable} onChange={(e) => set('email', e.target.value)} />
<Input label={t('portal.perfil.birth')} type="date" value={form.birth_date || ''} disabled={!editable} onChange={(e) => set('birth_date', e.target.value)} />
<Input label={t('portal.perfil.occupation')} value={form.occupation || ''} disabled={!editable} onChange={(e) => set('occupation', e.target.value)} />
<Input label={t('portal.perfil.emergency')} value={form.emergency_contact || ''} disabled={!editable} onChange={(e) => set('emergency_contact', e.target.value)} />
<Input label={t('portal.perfil.emergencyPhone')} value={form.emergency_phone || ''} disabled={!editable} onChange={(e) => set('emergency_phone', e.target.value)} />
<Input label={t('portal.perfil.address')} value={form.address_notes || ''} disabled={!editable} onChange={(e) => set('address_notes', e.target.value)} />
</div>
{editable && <Button className="mt-4" loading={busy} onClick={guardar}>{t('portal.perfil.save')}</Button>}
</Card>
<Card>
<h3 className="font-heading text-sm text-theme-heading">{t('portal.perfil.photos')}</h3>
{data.fotografias.length === 0 ? <p className="mt-2 text-xs text-theme-muted">{t('portal.perfil.noPhotos')}</p> : (
<div className="mt-2 grid grid-cols-3 gap-2 sm:grid-cols-5">
{data.fotografias.map((f) => (
<span key={f.id} className="rounded-xl bg-theme-bg px-2 py-3 text-center text-xs text-theme-muted">{f.nombre || f.clasificacion}</span>
))}
</div>
)}
</Card>
<Card>
<h3 className="font-heading text-sm text-theme-heading">{t('portal.perfil.passwordTitle')}</h3>
<div className="mt-2 flex items-end gap-2">
<Input label={t('portal.login.password')} type="password" value={pw} onChange={(e) => setPw(e.target.value)} />
<Button disabled={pw.length < 6} onClick={guardarPw}>{t('portal.perfil.setPassword')}</Button>
</div>
</Card>
</div>
);
};
// 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) =>
`<tr><td>${p.fecha ? String(p.fecha).slice(0, 10) : ''}</td><td>${p.folio}</td><td>${p.metodo_label}</td><td style="text-align:right">${p.moneda} ${Number(p.monto).toFixed(2)}</td></tr>`).join('');
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Estado de cuenta</title>
<style>body{font-family:system-ui,sans-serif;padding:24px;color:#1a1a1a}
table{width:100%;border-collapse:collapse;margin-top:12px}th,td{border-bottom:1px solid #ddd;padding:6px;font-size:13px;text-align:left}
.k{display:flex;justify-content:space-between;padding:4px 0}.tot{font-weight:700}</style></head><body>
<h2>Estado de cuenta</h2>
<p>${data.paciente.nombre} · ${data.paciente.patient_id || ''}<br>${data.paciente.telefono || ''} · ${data.paciente.email || ''}</p>
<p>Generado: ${String(data.generado).slice(0, 16)}</p>
<div class="k"><span>Puntos monedero</span><span>${ec.puntos_monedero}</span></div>
<div class="k"><span>Total gastado</span><span>${data.moneda} ${Number(ec.total_gastado).toFixed(2)}</span></div>
<div class="k"><span>Visitas</span><span>${ec.visitas_total}</span></div>
<div class="k tot"><span>Adeudo</span><span>${data.moneda} ${Number(ec.adeudo).toFixed(2)}</span></div>
<table><thead><tr><th>Fecha</th><th>Folio</th><th>Método</th><th style="text-align:right">Monto</th></tr></thead><tbody>${filas}</tbody></table>
<script>window.onload=function(){window.print();}</script></body></html>`;
const w = window.open('', '_blank');
if (w) { w.document.write(html); w.document.close(); }
};
export default MiCuenta;

View File

@@ -11,6 +11,7 @@ import {
descargarIcs, normalizarTelefono, telefonoValido, descargarIcs, normalizarTelefono, telefonoValido,
} from '../lib/publicBooking'; } from '../lib/publicBooking';
import { useI18n, GLOSARIO_EN } from '../lib/i18n'; 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 // 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. // 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 Reservar: FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { config, loading } = useBookingConfig(); const { config, loading } = useBookingConfig();
const { active: portalActivo } = usePortalConfig();
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const [paso, setPaso] = useState(0); const [paso, setPaso] = useState(0);
@@ -452,6 +454,13 @@ const Reservar: FC = () => {
</div> </div>
<p className="mt-6 text-center text-xs text-theme-muted">{config.brand.nombre}</p> <p className="mt-6 text-center text-xs text-theme-muted">{config.brand.nombre}</p>
{portalActivo && (
<p className="mt-2 text-center text-xs">
<a href="/mi-cuenta" className="text-theme-muted underline underline-offset-2 hover:opacity-80">
{t('login.isClient')}
</a>
</p>
)}
</div> </div>
</Centro> </Centro>
); );

View File

@@ -22,7 +22,7 @@ axios.interceptors.response.use(
// Rutas públicas (sin sesión): un 401 de un endpoint autenticado no debe // 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 // expulsar al visitante a /login. Cubre el portal de reservas, la página
// de pago y el portal de formularios. // 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 onPublica = PUBLICAS.some((p) => path === p || path.startsWith(p + '/'));
const isLoginCall = String(error.config?.url || '').includes('/auth/login'); const isLoginCall = String(error.config?.url || '').includes('/auth/login');
if (!onPublica && !isLoginCall) { if (!onPublica && !isLoginCall) {
@@ -1088,6 +1088,7 @@ export interface ClinicSettings {
compras: ComprasSettings; compras: ComprasSettings;
comisiones: ComisionesSettings; comisiones: ComisionesSettings;
licencia: LicenciaSettings; licencia: LicenciaSettings;
portal_cliente: PortalClienteSettings;
} }
// ---- Licencia por plan (bloque `licencia` de clinic-settings) ---- // ---- Licencia por plan (bloque `licencia` de clinic-settings) ----
@@ -1281,6 +1282,63 @@ export const RESERVAS_ONLINE_DEFAULT: ReservasOnlineSettings = {
anticipo: { ...ANTICIPO_DEFAULT }, 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 ---- // ---- Notificaciones y recordatorios de citas ----
// ---- Recursos físicos de la agenda ---- // ---- Recursos físicos de la agenda ----
export interface RecursosSettings { export interface RecursosSettings {
@@ -2367,6 +2425,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
compras: COMPRAS_DEFAULT, compras: COMPRAS_DEFAULT,
comisiones: COMISIONES_DEFAULT, comisiones: COMISIONES_DEFAULT,
licencia: LICENCIA_DEFAULT, licencia: LICENCIA_DEFAULT,
portal_cliente: PORTAL_CLIENTE_DEFAULT,
}; };
export interface FrontendUser { export interface FrontendUser {
@@ -3061,6 +3120,32 @@ export const odooApi = {
return data; 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 ---- // ---- Licencia por plan ----
async getLicencia(): Promise<LicenciaEstadoResponse> { async getLicencia(): Promise<LicenciaEstadoResponse> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/licencia`); const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/licencia`);

View File

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

View File

@@ -362,6 +362,111 @@ def get_anticipo(env):
"""Sub-bloque `anticipo` vigente (copia). Apagado por defecto.""" """Sub-bloque `anticipo` vigente (copia). Apagado por defecto."""
return get_reservas_online(env)['anticipo'] 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. # Recordatorios y notificaciones automáticas de citas.
# #
@@ -427,6 +532,16 @@ NOTIFICACIONES_DEFAULT = {
'Hola {nombre}, tu plan {plan} ({periodo}) está por renovarse. ' 'Hola {nombre}, tu plan {plan} ({periodo}) está por renovarse. '
'Paga {monto} desde este enlace: {enlace}' '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': '', 'whatsapp_numero': '',
'email_remitente': '', 'email_remitente': '',
} }
@@ -435,11 +550,14 @@ NOTIF_CANALES = ('whatsapp', 'email', 'ambos')
NOTIF_VARIABLES = ('nombre', 'cita', 'servicio', 'fecha', 'hora', 'doctor', NOTIF_VARIABLES = ('nombre', 'cita', 'servicio', 'fecha', 'hora', 'doctor',
'sucursal', 'telefono', 'enlace', 'formulario', 'sucursal', 'telefono', 'enlace', 'formulario',
# Variables del aviso de membresías. # 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', NOTIF_PLANTILLAS = ('plantilla_recordatorio', 'plantilla_confirmacion',
'plantilla_cumpleanos', 'plantilla_post_visita', 'plantilla_cumpleanos', 'plantilla_post_visita',
'plantilla_formulario', 'plantilla_renovacion_membresia', 'plantilla_formulario', 'plantilla_renovacion_membresia',
'plantilla_cobro_membresia') 'plantilla_cobro_membresia', 'plantilla_codigo_acceso',
'plantilla_enlace_acceso')
NOTIF_HORAS_MAX = 168 # 1 semana NOTIF_HORAS_MAX = 168 # 1 semana
NOTIF_PLANTILLA_MAX = 1000 # caracteres por plantilla NOTIF_PLANTILLA_MAX = 1000 # caracteres por plantilla
NOTIF_TEXTO_MAX = 120 # whatsapp_numero / email_remitente NOTIF_TEXTO_MAX = 120 # whatsapp_numero / email_remitente
@@ -1498,6 +1616,7 @@ def get_settings(env, aplicar_licencia=True):
'compras': dict(COMPRAS_DEFAULT), 'compras': dict(COMPRAS_DEFAULT),
'comisiones': dict(COMISIONES_DEFAULT), 'comisiones': dict(COMISIONES_DEFAULT),
'licencia': dict(LICENCIA_DEFAULT, limites=dict(LICENCIA_DEFAULT['limites'])), '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) raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
if raw: if raw:
@@ -1517,6 +1636,8 @@ def get_settings(env, aplicar_licencia=True):
out['compras'] = normalizar_compras(guardado.get('compras')) out['compras'] = normalizar_compras(guardado.get('compras'))
out['comisiones'] = normalizar_comisiones(guardado.get('comisiones')) out['comisiones'] = normalizar_comisiones(guardado.get('comisiones'))
out['licencia'] = normalizar_licencia(guardado.get('licencia')) out['licencia'] = normalizar_licencia(guardado.get('licencia'))
out['portal_cliente'] = normalizar_portal_cliente(
guardado.get('portal_cliente'))
if aplicar_licencia: if aplicar_licencia:
_aplicar_licencia(out) _aplicar_licencia(out)
return out return out
@@ -1570,6 +1691,11 @@ def set_settings(env, vals):
if bloque is not None: if bloque is not None:
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto. # Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
actual['comisiones'] = {**actual['comisiones'], **bloque} 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: if 'licencia' in vals:
bloque = validar_licencia(vals['licencia']) bloque = validar_licencia(vals['licencia'])
if bloque is not None: if bloque is not None:

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers

View File

@@ -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',
}

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import public_portal
from . import staff_portal

File diff suppressed because it is too large Load Diff

View File

@@ -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/<int:partner_id>', 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/<int:partner_id>/<string:accion>', 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)

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Limpieza diaria de códigos y sesiones caducados. Es inocua con el
portal apagado (solo borra filas ya vencidas). -->
<record id="cron_portal_limpieza" model="ir.cron">
<field name="name">Portal del cliente: limpiar códigos y sesiones</field>
<field name="model_id" ref="model_skeen_portal_acceso"/>
<field name="state">code</field>
<field name="code">model._cron_limpiar()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="active" eval="True"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import portal

View File

@@ -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

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_skeen_portal_acceso_user Acceso al portal del cliente model_skeen_portal_acceso base.group_user 1 1 1 1
3 access_skeen_portal_codigo_user Código de acceso al portal model_skeen_portal_codigo base.group_user 1 1 1 1
4 access_skeen_portal_sesion_user Sesión del portal del cliente model_skeen_portal_sesion base.group_user 1 1 1 1

View File

@@ -46,6 +46,8 @@ TIPOS = [
('formulario', 'Formulario'), ('formulario', 'Formulario'),
('renovacion_membresia', 'Renovación de membresía'), ('renovacion_membresia', 'Renovación de membresía'),
('cobro_membresia', 'Cobro 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'), ('manual', 'Manual'),
] ]
CANALES = [('whatsapp', 'WhatsApp'), ('email', 'Correo')] CANALES = [('whatsapp', 'WhatsApp'), ('email', 'Correo')]
@@ -73,6 +75,9 @@ PLANTILLA_POR_TIPO = {
'renovacion_membresia': 'plantilla_renovacion_membresia', 'renovacion_membresia': 'plantilla_renovacion_membresia',
# Aviso con enlace de pago del periodo (cobro automático de membresías). # Aviso con enlace de pago del periodo (cobro automático de membresías).
'cobro_membresia': 'plantilla_cobro_membresia', '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', 'manual': 'plantilla_recordatorio',
} }

View File

@@ -50,6 +50,8 @@ PERMISSION_GROUPS = [
('pacientes.eliminar', 'Eliminar pacientes'), ('pacientes.eliminar', 'Eliminar pacientes'),
('pacientes.exportar', 'Exportar pacientes'), ('pacientes.exportar', 'Exportar pacientes'),
('pacientes.saldos', 'Ver saldos y adeudos'), ('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', 'Visitas y expedientes', [
('visitas.ver', 'Ver visitas'), ('visitas.ver', 'Ver visitas'),
@@ -213,6 +215,10 @@ DEFAULT_PERM_ROLES = {
'pacientes.eliminar': W, 'pacientes.eliminar': W,
'pacientes.exportar': ALL, 'pacientes.exportar': ALL,
'pacientes.saldos': 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
'visitas.ver': ALL, 'visitas.ver': ALL,
'visitas.crear': W, 'visitas.crear': W,