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:
@@ -44,6 +44,7 @@ import Reservar from './pages/Reservar';
|
||||
import ReservarCita from './pages/ReservarCita';
|
||||
import Pagar from './pages/Pagar';
|
||||
import FormularioPublico from './pages/FormularioPublico';
|
||||
import MiCuenta from './pages/MiCuenta';
|
||||
import './App.css';
|
||||
|
||||
const FullScreenLoader: FC = () => (
|
||||
@@ -185,6 +186,10 @@ const App: FC = () => {
|
||||
<Route path="/pagar/:token" element={<Pagar />} />
|
||||
{/* Portal público de formularios: token HMAC como credencial. */}
|
||||
<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="/agenda" element={<RequireAuth href="/agenda"><Agenda /></RequireAuth>} />
|
||||
<Route path="/mis-ventas" element={<RequireAuth href="/mis-ventas"><MisVentas /></RequireAuth>} />
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Badge, Button, Skeleton } from './ui';
|
||||
import RecargarSaldoModal from './RecargarSaldoModal';
|
||||
import ReciboPrint from './ReciboPrint';
|
||||
import MembresiasPaciente from './MembresiasPaciente';
|
||||
import PortalPacienteAcceso from './PortalPacienteAcceso';
|
||||
import { waLink } from '../lib/whatsapp';
|
||||
import { badgeForAppointmentState, badgeForSaleState } from './ui';
|
||||
|
||||
@@ -320,6 +321,8 @@ const PatientDrawerPanel: FC<{ patientId: number; onClose: () => void }> = ({ pa
|
||||
<>
|
||||
{/* Membresías activas (solo si la clínica las tiene encendidas) */}
|
||||
<MembresiasPaciente partnerId={patient.id} />
|
||||
{/* Acceso al portal del cliente (solo si está activo) */}
|
||||
<PortalPacienteAcceso partnerId={patient.id} />
|
||||
{/* Contacto */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
|
||||
213
frontend/src/components/PortalClientePanel.tsx
Normal file
213
frontend/src/components/PortalClientePanel.tsx
Normal 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;
|
||||
97
frontend/src/components/PortalPacienteAcceso.tsx
Normal file
97
frontend/src/components/PortalPacienteAcceso.tsx
Normal 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;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, COBRO_AUTOMATICO_DEFAULT, RESERVAS_ONLINE_DEFAULT, ANTICIPO_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT, COMISIONES_DEFAULT, LICENCIA_DEFAULT, LICENCIA_LIMITES_DEFAULT } from '../services/odoo';
|
||||
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, COBRO_AUTOMATICO_DEFAULT, RESERVAS_ONLINE_DEFAULT, ANTICIPO_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT, COMISIONES_DEFAULT, LICENCIA_DEFAULT, LICENCIA_LIMITES_DEFAULT, PORTAL_CLIENTE_DEFAULT } from '../services/odoo';
|
||||
|
||||
// Ajustes de la clínica compartidos por la sesión (nombre, moneda e impuestos).
|
||||
// Se leen una sola vez del servidor y se avisa a quien los use cuando cambian.
|
||||
@@ -28,6 +28,7 @@ const normalizar = (settings?: Partial<ClinicSettings> | null): ClinicSettings =
|
||||
...(settings?.licencia || {}),
|
||||
limites: { ...LICENCIA_LIMITES_DEFAULT, ...((settings?.licencia || {}).limites || {}) },
|
||||
},
|
||||
portal_cliente: { ...PORTAL_CLIENTE_DEFAULT, ...(settings?.portal_cliente || {}) },
|
||||
});
|
||||
|
||||
let actual: ClinicSettings = normalizar(null);
|
||||
|
||||
@@ -670,6 +670,173 @@ export const es: Dict = {
|
||||
'config.cobroAuto.cancelHelp': 'Al agotar los reintentos, marca la membresía vencida. Apagado, sigue activa hasta el proceso normal de vencimiento.',
|
||||
'config.cobroAuto.simulated': 'Permitir pago simulado',
|
||||
'config.cobroAuto.simulatedHelp': 'Permite probar el flujo sin pasarela ni dinero real. Déjalo apagado en producción.',
|
||||
// --- Portal del cliente (cuenta de autoservicio) ---
|
||||
'config.tabs.portal': 'Portal del cliente',
|
||||
'config.portal.title': 'Portal del cliente',
|
||||
'config.portal.help': 'Cuenta de autoservicio donde el cliente entra con su teléfono o correo, sin contraseña, y ve sus citas, pagos, monedero, paquetes, membresías, formularios y documentos. Apagado, nada cambia en el sistema.',
|
||||
'config.portal.enable': 'Activar el portal del cliente',
|
||||
'config.portal.enableHelp': 'Mientras esté apagado, el portal responde deshabilitado y no aparece ninguna ruta ni enlace nuevo.',
|
||||
'config.portal.method': 'Método de acceso',
|
||||
'config.portal.methodWhatsapp': 'Código por WhatsApp',
|
||||
'config.portal.methodEmail': 'Enlace por correo',
|
||||
'config.portal.methodBoth': 'Código o enlace (según el dato)',
|
||||
'config.portal.requireEmail': 'Exigir correo registrado',
|
||||
'config.portal.requireEmailHelp': 'Si el acceso es por WhatsApp, requiere que el paciente tenga correo.',
|
||||
'config.portal.password': 'Permitir contraseña',
|
||||
'config.portal.passwordHelp': 'El cliente puede fijar una contraseña después de entrar la primera vez.',
|
||||
'config.portal.sessionHours': 'Duración de la sesión (horas)',
|
||||
'config.portal.allowReschedule': 'Permitir reagendar citas',
|
||||
'config.portal.cancelHours': 'Horas mínimas para cancelar',
|
||||
'config.portal.showPrices': 'Mostrar importes',
|
||||
'config.portal.showRecipes': 'Mostrar recetas',
|
||||
'config.portal.allowStatement': 'Permitir descargar estado de cuenta',
|
||||
'config.portal.allowUpdate': 'Permitir actualizar sus datos',
|
||||
'config.portal.save': 'Guardar portal del cliente',
|
||||
'config.portal.on': 'Activo',
|
||||
'config.portal.off': 'Inactivo',
|
||||
'config.portal.saved': 'Portal del cliente guardado',
|
||||
'config.portal.saveError': 'No se pudo guardar el portal del cliente',
|
||||
'config.portal.linkTitle': 'Enlace del portal',
|
||||
'config.portal.copy': 'Copiar',
|
||||
'config.portal.copied': 'Copiado',
|
||||
'config.portal.open': 'Abrir',
|
||||
'config.portal.linkHelp': 'El enlace usa la URL pública configurada en «Clínica»; si está vacía, la dirección actual.',
|
||||
'config.portal.accessTitle': 'Accesos al portal',
|
||||
'config.portal.accessHelp': 'Pacientes con acceso, última entrada y sesiones abiertas.',
|
||||
'config.portal.accessNone': 'Aún no hay accesos al portal.',
|
||||
'config.portal.colPatient': 'Paciente',
|
||||
'config.portal.colLast': 'Última entrada',
|
||||
'config.portal.colSessions': 'Sesiones abiertas',
|
||||
'config.portal.closeSessions': 'Cerrar sesiones',
|
||||
'config.portal.sessionsClosed': 'Sesiones cerradas',
|
||||
'portal.patient.title': 'Portal del cliente',
|
||||
'portal.patient.invite': 'Invitar al portal',
|
||||
'portal.patient.invited': 'Invitación enviada',
|
||||
'portal.patient.noAccess': 'Sin acceso al portal',
|
||||
'portal.patient.lastAccess': 'Última entrada',
|
||||
'portal.patient.sessions': 'Sesiones abiertas',
|
||||
'portal.patient.activate': 'Reactivar acceso',
|
||||
'portal.patient.deactivate': 'Desactivar acceso',
|
||||
'portal.patient.updated': 'Acceso actualizado',
|
||||
'portal.patient.noPermission': 'No tienes permiso para gestionar el portal.',
|
||||
'portal.patient.testCode': 'Código de prueba: {code}',
|
||||
'portal.patient.testLink': 'Enlace de prueba: {url}',
|
||||
'portal.loading': 'Cargando…',
|
||||
'login.isClient': '¿Eres cliente? Entra a tu cuenta',
|
||||
'portal.unavailableTitle': 'Cuenta no disponible',
|
||||
'portal.unavailableBody': 'El portal de clientes no está activo. Si necesitas algo, comunícate con la clínica.',
|
||||
'portal.title': 'Mi cuenta',
|
||||
'portal.logout': 'Cerrar sesión',
|
||||
'portal.login.intro': 'Entra con tu teléfono o correo. Te enviamos un acceso, sin contraseña.',
|
||||
'portal.login.identifier': 'Teléfono o correo',
|
||||
'portal.login.identifierPh': 'Ej. 664 123 4567 o tu correo',
|
||||
'portal.login.send': 'Enviar acceso',
|
||||
'portal.login.sent': 'Si los datos están registrados, te enviamos tu acceso.',
|
||||
'portal.login.code': 'Código de 6 dígitos',
|
||||
'portal.login.codePh': '000000',
|
||||
'portal.login.enter': 'Entrar',
|
||||
'portal.login.usePassword': 'Entrar con contraseña',
|
||||
'portal.login.useCode': 'Prefiero recibir un código',
|
||||
'portal.login.password': 'Contraseña',
|
||||
'portal.login.enterPw': 'Entrar',
|
||||
'portal.login.wrongCode': 'El código no es válido o ya expiró.',
|
||||
'portal.login.genericError': 'No se pudo completar. Intenta de nuevo.',
|
||||
'portal.login.testHint': 'Modo de prueba: tu código es {code}',
|
||||
'portal.login.testLink': 'Modo de prueba: abre este enlace para entrar: {url}',
|
||||
'portal.tabs.resumen': 'Resumen',
|
||||
'portal.tabs.citas': 'Mis citas',
|
||||
'portal.tabs.pagos': 'Mis pagos',
|
||||
'portal.tabs.monedero': 'Mi monedero',
|
||||
'portal.tabs.paquetes': 'Mis paquetes',
|
||||
'portal.tabs.membresias': 'Mis membresías',
|
||||
'portal.tabs.formularios': 'Mis formularios',
|
||||
'portal.tabs.documentos': 'Mis documentos',
|
||||
'portal.tabs.perfil': 'Mi perfil',
|
||||
'portal.resumen.hello': 'Hola, {name}',
|
||||
'portal.resumen.upcoming': 'Próximas citas',
|
||||
'portal.resumen.balance': 'Saldo pendiente',
|
||||
'portal.resumen.points': 'Puntos',
|
||||
'portal.resumen.forms': 'Formularios pendientes',
|
||||
'portal.citas.upcoming': 'Próximas',
|
||||
'portal.citas.past': 'Pasadas',
|
||||
'portal.citas.none': 'No tienes citas.',
|
||||
'portal.citas.cancel': 'Cancelar',
|
||||
'portal.citas.reschedule': 'Reagendar',
|
||||
'portal.citas.confirmCancel': '¿Cancelar esta cita?',
|
||||
'portal.citas.cancelled': 'Cita cancelada.',
|
||||
'portal.citas.cancelError': 'No se pudo cancelar la cita.',
|
||||
'portal.citas.rescheduled': 'Cita reagendada.',
|
||||
'portal.citas.rescheduleError': 'No se pudo reagendar la cita.',
|
||||
'portal.citas.date': 'Elige la nueva fecha',
|
||||
'portal.citas.slot': 'Elige un horario disponible',
|
||||
'portal.citas.noSlots': 'No hay horarios disponibles ese día.',
|
||||
'portal.citas.service': 'Servicio',
|
||||
'portal.citas.doctor': 'Especialista',
|
||||
'portal.citas.branch': 'Sucursal',
|
||||
'portal.citas.duration': 'Duración',
|
||||
'portal.citas.amount': 'Importe',
|
||||
'portal.citas.minutes': 'min',
|
||||
'portal.citas.anticipo': 'Anticipo pendiente',
|
||||
'portal.pagos.payments': 'Pagos',
|
||||
'portal.pagos.sales': 'Compras (notas)',
|
||||
'portal.pagos.balance': 'Saldo pendiente',
|
||||
'portal.pagos.download': 'Descargar estado de cuenta',
|
||||
'portal.pagos.none': 'No hay movimientos.',
|
||||
'portal.monedero.points': 'Puntos disponibles',
|
||||
'portal.monedero.mxn': 'Equivalente en pesos',
|
||||
'portal.monedero.usd': 'Equivalente en dólares',
|
||||
'portal.monedero.none': 'Aún no tienes movimientos en tu monedero.',
|
||||
'portal.paquetes.total': 'Incluidas',
|
||||
'portal.paquetes.used': 'Usadas',
|
||||
'portal.paquetes.left': 'Restantes',
|
||||
'portal.paquetes.valid': 'Vigencia',
|
||||
'portal.paquetes.none': 'No tienes paquetes.',
|
||||
'portal.membresias.plan': 'Plan',
|
||||
'portal.membresias.valid': 'Vigencia',
|
||||
'portal.membresias.next': 'Próximo cobro',
|
||||
'portal.membresias.sessions': 'Sesiones restantes',
|
||||
'portal.membresias.unlimited': 'Ilimitadas',
|
||||
'portal.membresias.due': 'Periodo pendiente',
|
||||
'portal.membresias.pay': 'Pagar periodo',
|
||||
'portal.membresias.none': 'No tienes membresías.',
|
||||
'portal.membresias.payError': 'No se pudo generar el pago.',
|
||||
'portal.formularios.pending': 'Pendientes',
|
||||
'portal.formularios.done': 'Respondidos',
|
||||
'portal.formularios.open': 'Llenar',
|
||||
'portal.formularios.none': 'No tienes formularios.',
|
||||
'portal.formularios.signed': 'Con firma',
|
||||
'portal.documentos.recetas': 'Recetas',
|
||||
'portal.documentos.estado': 'Estado de cuenta',
|
||||
'portal.documentos.none': 'No hay documentos.',
|
||||
'portal.documentos.noAccess': 'La descarga del estado de cuenta no está disponible.',
|
||||
'portal.documentos.download': 'Descargar',
|
||||
'portal.perfil.name': 'Nombre',
|
||||
'portal.perfil.phone': 'Teléfono',
|
||||
'portal.perfil.email': 'Correo',
|
||||
'portal.perfil.birth': 'Fecha de nacimiento',
|
||||
'portal.perfil.occupation': 'Ocupación',
|
||||
'portal.perfil.address': 'Dirección',
|
||||
'portal.perfil.emergency': 'Contacto de emergencia',
|
||||
'portal.perfil.emergencyPhone': 'Teléfono de emergencia',
|
||||
'portal.perfil.save': 'Guardar cambios',
|
||||
'portal.perfil.saved': 'Datos actualizados.',
|
||||
'portal.perfil.error': 'No se pudieron guardar los datos.',
|
||||
'portal.perfil.photos': 'Mis fotografías',
|
||||
'portal.perfil.noPhotos': 'Sin fotografías.',
|
||||
'portal.perfil.readonly': 'La actualización de datos está desactivada. Pide cambios a la clínica.',
|
||||
'portal.perfil.passwordTitle': 'Contraseña',
|
||||
'portal.perfil.setPassword': 'Establecer contraseña',
|
||||
'portal.perfil.passwordSaved': 'Contraseña guardada.',
|
||||
'portal.estado.title': 'Estado de cuenta de {name}',
|
||||
'portal.estado.generated': 'Generado el {date}',
|
||||
'portal.estado.points': 'Puntos monedero',
|
||||
'portal.estado.spent': 'Total gastado',
|
||||
'portal.estado.due': 'Adeudo',
|
||||
'portal.estado.visits': 'Visitas',
|
||||
'portal.estado.print': 'Imprimir',
|
||||
'portal.common.currency': 'Moneda',
|
||||
'portal.common.status': 'Estado',
|
||||
'portal.common.date': 'Fecha',
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------
|
||||
@@ -1295,6 +1462,173 @@ export const en: Dict = {
|
||||
'config.cobroAuto.cancelHelp': 'When retries are exhausted, marks the membership as expired. Off, it stays active until the normal expiration process.',
|
||||
'config.cobroAuto.simulated': 'Allow simulated payment',
|
||||
'config.cobroAuto.simulatedHelp': 'Lets you test the flow with no gateway and no real money. Keep it off in production.',
|
||||
// --- Customer portal (self-service account) ---
|
||||
'config.tabs.portal': 'Customer portal',
|
||||
'config.portal.title': 'Customer portal',
|
||||
'config.portal.help': 'Self-service account where the customer signs in with their phone or email, no password, and sees their appointments, payments, wallet, packages, memberships, forms and documents. When off, nothing changes in the system.',
|
||||
'config.portal.enable': 'Enable the customer portal',
|
||||
'config.portal.enableHelp': 'While off, the portal responds as disabled and no new route or link appears.',
|
||||
'config.portal.method': 'Access method',
|
||||
'config.portal.methodWhatsapp': 'Code via WhatsApp',
|
||||
'config.portal.methodEmail': 'Link via email',
|
||||
'config.portal.methodBoth': 'Code or link (based on the detail)',
|
||||
'config.portal.requireEmail': 'Require a registered email',
|
||||
'config.portal.requireEmailHelp': 'For WhatsApp access, requires the patient to have an email.',
|
||||
'config.portal.password': 'Allow password',
|
||||
'config.portal.passwordHelp': 'The customer may set a password after their first sign-in.',
|
||||
'config.portal.sessionHours': 'Session length (hours)',
|
||||
'config.portal.allowReschedule': 'Allow rescheduling',
|
||||
'config.portal.cancelHours': 'Minimum hours to cancel',
|
||||
'config.portal.showPrices': 'Show amounts',
|
||||
'config.portal.showRecipes': 'Show prescriptions',
|
||||
'config.portal.allowStatement': 'Allow statement download',
|
||||
'config.portal.allowUpdate': 'Allow editing their details',
|
||||
'config.portal.save': 'Save customer portal',
|
||||
'config.portal.on': 'Active',
|
||||
'config.portal.off': 'Inactive',
|
||||
'config.portal.saved': 'Customer portal saved',
|
||||
'config.portal.saveError': 'The customer portal could not be saved',
|
||||
'config.portal.linkTitle': 'Portal link',
|
||||
'config.portal.copy': 'Copy',
|
||||
'config.portal.copied': 'Copied',
|
||||
'config.portal.open': 'Open',
|
||||
'config.portal.linkHelp': 'The link uses the public URL set in “Clinic”; if empty, the current address.',
|
||||
'config.portal.accessTitle': 'Portal access',
|
||||
'config.portal.accessHelp': 'Patients with access, last sign-in and open sessions.',
|
||||
'config.portal.accessNone': 'No portal access yet.',
|
||||
'config.portal.colPatient': 'Patient',
|
||||
'config.portal.colLast': 'Last sign-in',
|
||||
'config.portal.colSessions': 'Open sessions',
|
||||
'config.portal.closeSessions': 'Close sessions',
|
||||
'config.portal.sessionsClosed': 'Sessions closed',
|
||||
'portal.patient.title': 'Customer portal',
|
||||
'portal.patient.invite': 'Invite to portal',
|
||||
'portal.patient.invited': 'Invitation sent',
|
||||
'portal.patient.noAccess': 'No portal access',
|
||||
'portal.patient.lastAccess': 'Last sign-in',
|
||||
'portal.patient.sessions': 'Open sessions',
|
||||
'portal.patient.activate': 'Reactivate access',
|
||||
'portal.patient.deactivate': 'Disable access',
|
||||
'portal.patient.updated': 'Access updated',
|
||||
'portal.patient.noPermission': 'You do not have permission to manage the portal.',
|
||||
'portal.patient.testCode': 'Test code: {code}',
|
||||
'portal.patient.testLink': 'Test link: {url}',
|
||||
'portal.loading': 'Loading…',
|
||||
'login.isClient': 'Are you a customer? Go to your account',
|
||||
'portal.unavailableTitle': 'Account unavailable',
|
||||
'portal.unavailableBody': 'The customer portal is not active. If you need anything, please contact the clinic.',
|
||||
'portal.title': 'My account',
|
||||
'portal.logout': 'Sign out',
|
||||
'portal.login.intro': 'Sign in with your phone or email. We will send you an access link or code — no password needed.',
|
||||
'portal.login.identifier': 'Phone or email',
|
||||
'portal.login.identifierPh': 'e.g. 664 123 4567 or your email',
|
||||
'portal.login.send': 'Send access',
|
||||
'portal.login.sent': 'If your details are registered, we sent you access.',
|
||||
'portal.login.code': '6-digit code',
|
||||
'portal.login.codePh': '000000',
|
||||
'portal.login.enter': 'Sign in',
|
||||
'portal.login.usePassword': 'Sign in with password',
|
||||
'portal.login.useCode': 'I prefer a code',
|
||||
'portal.login.password': 'Password',
|
||||
'portal.login.enterPw': 'Sign in',
|
||||
'portal.login.wrongCode': 'The code is invalid or has expired.',
|
||||
'portal.login.genericError': 'Could not complete. Please try again.',
|
||||
'portal.login.testHint': 'Test mode: your code is {code}',
|
||||
'portal.login.testLink': 'Test mode: open this link to sign in: {url}',
|
||||
'portal.tabs.resumen': 'Overview',
|
||||
'portal.tabs.citas': 'My appointments',
|
||||
'portal.tabs.pagos': 'My payments',
|
||||
'portal.tabs.monedero': 'My wallet',
|
||||
'portal.tabs.paquetes': 'My packages',
|
||||
'portal.tabs.membresias': 'My memberships',
|
||||
'portal.tabs.formularios': 'My forms',
|
||||
'portal.tabs.documentos': 'My documents',
|
||||
'portal.tabs.perfil': 'My profile',
|
||||
'portal.resumen.hello': 'Hi, {name}',
|
||||
'portal.resumen.upcoming': 'Upcoming appointments',
|
||||
'portal.resumen.balance': 'Outstanding balance',
|
||||
'portal.resumen.points': 'Points',
|
||||
'portal.resumen.forms': 'Pending forms',
|
||||
'portal.citas.upcoming': 'Upcoming',
|
||||
'portal.citas.past': 'Past',
|
||||
'portal.citas.none': 'You have no appointments.',
|
||||
'portal.citas.cancel': 'Cancel',
|
||||
'portal.citas.reschedule': 'Reschedule',
|
||||
'portal.citas.confirmCancel': 'Cancel this appointment?',
|
||||
'portal.citas.cancelled': 'Appointment cancelled.',
|
||||
'portal.citas.cancelError': 'The appointment could not be cancelled.',
|
||||
'portal.citas.rescheduled': 'Appointment rescheduled.',
|
||||
'portal.citas.rescheduleError': 'The appointment could not be rescheduled.',
|
||||
'portal.citas.date': 'Choose the new date',
|
||||
'portal.citas.slot': 'Choose an available time',
|
||||
'portal.citas.noSlots': 'No times available that day.',
|
||||
'portal.citas.service': 'Service',
|
||||
'portal.citas.doctor': 'Specialist',
|
||||
'portal.citas.branch': 'Branch',
|
||||
'portal.citas.duration': 'Duration',
|
||||
'portal.citas.amount': 'Amount',
|
||||
'portal.citas.minutes': 'min',
|
||||
'portal.citas.anticipo': 'Deposit pending',
|
||||
'portal.pagos.payments': 'Payments',
|
||||
'portal.pagos.sales': 'Sales (notes)',
|
||||
'portal.pagos.balance': 'Outstanding balance',
|
||||
'portal.pagos.download': 'Download statement',
|
||||
'portal.pagos.none': 'No transactions.',
|
||||
'portal.monedero.points': 'Available points',
|
||||
'portal.monedero.mxn': 'Equivalent in pesos',
|
||||
'portal.monedero.usd': 'Equivalent in dollars',
|
||||
'portal.monedero.none': 'You have no wallet activity yet.',
|
||||
'portal.paquetes.total': 'Included',
|
||||
'portal.paquetes.used': 'Used',
|
||||
'portal.paquetes.left': 'Remaining',
|
||||
'portal.paquetes.valid': 'Valid until',
|
||||
'portal.paquetes.none': 'You have no packages.',
|
||||
'portal.membresias.plan': 'Plan',
|
||||
'portal.membresias.valid': 'Term',
|
||||
'portal.membresias.next': 'Next charge',
|
||||
'portal.membresias.sessions': 'Remaining sessions',
|
||||
'portal.membresias.unlimited': 'Unlimited',
|
||||
'portal.membresias.due': 'Pending period',
|
||||
'portal.membresias.pay': 'Pay period',
|
||||
'portal.membresias.none': 'You have no memberships.',
|
||||
'portal.membresias.payError': 'The payment could not be generated.',
|
||||
'portal.formularios.pending': 'Pending',
|
||||
'portal.formularios.done': 'Answered',
|
||||
'portal.formularios.open': 'Fill out',
|
||||
'portal.formularios.none': 'You have no forms.',
|
||||
'portal.formularios.signed': 'Signed',
|
||||
'portal.documentos.recetas': 'Prescriptions',
|
||||
'portal.documentos.estado': 'Account statement',
|
||||
'portal.documentos.none': 'No documents.',
|
||||
'portal.documentos.noAccess': 'Statement download is not available.',
|
||||
'portal.documentos.download': 'Download',
|
||||
'portal.perfil.name': 'Name',
|
||||
'portal.perfil.phone': 'Phone',
|
||||
'portal.perfil.email': 'Email',
|
||||
'portal.perfil.birth': 'Date of birth',
|
||||
'portal.perfil.occupation': 'Occupation',
|
||||
'portal.perfil.address': 'Address',
|
||||
'portal.perfil.emergency': 'Emergency contact',
|
||||
'portal.perfil.emergencyPhone': 'Emergency phone',
|
||||
'portal.perfil.save': 'Save changes',
|
||||
'portal.perfil.saved': 'Details updated.',
|
||||
'portal.perfil.error': 'The details could not be saved.',
|
||||
'portal.perfil.photos': 'My photos',
|
||||
'portal.perfil.noPhotos': 'No photos.',
|
||||
'portal.perfil.readonly': 'Editing is disabled. Ask the clinic for changes.',
|
||||
'portal.perfil.passwordTitle': 'Password',
|
||||
'portal.perfil.setPassword': 'Set password',
|
||||
'portal.perfil.passwordSaved': 'Password saved.',
|
||||
'portal.estado.title': 'Account statement for {name}',
|
||||
'portal.estado.generated': 'Generated on {date}',
|
||||
'portal.estado.points': 'Wallet points',
|
||||
'portal.estado.spent': 'Total spent',
|
||||
'portal.estado.due': 'Amount due',
|
||||
'portal.estado.visits': 'Visits',
|
||||
'portal.estado.print': 'Print',
|
||||
'portal.common.currency': 'Currency',
|
||||
'portal.common.status': 'Status',
|
||||
'portal.common.date': 'Date',
|
||||
};
|
||||
|
||||
export const DICT: Record<Locale, Dict> = { es, en };
|
||||
|
||||
@@ -9,6 +9,7 @@ export const PERM_KEYS: string[] = [
|
||||
'recursos.ver', 'recursos.editar',
|
||||
'pacientes.ver', 'pacientes.expediente', 'pacientes.crear', 'pacientes.editar',
|
||||
'pacientes.eliminar', 'pacientes.exportar', 'pacientes.saldos',
|
||||
'portal.invitar', 'portal.sesiones',
|
||||
'visitas.ver', 'visitas.crear', 'visitas.editar', 'visitas.eliminar', 'visitas.archivos',
|
||||
'ventas.vender', 'ventas.cobrar', 'ventas.descuentos', 'ventas.impuestos', 'ventas.cancelar',
|
||||
'ventas.devolver', 'ventas.reimprimir',
|
||||
@@ -64,6 +65,8 @@ const DEFAULT_PERM_ROLES: Record<string, string[]> = {
|
||||
'pacientes.ver': ALL, 'pacientes.expediente': ALL, 'pacientes.crear': W,
|
||||
'pacientes.editar': W, 'pacientes.eliminar': W, 'pacientes.exportar': ALL,
|
||||
'pacientes.saldos': ALL,
|
||||
'portal.invitar': ['admin', 'recepcion', 'gerencia'],
|
||||
'portal.sesiones': ['admin', 'recepcion', 'gerencia'],
|
||||
'visitas.ver': ALL, 'visitas.crear': W, 'visitas.editar': W,
|
||||
'visitas.eliminar': W, 'visitas.archivos': W,
|
||||
'ventas.vender': MONEY, 'ventas.cobrar': WN, 'ventas.descuentos': MONEY,
|
||||
|
||||
@@ -15,6 +15,7 @@ import CategoriasPanel from '../components/CategoriasPanel';
|
||||
import RecursosPanel from '../components/RecursosPanel';
|
||||
import TerminologiaPanel from '../components/TerminologiaPanel';
|
||||
import ReservasOnlinePanel from '../components/ReservasOnlinePanel';
|
||||
import PortalClientePanel from '../components/PortalClientePanel';
|
||||
import NotificacionesPanel from '../components/NotificacionesPanel';
|
||||
import NotificacionesEnviadasPanel from '../components/NotificacionesEnviadasPanel';
|
||||
import FormulariosPanel from '../components/FormulariosPanel';
|
||||
@@ -34,6 +35,7 @@ import { LanguageSelect } from '../components/LanguageSelect';
|
||||
const TABS = [
|
||||
{ key: 'clinica', label: 'Clínica' },
|
||||
{ key: 'reservas', label: 'Reservas en línea' },
|
||||
{ key: 'portal', label: 'Portal del cliente' },
|
||||
{ key: 'notificaciones', label: 'Notificaciones' },
|
||||
{ key: 'notif_enviadas', label: 'Notificaciones enviadas' },
|
||||
{ key: 'formularios', label: 'Formularios' },
|
||||
@@ -56,7 +58,7 @@ const TABS = [
|
||||
] as const;
|
||||
|
||||
// Secciones que ve gerencia (admin del sistema ve todas).
|
||||
const TABS_GERENCIA = ['reservas', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'comisiones', 'terminologia', 'usuarios', 'roles', 'tickets', 'licencia', 'webhooks'];
|
||||
const TABS_GERENCIA = ['reservas', 'portal', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'comisiones', 'terminologia', 'usuarios', 'roles', 'tickets', 'licencia', 'webhooks'];
|
||||
|
||||
const SUBCATALOGOS = [
|
||||
{ key: 'diagnosticos', label: 'Diagnósticos' },
|
||||
@@ -450,6 +452,12 @@ const Configuracion: FC = () => {
|
||||
<ReservasOnlinePanel settings={settings} onSaved={setSettings} />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : tab === 'portal' ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<PortalClientePanel settings={settings} onSaved={setSettings} />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : tab === 'notificaciones' ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useAuth } from '../lib/auth';
|
||||
import { useTheme } from '../lib/theme';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
import { useBookingConfig } from '../lib/publicBooking';
|
||||
import { usePortalConfig } from '../services/portal';
|
||||
|
||||
const Login: FC = () => {
|
||||
const { login, brand, setBrand } = useAuth();
|
||||
@@ -17,6 +18,8 @@ const Login: FC = () => {
|
||||
// Enlace público de reservas: solo se muestra si el portal está activo
|
||||
// (con reservas_online.activo=false el endpoint responde 404 y no aparece).
|
||||
const { active: reservasActivas } = useBookingConfig();
|
||||
// Aviso a clientes que caen en el acceso del personal: enlace a su cuenta.
|
||||
const { active: portalActivo } = usePortalConfig();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
@@ -154,6 +157,18 @@ const Login: FC = () => {
|
||||
</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)' }}>
|
||||
{brand.pie}
|
||||
</p>
|
||||
|
||||
768
frontend/src/pages/MiCuenta.tsx
Normal file
768
frontend/src/pages/MiCuenta.tsx
Normal 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;
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
descargarIcs, normalizarTelefono, telefonoValido,
|
||||
} from '../lib/publicBooking';
|
||||
import { useI18n, GLOSARIO_EN } from '../lib/i18n';
|
||||
import { usePortalConfig } from '../services/portal';
|
||||
|
||||
// Portal público de reservas en línea (sin sesión). Mobile-first: la mayoría
|
||||
// de las clientas reservan del celular. Respeta brand y glosario del backend.
|
||||
@@ -21,6 +22,7 @@ type GrupoServicios = { code: string; nombre: string; servicios: ServicioPortal[
|
||||
const Reservar: FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { config, loading } = useBookingConfig();
|
||||
const { active: portalActivo } = usePortalConfig();
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const [paso, setPaso] = useState(0);
|
||||
@@ -452,6 +454,13 @@ const Reservar: FC = () => {
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</Centro>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ axios.interceptors.response.use(
|
||||
// Rutas públicas (sin sesión): un 401 de un endpoint autenticado no debe
|
||||
// expulsar al visitante a /login. Cubre el portal de reservas, la página
|
||||
// de pago y el portal de formularios.
|
||||
const PUBLICAS = ['/login', '/reservar', '/pagar', '/formulario'];
|
||||
const PUBLICAS = ['/login', '/reservar', '/pagar', '/formulario', '/mi-cuenta'];
|
||||
const onPublica = PUBLICAS.some((p) => path === p || path.startsWith(p + '/'));
|
||||
const isLoginCall = String(error.config?.url || '').includes('/auth/login');
|
||||
if (!onPublica && !isLoginCall) {
|
||||
@@ -1088,6 +1088,7 @@ export interface ClinicSettings {
|
||||
compras: ComprasSettings;
|
||||
comisiones: ComisionesSettings;
|
||||
licencia: LicenciaSettings;
|
||||
portal_cliente: PortalClienteSettings;
|
||||
}
|
||||
|
||||
// ---- Licencia por plan (bloque `licencia` de clinic-settings) ----
|
||||
@@ -1281,6 +1282,63 @@ export const RESERVAS_ONLINE_DEFAULT: ReservasOnlineSettings = {
|
||||
anticipo: { ...ANTICIPO_DEFAULT },
|
||||
};
|
||||
|
||||
// ---- Portal del cliente (cuenta de autoservicio del paciente) ----
|
||||
export interface PortalClienteSettings {
|
||||
/** Enciende TODO el portal del cliente. Apagado = nada cambia. */
|
||||
activo: boolean;
|
||||
/** Cómo entra el cliente: código por WhatsApp, enlace por correo o ambos. */
|
||||
metodo: 'otp_whatsapp' | 'enlace_correo' | 'ambos';
|
||||
/** Exige tener correo registrado para acceder. */
|
||||
requerir_correo_verificado: boolean;
|
||||
/** Permite al cliente fijar una contraseña tras entrar la primera vez. */
|
||||
permitir_contrasena: boolean;
|
||||
/** Vigencia de la sesión del cliente (horas). */
|
||||
sesion_horas: number;
|
||||
/** Permite reagendar citas en línea. */
|
||||
permitir_reagendar: boolean;
|
||||
/** Horas mínimas de antelación para cancelar en línea. */
|
||||
horas_minimas_cancelar: number;
|
||||
/** Muestra importes en citas y ventas. */
|
||||
mostrar_precios: boolean;
|
||||
/** Muestra recetas en el portal. */
|
||||
mostrar_recetas: boolean;
|
||||
/** Permite descargar el estado de cuenta. */
|
||||
permitir_descargar_estado_cuenta: boolean;
|
||||
/** Permite al cliente actualizar sus datos de contacto. */
|
||||
permitir_actualizar_datos: boolean;
|
||||
}
|
||||
|
||||
export const PORTAL_CLIENTE_DEFAULT: PortalClienteSettings = {
|
||||
activo: false,
|
||||
metodo: 'otp_whatsapp',
|
||||
requerir_correo_verificado: false,
|
||||
permitir_contrasena: false,
|
||||
sesion_horas: 24,
|
||||
permitir_reagendar: true,
|
||||
horas_minimas_cancelar: 24,
|
||||
mostrar_precios: true,
|
||||
mostrar_recetas: true,
|
||||
permitir_descargar_estado_cuenta: true,
|
||||
permitir_actualizar_datos: true,
|
||||
};
|
||||
|
||||
// Estado del acceso al portal de un paciente (personal).
|
||||
export interface PortalAcceso {
|
||||
partner_id: number;
|
||||
paciente?: string;
|
||||
patient_id?: string;
|
||||
telefono?: string;
|
||||
email?: string;
|
||||
tiene_acceso?: boolean;
|
||||
activo: boolean;
|
||||
ultimo_acceso: string | null;
|
||||
total_accesos: number;
|
||||
sesiones_abiertas: number;
|
||||
metodo: string | null;
|
||||
invitado_por?: string;
|
||||
invitado_at?: string | null;
|
||||
}
|
||||
|
||||
// ---- Notificaciones y recordatorios de citas ----
|
||||
// ---- Recursos físicos de la agenda ----
|
||||
export interface RecursosSettings {
|
||||
@@ -2367,6 +2425,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
|
||||
compras: COMPRAS_DEFAULT,
|
||||
comisiones: COMISIONES_DEFAULT,
|
||||
licencia: LICENCIA_DEFAULT,
|
||||
portal_cliente: PORTAL_CLIENTE_DEFAULT,
|
||||
};
|
||||
|
||||
export interface FrontendUser {
|
||||
@@ -3061,6 +3120,32 @@ export const odooApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
// ---- Portal del cliente (personal: invitar, accesos, sesiones) ----
|
||||
async getPortalAccesos(): Promise<{ status: string; activo: boolean; accesos: PortalAcceso[]; message?: string }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/portal/accesos`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async getPortalPaciente(partnerId: number): Promise<{ status: string; activo: boolean; estado: PortalAcceso; message?: string }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/portal/paciente/${partnerId}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async invitarPortal(partnerId: number, canal?: 'whatsapp' | 'email'): Promise<{ status: string; ok?: boolean; canal?: string; modo_prueba?: boolean; codigo_prueba?: string; enlace_prueba?: string; acceso?: PortalAcceso; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/portal/invitar`, { partner_id: partnerId, canal });
|
||||
return data;
|
||||
},
|
||||
|
||||
async cerrarPortalSesiones(partnerId: number): Promise<{ status: string; ok?: boolean; acceso?: PortalAcceso; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/portal/sesiones/cerrar`, { partner_id: partnerId });
|
||||
return data;
|
||||
},
|
||||
|
||||
async portalAccesoEstado(partnerId: number, accion: 'activar' | 'desactivar'): Promise<{ status: string; ok?: boolean; acceso?: PortalAcceso; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/portal/acceso/${partnerId}/${accion}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
// ---- Licencia por plan ----
|
||||
async getLicencia(): Promise<LicenciaEstadoResponse> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/licencia`);
|
||||
|
||||
233
frontend/src/services/portal.ts
Normal file
233
frontend/src/services/portal.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import type { ClinicBrand, GlossaryTerms } from './odoo';
|
||||
|
||||
// Cliente del portal del cliente (cuenta de autoservicio). Es ANÓNIMO salvo las
|
||||
// peticiones de datos, que llevan su propio token (Bearer), distinto del token
|
||||
// del personal. La sesión vive en localStorage y no toca la del personal.
|
||||
|
||||
const ODOO_BASE = import.meta.env.VITE_API_BASE_URL || '/api/odoo';
|
||||
const BASE = `${ODOO_BASE}/skeen/frontend/v1/public/portal`;
|
||||
export const PORTAL_TOKEN_KEY = 'skeen_portal_token';
|
||||
|
||||
export const portalToken = (): string | null => localStorage.getItem(PORTAL_TOKEN_KEY);
|
||||
export const setPortalToken = (t: string | null) => {
|
||||
if (t) localStorage.setItem(PORTAL_TOKEN_KEY, t);
|
||||
else localStorage.removeItem(PORTAL_TOKEN_KEY);
|
||||
};
|
||||
|
||||
export const portalHttp = axios.create({ baseURL: BASE });
|
||||
portalHttp.interceptors.request.use((config) => {
|
||||
const t = portalToken();
|
||||
if (t) {
|
||||
config.headers = config.headers || {};
|
||||
(config.headers as Record<string, string>).Authorization = `Bearer ${t}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
portalHttp.interceptors.response.use(
|
||||
(res) => res,
|
||||
(error) => {
|
||||
if (error?.response?.status === 401) {
|
||||
setPortalToken(null);
|
||||
window.dispatchEvent(new Event('portal:logout'));
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export interface PortalConfig {
|
||||
active: boolean;
|
||||
brand: ClinicBrand;
|
||||
terms: GlossaryTerms;
|
||||
config: {
|
||||
metodo: 'otp_whatsapp' | 'enlace_correo' | 'ambos';
|
||||
requerir_correo_verificado: boolean;
|
||||
permitir_contrasena: boolean;
|
||||
sesion_horas: number;
|
||||
permitir_reagendar: boolean;
|
||||
horas_minimas_cancelar: number;
|
||||
mostrar_precios: boolean;
|
||||
mostrar_recetas: boolean;
|
||||
permitir_descargar_estado_cuenta: boolean;
|
||||
permitir_actualizar_datos: boolean;
|
||||
reservas_online: boolean;
|
||||
};
|
||||
zona_horaria?: string;
|
||||
moneda?: string;
|
||||
}
|
||||
|
||||
export interface PortalCita {
|
||||
id: number;
|
||||
folio: string;
|
||||
fecha: string | null;
|
||||
hora: string;
|
||||
inicio: string | null;
|
||||
servicio_id: number | null;
|
||||
servicio: string;
|
||||
doctor_id: number | null;
|
||||
doctor: string;
|
||||
sucursal: string;
|
||||
duracion_min: number;
|
||||
importe: number | null;
|
||||
moneda: string;
|
||||
estado: string;
|
||||
estado_label: string;
|
||||
notas: string;
|
||||
puede_cancelar: boolean;
|
||||
puede_reagendar: boolean;
|
||||
anticipo_estado: string;
|
||||
}
|
||||
|
||||
export interface PortalPerfil {
|
||||
nombre: string;
|
||||
patient_id: string;
|
||||
telefono: string;
|
||||
mobile: string;
|
||||
email: string;
|
||||
birth_date: string | null;
|
||||
gender: string;
|
||||
occupation: string;
|
||||
marital_status: string;
|
||||
birthplace: string;
|
||||
address_notes: string;
|
||||
emergency_contact: string;
|
||||
emergency_phone: string;
|
||||
tiene_foto: boolean;
|
||||
puede_editar: boolean;
|
||||
fotografias: { id: number; nombre: string; clasificacion: string; fecha: string | null }[];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type Json = any;
|
||||
|
||||
export const portalApi = {
|
||||
async config(): Promise<PortalConfig | null> {
|
||||
const { data } = await portalHttp.get('/config');
|
||||
if (!data || !data.activo) return null;
|
||||
return {
|
||||
active: true, brand: data.brand, terms: data.terms,
|
||||
config: data.config, zona_horaria: data.zona_horaria, moneda: data.moneda,
|
||||
};
|
||||
},
|
||||
async solicitar(identificador: string): Promise<Json> {
|
||||
const { data } = await portalHttp.post('/acceso/solicitar', { identificador });
|
||||
return data;
|
||||
},
|
||||
async verificar(identificador: string, codigo: string): Promise<Json> {
|
||||
const { data } = await portalHttp.post('/acceso/verificar', { identificador, codigo });
|
||||
return data;
|
||||
},
|
||||
async entrarEnlace(token: string): Promise<Json> {
|
||||
const { data } = await portalHttp.post('/acceso/enlace', { token });
|
||||
return data;
|
||||
},
|
||||
async entrarPassword(identificador: string, password: string): Promise<Json> {
|
||||
const { data } = await portalHttp.post('/acceso/password', { identificador, password });
|
||||
return data;
|
||||
},
|
||||
async sesion(): Promise<Json> {
|
||||
const { data } = await portalHttp.get('/sesion');
|
||||
return data;
|
||||
},
|
||||
async cerrarSesion(): Promise<Json> {
|
||||
try {
|
||||
const { data } = await portalHttp.post('/sesion/cerrar');
|
||||
return data;
|
||||
} catch {
|
||||
return { status: 'success' };
|
||||
}
|
||||
},
|
||||
async resumen(): Promise<Json> {
|
||||
const { data } = await portalHttp.get('/resumen');
|
||||
return data;
|
||||
},
|
||||
async citas(): Promise<{ proximas: PortalCita[]; pasadas: PortalCita[] }> {
|
||||
const { data } = await portalHttp.get('/citas');
|
||||
return { proximas: data.proximas || [], pasadas: data.pasadas || [] };
|
||||
},
|
||||
async disponibilidad(citaId: number, fecha: string): Promise<string[]> {
|
||||
const { data } = await portalHttp.get('/citas/disponibilidad', {
|
||||
params: { cita_id: citaId, fecha },
|
||||
});
|
||||
return data.slots || [];
|
||||
},
|
||||
async cancelarCita(citaId: number, motivo?: string): Promise<Json> {
|
||||
const { data } = await portalHttp.post('/citas/cancelar', { cita_id: citaId, motivo });
|
||||
return data;
|
||||
},
|
||||
async reagendar(citaId: number, fecha: string, hora: string): Promise<Json> {
|
||||
const { data } = await portalHttp.post('/citas/reagendar', { cita_id: citaId, fecha, hora });
|
||||
return data;
|
||||
},
|
||||
async pagos(): Promise<Json> {
|
||||
const { data } = await portalHttp.get('/pagos');
|
||||
return data;
|
||||
},
|
||||
async estadoCuenta(): Promise<Json> {
|
||||
const { data } = await portalHttp.get('/estado-cuenta');
|
||||
return data;
|
||||
},
|
||||
async monedero(): Promise<Json> {
|
||||
const { data } = await portalHttp.get('/monedero');
|
||||
return data;
|
||||
},
|
||||
async paquetes(): Promise<Json> {
|
||||
const { data } = await portalHttp.get('/paquetes');
|
||||
return data;
|
||||
},
|
||||
async membresias(): Promise<Json> {
|
||||
const { data } = await portalHttp.get('/membresias');
|
||||
return data;
|
||||
},
|
||||
async pagarMembresia(membresiaId: number, periodoId?: number): Promise<Json> {
|
||||
const { data } = await portalHttp.post('/membresias/pagar', {
|
||||
membresia_id: membresiaId, periodo_id: periodoId,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
async formularios(): Promise<Json> {
|
||||
const { data } = await portalHttp.get('/formularios');
|
||||
return data;
|
||||
},
|
||||
async documentos(): Promise<Json> {
|
||||
const { data } = await portalHttp.get('/documentos');
|
||||
return data;
|
||||
},
|
||||
async perfil(): Promise<PortalPerfil> {
|
||||
const { data } = await portalHttp.get('/perfil');
|
||||
return data.perfil;
|
||||
},
|
||||
async guardarPerfil(vals: Partial<PortalPerfil>): Promise<Json> {
|
||||
const { data } = await portalHttp.put('/perfil', vals);
|
||||
return data;
|
||||
},
|
||||
async setPassword(password: string): Promise<Json> {
|
||||
const { data } = await portalHttp.post('/perfil/password', { password });
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
/** Config del portal. `active=false` cuando está apagado o no responde. */
|
||||
export const usePortalConfig = () => {
|
||||
const [config, setConfig] = useState<PortalConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
portalApi.config()
|
||||
.then((res) => { if (alive) setConfig(res); })
|
||||
.catch(() => { if (alive) setConfig(null); })
|
||||
.finally(() => { if (alive) setLoading(false); });
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
return { config, loading, active: !!config };
|
||||
};
|
||||
|
||||
/** Escucha el evento de cierre de sesión del portal (401 global). */
|
||||
export const usePortalLogout = (onLogout: () => void) => {
|
||||
const handler = useCallback(() => onLogout(), [onLogout]);
|
||||
useEffect(() => {
|
||||
window.addEventListener('portal:logout', handler);
|
||||
return () => window.removeEventListener('portal:logout', handler);
|
||||
}, [handler]);
|
||||
};
|
||||
Reference in New Issue
Block a user