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

@@ -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>