feat: Phase 6 — SMS Twilio, PDF reports, QR join page, verifyQr endpoint
This commit is contained in:
parent
6103279dd4
commit
9bd134fb5d
7 changed files with 717 additions and 1 deletions
|
|
@ -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
194
client/src/pages/QrJoin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue