Files
SKEEN-Proyecto/frontend/src/pages/ReservarCita.tsx

155 lines
6.5 KiB
TypeScript

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;