feat: Phase 6 — SMS Twilio, PDF reports, QR join page, verifyQr endpoint

This commit is contained in:
Hermes 2026-04-25 18:05:15 +00:00
parent 6103279dd4
commit 9bd134fb5d
7 changed files with 717 additions and 1 deletions

View file

@ -14,6 +14,7 @@ import DoctorClinics from "@/pages/DoctorClinics";
import QueueManagement from "@/pages/QueueManagement";
import Analytics from "@/pages/Analytics";
import PatientQueue from "@/pages/PatientQueue";
import QrJoin from "@/pages/QrJoin";
import DisplayScreen from "@/pages/DisplayScreen";
import SubscriptionPage from "@/pages/SubscriptionPage";
import PrintTicket from "@/pages/PrintTicket";
@ -58,7 +59,7 @@ export default function App() {
<Route path="/queue/:token" component={PatientQueue} />
<Route path="/display/:clinicId" component={DisplayScreen} />
<Route path="/ticket/:entryId" component={PrintTicket} />
<Route path="/q/:clinicId/:qrToken" component={PatientQueue} />
<Route path="/q/:clinicId/:qrToken" component={QrJoin} />
{/* Authenticated routes (wrapped in Layout) */}
<Route path="/dashboard">

194
client/src/pages/QrJoin.tsx Normal file
View file

@ -0,0 +1,194 @@
import { useState } from "react";
import { useParams, useLocation } from "wouter";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { motion } from "framer-motion";
import {
Stethoscope, QrCode, Smartphone, Loader2, XCircle, User, Phone, MessageCircle,
} from "lucide-react";
import { trpc } from "@/lib/trpc";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
const VISIT_REASONS = [
"consultation", "urgence", "certificat_scolaire", "certificat_sportif",
"arret_travail", "administratif", "autre",
] as const;
export default function QrJoin() {
const { t } = useTranslation();
const params = useParams<{ clinicId: string; qrToken: string }>();
const [, navigate] = useLocation();
const clinicId = parseInt(params.clinicId ?? "0", 10);
const qrToken = params.qrToken ?? "";
const [name, setName] = useState("");
const [phone, setPhone] = useState("");
const [whatsappPhone, setWhatsappPhone] = useState("");
const [reason, setReason] = useState<string>("consultation");
const [useSamePhone, setUseSamePhone] = useState(true);
// Verify QR is valid first
const verifyQuery = trpc.queue.verifyQr.useQuery(
{ clinicId, qrToken },
{ enabled: !!qrToken && clinicId > 0, retry: false }
);
const joinMutation = trpc.queue.join.useMutation({
onSuccess: (data) => {
toast.success(t("patient.toastJoined"));
navigate(`/queue/${data.patientToken}`);
},
onError: (e) => {
toast.error(e.message);
},
});
if (verifyQuery.isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Helmet><title>QueueMed</title></Helmet>
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
</div>
);
}
if (verifyQuery.error || !verifyQuery.data) {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Helmet><title>QueueMed</title></Helmet>
<div className="glass-card rounded-3xl p-10 text-center max-w-md">
<XCircle className="w-12 h-12 text-red-400 mx-auto mb-4" />
<h1 className="font-bold text-2xl mb-2">{t("patient.ticketNotFound")}</h1>
<p className="text-slate-500 text-sm mb-6">
{t("patient.ticketNotFoundDesc")}
</p>
<Button variant="gradient" onClick={() => navigate("/")}>{t("common.backToHome")}</Button>
</div>
</div>
);
}
const { clinicName, isQueueOpen, whatsappConnected } = verifyQuery.data;
if (!isQueueOpen) {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Helmet><title>QueueMed {clinicName}</title></Helmet>
<div className="glass-card rounded-3xl p-10 text-center max-w-md">
<XCircle className="w-12 h-12 text-orange-400 mx-auto mb-4" />
<h1 className="font-bold text-2xl mb-2">File d'attente fermée</h1>
<p className="text-slate-500 text-sm mb-6">
Le cabinet {clinicName} n'accepte plus de patients pour le moment.
</p>
<Button variant="gradient" onClick={() => navigate("/")}>{t("common.backToHome")}</Button>
</div>
</div>
);
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
joinMutation.mutate({
clinicId,
qrToken,
patientName: name || undefined,
patientPhone: phone || undefined,
whatsappPhone: useSamePhone ? phone : whatsappPhone || undefined,
visitReason: reason as any,
});
};
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Helmet><title>QueueMed {clinicName}</title></Helmet>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="glass-card rounded-3xl p-8 max-w-md w-full"
>
<div className="text-center mb-6">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center">
<QrCode className="w-8 h-8 text-white" />
</div>
<h1 className="font-bold text-2xl text-slate-800">{clinicName}</h1>
<p className="text-slate-500 text-sm mt-1">Rejoignez la salle d'attente virtuelle</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Nom (optionnel)</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Votre nom"
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-slate-200 bg-white/80 focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-500 transition"
/>
</div>
</div>
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Téléphone (optionnel)</label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+594..."
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-slate-200 bg-white/80 focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-500 transition"
/>
</div>
</div>
{whatsappConnected && phone && (
<label className="flex items-center gap-2 text-sm text-slate-600">
<input
type="checkbox"
checked={useSamePhone}
onChange={(e) => setUseSamePhone(e.target.checked)}
className="rounded border-slate-300 text-emerald-600 focus:ring-emerald-500"
/>
<MessageCircle className="w-4 h-4 text-emerald-500" />
Recevoir les notifications WhatsApp
</label>
)}
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Motif de visite</label>
<select
value={reason}
onChange={(e) => setReason(e.target.value)}
className="w-full px-4 py-2.5 rounded-xl border border-slate-200 bg-white/80 focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-500 transition"
>
{VISIT_REASONS.map((r) => (
<option key={r} value={r}>
{t(`visitReasons.${r}`, r)}
</option>
))}
</select>
</div>
<Button
type="submit"
variant="gradient"
className="w-full"
disabled={joinMutation.isPending}
>
{joinMutation.isPending ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<>
<Stethoscope className="w-5 h-5 mr-2" />
Prendre un ticket
</>
)}
</Button>
</form>
</motion.div>
</div>
);
}