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;