fase 2.3: portal publico de reservas en linea (5 endpoints anonimos con rate limit y token HMAC, wizard movil, gestion/cancelacion por enlace, distintivo en agenda, editor y enlace publico en Configuracion); apagado por defecto
This commit is contained in:
@@ -33,6 +33,8 @@ import WacrmLeads from './pages/WacrmLeads';
|
|||||||
import Chat from './pages/Chat';
|
import Chat from './pages/Chat';
|
||||||
import MisVentas from './pages/MisVentas';
|
import MisVentas from './pages/MisVentas';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
|
import Reservar from './pages/Reservar';
|
||||||
|
import ReservarCita from './pages/ReservarCita';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
const FullScreenLoader: FC = () => (
|
const FullScreenLoader: FC = () => (
|
||||||
@@ -135,6 +137,9 @@ const App: FC = () => {
|
|||||||
<ChatNotifications />
|
<ChatNotifications />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginRoute />} />
|
<Route path="/login" element={<LoginRoute />} />
|
||||||
|
{/* Portal público de reservas: FUERA del gate de autenticación. */}
|
||||||
|
<Route path="/reservar" element={<Reservar />} />
|
||||||
|
<Route path="/reservar/cita/:token" element={<ReservarCita />} />
|
||||||
<Route path="/" element={<RequireAuth href="/"><HomeRoute /></RequireAuth>} />
|
<Route path="/" element={<RequireAuth href="/"><HomeRoute /></RequireAuth>} />
|
||||||
<Route path="/agenda" element={<RequireAuth href="/agenda"><Agenda /></RequireAuth>} />
|
<Route path="/agenda" element={<RequireAuth href="/agenda"><Agenda /></RequireAuth>} />
|
||||||
<Route path="/mis-ventas" element={<RequireAuth href="/mis-ventas"><MisVentas /></RequireAuth>} />
|
<Route path="/mis-ventas" element={<RequireAuth href="/mis-ventas"><MisVentas /></RequireAuth>} />
|
||||||
|
|||||||
165
frontend/src/components/ReservasOnlinePanel.tsx
Normal file
165
frontend/src/components/ReservasOnlinePanel.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import type { FC, ReactNode } from 'react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { CalendarClock, Copy, ExternalLink, Save } from 'lucide-react';
|
||||||
|
import { Button, Input, TextArea, toast } from './ui';
|
||||||
|
import {
|
||||||
|
odooApi, RESERVAS_ONLINE_DEFAULT,
|
||||||
|
type ClinicSettings, type ReservasOnlineSettings,
|
||||||
|
} from '../services/odoo';
|
||||||
|
import { enlaceReserva } from '../lib/publicBooking';
|
||||||
|
|
||||||
|
// Editor del bloque `reservas_online` (portal público de auto-agendado) y
|
||||||
|
// botón para copiar/abrir el enlace público de la clínica.
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: ClinicSettings;
|
||||||
|
onSaved: (settings: ClinicSettings) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReservasOnlinePanel: FC<Props> = ({ settings, onSaved }) => {
|
||||||
|
const [form, setForm] = useState<ReservasOnlineSettings>(
|
||||||
|
settings.reservas_online || RESERVAS_ONLINE_DEFAULT);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [copiado, setCopiado] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setForm(settings.reservas_online || RESERVAS_ONLINE_DEFAULT);
|
||||||
|
}, [settings.reservas_online]);
|
||||||
|
|
||||||
|
const set = <K extends keyof ReservasOnlineSettings>(k: K, v: ReservasOnlineSettings[K]) =>
|
||||||
|
setForm((prev) => ({ ...prev, [k]: v }));
|
||||||
|
|
||||||
|
const enlace = enlaceReserva(settings.url_publica);
|
||||||
|
|
||||||
|
const guardar = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await odooApi.putClinicSettings({
|
||||||
|
reservas_online: form,
|
||||||
|
motivo: 'Reservas en línea desde Configuración',
|
||||||
|
});
|
||||||
|
if (res.status === 'success') {
|
||||||
|
onSaved(res.settings);
|
||||||
|
toast.success('Reservas en línea guardadas');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
toast.error(msg || 'No se pudieron guardar las reservas en línea');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copiar = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(enlace);
|
||||||
|
setCopiado(true);
|
||||||
|
setTimeout(() => setCopiado(false), 2000);
|
||||||
|
} catch {
|
||||||
|
toast.error('No se pudo copiar el enlace');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<CalendarClock size={20} className="mt-0.5 shrink-0 text-theme-accent" />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-heading text-xl text-theme-heading">Reservas en línea</h3>
|
||||||
|
<p className="text-sm text-theme-muted">
|
||||||
|
Portal público donde tus clientes agendan solos, sin usuario ni contraseña.
|
||||||
|
Mientras esté desactivado, nada cambia en el sistema ni se muestra ningún enlace.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Toggle
|
||||||
|
label="Activar reservas en línea"
|
||||||
|
checked={form.activo}
|
||||||
|
onChange={(v) => set('activo', v)}
|
||||||
|
ayuda="Al desactivarlo, el portal responde deshabilitado y desaparecen los enlaces."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<Input label="Título del portal" value={form.titulo} onChange={(e) => set('titulo', e.target.value)} />
|
||||||
|
<Input label="Teléfono de contacto" value={form.telefono_contacto}
|
||||||
|
onChange={(e) => set('telefono_contacto', e.target.value)}
|
||||||
|
placeholder="Opcional, para dudas de los clientes" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextArea label="Descripción (opcional)" value={form.descripcion}
|
||||||
|
onChange={(e) => set('descripcion', e.target.value)} rows={2} />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<Toggle label="Requiere confirmación de la clínica" checked={form.requiere_confirmacion}
|
||||||
|
onChange={(v) => set('requiere_confirmacion', v)}
|
||||||
|
ayuda="Las reservas entran como agendadas y el equipo las confirma." />
|
||||||
|
<Toggle label="Permitir elegir especialista" checked={form.permitir_eleccion_doctor}
|
||||||
|
onChange={(v) => set('permitir_eleccion_doctor', v)}
|
||||||
|
ayuda="Incluye la opción 'cualquiera disponible'." />
|
||||||
|
<Toggle label="Permitir elegir sucursal" checked={form.permitir_sucursal}
|
||||||
|
onChange={(v) => set('permitir_sucursal', v)} />
|
||||||
|
<Toggle label="Permitir cancelar en línea" checked={form.permitir_cancelar}
|
||||||
|
onChange={(v) => set('permitir_cancelar', v)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<Input label="Máximo días a futuro" type="number" min={1} max={365}
|
||||||
|
value={String(form.max_dias_futuro)}
|
||||||
|
onChange={(e) => set('max_dias_futuro', Number(e.target.value) || 1)} />
|
||||||
|
<Input label="Horas mínimas de anticipación" type="number" min={0} max={168}
|
||||||
|
value={String(form.min_horas_anticipacion)}
|
||||||
|
onChange={(e) => set('min_horas_anticipacion', Number(e.target.value) || 0)} />
|
||||||
|
<Input label="Cancelar hasta (horas antes)" type="number" min={0} max={168}
|
||||||
|
value={String(form.cancelar_hasta_horas)}
|
||||||
|
onChange={(e) => set('cancelar_hasta_horas', Number(e.target.value) || 0)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextArea label="Mensaje de confirmación (opcional)" value={form.mensaje_confirmacion}
|
||||||
|
onChange={(e) => set('mensaje_confirmacion', e.target.value)} rows={2}
|
||||||
|
placeholder="Se muestra al cliente al terminar su reserva" />
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-theme-border bg-theme-bg p-4">
|
||||||
|
<p className="text-xs font-medium text-theme-muted">Enlace público de la clínica</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 ? 'Copiado' : 'Copiar'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={() => window.open('/reservar', '_blank', 'noopener')}>
|
||||||
|
<ExternalLink size={14} className="mr-1" /> Abrir
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[11px] text-theme-muted">
|
||||||
|
El enlace se arma con la URL pública configurada en «Clínica»; si está vacía, usa la
|
||||||
|
dirección actual del navegador.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button loading={saving} onClick={guardar}>
|
||||||
|
<Save size={16} className="mr-2" /> Guardar reservas en línea
|
||||||
|
</Button>
|
||||||
|
</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 ReservasOnlinePanel;
|
||||||
130
frontend/src/lib/publicBooking.ts
Normal file
130
frontend/src/lib/publicBooking.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { odooApi, type BookingConfig } from '../services/odoo';
|
||||||
|
|
||||||
|
// Utilidades y hook compartidos del portal público de reservas en línea.
|
||||||
|
// El portal es anónimo: solo usa los endpoints /public/booking/*, que
|
||||||
|
// responden 404 cuando el módulo está desactivado.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Config del portal público. Devuelve `config=null` cuando está desactivado
|
||||||
|
* (404) o el backend no responde: en ese caso el portal se muestra como "no
|
||||||
|
* disponible" y no se ofrece ningún enlace.
|
||||||
|
*/
|
||||||
|
export const useBookingConfig = () => {
|
||||||
|
const [config, setConfig] = useState<BookingConfig | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
odooApi.getBookingConfig()
|
||||||
|
.then((res) => { if (alive && res && res.activo) setConfig(res); })
|
||||||
|
.catch(() => { if (alive) setConfig(null); })
|
||||||
|
.finally(() => { if (alive) setLoading(false); });
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, []);
|
||||||
|
return { config, loading, active: !!config };
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIAS = ['Domingo', 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado'];
|
||||||
|
const DIAS_ABREV = ['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'];
|
||||||
|
const MESES = ['enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio',
|
||||||
|
'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre'];
|
||||||
|
|
||||||
|
/** 'YYYY-MM-DD' local de un Date (sin cruzar a UTC). */
|
||||||
|
export const fechaISO = (d: Date): string => {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Próximos `max` días desde hoy (inclusive), para el selector de fecha. */
|
||||||
|
export const proximosDias = (max: number, tope = 30): Date[] => {
|
||||||
|
const n = Math.max(1, Math.min(max || 1, tope));
|
||||||
|
const hoy = new Date();
|
||||||
|
hoy.setHours(0, 0, 0, 0);
|
||||||
|
return Array.from({ length: n }, (_, i) => new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate() + i));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const etiquetaDiaCorto = (d: Date): { abrev: string; dia: string } => ({
|
||||||
|
abrev: DIAS_ABREV[d.getDay()],
|
||||||
|
dia: String(d.getDate()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fechaLarga = (d: Date): string =>
|
||||||
|
`${DIAS[d.getDay()]} ${d.getDate()} de ${MESES[d.getMonth()]}`;
|
||||||
|
|
||||||
|
export const fechaLargaISO = (iso: string): string => {
|
||||||
|
const [y, m, day] = iso.split('-').map(Number);
|
||||||
|
return fechaLarga(new Date(y, (m || 1) - 1, day || 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Evento .ics (hora local flotante) para "agregar al calendario". */
|
||||||
|
export const construirIcs = (opts: {
|
||||||
|
folio: string;
|
||||||
|
fecha: string; // YYYY-MM-DD
|
||||||
|
hora: string; // HH:MM
|
||||||
|
duracionMin: number;
|
||||||
|
titulo: string;
|
||||||
|
descripcion?: string;
|
||||||
|
lugar?: string;
|
||||||
|
}): string => {
|
||||||
|
const [y, m, d] = opts.fecha.split('-').map(Number);
|
||||||
|
const [hh, mm] = opts.hora.split(':').map(Number);
|
||||||
|
const fmt = (dt: Date) =>
|
||||||
|
`${dt.getFullYear()}${String(dt.getMonth() + 1).padStart(2, '0')}${String(dt.getDate()).padStart(2, '0')}` +
|
||||||
|
`T${String(dt.getHours()).padStart(2, '0')}${String(dt.getMinutes()).padStart(2, '0')}00`;
|
||||||
|
const inicio = new Date(y, (m || 1) - 1, d || 1, hh || 0, mm || 0);
|
||||||
|
const fin = new Date(inicio.getTime() + Math.max(opts.duracionMin || 30, 5) * 60000);
|
||||||
|
const esc = (s: string) => String(s || '').replace(/([,;\\])/g, '\\$1').replace(/\n/g, '\\n');
|
||||||
|
return [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'PRODID:-//SKEEN//Reservas en linea//ES',
|
||||||
|
'CALSCALE:GREGORIAN',
|
||||||
|
'METHOD:PUBLISH',
|
||||||
|
'BEGIN:VEVENT',
|
||||||
|
`UID:${esc(opts.folio)}@skeen`,
|
||||||
|
`DTSTAMP:${fmt(new Date())}`,
|
||||||
|
`DTSTART:${fmt(inicio)}`,
|
||||||
|
`DTEND:${fmt(fin)}`,
|
||||||
|
`SUMMARY:${esc(opts.titulo)}`,
|
||||||
|
opts.lugar ? `LOCATION:${esc(opts.lugar)}` : '',
|
||||||
|
opts.descripcion ? `DESCRIPTION:${esc(opts.descripcion)}` : '',
|
||||||
|
'END:VEVENT',
|
||||||
|
'END:VCALENDAR',
|
||||||
|
].filter(Boolean).join('\r\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const descargarIcs = (nombreArchivo: string, contenido: string) => {
|
||||||
|
const blob = new Blob([contenido], { type: 'text/calendar;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = nombreArchivo.endsWith('.ics') ? nombreArchivo : `${nombreArchivo}.ics`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** URL base pública de la clínica (para el enlace /reservar compartible). */
|
||||||
|
export const urlBasePublica = (urlPublica?: string): string => {
|
||||||
|
const base = (urlPublica || '').trim();
|
||||||
|
if (base) return base.replace(/\/+$/, '');
|
||||||
|
if (typeof window !== 'undefined') return window.location.origin;
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const enlaceReserva = (urlPublica?: string): string => `${urlBasePublica(urlPublica)}/reservar`;
|
||||||
|
|
||||||
|
/** Formatea el teléfono a solo dígitos (con + inicial si venía). */
|
||||||
|
export const normalizarTelefono = (valor: string): string => {
|
||||||
|
const raw = (valor || '').trim();
|
||||||
|
const digitos = raw.replace(/\D/g, '');
|
||||||
|
return raw.startsWith('+') && digitos ? `+${digitos}` : digitos;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const telefonoValido = (valor: string): boolean => {
|
||||||
|
const digitos = normalizarTelefono(valor).replace(/\D/g, '');
|
||||||
|
return digitos.length >= 10 && digitos.length <= 15;
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||||
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, CalendarRange, CalendarClock, ChevronLeft, ChevronRight, Ban, Phone, Cake, CreditCard, Pencil, AlertTriangle, Printer, PhoneCall, Wallet, ChevronDown, CalendarOff, Users, Play, Check } from 'lucide-react';
|
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, CalendarRange, CalendarClock, ChevronLeft, ChevronRight, Ban, Phone, Cake, CreditCard, Pencil, AlertTriangle, Printer, PhoneCall, Wallet, ChevronDown, CalendarOff, Users, Play, Check, Globe } from 'lucide-react';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import { usePatientDrawer } from '../components/PatientDrawer';
|
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||||
import AgendaPrint from '../components/AgendaPrint';
|
import AgendaPrint from '../components/AgendaPrint';
|
||||||
@@ -399,6 +399,12 @@ const Agenda: FC = () => {
|
|||||||
<Cake size={size} className="inline shrink-0 text-pink-500" aria-label="Cumpleaños del paciente" />
|
<Cake size={size} className="inline shrink-0 text-pink-500" aria-label="Cumpleaños del paciente" />
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
|
// Distintivo de origen: la cita se agendó desde el portal público de reservas.
|
||||||
|
const portalIcon = (apt: Appointment, size = 11) =>
|
||||||
|
apt.booking_source === 'public_portal' ? (
|
||||||
|
<Globe size={size} className="inline shrink-0 text-sky-600" aria-label="Reservada en línea por el cliente" />
|
||||||
|
) : null;
|
||||||
|
|
||||||
const confirmTitle = (apt: Appointment): string => {
|
const confirmTitle = (apt: Appointment): string => {
|
||||||
if (['arrived', 'in_progress', 'done'].includes(apt.state)) return '2da confirmación: llegó a la clínica';
|
if (['arrived', 'in_progress', 'done'].includes(apt.state)) return '2da confirmación: llegó a la clínica';
|
||||||
if (apt.phone_confirmed) {
|
if (apt.phone_confirmed) {
|
||||||
@@ -2088,7 +2094,7 @@ const Agenda: FC = () => {
|
|||||||
onClick={() => openAptDetail(apt)}
|
onClick={() => openAptDetail(apt)}
|
||||||
className="rounded-xl border border-sky-700 bg-theme-surface px-3 py-2 text-left shadow-card transition-shadow hover:shadow-soft"
|
className="rounded-xl border border-sky-700 bg-theme-surface px-3 py-2 text-left shadow-card transition-shadow hover:shadow-soft"
|
||||||
>
|
>
|
||||||
<p className="text-sm font-semibold text-theme-heading">{apt.time} · {apt.patient}</p>
|
<p className="text-sm font-semibold text-theme-heading">{portalIcon(apt)} {apt.time} · {apt.patient}</p>
|
||||||
<p className="text-xs text-theme-muted truncate max-w-[240px]">
|
<p className="text-xs text-theme-muted truncate max-w-[240px]">
|
||||||
{apt.service}{!isMedico && apt.doctor ? ` · ${apt.doctor}` : ''}
|
{apt.service}{!isMedico && apt.doctor ? ` · ${apt.doctor}` : ''}
|
||||||
</p>
|
</p>
|
||||||
@@ -2230,6 +2236,7 @@ const Agenda: FC = () => {
|
|||||||
<div className="font-semibold truncate">
|
<div className="font-semibold truncate">
|
||||||
<span title={confirmTitle(apt)} className="mr-1 align-middle">{confirmIndicator(apt)}</span>
|
<span title={confirmTitle(apt)} className="mr-1 align-middle">{confirmIndicator(apt)}</span>
|
||||||
{apt.patient_birthday && <span title="Cumpleaños del paciente" className="mr-0.5 align-middle">{birthdayIcon(apt)}</span>}
|
{apt.patient_birthday && <span title="Cumpleaños del paciente" className="mr-0.5 align-middle">{birthdayIcon(apt)}</span>}
|
||||||
|
{portalIcon(apt) && <span title="Reservada en línea" className="mr-0.5 align-middle">{portalIcon(apt)}</span>}
|
||||||
{apt.time} · {apt.patient}
|
{apt.time} · {apt.patient}
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate opacity-75">
|
<div className="truncate opacity-75">
|
||||||
@@ -2365,6 +2372,7 @@ const Agenda: FC = () => {
|
|||||||
<div className="font-semibold truncate">
|
<div className="font-semibold truncate">
|
||||||
<span title={confirmTitle(apt)} className="mr-0.5 align-middle">{confirmIndicator(apt, 10)}</span>
|
<span title={confirmTitle(apt)} className="mr-0.5 align-middle">{confirmIndicator(apt, 10)}</span>
|
||||||
{apt.patient_birthday && <span title="Cumpleaños del paciente" className="mr-0.5 align-middle">{birthdayIcon(apt, 10)}</span>}
|
{apt.patient_birthday && <span title="Cumpleaños del paciente" className="mr-0.5 align-middle">{birthdayIcon(apt, 10)}</span>}
|
||||||
|
{portalIcon(apt, 10) && <span title="Reservada en línea" className="mr-0.5 align-middle">{portalIcon(apt, 10)}</span>}
|
||||||
{apt.time} · {firstName(apt.patient)}
|
{apt.time} · {firstName(apt.patient)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2483,6 +2491,7 @@ const Agenda: FC = () => {
|
|||||||
>
|
>
|
||||||
<span className="mr-0.5 align-middle">{confirmIndicator(apt, 10)}</span>
|
<span className="mr-0.5 align-middle">{confirmIndicator(apt, 10)}</span>
|
||||||
{apt.patient_birthday && <span title="Cumpleaños del paciente" className="mr-0.5 align-middle">{birthdayIcon(apt, 10)}</span>}
|
{apt.patient_birthday && <span title="Cumpleaños del paciente" className="mr-0.5 align-middle">{birthdayIcon(apt, 10)}</span>}
|
||||||
|
{portalIcon(apt, 10) && <span title="Reservada en línea" className="mr-0.5 align-middle">{portalIcon(apt, 10)}</span>}
|
||||||
<span className="font-semibold">{apt.time}</span> {firstName(apt.patient)}
|
<span className="font-semibold">{apt.time}</span> {firstName(apt.patient)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@@ -2560,6 +2569,7 @@ const Agenda: FC = () => {
|
|||||||
title="Abrir ficha del paciente"
|
title="Abrir ficha del paciente"
|
||||||
>
|
>
|
||||||
{apt.patient_birthday && <span title="Cumpleaños del paciente" className="mr-1 align-middle">{birthdayIcon(apt, 12)}</span>}
|
{apt.patient_birthday && <span title="Cumpleaños del paciente" className="mr-1 align-middle">{birthdayIcon(apt, 12)}</span>}
|
||||||
|
{portalIcon(apt, 12) && <span title="Reservada en línea" className="mr-1 align-middle">{portalIcon(apt, 12)}</span>}
|
||||||
{apt.patient}
|
{apt.patient}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -13,13 +13,15 @@ import ImpactoModal from '../components/ImpactoModal';
|
|||||||
import SucursalesPanel from '../components/SucursalesPanel';
|
import SucursalesPanel from '../components/SucursalesPanel';
|
||||||
import CategoriasPanel from '../components/CategoriasPanel';
|
import CategoriasPanel from '../components/CategoriasPanel';
|
||||||
import TerminologiaPanel from '../components/TerminologiaPanel';
|
import TerminologiaPanel from '../components/TerminologiaPanel';
|
||||||
|
import ReservasOnlinePanel from '../components/ReservasOnlinePanel';
|
||||||
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
|
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
|
||||||
import { odooApi, type Doctor, type ClinicSettings, type ImpuestosSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT } from '../services/odoo';
|
import { odooApi, type Doctor, type ClinicSettings, type ImpuestosSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, RESERVAS_ONLINE_DEFAULT } from '../services/odoo';
|
||||||
import { useAuth } from '../lib/auth';
|
import { useAuth } from '../lib/auth';
|
||||||
import { publicarClinicSettings } from '../lib/clinic-settings';
|
import { publicarClinicSettings } from '../lib/clinic-settings';
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ key: 'clinica', label: 'Clínica' },
|
{ key: 'clinica', label: 'Clínica' },
|
||||||
|
{ key: 'reservas', label: 'Reservas en línea' },
|
||||||
{ key: 'sucursales', label: 'Sucursales' },
|
{ key: 'sucursales', label: 'Sucursales' },
|
||||||
{ key: 'categorias', label: 'Categorías' },
|
{ key: 'categorias', label: 'Categorías' },
|
||||||
{ key: 'terminologia', label: 'Terminología' },
|
{ key: 'terminologia', label: 'Terminología' },
|
||||||
@@ -32,7 +34,7 @@ const TABS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Secciones que ve gerencia (admin del sistema ve todas).
|
// Secciones que ve gerencia (admin del sistema ve todas).
|
||||||
const TABS_GERENCIA = ['sucursales', 'categorias', 'terminologia', 'usuarios', 'roles', 'tickets'];
|
const TABS_GERENCIA = ['reservas', 'sucursales', 'categorias', 'terminologia', 'usuarios', 'roles', 'tickets'];
|
||||||
|
|
||||||
const SUBCATALOGOS = [
|
const SUBCATALOGOS = [
|
||||||
{ key: 'diagnosticos', label: 'Diagnósticos' },
|
{ key: 'diagnosticos', label: 'Diagnósticos' },
|
||||||
@@ -106,6 +108,7 @@ const Configuracion: FC = () => {
|
|||||||
...CLINIC_SETTINGS_DEFAULT,
|
...CLINIC_SETTINGS_DEFAULT,
|
||||||
...next,
|
...next,
|
||||||
impuestos: { ...IMPUESTOS_DEFAULT, ...(next.impuestos || {}) },
|
impuestos: { ...IMPUESTOS_DEFAULT, ...(next.impuestos || {}) },
|
||||||
|
reservas_online: { ...RESERVAS_ONLINE_DEFAULT, ...(next.reservas_online || {}) },
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -372,6 +375,12 @@ const Configuracion: FC = () => {
|
|||||||
<TerminologiaPanel />
|
<TerminologiaPanel />
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
</Card>
|
</Card>
|
||||||
|
) : tab === 'reservas' ? (
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<ReservasOnlinePanel settings={settings} onSaved={setSettings} />
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ import { Button, Input } from '../components/ui';
|
|||||||
import { odooApi } from '../services/odoo';
|
import { odooApi } from '../services/odoo';
|
||||||
import { useAuth } from '../lib/auth';
|
import { useAuth } from '../lib/auth';
|
||||||
import { useTheme } from '../lib/theme';
|
import { useTheme } from '../lib/theme';
|
||||||
|
import { useBookingConfig } from '../lib/publicBooking';
|
||||||
|
|
||||||
const Login: FC = () => {
|
const Login: FC = () => {
|
||||||
const { login, brand, setBrand } = useAuth();
|
const { login, brand, setBrand } = useAuth();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
// 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();
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
@@ -136,6 +140,18 @@ const Login: FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{reservasActivas && (
|
||||||
|
<p className="text-center text-xs mt-5">
|
||||||
|
<a
|
||||||
|
href="/reservar"
|
||||||
|
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)' }}
|
||||||
|
>
|
||||||
|
Reservar en línea
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="text-center text-xs mt-6" style={{ color: isHomeNest ? 'var(--text-muted)' : 'rgba(255,255,255,0.3)' }}>
|
<p className="text-center text-xs mt-6" style={{ color: isHomeNest ? 'var(--text-muted)' : 'rgba(255,255,255,0.3)' }}>
|
||||||
{brand.pie}
|
{brand.pie}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
446
frontend/src/pages/Reservar.tsx
Normal file
446
frontend/src/pages/Reservar.tsx
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
import type { FC, ReactNode } from 'react';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
ArrowLeft, CalendarPlus, Check, ChevronRight, Clock, Copy, Loader2, MapPin, MessageCircle, User,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button, Input, TextArea } from '../components/ui';
|
||||||
|
import { odooApi, type BookingConfig, type BookingCreateResult } from '../services/odoo';
|
||||||
|
import {
|
||||||
|
useBookingConfig, proximosDias, fechaISO, etiquetaDiaCorto, fechaLargaISO, construirIcs,
|
||||||
|
descargarIcs, normalizarTelefono, telefonoValido,
|
||||||
|
} from '../lib/publicBooking';
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
type ServicioPortal = BookingConfig['servicios'][number];
|
||||||
|
type GrupoServicios = { code: string; nombre: string; servicios: ServicioPortal[] };
|
||||||
|
|
||||||
|
const Reservar: FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { config, loading } = useBookingConfig();
|
||||||
|
|
||||||
|
const [paso, setPaso] = useState(0);
|
||||||
|
const [servicioId, setServicioId] = useState<number | null>(null);
|
||||||
|
const [sucursal, setSucursal] = useState<string | null>(null);
|
||||||
|
const [medicoId, setMedicoId] = useState<number | null>(null);
|
||||||
|
const [fecha, setFecha] = useState<string>(() => fechaISO(new Date()));
|
||||||
|
const [hora, setHora] = useState<string | null>(null);
|
||||||
|
const [slots, setSlots] = useState<string[]>([]);
|
||||||
|
const [slotsLoading, setSlotsLoading] = useState(false);
|
||||||
|
const [nombre, setNombre] = useState('');
|
||||||
|
const [telefono, setTelefono] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [notas, setNotas] = useState('');
|
||||||
|
const [hp, setHp] = useState(''); // honeypot anti-bot
|
||||||
|
const [enviando, setEnviando] = useState(false);
|
||||||
|
const [resultado, setResultado] = useState<BookingCreateResult | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [copiado, setCopiado] = useState(false);
|
||||||
|
|
||||||
|
const pasos = useMemo(() => {
|
||||||
|
const p: string[] = ['servicio'];
|
||||||
|
if (config?.config.permite_sucursal && config.sucursales.length > 1) p.push('sucursal');
|
||||||
|
if (config?.config.permite_eleccion_doctor) p.push('especialista');
|
||||||
|
p.push('fecha', 'datos');
|
||||||
|
return p;
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const pasoActual = pasos[Math.min(paso, pasos.length - 1)];
|
||||||
|
const servicioSel = config?.servicios.find((s) => s.id === servicioId) || null;
|
||||||
|
const medicoSel = config?.medicos.find((m) => m.id === medicoId) || null;
|
||||||
|
|
||||||
|
const grupos = useMemo<GrupoServicios[]>(() => {
|
||||||
|
if (!config) return [];
|
||||||
|
const out: GrupoServicios[] = [];
|
||||||
|
const usados = new Set<string>();
|
||||||
|
for (const c of config.categorias) {
|
||||||
|
const svcs = config.servicios.filter((s) => s.categoria === c.code);
|
||||||
|
if (svcs.length) { out.push({ code: c.code, nombre: c.name, servicios: svcs }); usados.add(c.code); }
|
||||||
|
}
|
||||||
|
const resto = config.servicios.filter((s) => !usados.has(s.categoria));
|
||||||
|
if (resto.length) out.push({ code: 'otros', nombre: 'Otros', servicios: resto });
|
||||||
|
return out;
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!config || pasoActual !== 'fecha' || !servicioId) return;
|
||||||
|
let alive = true;
|
||||||
|
setSlotsLoading(true);
|
||||||
|
setSlots([]);
|
||||||
|
odooApi.getBookingSlots({ servicio_id: servicioId, fecha, medico_id: medicoId })
|
||||||
|
.then((res) => { if (alive && res.status === 'success') setSlots(res.slots || []); })
|
||||||
|
.catch(() => { if (alive) setSlots([]); })
|
||||||
|
.finally(() => { if (alive) setSlotsLoading(false); });
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, [config, pasoActual, servicioId, fecha, medicoId]);
|
||||||
|
|
||||||
|
const siguiente = () => setPaso((p) => Math.min(p + 1, pasos.length));
|
||||||
|
const atras = () => setPaso((p) => Math.max(p - 1, 0));
|
||||||
|
|
||||||
|
const enviar = async () => {
|
||||||
|
if (!servicioId || !hora) return;
|
||||||
|
if (nombre.trim().length < 2) { setError('Escribe tu nombre'); return; }
|
||||||
|
if (!telefonoValido(telefono)) { setError('Escribe un teléfono válido (10 dígitos)'); return; }
|
||||||
|
setEnviando(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await odooApi.createBooking({
|
||||||
|
servicio_id: servicioId,
|
||||||
|
fecha,
|
||||||
|
hora,
|
||||||
|
medico_id: medicoId,
|
||||||
|
sucursal,
|
||||||
|
nombre: nombre.trim(),
|
||||||
|
telefono: normalizarTelefono(telefono),
|
||||||
|
email: email.trim(),
|
||||||
|
notas: notas.trim(),
|
||||||
|
hp,
|
||||||
|
});
|
||||||
|
setResultado(res);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
setError(msg || 'No se pudo completar la reserva. Intenta de nuevo.');
|
||||||
|
} finally {
|
||||||
|
setEnviando(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const term = (k: string, fallback: string) => config?.terms?.[k as keyof typeof config.terms] || fallback;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Centro>
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-theme-muted" />
|
||||||
|
</Centro>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!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">
|
||||||
|
<Clock className="mx-auto mb-3 h-8 w-8 text-theme-muted" />
|
||||||
|
<h1 className="font-heading text-lg text-theme-heading">Reservas en línea no disponibles</h1>
|
||||||
|
<p className="mt-2 text-sm text-theme-muted">
|
||||||
|
Por el momento no puedes agendar desde aquí. Comunícate directamente con la clínica.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Centro>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const icalUrl = resultado
|
||||||
|
? construirIcs({
|
||||||
|
folio: resultado.folio,
|
||||||
|
fecha: resultado.fecha,
|
||||||
|
hora: resultado.hora,
|
||||||
|
duracionMin: servicioSel?.duracion_min || 30,
|
||||||
|
titulo: `${resultado.servicio} · ${config.brand.nombre}`,
|
||||||
|
descripcion: `Folio ${resultado.folio}`,
|
||||||
|
lugar: resultado.sucursal,
|
||||||
|
})
|
||||||
|
: '';
|
||||||
|
const enlaceGestion = resultado
|
||||||
|
? `${window.location.origin}/reservar/cita/${resultado.token_de_gestion}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// ---- Pantalla final: folio + calendario + enlace de gestión ----
|
||||||
|
if (resultado) {
|
||||||
|
return (
|
||||||
|
<Centro>
|
||||||
|
<div className="w-full max-w-lg space-y-4">
|
||||||
|
<div className="rounded-3xl border border-theme-border bg-theme-surface p-6 text-center shadow-soft">
|
||||||
|
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100">
|
||||||
|
<Check className="h-6 w-6 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<h1 className="font-heading text-xl text-theme-heading">
|
||||||
|
{resultado.requiere_confirmacion ? 'Solicitud recibida' : '¡Reserva confirmada!'}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-theme-muted">Folio <span className="font-semibold text-theme-heading">{resultado.folio}</span></p>
|
||||||
|
<div className="mt-5 space-y-2 rounded-2xl bg-theme-bg p-4 text-left text-sm">
|
||||||
|
<Linea icon={<CalendarPlus className="h-4 w-4" />} texto={`${fechaLargaISO(resultado.fecha)} · ${resultado.hora}`} />
|
||||||
|
<Linea icon={<Check className="h-4 w-4" />} texto={resultado.servicio} />
|
||||||
|
{resultado.medico && <Linea icon={<User className="h-4 w-4" />} texto={resultado.medico} />}
|
||||||
|
{resultado.sucursal && <Linea icon={<MapPin className="h-4 w-4" />} texto={resultado.sucursal} />}
|
||||||
|
</div>
|
||||||
|
{resultado.mensaje_confirmacion && (
|
||||||
|
<p className="mt-4 text-sm text-theme-muted">{resultado.mensaje_confirmacion}</p>
|
||||||
|
)}
|
||||||
|
{resultado.requiere_confirmacion && !resultado.mensaje_confirmacion && (
|
||||||
|
<p className="mt-4 text-sm text-theme-muted">
|
||||||
|
Tu reserva quedó registrada y la clínica la confirmará en breve.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-6 flex flex-col gap-2">
|
||||||
|
<Button onClick={() => descargarIcs(`cita-${resultado.folio}`, icalUrl)}>
|
||||||
|
<CalendarPlus size={16} className="mr-2" /> Agregar al calendario
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={() => navigate(`/reservar/cita/${resultado.token_de_gestion}`)}>
|
||||||
|
Ver o cancelar mi reserva
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-theme-border bg-theme-surface p-4">
|
||||||
|
<p className="text-xs font-medium text-theme-muted">Guarda este enlace para gestionar tu reserva</p>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
value={enlaceGestion}
|
||||||
|
className="min-w-0 flex-1 rounded-xl border border-theme-border bg-theme-bg px-3 py-2 text-xs text-theme-heading"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={async () => {
|
||||||
|
try { await navigator.clipboard.writeText(enlaceGestion); setCopiado(true); } catch { /* ignore */ }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy size={14} className="mr-1" /> {copiado ? 'Copiado' : 'Copiar'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Centro>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tituloPaso = (): string => {
|
||||||
|
switch (pasoActual) {
|
||||||
|
case 'servicio': return `Elige tu ${term('service_singular', 'servicio').toLowerCase()}`;
|
||||||
|
case 'sucursal': return `Elige ${term('branch_singular', 'sucursal').toLowerCase()}`;
|
||||||
|
case 'especialista': return `Elige ${term('doctor_singular', 'especialista').toLowerCase()}`;
|
||||||
|
case 'fecha': return 'Elige fecha y hora';
|
||||||
|
case 'datos': return 'Tus datos';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const puedeSeguir = (
|
||||||
|
(pasoActual === 'servicio' && servicioId)
|
||||||
|
|| (pasoActual === 'sucursal' && !!sucursal)
|
||||||
|
|| (pasoActual === 'especialista')
|
||||||
|
|| (pasoActual === 'fecha' && !!hora)
|
||||||
|
|| pasoActual === 'datos'
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Centro>
|
||||||
|
<div className="w-full max-w-lg">
|
||||||
|
<header className="mb-5 text-center">
|
||||||
|
{config.brand.logo_oscuro && (
|
||||||
|
<img src={config.brand.logo_oscuro} alt={config.brand.nombre} className="mx-auto mb-4 h-9 w-auto object-contain" />
|
||||||
|
)}
|
||||||
|
<h1 className="font-heading text-xl text-theme-heading">{config.config.titulo}</h1>
|
||||||
|
{config.config.descripcion && (
|
||||||
|
<p className="mt-1 text-sm text-theme-muted">{config.config.descripcion}</p>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="rounded-3xl border border-theme-border bg-theme-surface p-5 shadow-soft">
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
{paso > 0 && (
|
||||||
|
<button type="button" onClick={atras} aria-label="Atrás"
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full text-theme-muted hover:bg-theme-bg">
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<h2 className="font-heading text-base text-theme-heading">{tituloPaso()}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Paso 1: servicio agrupado por categoría */}
|
||||||
|
{pasoActual === 'servicio' && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{grupos.map((g) => (
|
||||||
|
<div key={g.code}>
|
||||||
|
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-theme-muted">{g.nombre}</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{g.servicios.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setServicioId(s.id); setHora(null); siguiente(); }}
|
||||||
|
className={`flex w-full items-center justify-between rounded-2xl border px-4 py-3 text-left transition ${
|
||||||
|
servicioId === s.id
|
||||||
|
? 'border-theme-accent bg-theme-accent-bg'
|
||||||
|
: 'border-theme-border hover:border-theme-border-strong'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-medium text-theme-heading">{s.nombre}</span>
|
||||||
|
<span className="mt-0.5 block text-xs text-theme-muted">
|
||||||
|
{s.duracion_min} min{s.precio > 0 ? ` · $${s.precio.toFixed(0)}` : ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ChevronRight size={18} className="shrink-0 text-theme-muted" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Paso 2: sucursal */}
|
||||||
|
{pasoActual === 'sucursal' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{config.sucursales.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.code}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setSucursal(s.code); siguiente(); }}
|
||||||
|
className={`flex w-full items-center justify-between rounded-2xl border px-4 py-3 text-left transition ${
|
||||||
|
sucursal === s.code ? 'border-theme-accent bg-theme-accent-bg' : 'border-theme-border hover:border-theme-border-strong'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-medium text-theme-heading">{s.name}</span>
|
||||||
|
{s.address && <span className="mt-0.5 block text-xs text-theme-muted">{s.address}</span>}
|
||||||
|
</span>
|
||||||
|
<ChevronRight size={18} className="shrink-0 text-theme-muted" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Paso 3: especialista */}
|
||||||
|
{pasoActual === 'especialista' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setMedicoId(null); setHora(null); siguiente(); }}
|
||||||
|
className={`flex w-full items-center justify-between rounded-2xl border px-4 py-3 text-left transition ${
|
||||||
|
medicoId === null ? 'border-theme-accent bg-theme-accent-bg' : 'border-theme-border hover:border-theme-border-strong'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium text-theme-heading">Cualquiera disponible</span>
|
||||||
|
<ChevronRight size={18} className="shrink-0 text-theme-muted" />
|
||||||
|
</button>
|
||||||
|
{config.medicos.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setMedicoId(m.id); setHora(null); siguiente(); }}
|
||||||
|
className={`flex w-full items-center justify-between rounded-2xl border px-4 py-3 text-left transition ${
|
||||||
|
medicoId === m.id ? 'border-theme-accent bg-theme-accent-bg' : 'border-theme-border hover:border-theme-border-strong'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium text-theme-heading">{m.nombre}</span>
|
||||||
|
<ChevronRight size={18} className="shrink-0 text-theme-muted" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Paso 4: fecha y hora */}
|
||||||
|
{pasoActual === 'fecha' && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1">
|
||||||
|
{proximosDias(config.config.max_dias_futuro).map((d) => {
|
||||||
|
const iso = fechaISO(d);
|
||||||
|
const et = etiquetaDiaCorto(d);
|
||||||
|
const activo = fecha === iso;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={iso}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setFecha(iso); setHora(null); }}
|
||||||
|
className={`flex min-w-[3.25rem] flex-col items-center rounded-2xl border px-2 py-2 text-xs transition ${
|
||||||
|
activo ? 'border-theme-accent bg-theme-accent text-theme-inverse' : 'border-theme-border text-theme-muted hover:border-theme-border-strong'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{et.abrev}</span>
|
||||||
|
<span className="text-base font-semibold">{et.dia}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{slotsLoading ? (
|
||||||
|
<div className="flex justify-center py-6"><Loader2 className="h-5 w-5 animate-spin text-theme-muted" /></div>
|
||||||
|
) : slots.length === 0 ? (
|
||||||
|
<p className="py-4 text-center text-sm text-theme-muted">
|
||||||
|
No hay horarios disponibles ese día. Prueba con otra fecha.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
|
||||||
|
{slots.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setHora(s)}
|
||||||
|
className={`rounded-xl border px-2 py-2.5 text-sm font-medium transition ${
|
||||||
|
hora === s ? 'border-theme-accent bg-theme-accent text-theme-inverse' : 'border-theme-border text-theme-heading hover:border-theme-border-strong'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button className="w-full" disabled={!hora} onClick={siguiente}>Continuar</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Paso 5: datos del cliente */}
|
||||||
|
{pasoActual === 'datos' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-2xl bg-theme-bg p-3 text-xs text-theme-muted">
|
||||||
|
{servicioSel?.nombre} · {fechaLargaISO(fecha)} · {hora}
|
||||||
|
{medicoSel ? ` · ${medicoSel.nombre}` : ''}
|
||||||
|
</div>
|
||||||
|
<Input label="Nombre completo" value={nombre} onChange={(e) => setNombre(e.target.value)} autoComplete="name" />
|
||||||
|
<Input label="Teléfono (WhatsApp)" value={telefono} onChange={(e) => setTelefono(e.target.value)} inputMode="tel" autoComplete="tel" />
|
||||||
|
<Input label="Email (opcional)" value={email} onChange={(e) => setEmail(e.target.value)} inputMode="email" autoComplete="email" />
|
||||||
|
<TextArea label="Notas (opcional)" value={notas} onChange={(e) => setNotas(e.target.value)} rows={3} />
|
||||||
|
{/* Honeypot: invisible para personas; los bots suelen rellenarlo. */}
|
||||||
|
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', width: 1, height: 1, overflow: 'hidden' }}>
|
||||||
|
<label>No llenar este campo
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="empresa"
|
||||||
|
tabIndex={-1}
|
||||||
|
autoComplete="off"
|
||||||
|
value={hp}
|
||||||
|
onChange={(e) => setHp(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-rose-600">{error}</p>}
|
||||||
|
<Button className="w-full" loading={enviando} onClick={enviar}>
|
||||||
|
{config.config.requiere_confirmacion ? 'Solicitar reserva' : 'Confirmar reserva'}
|
||||||
|
</Button>
|
||||||
|
{config.config.telefono_contacto && (
|
||||||
|
<p className="flex items-center justify-center gap-1 text-center text-xs text-theme-muted">
|
||||||
|
<MessageCircle size={12} /> ¿Dudas? {config.config.telefono_contacto}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!puedeSeguir && error && <p className="mt-3 text-sm text-rose-600">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-xs text-theme-muted">{config.brand.nombre}</p>
|
||||||
|
</div>
|
||||||
|
</Centro>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 Linea: FC<{ icon: ReactNode; texto: string }> = ({ icon, texto }) => (
|
||||||
|
<div className="flex items-center gap-2 text-theme-heading">
|
||||||
|
<span className="text-theme-muted">{icon}</span>
|
||||||
|
<span>{texto}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Reservar;
|
||||||
154
frontend/src/pages/ReservarCita.tsx
Normal file
154
frontend/src/pages/ReservarCita.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import type { FC, ReactNode } from 'react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { AlertTriangle, ArrowLeft, Check, Clock, Loader2, MapPin, User } from 'lucide-react';
|
||||||
|
import { Button, Input } from '../components/ui';
|
||||||
|
import { odooApi, type BookingCita } from '../services/odoo';
|
||||||
|
import { fechaLargaISO } from '../lib/publicBooking';
|
||||||
|
|
||||||
|
// Página de gestión de una reserva hecha en el portal (ver / cancelar) usando
|
||||||
|
// el token HMAC del enlace. Anónima: el token es la única credencial.
|
||||||
|
|
||||||
|
const ReservarCita: FC = () => {
|
||||||
|
const { token = '' } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [cita, setCita] = useState<BookingCita | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [motivo, setMotivo] = useState('');
|
||||||
|
const [cancelando, setCancelando] = useState(false);
|
||||||
|
const [confirmar, setConfirmar] = useState(false);
|
||||||
|
const [cancelada, setCancelada] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
if (!token) { setLoading(false); setError('Enlace no válido'); return; }
|
||||||
|
odooApi.getBookingCita(token)
|
||||||
|
.then((res) => { if (alive) setCita(res); })
|
||||||
|
.catch((err) => {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string }; status?: number } });
|
||||||
|
if (alive) setError(msg?.response?.data?.message || 'No encontramos esta reserva.');
|
||||||
|
})
|
||||||
|
.finally(() => { if (alive) setLoading(false); });
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const cancelar = async () => {
|
||||||
|
setCancelando(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await odooApi.cancelBooking(token, motivo.trim());
|
||||||
|
setCancelada(true);
|
||||||
|
setCita((prev) => (prev ? { ...prev, estado: 'cancelled', estado_label: 'Canceló', puede_cancelar: false } : prev));
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
setError(msg || 'No se pudo cancelar la reserva.');
|
||||||
|
} finally {
|
||||||
|
setCancelando(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Centro>
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-theme-muted" />
|
||||||
|
</Centro>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cita) {
|
||||||
|
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">No encontramos tu reserva</h1>
|
||||||
|
<p className="mt-2 text-sm text-theme-muted">{error || 'El enlace no es válido o ya expiró.'}</p>
|
||||||
|
<Button className="mt-6 w-full" onClick={() => navigate('/reservar')}>Hacer una reserva</Button>
|
||||||
|
</div>
|
||||||
|
</Centro>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Centro>
|
||||||
|
<div className="w-full max-w-lg space-y-4">
|
||||||
|
<button type="button" onClick={() => navigate('/reservar')}
|
||||||
|
className="flex items-center gap-1 text-sm text-theme-muted hover:text-theme-heading">
|
||||||
|
<ArrowLeft size={16} /> Reservar otra cita
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="rounded-3xl border border-theme-border bg-theme-surface p-6 shadow-soft">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="font-heading text-lg text-theme-heading">Tu reserva</h1>
|
||||||
|
<span className={`rounded-full px-3 py-1 text-xs font-medium ${
|
||||||
|
cita.estado === 'cancelled' ? 'bg-rose-100 text-rose-700' : 'bg-emerald-100 text-emerald-700'
|
||||||
|
}`}>
|
||||||
|
{cita.estado_label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-theme-muted">Folio <span className="font-semibold text-theme-heading">{cita.folio}</span></p>
|
||||||
|
|
||||||
|
<div className="mt-5 space-y-2 rounded-2xl bg-theme-bg p-4 text-sm">
|
||||||
|
<Linea icon={<Clock className="h-4 w-4" />} texto={`${fechaLargaISO(cita.fecha)} · ${cita.hora}`} />
|
||||||
|
<Linea icon={<Check className="h-4 w-4" />} texto={cita.servicio} />
|
||||||
|
{cita.medico && <Linea icon={<User className="h-4 w-4" />} texto={cita.medico} />}
|
||||||
|
{cita.sucursal && <Linea icon={<MapPin className="h-4 w-4" />} texto={cita.sucursal} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cancelada && (
|
||||||
|
<p className="mt-4 rounded-xl bg-emerald-50 p-3 text-sm text-emerald-700">
|
||||||
|
Tu reserva quedó cancelada. Si fue un error, puedes agendar de nuevo.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!cancelada && cita.puede_cancelar && (
|
||||||
|
<div className="mt-5">
|
||||||
|
{!confirmar ? (
|
||||||
|
<Button variant="outline" className="w-full" onClick={() => setConfirmar(true)}>
|
||||||
|
Cancelar mi reserva
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-theme-muted">
|
||||||
|
¿Seguro que quieres cancelar? Puedes dejarnos un motivo (opcional).
|
||||||
|
</p>
|
||||||
|
<Input label="Motivo (opcional)" value={motivo} onChange={(e) => setMotivo(e.target.value)} />
|
||||||
|
{error && <p className="text-sm text-rose-600">{error}</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="danger" className="flex-1" loading={cancelando} onClick={cancelar}>
|
||||||
|
Sí, cancelar
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" className="flex-1" onClick={() => setConfirmar(false)}>No</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!cancelada && !cita.puede_cancelar && cita.estado !== 'cancelled' && (
|
||||||
|
<p className="mt-5 text-sm text-theme-muted">
|
||||||
|
Esta reserva ya no se puede cancelar en línea
|
||||||
|
{cita.permitir_cancelar ? ` (con menos de ${cita.cancelar_hasta_horas} h de anticipación)` : ''}.
|
||||||
|
{cita.telefono_contacto ? ` Comunícate al ${cita.telefono_contacto}.` : ' Comunícate con la clínica.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Centro>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 Linea: FC<{ icon: ReactNode; texto: string }> = ({ icon, texto }) => (
|
||||||
|
<div className="flex items-center gap-2 text-theme-heading">
|
||||||
|
<span className="text-theme-muted">{icon}</span>
|
||||||
|
<span>{texto}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ReservarCita;
|
||||||
@@ -72,6 +72,8 @@ export interface Appointment {
|
|||||||
doctor?: string;
|
doctor?: string;
|
||||||
branch?: string;
|
branch?: string;
|
||||||
medium?: string;
|
medium?: string;
|
||||||
|
/** Origen de la reserva: 'internal' (default) o 'public_portal' (portal en línea) */
|
||||||
|
booking_source?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
package_finished?: boolean;
|
package_finished?: boolean;
|
||||||
package_finished_date?: string | null;
|
package_finished_date?: string | null;
|
||||||
@@ -1010,6 +1012,106 @@ export interface ClinicSettings {
|
|||||||
moneda_base: string;
|
moneda_base: string;
|
||||||
nota_ticket: string;
|
nota_ticket: string;
|
||||||
impuestos: ImpuestosSettings;
|
impuestos: ImpuestosSettings;
|
||||||
|
reservas_online: ReservasOnlineSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Reservas en línea (portal público de auto-agendado) ----
|
||||||
|
export interface ReservasOnlineSettings {
|
||||||
|
activo: boolean;
|
||||||
|
titulo: string;
|
||||||
|
descripcion: string;
|
||||||
|
requiere_confirmacion: boolean;
|
||||||
|
permitir_eleccion_doctor: boolean;
|
||||||
|
permitir_sucursal: boolean;
|
||||||
|
max_dias_futuro: number;
|
||||||
|
min_horas_anticipacion: number;
|
||||||
|
permitir_cancelar: boolean;
|
||||||
|
cancelar_hasta_horas: number;
|
||||||
|
mensaje_confirmacion: string;
|
||||||
|
telefono_contacto: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RESERVAS_ONLINE_DEFAULT: ReservasOnlineSettings = {
|
||||||
|
activo: false,
|
||||||
|
titulo: 'Reserva tu cita en línea',
|
||||||
|
descripcion: '',
|
||||||
|
requiere_confirmacion: true,
|
||||||
|
permitir_eleccion_doctor: true,
|
||||||
|
permitir_sucursal: false,
|
||||||
|
max_dias_futuro: 60,
|
||||||
|
min_horas_anticipacion: 2,
|
||||||
|
permitir_cancelar: true,
|
||||||
|
cancelar_hasta_horas: 24,
|
||||||
|
mensaje_confirmacion: '',
|
||||||
|
telefono_contacto: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Contrato del portal público (endpoints /public/booking/*)
|
||||||
|
export interface BookingConfig {
|
||||||
|
activo: boolean;
|
||||||
|
brand: ClinicBrand;
|
||||||
|
terms: GlossaryTerms;
|
||||||
|
sucursales: { code: string; name: string; address?: string; phone?: string }[];
|
||||||
|
categorias: { code: string; name: string }[];
|
||||||
|
servicios: { id: number; nombre: string; categoria: string; precio: number; duracion_min: number }[];
|
||||||
|
medicos: { id: number; nombre: string; staff_type: string }[];
|
||||||
|
config: {
|
||||||
|
max_dias_futuro: number;
|
||||||
|
min_horas_anticipacion: number;
|
||||||
|
permite_sucursal: boolean;
|
||||||
|
permite_eleccion_doctor: boolean;
|
||||||
|
requiere_confirmacion: boolean;
|
||||||
|
permitir_cancelar: boolean;
|
||||||
|
cancelar_hasta_horas: number;
|
||||||
|
telefono_contacto: string;
|
||||||
|
titulo: string;
|
||||||
|
descripcion: string;
|
||||||
|
mensaje_confirmacion: string;
|
||||||
|
zona_horaria?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingCreatePayload {
|
||||||
|
servicio_id: number;
|
||||||
|
fecha: string;
|
||||||
|
hora: string;
|
||||||
|
medico_id?: number | null;
|
||||||
|
sucursal?: string | null;
|
||||||
|
nombre: string;
|
||||||
|
telefono: string;
|
||||||
|
email?: string;
|
||||||
|
notas?: string;
|
||||||
|
hp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingCreateResult {
|
||||||
|
ok: boolean;
|
||||||
|
folio: string;
|
||||||
|
fecha: string;
|
||||||
|
hora: string;
|
||||||
|
medico: string | null;
|
||||||
|
servicio: string;
|
||||||
|
sucursal: string;
|
||||||
|
estado: string;
|
||||||
|
requiere_confirmacion: boolean;
|
||||||
|
mensaje_confirmacion: string;
|
||||||
|
token_de_gestion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingCita {
|
||||||
|
status: string;
|
||||||
|
folio: string;
|
||||||
|
fecha: string;
|
||||||
|
hora: string;
|
||||||
|
servicio: string;
|
||||||
|
medico: string | null;
|
||||||
|
sucursal: string;
|
||||||
|
estado: string;
|
||||||
|
estado_label: string;
|
||||||
|
puede_cancelar: boolean;
|
||||||
|
permitir_cancelar: boolean;
|
||||||
|
cancelar_hasta_horas: number;
|
||||||
|
telefono_contacto: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
|
export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
|
||||||
@@ -1026,6 +1128,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
|
|||||||
moneda_base: 'MXN',
|
moneda_base: 'MXN',
|
||||||
nota_ticket: '',
|
nota_ticket: '',
|
||||||
impuestos: IMPUESTOS_DEFAULT,
|
impuestos: IMPUESTOS_DEFAULT,
|
||||||
|
reservas_online: RESERVAS_ONLINE_DEFAULT,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface FrontendUser {
|
export interface FrontendUser {
|
||||||
@@ -1686,6 +1789,32 @@ export const odooApi = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ---- Portal público de reservas en línea (sin autenticación) ----
|
||||||
|
async getBookingConfig(): Promise<BookingConfig> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/public/booking/config`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getBookingSlots(params: { servicio_id: number; fecha: string; medico_id?: number | null; sucursal?: string | null }): Promise<{ status: string; slots: string[]; medico_id: number | null; duracion_min: number; message?: string }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/public/booking/slots`, { params });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createBooking(payload: BookingCreatePayload): Promise<BookingCreateResult> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/public/booking/create`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getBookingCita(token: string): Promise<BookingCita> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/public/booking/cita`, { params: { token } });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async cancelBooking(token: string, motivo?: string): Promise<{ status: string; ok?: boolean; folio?: string; estado?: string; message?: string }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/public/booking/cancel`, { token, motivo });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
async updateRolePermissions(matrix: Record<string, FrontendRole[]>): Promise<{ status: string; matrix: Record<string, FrontendRole[]>; message?: string }> {
|
async updateRolePermissions(matrix: Record<string, FrontendRole[]>): Promise<{ status: string; matrix: Record<string, FrontendRole[]>; message?: string }> {
|
||||||
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/roles/permissions`, { matrix });
|
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/roles/permissions`, { matrix });
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -158,6 +158,110 @@ def get_impuestos(env):
|
|||||||
"""Bloque de impuestos vigente (copia). Fuente única para modelos y API."""
|
"""Bloque de impuestos vigente (copia). Fuente única para modelos y API."""
|
||||||
return normalizar_impuestos(get_settings(env).get('impuestos'))
|
return normalizar_impuestos(get_settings(env).get('impuestos'))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reservas en línea (portal público de auto-agendado).
|
||||||
|
#
|
||||||
|
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
|
||||||
|
# oro: con `activo=False` NADA cambia en la instancia (el portal público
|
||||||
|
# responde 404 y el frontend no muestra enlaces). Por eso el default es apagado.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
RESERVAS_ONLINE_DEFAULT = {
|
||||||
|
'activo': False,
|
||||||
|
'titulo': 'Reserva tu cita en línea',
|
||||||
|
'descripcion': '',
|
||||||
|
'requiere_confirmacion': True,
|
||||||
|
'permitir_eleccion_doctor': True,
|
||||||
|
'permitir_sucursal': False,
|
||||||
|
'max_dias_futuro': 60,
|
||||||
|
'min_horas_anticipacion': 2,
|
||||||
|
'permitir_cancelar': True,
|
||||||
|
'cancelar_hasta_horas': 24,
|
||||||
|
'mensaje_confirmacion': '',
|
||||||
|
'telefono_contacto': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rangos razonables de validación del PUT (días / horas).
|
||||||
|
RESERVAS_MAX_DIAS_MIN, RESERVAS_MAX_DIAS_MAX = 1, 365
|
||||||
|
RESERVAS_MIN_ANTICIPACION_MAX = 168 # 1 semana
|
||||||
|
RESERVAS_CANCELAR_HASTA_MAX = 168 # 1 semana
|
||||||
|
|
||||||
|
|
||||||
|
def _int(valor, por_defecto=0):
|
||||||
|
try:
|
||||||
|
return int(valor)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return por_defecto
|
||||||
|
|
||||||
|
|
||||||
|
def normalizar_reservas_online(raw):
|
||||||
|
"""Bloque `reservas_online` completo, con defaults. Tolerante: nunca lanza."""
|
||||||
|
out = dict(RESERVAS_ONLINE_DEFAULT)
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return out
|
||||||
|
for k in ('activo', 'requiere_confirmacion', 'permitir_eleccion_doctor',
|
||||||
|
'permitir_sucursal', 'permitir_cancelar'):
|
||||||
|
out[k] = _bool(raw.get(k), RESERVAS_ONLINE_DEFAULT[k])
|
||||||
|
out['titulo'] = (str(raw.get('titulo') or '').strip()
|
||||||
|
or RESERVAS_ONLINE_DEFAULT['titulo'])[:120]
|
||||||
|
out['descripcion'] = str(raw.get('descripcion') or '')[:500]
|
||||||
|
out['mensaje_confirmacion'] = str(raw.get('mensaje_confirmacion') or '')[:500]
|
||||||
|
out['telefono_contacto'] = str(raw.get('telefono_contacto') or '').strip()[:40]
|
||||||
|
out['max_dias_futuro'] = min(max(_int(raw.get('max_dias_futuro'),
|
||||||
|
RESERVAS_ONLINE_DEFAULT['max_dias_futuro']),
|
||||||
|
RESERVAS_MAX_DIAS_MIN), RESERVAS_MAX_DIAS_MAX)
|
||||||
|
out['min_horas_anticipacion'] = min(max(_int(raw.get('min_horas_anticipacion'),
|
||||||
|
RESERVAS_ONLINE_DEFAULT['min_horas_anticipacion']),
|
||||||
|
0), RESERVAS_MIN_ANTICIPACION_MAX)
|
||||||
|
out['cancelar_hasta_horas'] = min(max(_int(raw.get('cancelar_hasta_horas'),
|
||||||
|
RESERVAS_ONLINE_DEFAULT['cancelar_hasta_horas']),
|
||||||
|
0), RESERVAS_CANCELAR_HASTA_MAX)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def validar_reservas_online(raw):
|
||||||
|
"""Valida el bloque `reservas_online` de un PUT. Lanza ValueError si algo no
|
||||||
|
cuadra (se traduce a 400). Devuelve SOLO las claves presentes y normalizadas
|
||||||
|
para que el merge no reinicie lo que el PUT no tocó."""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ValueError('El bloque de reservas en línea debe ser un objeto')
|
||||||
|
out = {}
|
||||||
|
for k in ('activo', 'requiere_confirmacion', 'permitir_eleccion_doctor',
|
||||||
|
'permitir_sucursal', 'permitir_cancelar'):
|
||||||
|
if k in raw:
|
||||||
|
if not isinstance(raw[k], bool):
|
||||||
|
raise ValueError('El campo %s debe ser verdadero o falso' % k)
|
||||||
|
out[k] = raw[k]
|
||||||
|
for k, limite in (('titulo', 120), ('descripcion', 500),
|
||||||
|
('mensaje_confirmacion', 500), ('telefono_contacto', 40)):
|
||||||
|
if k in raw:
|
||||||
|
if not isinstance(raw[k], str):
|
||||||
|
raise ValueError('El campo %s debe ser texto' % k)
|
||||||
|
if len(raw[k].strip()) > limite:
|
||||||
|
raise ValueError('El campo %s es demasiado largo (máx. %d)' % (k, limite))
|
||||||
|
out[k] = raw[k].strip()
|
||||||
|
if 'titulo' in raw and not raw['titulo'].strip():
|
||||||
|
raise ValueError('El título no puede estar vacío')
|
||||||
|
rangos = (
|
||||||
|
('max_dias_futuro', RESERVAS_MAX_DIAS_MIN, RESERVAS_MAX_DIAS_MAX),
|
||||||
|
('min_horas_anticipacion', 0, RESERVAS_MIN_ANTICIPACION_MAX),
|
||||||
|
('cancelar_hasta_horas', 0, RESERVAS_CANCELAR_HASTA_MAX),
|
||||||
|
)
|
||||||
|
for k, lo, hi in rangos:
|
||||||
|
if k in raw:
|
||||||
|
if isinstance(raw[k], bool) or not isinstance(raw[k], int):
|
||||||
|
raise ValueError('El campo %s debe ser un número entero' % k)
|
||||||
|
if not (lo <= raw[k] <= hi):
|
||||||
|
raise ValueError('El campo %s debe estar entre %d y %d' % (k, lo, hi))
|
||||||
|
out[k] = raw[k]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def get_reservas_online(env):
|
||||||
|
"""Bloque de reservas en línea vigente (copia). Fuente única para la API."""
|
||||||
|
return normalizar_reservas_online(get_settings(env).get('reservas_online'))
|
||||||
|
|
||||||
# Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00).
|
# Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00).
|
||||||
AGENDA_START_DEFAULT = 8.0
|
AGENDA_START_DEFAULT = 8.0
|
||||||
AGENDA_END_DEFAULT = 19.0
|
AGENDA_END_DEFAULT = 19.0
|
||||||
@@ -198,6 +302,7 @@ def get_settings(env):
|
|||||||
'moneda_base': MONEDA_DEFAULT,
|
'moneda_base': MONEDA_DEFAULT,
|
||||||
'nota_ticket': '',
|
'nota_ticket': '',
|
||||||
'impuestos': dict(IMPUESTOS_DEFAULT),
|
'impuestos': dict(IMPUESTOS_DEFAULT),
|
||||||
|
'reservas_online': dict(RESERVAS_ONLINE_DEFAULT),
|
||||||
}
|
}
|
||||||
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
|
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
|
||||||
if raw:
|
if raw:
|
||||||
@@ -210,6 +315,7 @@ def get_settings(env):
|
|||||||
if k in guardado and guardado[k] is not None:
|
if k in guardado and guardado[k] is not None:
|
||||||
out[k] = str(guardado[k])
|
out[k] = str(guardado[k])
|
||||||
out['impuestos'] = normalizar_impuestos(guardado.get('impuestos'))
|
out['impuestos'] = normalizar_impuestos(guardado.get('impuestos'))
|
||||||
|
out['reservas_online'] = normalizar_reservas_online(guardado.get('reservas_online'))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -226,6 +332,10 @@ def set_settings(env, vals):
|
|||||||
if bloque is not None:
|
if bloque is not None:
|
||||||
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
|
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
|
||||||
actual['impuestos'] = {**actual['impuestos'], **bloque}
|
actual['impuestos'] = {**actual['impuestos'], **bloque}
|
||||||
|
if 'reservas_online' in vals:
|
||||||
|
bloque = validar_reservas_online(vals['reservas_online'])
|
||||||
|
if bloque is not None:
|
||||||
|
actual['reservas_online'] = {**actual['reservas_online'], **bloque}
|
||||||
env['ir.config_parameter'].sudo().set_param(
|
env['ir.config_parameter'].sudo().set_param(
|
||||||
SETTINGS_KEY, json.dumps(actual, ensure_ascii=False))
|
SETTINGS_KEY, json.dumps(actual, ensure_ascii=False))
|
||||||
return get_settings(env)
|
return get_settings(env)
|
||||||
|
|||||||
@@ -200,6 +200,15 @@ class SkeenCita(models.Model):
|
|||||||
# WACRM
|
# WACRM
|
||||||
wacrm_conversation_id = fields.Char(string='WACRM Conversation ID')
|
wacrm_conversation_id = fields.Char(string='WACRM Conversation ID')
|
||||||
|
|
||||||
|
# Origen de la reserva: permite distinguir en la agenda las citas que
|
||||||
|
# entraron por el portal público de reservas en línea. 'internal' es el
|
||||||
|
# comportamiento de siempre (no cambia nada); 'public_portal' lo pone el
|
||||||
|
# controlador de reservas públicas.
|
||||||
|
booking_source = fields.Selection([
|
||||||
|
('internal', 'Interno'),
|
||||||
|
('public_portal', 'Portal en línea'),
|
||||||
|
], string='Origen de la reserva', default='internal', index=True)
|
||||||
|
|
||||||
# Paquete terminado (para servicios con categoría 'paquete')
|
# Paquete terminado (para servicios con categoría 'paquete')
|
||||||
package_finished = fields.Boolean(string='Paquete Terminado', default=False)
|
package_finished = fields.Boolean(string='Paquete Terminado', default=False)
|
||||||
package_finished_date = fields.Datetime(string='Fecha Término Paquete')
|
package_finished_date = fields.Datetime(string='Fecha Término Paquete')
|
||||||
|
|||||||
@@ -2,4 +2,5 @@
|
|||||||
|
|
||||||
from . import main
|
from . import main
|
||||||
from . import frontend
|
from . import frontend
|
||||||
|
from . import public_booking
|
||||||
from . import wacrm_proxy
|
from . import wacrm_proxy
|
||||||
|
|||||||
@@ -1859,6 +1859,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'doctor': cita.doctor_id.name if cita.doctor_id else None,
|
'doctor': cita.doctor_id.name if cita.doctor_id else None,
|
||||||
'branch': cita.branch,
|
'branch': cita.branch,
|
||||||
'medium': cita.medium,
|
'medium': cita.medium,
|
||||||
|
'booking_source': cita.booking_source or 'internal',
|
||||||
'notes': cita.notes or '',
|
'notes': cita.notes or '',
|
||||||
'is_first_visit': False,
|
'is_first_visit': False,
|
||||||
'package_finished': cita.package_finished,
|
'package_finished': cita.package_finished,
|
||||||
|
|||||||
670
odoo-addons/skeen_whatsapp/controllers/public_booking.py
Normal file
670
odoo-addons/skeen_whatsapp/controllers/public_booking.py
Normal file
@@ -0,0 +1,670 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Portal público de reservas en línea (auto-agendado del cliente final).
|
||||||
|
|
||||||
|
Endpoints anónimos (sin token de sesión) bajo `/skeen/frontend/v1/public/booking/`.
|
||||||
|
nginx los sirve con el mismo proxy que el resto del API (`/api/odoo/...`).
|
||||||
|
|
||||||
|
Reglas de oro:
|
||||||
|
- Con `reservas_online.activo=False` TODO responde 404: nada cambia en la
|
||||||
|
instancia ni se expone información.
|
||||||
|
- No se expone ningún dato personal de pacientes: solo disponibilidad
|
||||||
|
(horarios libres) y la propia cita vía token HMAC.
|
||||||
|
- Rate limiting básico por IP y honeypot en la creación, para frenar bots.
|
||||||
|
|
||||||
|
La disponibilidad se delega por completo en `skeen.cita.get_available_slots`
|
||||||
|
(bloqueos, duración del servicio, solapes por médico, horario de la clínica y
|
||||||
|
del médico): no se reimplementan reglas de agenda.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime, time as dtime, timedelta
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
from odoo import http
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
from odoo.http import request, Response
|
||||||
|
|
||||||
|
from .auth import _get_secret
|
||||||
|
from odoo.addons.skeen_citas.clinic_settings import (
|
||||||
|
get_settings as get_clinic_settings,
|
||||||
|
get_reservas_online,
|
||||||
|
brand_dict,
|
||||||
|
)
|
||||||
|
from odoo.addons.skeen_citas.glosario import resolve as resolver_glosario
|
||||||
|
|
||||||
|
PREFIX = '/skeen/frontend/v1/public/booking'
|
||||||
|
|
||||||
|
# Tiempo de vida del token de gestión de una cita (suficiente para ver/cancelar).
|
||||||
|
TOKEN_TTL = 60 * 60 * 24 * 180 # 180 días
|
||||||
|
TOKEN_KIND = 'booking'
|
||||||
|
|
||||||
|
# Marcador visible/auditable del origen de la reserva.
|
||||||
|
ORIGEN_MARCA = 'Reserva en línea'
|
||||||
|
|
||||||
|
# Rate limiting en memoria por proceso. `workers=0` => un único proceso, por lo
|
||||||
|
# que un dict con lock basta y evita meter Redis/tabla para algo tan básico.
|
||||||
|
RATE_LIMITS = {
|
||||||
|
'slots': (20, 60),
|
||||||
|
'create': (5, 60),
|
||||||
|
'cita': (30, 60),
|
||||||
|
'cancel': (10, 60),
|
||||||
|
}
|
||||||
|
_RATE_LOCK = threading.Lock()
|
||||||
|
_RATE_HITS = {}
|
||||||
|
|
||||||
|
_EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
|
||||||
|
|
||||||
|
|
||||||
|
def _json(data, status=200):
|
||||||
|
return Response(
|
||||||
|
json.dumps(data, default=str),
|
||||||
|
status=status,
|
||||||
|
mimetype='application/json',
|
||||||
|
headers={
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Mensaje de error genérico para fallos inesperados: en un endpoint público no
|
||||||
|
# se filtran mensajes internos (nombres de modelos/campos o trazas).
|
||||||
|
_ERROR_INTERNO = 'No se pudo procesar la solicitud. Intenta de nuevo.'
|
||||||
|
|
||||||
|
|
||||||
|
def _error_interno(exc):
|
||||||
|
_logger.exception('Error en el portal público de reservas: %s', exc)
|
||||||
|
return _json({'status': 'error', 'message': _ERROR_INTERNO}, 500)
|
||||||
|
|
||||||
|
|
||||||
|
def _b64url_encode(raw):
|
||||||
|
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii')
|
||||||
|
|
||||||
|
|
||||||
|
def _b64url_decode(s):
|
||||||
|
return base64.urlsafe_b64decode(s + '=' * (-len(s) % 4))
|
||||||
|
|
||||||
|
|
||||||
|
def _client_ip():
|
||||||
|
"""IP del cliente. nginx reenvía X-Forwarded-For; si no, remote_addr."""
|
||||||
|
xff = request.httprequest.headers.get('X-Forwarded-For') or ''
|
||||||
|
if xff:
|
||||||
|
return xff.split(',')[0].strip()
|
||||||
|
return request.httprequest.remote_addr or 'desconocida'
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_limited(bucket):
|
||||||
|
"""True si la IP ya agotó el cupo del bucket en la ventana. Registra el hit."""
|
||||||
|
limit, window = RATE_LIMITS.get(bucket, (30, 60))
|
||||||
|
key = (_client_ip(), bucket)
|
||||||
|
ahora = time.time()
|
||||||
|
with _RATE_LOCK:
|
||||||
|
hits = [t for t in _RATE_HITS.get(key, []) if ahora - t < window]
|
||||||
|
if len(hits) >= limit:
|
||||||
|
_RATE_HITS[key] = hits
|
||||||
|
return True
|
||||||
|
hits.append(ahora)
|
||||||
|
_RATE_HITS[key] = hits
|
||||||
|
# Poda ligera para que el dict no crezca sin control con muchas IPs.
|
||||||
|
if len(_RATE_HITS) > 5000:
|
||||||
|
for k in list(_RATE_HITS):
|
||||||
|
if not [t for t in _RATE_HITS[k] if ahora - t < window]:
|
||||||
|
del _RATE_HITS[k]
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_fecha(valor):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(str(valor).strip(), '%Y-%m-%d').date()
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_hora(valor):
|
||||||
|
"""'HH:MM' -> hora float. None si el formato no es válido."""
|
||||||
|
m = re.match(r'^(\d{1,2}):(\d{2})$', str(valor or '').strip())
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
h, mi = int(m.group(1)), int(m.group(2))
|
||||||
|
if not (0 <= h <= 23 and 0 <= mi <= 59):
|
||||||
|
return None
|
||||||
|
return h + mi / 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def _normaliza_telefono(valor):
|
||||||
|
"""Teléfono normalizado para guardar/buscar. Conserva un '+' inicial."""
|
||||||
|
raw = str(valor or '').strip()
|
||||||
|
mas = raw.startswith('+')
|
||||||
|
digitos = re.sub(r'\D', '', raw)
|
||||||
|
return ('+' + digitos) if (mas and digitos) else digitos
|
||||||
|
|
||||||
|
|
||||||
|
def _selection_label(env, model_name, fname, value):
|
||||||
|
"""Etiqueta legible de un Selection, incluidos los dinámicos (método).
|
||||||
|
|
||||||
|
`branch` resuelve su selección con un método, así que `dict(field.selection)`
|
||||||
|
no sirve: se usa `_description_selection`, que siempre devuelve las tuplas.
|
||||||
|
"""
|
||||||
|
field = env[model_name]._fields.get(fname)
|
||||||
|
if not field:
|
||||||
|
return value
|
||||||
|
try:
|
||||||
|
opciones = field._description_selection(env)
|
||||||
|
except Exception:
|
||||||
|
opciones = field.selection if isinstance(field.selection, (list, tuple)) else []
|
||||||
|
return dict(opciones).get(value, value)
|
||||||
|
|
||||||
|
|
||||||
|
def _tz_clinica(settings):
|
||||||
|
nombre = (settings.get('zona_horaria') or 'America/Tijuana').strip()
|
||||||
|
try:
|
||||||
|
return pytz.timezone(nombre)
|
||||||
|
except Exception:
|
||||||
|
return pytz.timezone('America/Tijuana')
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenPublicBookingController(http.Controller):
|
||||||
|
"""Portal público de reservas en línea."""
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Infra interna
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _activo(self):
|
||||||
|
try:
|
||||||
|
return bool(get_reservas_online(request.env).get('activo'))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _deshabilitado(self):
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'Reservas en línea no disponibles'}, 404)
|
||||||
|
|
||||||
|
def _config(self):
|
||||||
|
return get_reservas_online(request.env)
|
||||||
|
|
||||||
|
def _settings(self):
|
||||||
|
return get_clinic_settings(request.env)
|
||||||
|
|
||||||
|
def _brand(self):
|
||||||
|
"""Brand vigente con defaults, para el portal sin sesión."""
|
||||||
|
guardado = brand_dict(request.env)
|
||||||
|
out = {
|
||||||
|
'nombre': self._settings().get('nombre') or 'Clínica',
|
||||||
|
'logo_claro': '/brand/logo-blanco.svg',
|
||||||
|
'logo_oscuro': '/brand/logo-negro.svg',
|
||||||
|
'colores': {'primario': '', 'acento': ''},
|
||||||
|
}
|
||||||
|
if isinstance(guardado, dict):
|
||||||
|
if guardado.get('nombre'):
|
||||||
|
out['nombre'] = str(guardado['nombre'])
|
||||||
|
for k in ('logo_claro', 'logo_oscuro'):
|
||||||
|
if guardado.get(k):
|
||||||
|
out[k] = str(guardado[k])
|
||||||
|
colores = guardado.get('colores')
|
||||||
|
if isinstance(colores, dict):
|
||||||
|
for c in ('primario', 'acento'):
|
||||||
|
if colores.get(c):
|
||||||
|
out['colores'][c] = str(colores[c])
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _sign_token(self, cita):
|
||||||
|
payload = {'cid': cita.id, 'kind': TOKEN_KIND,
|
||||||
|
'exp': int(time.time()) + TOKEN_TTL}
|
||||||
|
body = _b64url_encode(json.dumps(payload, separators=(',', ':')).encode('utf-8'))
|
||||||
|
sig = hmac.new(_get_secret(request.env).encode('utf-8'),
|
||||||
|
body.encode('ascii'), hashlib.sha256).hexdigest()
|
||||||
|
return '%s.%s' % (body, sig)
|
||||||
|
|
||||||
|
def _verify_token(self, token):
|
||||||
|
if not token or '.' not in str(token):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
body, sig = str(token).rsplit('.', 1)
|
||||||
|
expected = hmac.new(_get_secret(request.env).encode('utf-8'),
|
||||||
|
body.encode('ascii'), hashlib.sha256).hexdigest()
|
||||||
|
if not hmac.compare_digest(sig, expected):
|
||||||
|
return None
|
||||||
|
payload = json.loads(_b64url_decode(body).decode('utf-8'))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if payload.get('kind') != TOKEN_KIND:
|
||||||
|
return None
|
||||||
|
if int(payload.get('exp', 0)) < int(time.time()):
|
||||||
|
return None
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def _cita_de_token(self, token):
|
||||||
|
payload = self._verify_token(token)
|
||||||
|
if not payload:
|
||||||
|
return None
|
||||||
|
cita = request.env['skeen.cita'].sudo().browse(int(payload.get('cid') or 0))
|
||||||
|
if not cita.exists() or cita.booking_source != 'public_portal':
|
||||||
|
return None
|
||||||
|
return cita
|
||||||
|
|
||||||
|
def _encolar_confirmacion(self, cita):
|
||||||
|
"""Hook Fase 2.4: avisar al cliente que la clínica confirmará su cita.
|
||||||
|
|
||||||
|
A propósito es un no-op: la Fase 2.4 enviará el mensaje de confirmación
|
||||||
|
por WhatsApp/WACRM (usando `cita.wacrm_conversation_id` o el teléfono del
|
||||||
|
paciente) cuando esas credenciales estén configuradas. Se deja el punto
|
||||||
|
de extensión listo, sin acoplar el portal público a WhatsApp todavía.
|
||||||
|
"""
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 1. Configuración pública del portal
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route(PREFIX + '/config', type='http', auth='none',
|
||||||
|
methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
def booking_config(self, **kw):
|
||||||
|
if request.httprequest.method == 'OPTIONS':
|
||||||
|
return _json({}, 200)
|
||||||
|
if not self._activo():
|
||||||
|
return self._deshabilitado()
|
||||||
|
cfg = self._config()
|
||||||
|
settings = self._settings()
|
||||||
|
env = request.env
|
||||||
|
|
||||||
|
sucursales = env['skeen.sucursal'].sudo().search([('active', '=', True)],
|
||||||
|
order='sequence, name')
|
||||||
|
categorias = env['skeen.service.category'].sudo().search([('active', '=', True)],
|
||||||
|
order='sequence, name')
|
||||||
|
servicios = env['skeen.servicio'].sudo().search([('active', '=', True)],
|
||||||
|
order='sequence, name')
|
||||||
|
|
||||||
|
medicos = []
|
||||||
|
if cfg['permitir_eleccion_doctor']:
|
||||||
|
empleados = env['hr.employee'].sudo().search([
|
||||||
|
('active', '=', True),
|
||||||
|
('staff_type', 'in', ('doctor', 'cosmetologa')),
|
||||||
|
], order='name')
|
||||||
|
medicos = [{'id': e.id, 'nombre': e.name,
|
||||||
|
'staff_type': e.staff_type or 'doctor'} for e in empleados]
|
||||||
|
|
||||||
|
_pack, terms = resolver_glosario(env)
|
||||||
|
|
||||||
|
return _json({
|
||||||
|
'activo': True,
|
||||||
|
'brand': self._brand(),
|
||||||
|
'terms': terms,
|
||||||
|
'sucursales': [{'code': s.code, 'name': s.name,
|
||||||
|
'address': s.address or '', 'phone': s.phone or ''}
|
||||||
|
for s in sucursales],
|
||||||
|
'categorias': [{'code': c.code, 'name': c.name} for c in categorias],
|
||||||
|
'servicios': [{'id': s.id, 'nombre': s.name, 'categoria': s.category,
|
||||||
|
'precio': s.price, 'duracion_min': s.duration_min}
|
||||||
|
for s in servicios],
|
||||||
|
'medicos': medicos,
|
||||||
|
'config': {
|
||||||
|
'max_dias_futuro': cfg['max_dias_futuro'],
|
||||||
|
'min_horas_anticipacion': cfg['min_horas_anticipacion'],
|
||||||
|
'permite_sucursal': cfg['permitir_sucursal'],
|
||||||
|
'permite_eleccion_doctor': cfg['permitir_eleccion_doctor'],
|
||||||
|
'requiere_confirmacion': cfg['requiere_confirmacion'],
|
||||||
|
'permitir_cancelar': cfg['permitir_cancelar'],
|
||||||
|
'cancelar_hasta_horas': cfg['cancelar_hasta_horas'],
|
||||||
|
'telefono_contacto': cfg['telefono_contacto'],
|
||||||
|
'titulo': cfg['titulo'],
|
||||||
|
'descripcion': cfg['descripcion'],
|
||||||
|
'mensaje_confirmacion': cfg['mensaje_confirmacion'],
|
||||||
|
'zona_horaria': settings.get('zona_horaria') or 'America/Tijuana',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 2. Disponibilidad de horarios
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route(PREFIX + '/slots', type='http', auth='none',
|
||||||
|
methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
def booking_slots(self, **kw):
|
||||||
|
if request.httprequest.method == 'OPTIONS':
|
||||||
|
return _json({}, 200)
|
||||||
|
if not self._activo():
|
||||||
|
return self._deshabilitado()
|
||||||
|
if _rate_limited('slots'):
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'Demasiadas solicitudes. Intenta más tarde.'}, 429)
|
||||||
|
try:
|
||||||
|
servicio_id = int(kw.get('servicio_id') or 0)
|
||||||
|
fecha = _parse_fecha(kw.get('fecha'))
|
||||||
|
doctor_id = int(kw.get('medico_id') or 0) or None
|
||||||
|
if not servicio_id or not fecha:
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'Faltan servicio_id o fecha (YYYY-MM-DD)'}, 400)
|
||||||
|
|
||||||
|
cfg = self._config()
|
||||||
|
settings = self._settings()
|
||||||
|
tz = _tz_clinica(settings)
|
||||||
|
ahora = datetime.now(tz)
|
||||||
|
|
||||||
|
servicio = request.env['skeen.servicio'].sudo().browse(servicio_id)
|
||||||
|
if not servicio.exists() or not servicio.active:
|
||||||
|
return _json({'status': 'error', 'message': 'Servicio no disponible'}, 404)
|
||||||
|
|
||||||
|
if fecha < ahora.date():
|
||||||
|
return _json({'status': 'error', 'message': 'La fecha ya pasó'}, 400)
|
||||||
|
if fecha > ahora.date() + timedelta(days=cfg['max_dias_futuro']):
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'La fecha está fuera del rango permitido'}, 400)
|
||||||
|
|
||||||
|
if doctor_id:
|
||||||
|
doctor = request.env['hr.employee'].sudo().browse(doctor_id)
|
||||||
|
if not doctor.exists() or not doctor.active or \
|
||||||
|
doctor.staff_type not in ('doctor', 'cosmetologa'):
|
||||||
|
return _json({'status': 'error', 'message': 'Especialista no disponible'}, 404)
|
||||||
|
|
||||||
|
slots = request.env['skeen.cita'].sudo().get_available_slots(
|
||||||
|
fecha, servicio_id, doctor_id=doctor_id)
|
||||||
|
|
||||||
|
# Respeta el mínimo de anticipación (en la zona horaria de la clínica).
|
||||||
|
minimo = ahora + timedelta(hours=cfg['min_horas_anticipacion'])
|
||||||
|
disponibles = []
|
||||||
|
for s in slots:
|
||||||
|
hh = int(s['time'])
|
||||||
|
mm = int(round((s['time'] - hh) * 60))
|
||||||
|
inicio = tz.localize(datetime.combine(fecha, dtime(hh, mm)))
|
||||||
|
if inicio >= minimo:
|
||||||
|
disponibles.append(s['time_str'])
|
||||||
|
|
||||||
|
return _json({
|
||||||
|
'status': 'success',
|
||||||
|
'slots': disponibles,
|
||||||
|
'medico_id': doctor_id,
|
||||||
|
'duracion_min': servicio.duration_min or 30,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return _error_interno(e)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 3. Crear la reserva
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route(PREFIX + '/create', type='http', auth='none',
|
||||||
|
methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
def booking_create(self, **kw):
|
||||||
|
if request.httprequest.method == 'OPTIONS':
|
||||||
|
return _json({}, 200)
|
||||||
|
if not self._activo():
|
||||||
|
return self._deshabilitado()
|
||||||
|
if _rate_limited('create'):
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'Demasiadas solicitudes. Intenta más tarde.'}, 429)
|
||||||
|
try:
|
||||||
|
data = json.loads(request.httprequest.get_data(as_text=True) or '{}')
|
||||||
|
except Exception:
|
||||||
|
data = {}
|
||||||
|
# Honeypot: campo oculto que solo un bot rellena.
|
||||||
|
if str(data.get('hp') or '').strip():
|
||||||
|
return _json({'status': 'error', 'message': 'Solicitud rechazada'}, 400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cfg = self._config()
|
||||||
|
settings = self._settings()
|
||||||
|
tz = _tz_clinica(settings)
|
||||||
|
ahora = datetime.now(tz)
|
||||||
|
|
||||||
|
nombre = str(data.get('nombre') or '').strip()
|
||||||
|
if not (2 <= len(nombre) <= 120):
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'Escribe tu nombre (2 a 120 caracteres)'}, 400)
|
||||||
|
telefono = _normaliza_telefono(data.get('telefono'))
|
||||||
|
digitos = re.sub(r'\D', '', telefono)
|
||||||
|
if not (10 <= len(digitos) <= 15):
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'Escribe un teléfono válido (10 dígitos)'}, 400)
|
||||||
|
email = str(data.get('email') or '').strip()
|
||||||
|
if email and not _EMAIL_RE.match(email):
|
||||||
|
return _json({'status': 'error', 'message': 'El email no es válido'}, 400)
|
||||||
|
notas = str(data.get('notas') or '').strip()[:500]
|
||||||
|
|
||||||
|
servicio_id = int(data.get('servicio_id') or 0)
|
||||||
|
fecha = _parse_fecha(data.get('fecha'))
|
||||||
|
hora = _parse_hora(data.get('hora'))
|
||||||
|
doctor_id = int(data.get('medico_id') or 0) or None
|
||||||
|
if not servicio_id or not fecha or hora is None:
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'Faltan servicio, fecha u hora'}, 400)
|
||||||
|
|
||||||
|
servicio = request.env['skeen.servicio'].sudo().browse(servicio_id)
|
||||||
|
if not servicio.exists() or not servicio.active:
|
||||||
|
return _json({'status': 'error', 'message': 'Servicio no disponible'}, 404)
|
||||||
|
|
||||||
|
if fecha < ahora.date():
|
||||||
|
return _json({'status': 'error', 'message': 'La fecha ya pasó'}, 400)
|
||||||
|
if fecha > ahora.date() + timedelta(days=cfg['max_dias_futuro']):
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'La fecha está fuera del rango permitido'}, 400)
|
||||||
|
hh = int(hora)
|
||||||
|
mm = int(round((hora - hh) * 60))
|
||||||
|
inicio = tz.localize(datetime.combine(fecha, dtime(hh, mm)))
|
||||||
|
if inicio < ahora + timedelta(hours=cfg['min_horas_anticipacion']):
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'La hora elegida ya no está disponible'}, 409)
|
||||||
|
|
||||||
|
if doctor_id:
|
||||||
|
doctor = request.env['hr.employee'].sudo().browse(doctor_id)
|
||||||
|
if not doctor.exists() or not doctor.active or \
|
||||||
|
doctor.staff_type not in ('doctor', 'cosmetologa'):
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'Especialista no disponible'}, 404)
|
||||||
|
|
||||||
|
# El horario debe ser uno de los disponibles según la agenda real.
|
||||||
|
slots = request.env['skeen.cita'].sudo().get_available_slots(
|
||||||
|
fecha, servicio_id, doctor_id=doctor_id)
|
||||||
|
if not any(abs(hora - s['time']) < 1e-6 for s in slots):
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'Ese horario ya no está disponible'}, 409)
|
||||||
|
|
||||||
|
# Sucursal: la elegida (si aplica) o la predeterminada de la clínica.
|
||||||
|
Sucursal = request.env['skeen.sucursal'].sudo()
|
||||||
|
branch = None
|
||||||
|
if cfg['permitir_sucursal'] and data.get('sucursal'):
|
||||||
|
code = str(data.get('sucursal')).strip()
|
||||||
|
suc = Sucursal.search([('code', '=', code), ('active', '=', True)], limit=1)
|
||||||
|
if not suc:
|
||||||
|
return _json({'status': 'error', 'message': 'Sucursal no disponible'}, 404)
|
||||||
|
branch = suc.code
|
||||||
|
if not branch:
|
||||||
|
branch = Sucursal.default_code()
|
||||||
|
|
||||||
|
# Paciente: se reutiliza por teléfono; si no existe se crea mínimo.
|
||||||
|
Partner = request.env['res.partner'].sudo()
|
||||||
|
variantes = {telefono, digitos, '52' + digitos, '+52' + digitos}
|
||||||
|
partner = Partner.search([
|
||||||
|
('is_patient', '=', True),
|
||||||
|
'|', ('phone', 'in', list(variantes)), ('mobile', 'in', list(variantes)),
|
||||||
|
], limit=1)
|
||||||
|
if not partner:
|
||||||
|
partner = Partner.search([
|
||||||
|
('is_patient', '=', True),
|
||||||
|
'|', ('phone', 'ilike', digitos), ('mobile', 'ilike', digitos),
|
||||||
|
], limit=1)
|
||||||
|
creado_paciente = False
|
||||||
|
if not partner:
|
||||||
|
partner_vals = {'name': nombre, 'phone': telefono,
|
||||||
|
'is_patient': True, 'source': 'other'}
|
||||||
|
if email:
|
||||||
|
partner_vals['email'] = email
|
||||||
|
try:
|
||||||
|
tag = request.env['skeen.patient.tag'].sudo().search(
|
||||||
|
[('name', '=ilike', ORIGEN_MARCA)], limit=1)
|
||||||
|
if not tag:
|
||||||
|
tag = request.env['skeen.patient.tag'].sudo().search(
|
||||||
|
[('name', 'ilike', 'reserva')], limit=1)
|
||||||
|
if tag:
|
||||||
|
partner_vals['tag_ids'] = [(6, 0, [tag.id])]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
partner = Partner.create(partner_vals)
|
||||||
|
creado_paciente = True
|
||||||
|
|
||||||
|
state = 'pending' if cfg['requiere_confirmacion'] else 'confirmed'
|
||||||
|
nota = ORIGEN_MARCA
|
||||||
|
if notas:
|
||||||
|
nota = '%s\n%s' % (ORIGEN_MARCA, notas)
|
||||||
|
cita_vals = {
|
||||||
|
'partner_id': partner.id,
|
||||||
|
'servicio_id': servicio_id,
|
||||||
|
'date': fecha.strftime('%Y-%m-%d'),
|
||||||
|
'time': hora,
|
||||||
|
'branch': branch,
|
||||||
|
'medium': 'onsite',
|
||||||
|
'state': state,
|
||||||
|
'notes': nota,
|
||||||
|
'booking_source': 'public_portal',
|
||||||
|
}
|
||||||
|
if doctor_id:
|
||||||
|
cita_vals['doctor_id'] = doctor_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
cita = request.env['skeen.cita'].sudo().create(cita_vals)
|
||||||
|
except ValidationError as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 409)
|
||||||
|
|
||||||
|
# Auditoría: quién/cuándo/cómo entró la reserva (nunca tumba la op.).
|
||||||
|
try:
|
||||||
|
cita.sudo().message_post(
|
||||||
|
subject='Reserva en línea',
|
||||||
|
body=('Reserva creada desde el portal público.<br/>'
|
||||||
|
'IP: %s<br/>Teléfono: %s' % (_client_ip(), telefono)),
|
||||||
|
message_type='comment')
|
||||||
|
cita.sudo()._log_state('', state, user='Portal en línea',
|
||||||
|
note='Reserva en línea (portal)')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Hook Fase 2.4: aviso de confirmación por WhatsApp/WACRM.
|
||||||
|
if cfg['requiere_confirmacion']:
|
||||||
|
self._encolar_confirmacion(cita)
|
||||||
|
|
||||||
|
return _json({
|
||||||
|
'ok': True,
|
||||||
|
'folio': cita.name,
|
||||||
|
'fecha': cita.date.strftime('%Y-%m-%d'),
|
||||||
|
'hora': cita.time_str,
|
||||||
|
'medico': cita.doctor_id.name if cita.doctor_id else None,
|
||||||
|
'servicio': cita.servicio_id.name,
|
||||||
|
'sucursal': _selection_label(request.env, 'skeen.cita', 'branch', cita.branch),
|
||||||
|
'estado': cita.state,
|
||||||
|
'requiere_confirmacion': cfg['requiere_confirmacion'],
|
||||||
|
'mensaje_confirmacion': cfg['mensaje_confirmacion'],
|
||||||
|
'token_de_gestion': self._sign_token(cita),
|
||||||
|
}, 201)
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _error_interno(e)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 4. Consultar una cita por token
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route(PREFIX + '/cita', type='http', auth='none',
|
||||||
|
methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
def booking_cita(self, **kw):
|
||||||
|
if request.httprequest.method == 'OPTIONS':
|
||||||
|
return _json({}, 200)
|
||||||
|
if not self._activo():
|
||||||
|
return self._deshabilitado()
|
||||||
|
if _rate_limited('cita'):
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'Demasiadas solicitudes. Intenta más tarde.'}, 429)
|
||||||
|
try:
|
||||||
|
cita = self._cita_de_token(kw.get('token'))
|
||||||
|
if not cita:
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'Enlace no válido o expirado'}, 404)
|
||||||
|
cfg = self._config()
|
||||||
|
settings = self._settings()
|
||||||
|
tz = _tz_clinica(settings)
|
||||||
|
ahora = datetime.now(tz)
|
||||||
|
inicio = tz.localize(datetime.combine(
|
||||||
|
cita.date, dtime(int(cita.time), int(round((cita.time % 1) * 60)))))
|
||||||
|
puede_cancelar = bool(
|
||||||
|
cfg['permitir_cancelar']
|
||||||
|
and cita.state in ('pending', 'confirmed')
|
||||||
|
and inicio - ahora > timedelta(hours=cfg['cancelar_hasta_horas']))
|
||||||
|
return _json({
|
||||||
|
'status': 'success',
|
||||||
|
'folio': cita.name,
|
||||||
|
'fecha': cita.date.strftime('%Y-%m-%d'),
|
||||||
|
'hora': cita.time_str,
|
||||||
|
'servicio': cita.servicio_id.name,
|
||||||
|
'medico': cita.doctor_id.name if cita.doctor_id else None,
|
||||||
|
'sucursal': _selection_label(request.env, 'skeen.cita', 'branch', cita.branch),
|
||||||
|
'estado': cita.state,
|
||||||
|
'estado_label': _selection_label(request.env, 'skeen.cita', 'state', cita.state),
|
||||||
|
'puede_cancelar': puede_cancelar,
|
||||||
|
'permitir_cancelar': cfg['permitir_cancelar'],
|
||||||
|
'cancelar_hasta_horas': cfg['cancelar_hasta_horas'],
|
||||||
|
'telefono_contacto': cfg['telefono_contacto'],
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return _error_interno(e)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 5. Cancelar una cita por token
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route(PREFIX + '/cancel', type='http', auth='none',
|
||||||
|
methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
def booking_cancel(self, **kw):
|
||||||
|
if request.httprequest.method == 'OPTIONS':
|
||||||
|
return _json({}, 200)
|
||||||
|
if not self._activo():
|
||||||
|
return self._deshabilitado()
|
||||||
|
if _rate_limited('cancel'):
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'Demasiadas solicitudes. Intenta más tarde.'}, 429)
|
||||||
|
try:
|
||||||
|
data = json.loads(request.httprequest.get_data(as_text=True) or '{}')
|
||||||
|
except Exception:
|
||||||
|
data = {}
|
||||||
|
try:
|
||||||
|
cita = self._cita_de_token(data.get('token'))
|
||||||
|
if not cita:
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'Enlace no válido o expirado'}, 404)
|
||||||
|
cfg = self._config()
|
||||||
|
if not cfg['permitir_cancelar']:
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'La cancelación en línea no está disponible. '
|
||||||
|
'Contacta a la clínica.'}, 403)
|
||||||
|
if cita.state not in ('pending', 'confirmed'):
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'Esta cita ya no se puede cancelar en línea.'}, 409)
|
||||||
|
settings = self._settings()
|
||||||
|
tz = _tz_clinica(settings)
|
||||||
|
ahora = datetime.now(tz)
|
||||||
|
inicio = tz.localize(datetime.combine(
|
||||||
|
cita.date, dtime(int(cita.time), int(round((cita.time % 1) * 60)))))
|
||||||
|
if inicio - ahora <= timedelta(hours=cfg['cancelar_hasta_horas']):
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'Ya no es posible cancelar en línea: faltan menos de '
|
||||||
|
'%d horas. Contacta a la clínica.' % cfg['cancelar_hasta_horas']},
|
||||||
|
409)
|
||||||
|
motivo = str(data.get('motivo') or '').strip()[:300]
|
||||||
|
cita.write({'cancellation_reason': motivo or 'Cancelada por el cliente en el portal'})
|
||||||
|
cita.set_state('cancelled', user='Portal en línea',
|
||||||
|
note=motivo or 'Cancelada en el portal público')
|
||||||
|
try:
|
||||||
|
cita.sudo().message_post(
|
||||||
|
subject='Cancelación en línea',
|
||||||
|
body='Cancelada desde el portal público. IP: %s' % _client_ip(),
|
||||||
|
message_type='comment')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return _json({'status': 'success', 'ok': True,
|
||||||
|
'folio': cita.name, 'estado': 'cancelled'})
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _error_interno(e)
|
||||||
Reference in New Issue
Block a user