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:
2026-10-11 08:28:33 +00:00
parent 358b85afcd
commit add9b28188
14 changed files with 1859 additions and 4 deletions

View File

@@ -33,6 +33,8 @@ import WacrmLeads from './pages/WacrmLeads';
import Chat from './pages/Chat';
import MisVentas from './pages/MisVentas';
import Login from './pages/Login';
import Reservar from './pages/Reservar';
import ReservarCita from './pages/ReservarCita';
import './App.css';
const FullScreenLoader: FC = () => (
@@ -135,6 +137,9 @@ const App: FC = () => {
<ChatNotifications />
<Routes>
<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="/agenda" element={<RequireAuth href="/agenda"><Agenda /></RequireAuth>} />
<Route path="/mis-ventas" element={<RequireAuth href="/mis-ventas"><MisVentas /></RequireAuth>} />

View 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;

View 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;
};

View File

@@ -1,6 +1,6 @@
import type { FC } 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 { usePatientDrawer } from '../components/PatientDrawer';
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" />
) : 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 => {
if (['arrived', 'in_progress', 'done'].includes(apt.state)) return '2da confirmación: llegó a la clínica';
if (apt.phone_confirmed) {
@@ -2088,7 +2094,7 @@ const Agenda: FC = () => {
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"
>
<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]">
{apt.service}{!isMedico && apt.doctor ? ` · ${apt.doctor}` : ''}
</p>
@@ -2230,6 +2236,7 @@ const Agenda: FC = () => {
<div className="font-semibold truncate">
<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>}
{portalIcon(apt) && <span title="Reservada en línea" className="mr-0.5 align-middle">{portalIcon(apt)}</span>}
{apt.time} · {apt.patient}
</div>
<div className="truncate opacity-75">
@@ -2365,6 +2372,7 @@ const Agenda: FC = () => {
<div className="font-semibold truncate">
<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>}
{portalIcon(apt, 10) && <span title="Reservada en línea" className="mr-0.5 align-middle">{portalIcon(apt, 10)}</span>}
{apt.time} · {firstName(apt.patient)}
</div>
</div>
@@ -2483,6 +2491,7 @@ const Agenda: FC = () => {
>
<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>}
{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)}
</div>
))
@@ -2560,6 +2569,7 @@ const Agenda: FC = () => {
title="Abrir ficha del paciente"
>
{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}
</button>
</td>

View File

@@ -13,13 +13,15 @@ import ImpactoModal from '../components/ImpactoModal';
import SucursalesPanel from '../components/SucursalesPanel';
import CategoriasPanel from '../components/CategoriasPanel';
import TerminologiaPanel from '../components/TerminologiaPanel';
import ReservasOnlinePanel from '../components/ReservasOnlinePanel';
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 { publicarClinicSettings } from '../lib/clinic-settings';
const TABS = [
{ key: 'clinica', label: 'Clínica' },
{ key: 'reservas', label: 'Reservas en línea' },
{ key: 'sucursales', label: 'Sucursales' },
{ key: 'categorias', label: 'Categorías' },
{ key: 'terminologia', label: 'Terminología' },
@@ -32,7 +34,7 @@ const TABS = [
] as const;
// 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 = [
{ key: 'diagnosticos', label: 'Diagnósticos' },
@@ -106,6 +108,7 @@ const Configuracion: FC = () => {
...CLINIC_SETTINGS_DEFAULT,
...next,
impuestos: { ...IMPUESTOS_DEFAULT, ...(next.impuestos || {}) },
reservas_online: { ...RESERVAS_ONLINE_DEFAULT, ...(next.reservas_online || {}) },
});
})
.catch(() => {
@@ -372,6 +375,12 @@ const Configuracion: FC = () => {
<TerminologiaPanel />
</Card.Body>
</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">
<Card>

View File

@@ -6,11 +6,15 @@ import { Button, Input } from '../components/ui';
import { odooApi } from '../services/odoo';
import { useAuth } from '../lib/auth';
import { useTheme } from '../lib/theme';
import { useBookingConfig } from '../lib/publicBooking';
const Login: FC = () => {
const { login, brand, setBrand } = useAuth();
const { theme } = useTheme();
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 [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
@@ -136,6 +140,18 @@ const Login: FC = () => {
</Button>
</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)' }}>
{brand.pie}
</p>

View 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;

View 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;

View File

@@ -72,6 +72,8 @@ export interface Appointment {
doctor?: string;
branch?: string;
medium?: string;
/** Origen de la reserva: 'internal' (default) o 'public_portal' (portal en línea) */
booking_source?: string;
notes?: string;
package_finished?: boolean;
package_finished_date?: string | null;
@@ -1010,6 +1012,106 @@ export interface ClinicSettings {
moneda_base: string;
nota_ticket: string;
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 = {
@@ -1026,6 +1128,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
moneda_base: 'MXN',
nota_ticket: '',
impuestos: IMPUESTOS_DEFAULT,
reservas_online: RESERVAS_ONLINE_DEFAULT,
};
export interface FrontendUser {
@@ -1686,6 +1789,32 @@ export const odooApi = {
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 }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/roles/permissions`, { matrix });
return data;

View File

@@ -158,6 +158,110 @@ def get_impuestos(env):
"""Bloque de impuestos vigente (copia). Fuente única para modelos y API."""
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).
AGENDA_START_DEFAULT = 8.0
AGENDA_END_DEFAULT = 19.0
@@ -198,6 +302,7 @@ def get_settings(env):
'moneda_base': MONEDA_DEFAULT,
'nota_ticket': '',
'impuestos': dict(IMPUESTOS_DEFAULT),
'reservas_online': dict(RESERVAS_ONLINE_DEFAULT),
}
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
if raw:
@@ -210,6 +315,7 @@ def get_settings(env):
if k in guardado and guardado[k] is not None:
out[k] = str(guardado[k])
out['impuestos'] = normalizar_impuestos(guardado.get('impuestos'))
out['reservas_online'] = normalizar_reservas_online(guardado.get('reservas_online'))
return out
@@ -226,6 +332,10 @@ def set_settings(env, vals):
if bloque is not None:
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
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(
SETTINGS_KEY, json.dumps(actual, ensure_ascii=False))
return get_settings(env)

View File

@@ -200,6 +200,15 @@ class SkeenCita(models.Model):
# WACRM
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')
package_finished = fields.Boolean(string='Paquete Terminado', default=False)
package_finished_date = fields.Datetime(string='Fecha Término Paquete')

View File

@@ -2,4 +2,5 @@
from . import main
from . import frontend
from . import public_booking
from . import wacrm_proxy

View File

@@ -1859,6 +1859,7 @@ class SkeenFrontendController(http.Controller):
'doctor': cita.doctor_id.name if cita.doctor_id else None,
'branch': cita.branch,
'medium': cita.medium,
'booking_source': cita.booking_source or 'internal',
'notes': cita.notes or '',
'is_first_visit': False,
'package_finished': cita.package_finished,

View 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)