293 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|