queue-med/client/src/pages/DisplayScreen.tsx
Hermes 5d1ea1acc3 feat: Phase 3 complete — advanced analytics, practitioner display screen
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:21:14 +00:00

293 lines
12 KiB
TypeScript

import { useEffect, useState } from "react";
import { useParams } from "wouter";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { motion, AnimatePresence } from "framer-motion";
import { Stethoscope, Wifi, WifiOff, Loader2, Clock, Users } from "lucide-react";
import { trpc } from "@/lib/trpc";
import { getSocket } from "@/lib/socket";
import { formatTicket } from "@/lib/utils";
export default function DisplayScreen() {
const { t, i18n } = useTranslation();
const params = useParams<{ clinicId: string }>();
const clinicId = Number(params.clinicId ?? 0);
const utils = trpc.useUtils();
const [connected, setConnected] = useState(false);
const [now, setNow] = useState(new Date());
const queueQuery = trpc.queue.getPublic.useQuery(
{ clinicId },
{ enabled: !!clinicId, refetchInterval: 30_000 }
);
const membersQuery = trpc.clinic.listMembersPublic.useQuery(
{ clinicId },
{ enabled: !!clinicId, staleTime: 60_000 }
);
// Tick clock
useEffect(() => {
const t = setInterval(() => setNow(new Date()), 30_000);
return () => clearInterval(t);
}, []);
// Socket
useEffect(() => {
if (!clinicId) return;
const s = getSocket();
s.emit("display:subscribe", clinicId);
setConnected(s.connected);
const onConnect = () => setConnected(true);
const onDisconnect = () => setConnected(false);
const onUpdate = () => utils.queue.getPublic.invalidate({ clinicId });
s.on("connect", onConnect);
s.on("disconnect", onDisconnect);
s.on("queue:update", onUpdate);
return () => {
s.emit("display:unsubscribe", clinicId);
s.off("connect", onConnect);
s.off("disconnect", onDisconnect);
s.off("queue:update", onUpdate);
};
}, [clinicId, utils]);
if (queueQuery.isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-emerald-50 via-white to-cyan-50">
<Helmet>
<title>{t("display.metaTitle")}</title>
<meta name="description" content={t("display.metaDescription")} />
</Helmet>
<Loader2 className="w-12 h-12 text-emerald-500 animate-spin" />
</div>
);
}
if (queueQuery.error || !queueQuery.data) {
return (
<div className="min-h-screen flex items-center justify-center p-8 bg-gradient-to-br from-emerald-50 via-white to-cyan-50">
<Helmet>
<title>{t("display.metaTitle")}</title>
<meta name="description" content={t("display.metaDescription")} />
</Helmet>
<div className="glass-card-strong rounded-3xl p-12 text-center max-w-md">
<h1 className="font-bold text-3xl mb-3">{t("display.clinicNotFound")}</h1>
<p className="text-slate-500">{t("display.clinicNotFoundDesc")}</p>
</div>
</div>
);
}
const { clinic, queue, callingNow, waitingCount } = queueQuery.data;
const upcoming = queue.filter((e) => e.status === "waiting").slice(0, 5);
const accent = clinic.color ?? "#10b981";
const calledPractitionerId =
(callingNow as { practitionerId?: number | null } | null)?.practitionerId ?? null;
const calledPractitioner =
calledPractitionerId && membersQuery.data
? membersQuery.data.find((m) => m.userId === calledPractitionerId) ?? null
: null;
const dateLocale = i18n.language?.startsWith("en") ? "en-US" : "fr-FR";
return (
<div className="min-h-screen bg-gradient-to-br from-emerald-50 via-white to-cyan-50 relative overflow-hidden">
<Helmet>
<title>{t("display.metaTitle")}</title>
<meta name="description" content={t("display.metaDescription")} />
</Helmet>
{/* Animated bg blobs */}
<div className="absolute inset-0 pointer-events-none overflow-hidden">
<div className="absolute -top-40 -left-40 w-[40rem] h-[40rem] rounded-full bg-emerald-300/30 blur-3xl animate-pulse-glow" />
<div className="absolute -bottom-40 -right-40 w-[40rem] h-[40rem] rounded-full bg-cyan-300/30 blur-3xl animate-pulse-glow" style={{ animationDelay: "1.5s" }} />
</div>
{/* Header */}
<header className="relative z-10 flex items-center justify-between px-10 py-6 border-b border-emerald-100/60 backdrop-blur-md bg-white/40">
<div className="flex items-center gap-3">
<div
className="w-12 h-12 rounded-2xl flex items-center justify-center shadow-md"
style={{ background: `linear-gradient(135deg, ${accent}, #06b6d4)` }}
>
<Stethoscope className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="font-bold text-2xl text-slate-900">{clinic.name}</h1>
<p className="text-sm text-slate-500">{t("display.brandTagline")}</p>
</div>
</div>
<div className="flex items-center gap-6">
<div className="text-right">
<div className="text-3xl font-bold text-slate-900 tabular-nums">
{now.toLocaleTimeString(dateLocale, { hour: "2-digit", minute: "2-digit" })}
</div>
<div className="text-xs text-slate-500 capitalize">
{now.toLocaleDateString(dateLocale, { weekday: "long", day: "numeric", month: "long" })}
</div>
</div>
<div
className={`px-3 py-1.5 rounded-full text-xs font-bold flex items-center gap-1.5 ${
connected ? "bg-emerald-100 text-emerald-700" : "bg-red-100 text-red-700"
}`}
>
{connected ? <Wifi className="w-3 h-3" /> : <WifiOff className="w-3 h-3" />}
{connected ? t("display.live") : t("display.reconnecting")}
</div>
</div>
</header>
{/* Main */}
<main className="relative z-10 grid lg:grid-cols-2 gap-8 p-10 min-h-[calc(100vh-200px)]">
{/* Calling now (huge) */}
<div className="flex items-center justify-center">
<div className="glass-card-strong rounded-[3rem] p-12 text-center w-full max-w-2xl shadow-2xl">
<div className="text-sm uppercase tracking-[0.3em] text-emerald-700 font-bold mb-6">
{t("display.patientCalled")}
</div>
<AnimatePresence mode="wait">
{callingNow ? (
<motion.div
key={callingNow.ticketNumber}
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 1.2, opacity: 0 }}
transition={{ type: "spring", damping: 15 }}
className=""
>
<div
className="font-black leading-none mb-4"
style={{
fontSize: "clamp(8rem, 22vw, 18rem)",
background: `linear-gradient(135deg, ${accent}, #06b6d4)`,
WebkitBackgroundClip: "text",
backgroundClip: "text",
WebkitTextFillColor: "transparent",
}}
>
{formatTicket(callingNow.ticketNumber)}
</div>
{callingNow.patientName && (
<div className="text-3xl font-bold text-slate-700 mt-4">
{callingNow.patientName}
</div>
)}
{calledPractitioner && (
<div className="mt-5 inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/80 border border-slate-200 shadow-sm">
<span
className="w-3 h-3 rounded-full ring-2 ring-white"
style={{ backgroundColor: calledPractitioner.color ?? "#10b981" }}
aria-hidden="true"
/>
<span className="text-base font-bold text-slate-800">
{calledPractitioner.displayName ?? t("display.practitionerFallback")}
</span>
</div>
)}
<motion.div
animate={{ scale: [1, 1.05, 1] }}
transition={{ duration: 2, repeat: Infinity }}
className="mt-8 inline-flex px-6 py-3 rounded-full bg-emerald-100 border-2 border-emerald-300 text-emerald-700 font-bold uppercase tracking-widest"
>
{t("display.consultationRoom")}
</motion.div>
</motion.div>
) : (
<motion.div
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="py-20"
>
<Users className="w-32 h-32 text-slate-200 mx-auto mb-6" />
<div className="text-3xl font-bold text-slate-400">
{clinic.isQueueOpen ? t("display.noPatientCalled") : t("display.queueClosed")}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Upcoming */}
<div className="flex flex-col gap-4">
<div className="glass-card-strong rounded-3xl p-6 flex-1 shadow-xl">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="font-bold text-2xl text-slate-900">{t("display.upcoming")}</h2>
<p className="text-sm text-slate-500">{t("display.waitingCount", { count: waitingCount })}</p>
</div>
<div
className={`px-4 py-2 rounded-full text-sm font-bold ${
clinic.isQueueOpen ? "bg-emerald-500 text-white" : "bg-slate-200 text-slate-500"
}`}
>
{clinic.isQueueOpen ? t("display.statusOpen") : t("display.statusClosed")}
</div>
</div>
<div className="space-y-3">
<AnimatePresence>
{upcoming.length === 0 ? (
<div className="text-center py-12 text-slate-400">
{t("display.noWaiting")}
</div>
) : (
upcoming.map((e, i) => (
<motion.div
key={e.id}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ delay: i * 0.05 }}
className={`flex items-center gap-4 p-4 rounded-2xl border-2 ${
i === 0
? "bg-gradient-to-r from-emerald-50 to-cyan-50 border-emerald-300"
: "bg-white/50 border-slate-100"
}`}
>
<div
className={`w-16 h-16 rounded-xl flex items-center justify-center font-bold text-2xl flex-shrink-0 ${
i === 0
? "bg-gradient-to-br from-emerald-500 to-cyan-500 text-white shadow-md"
: "bg-slate-100 text-slate-700"
}`}
>
{formatTicket(e.ticketNumber)}
</div>
<div className="flex-1 min-w-0">
<div className="font-bold text-slate-900 truncate">
{e.patientName ?? t("display.anonymousPatient")}
</div>
<div className="flex items-center gap-2 text-sm text-slate-500 mt-0.5">
<Clock className="w-3.5 h-3.5" />
~{e.estimatedWaitMinutes ?? "?"} {t("display.minShort")}
<span>·</span>
<span>{t("display.position")} {e.position}</span>
</div>
</div>
{i === 0 && (
<div className="text-xs uppercase tracking-wider font-bold text-emerald-700">
{t("display.nextLabel")}
</div>
)}
</motion.div>
))
)}
</AnimatePresence>
</div>
</div>
</div>
</main>
{/* Ticker */}
<footer className="relative z-10 border-t border-emerald-100/60 backdrop-blur-md bg-white/40 overflow-hidden h-12 flex items-center">
<div className="whitespace-nowrap text-emerald-700 font-medium text-sm animate-ticker px-8">
{t("display.ticker", { clinic: clinic.name })}
</div>
</footer>
</div>
);
}