feat: Phase 3 complete — advanced analytics, practitioner display screen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hermes 2026-04-25 17:21:14 +00:00
parent d495cfc033
commit 5d1ea1acc3
4 changed files with 149 additions and 70 deletions

View file

@ -412,6 +412,7 @@
"minShort": "min",
"position": "Position",
"nextLabel": "NEXT",
"practitionerFallback": "Practitioner",
"ticker": "✨ Welcome to {{clinic}} — Scan the QR code at reception to join the online queue — Track your position in real time on your phone — You'll be notified when your turn approaches"
},
"analytics": {
@ -440,13 +441,21 @@
"kpiAbsent": "No-shows",
"kpiAvgWait": "Avg wait",
"kpiAvgConsultation": "Avg cons.",
"kpiNoShowRate": "No-show rate",
"kpiPeakHour": "Peak hour",
"kpiBusiestDay": "Busiest day",
"flowJoined": "Joined",
"flowServed": "Served",
"flowAbsent": "No-shows",
"noShowServed": "Served",
"noShowAbsent": "No-shows",
"chartByHour": "Patient flow by hour",
"chartByDay": "Patient flow by day",
"chartFlow": "Patient flow",
"chartAvgWait": "Average wait time",
"chartWaitTrend": "Average wait per day",
"chartNoShow": "Attendance rate",
"noTrendData": "Not enough data for trend yet",
"peakHour": "Peak hour:",
"peakDay": "Busiest day:",
"minutesOnAverage": "minutes on average",

View file

@ -412,6 +412,7 @@
"minShort": "min",
"position": "Position",
"nextLabel": "SUIVANT",
"practitionerFallback": "Praticien",
"ticker": "✨ Bienvenue au {{clinic}} — Scannez le QR code à l'accueil pour rejoindre la file en ligne — Suivez votre position en temps réel sur votre téléphone — Vous serez notifié quand votre tour approche"
},
"analytics": {
@ -440,13 +441,21 @@
"kpiAbsent": "Absents",
"kpiAvgWait": "Attente moy.",
"kpiAvgConsultation": "Cons. moy.",
"kpiNoShowRate": "Taux d'absence",
"kpiPeakHour": "Heure de pointe",
"kpiBusiestDay": "Jour le plus chargé",
"flowJoined": "Joints",
"flowServed": "Servis",
"flowAbsent": "Absents",
"noShowServed": "Servis",
"noShowAbsent": "Absents",
"chartByHour": "Affluence par heure",
"chartByDay": "Affluence par jour",
"chartFlow": "Flux patients",
"chartAvgWait": "Temps d'attente moyen",
"chartWaitTrend": "Attente moyenne par jour",
"chartNoShow": "Taux de présence",
"noTrendData": "Pas encore de données pour la tendance",
"peakHour": "Pic d'affluence :",
"peakDay": "Jour le plus chargé :",
"minutesOnAverage": "minutes en moyenne",

View file

@ -7,7 +7,7 @@ import {
} from "lucide-react";
import {
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid,
AreaChart, Area, Cell, PieChart, Pie,
AreaChart, Area, Cell, PieChart, Pie, Legend,
} from "recharts";
import { trpc } from "@/lib/trpc";
import { Button } from "@/components/ui/button";
@ -32,9 +32,11 @@ export default function Analytics() {
const clinicsQuery = trpc.clinic.list.useQuery();
const summaryQuery = trpc.analytics.summary.useQuery({ days, clinicId });
const advancedQuery = trpc.analytics.getAdvanced.useQuery({ days, clinicId });
const clinics = clinicsQuery.data ?? [];
const summary = summaryQuery.data;
const advanced = advancedQuery.data;
const exportCsv = trpc.analytics.exportCsv.useQuery(
{ clinicId: clinicId ?? clinics[0]?.id ?? 0, days },
@ -63,15 +65,25 @@ export default function Analytics() {
toast.success(t("analytics.toastExportSuccess"));
};
const hourData = (summary?.byHour ?? []).map((count, hour) => ({ hour: `${hour}${t("analytics.hourSuffix")}`, count }));
const hourSource = advanced?.byHour ?? summary?.byHour ?? [];
const hourData = hourSource.map((count, hour) => ({ hour: `${hour}${t("analytics.hourSuffix")}`, count }));
const dayData = (summary?.byDay ?? []).map((count, dow) => ({ day: DAY_NAMES[dow], count }));
const flowData = [
{ name: t("analytics.flowJoined"), value: summary?.totalJoined ?? 0 },
{ name: t("analytics.flowServed"), value: summary?.totalServed ?? 0 },
{ name: t("analytics.flowAbsent"), value: summary?.totalAbsent ?? 0 },
const noShowPct = Math.round((advanced?.noShowRate ?? 0) * 100);
const noShowData = [
{ name: t("analytics.noShowServed"), value: 100 - noShowPct },
{ name: t("analytics.noShowAbsent"), value: noShowPct },
];
const waitTrendData = (advanced?.avgWaitByDay ?? []).map((d) => ({
date: d.date.slice(5),
avgWaitMinutes: d.avgWaitMinutes,
count: d.count,
}));
const advancedPeakHour = advanced?.peakHour ?? -1;
const advancedBusiestDay = advanced?.busiestDayOfWeek ?? -1;
return (
<div className="container py-8">
<Helmet>
@ -130,13 +142,32 @@ export default function Analytics() {
) : (
<>
{/* KPI cards */}
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{[
{ label: t("analytics.kpiJoined"), value: summary?.totalJoined ?? 0, icon: Users, color: "from-emerald-500 to-teal-500" },
{ label: t("analytics.kpiServed"), value: summary?.totalServed ?? 0, icon: Activity, color: "from-cyan-500 to-blue-500" },
{ label: t("analytics.kpiAbsent"), value: summary?.totalAbsent ?? 0, icon: Calendar, color: "from-orange-500 to-amber-500" },
{ label: t("analytics.kpiAvgWait"), value: `${summary?.avgWaitMinutes ?? 0} ${t("analytics.minShort")}`, icon: Clock, color: "from-violet-500 to-purple-500" },
{ label: t("analytics.kpiAvgConsultation"), value: `${summary?.avgConsultationMinutes ?? 0} ${t("analytics.minShort")}`, icon: TrendingUp, color: "from-pink-500 to-rose-500" },
{
label: t("analytics.kpiAvgWait"),
value: `${summary?.avgWaitMinutes ?? 0} ${t("analytics.minShort")}`,
icon: Clock,
color: "from-violet-500 to-purple-500",
},
{
label: t("analytics.kpiNoShowRate"),
value: `${noShowPct}%`,
icon: Calendar,
color: "from-orange-500 to-amber-500",
},
{
label: t("analytics.kpiPeakHour"),
value: advancedPeakHour >= 0 ? `${advancedPeakHour}${t("analytics.hourSuffix")}` : "—",
icon: TrendingUp,
color: "from-emerald-500 to-teal-500",
},
{
label: t("analytics.kpiBusiestDay"),
value: advancedBusiestDay >= 0 ? DAY_NAMES[advancedBusiestDay] : "—",
icon: Activity,
color: "from-cyan-500 to-blue-500",
},
].map((s) => {
const Icon = s.icon;
return (
@ -198,20 +229,20 @@ export default function Analytics() {
<Bar dataKey="count" radius={[6, 6, 0, 0]} fill="url(#barGrad)" />
</BarChart>
</ResponsiveContainer>
{summary && summary.peakHour >= 0 && (
{advancedPeakHour >= 0 && (
<p className="text-xs text-slate-500 mt-3">
{t("analytics.peakHour")} <strong className="text-emerald-700">{summary.peakHour}{t("analytics.hourSuffix")}</strong>
{t("analytics.peakHour")} <strong className="text-emerald-700">{advancedPeakHour}{t("analytics.hourSuffix")}</strong>
</p>
)}
</div>
<div className="glass-card rounded-2xl p-6">
<h3 className="font-bold mb-4 flex items-center gap-2">
<Activity className="w-4 h-4 text-cyan-600" />
{t("analytics.chartByDay")}
<Clock className="w-4 h-4 text-cyan-600" />
{t("analytics.chartWaitTrend")}
</h3>
<ResponsiveContainer width="100%" height={280}>
<AreaChart data={dayData}>
<AreaChart data={waitTrendData}>
<defs>
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#06b6d4" stopOpacity={0.5} />
@ -219,69 +250,75 @@ export default function Analytics() {
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" vertical={false} />
<XAxis dataKey="date" stroke="#94a3b8" fontSize={11} tickLine={false} axisLine={false} />
<YAxis stroke="#94a3b8" fontSize={11} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{ borderRadius: "12px", border: "1px solid #e2e8f0", background: "rgba(255,255,255,0.95)" }}
formatter={(v: number) => [`${v} ${t("analytics.minShort")}`, t("analytics.kpiAvgWait")]}
/>
<Area type="monotone" dataKey="avgWaitMinutes" stroke="#06b6d4" strokeWidth={2.5} fill="url(#areaGrad)" />
</AreaChart>
</ResponsiveContainer>
{waitTrendData.length === 0 && (
<p className="text-xs text-slate-400 mt-3 text-center">{t("analytics.noTrendData")}</p>
)}
</div>
<div className="glass-card rounded-2xl p-6">
<h3 className="font-bold mb-4 flex items-center gap-2">
<Users className="w-4 h-4 text-violet-600" />
{t("analytics.chartNoShow")}
</h3>
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
data={noShowData}
cx="50%" cy="50%"
innerRadius={60} outerRadius={100}
paddingAngle={4}
dataKey="value"
label={(entry) => `${entry.name}: ${entry.value}%`}
>
<Cell fill="#10b981" />
<Cell fill="#f97316" />
</Pie>
<Tooltip
contentStyle={{ borderRadius: "12px", border: "1px solid #e2e8f0", background: "rgba(255,255,255,0.95)" }}
formatter={(v: number) => [`${v}%`, ""]}
/>
<Legend verticalAlign="bottom" height={28} iconType="circle" />
</PieChart>
</ResponsiveContainer>
</div>
<div className="glass-card rounded-2xl p-6">
<h3 className="font-bold mb-4 flex items-center gap-2">
<Activity className="w-4 h-4 text-orange-600" />
{t("analytics.chartByDay")}
</h3>
<ResponsiveContainer width="100%" height={280}>
<BarChart data={dayData}>
<defs>
<linearGradient id="dayBarGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#0d9488" stopOpacity={0.95} />
<stop offset="100%" stopColor="#22d3ee" stopOpacity={0.85} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" vertical={false} />
<XAxis dataKey="day" stroke="#94a3b8" fontSize={11} tickLine={false} axisLine={false} />
<YAxis stroke="#94a3b8" fontSize={11} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{ borderRadius: "12px", border: "1px solid #e2e8f0", background: "rgba(255,255,255,0.95)" }}
/>
<Area type="monotone" dataKey="count" stroke="#06b6d4" strokeWidth={2.5} fill="url(#areaGrad)" />
</AreaChart>
<Bar dataKey="count" radius={[6, 6, 0, 0]} fill="url(#dayBarGrad)" />
</BarChart>
</ResponsiveContainer>
{summary && summary.peakDay >= 0 && (
{advancedBusiestDay >= 0 && (
<p className="text-xs text-slate-500 mt-3">
{t("analytics.peakDay")} <strong className="text-cyan-700">{DAY_NAMES[summary.peakDay]}</strong>
{t("analytics.peakDay")} <strong className="text-cyan-700">{DAY_NAMES[advancedBusiestDay]}</strong>
</p>
)}
</div>
<div className="glass-card rounded-2xl p-6">
<h3 className="font-bold mb-4 flex items-center gap-2">
<Users className="w-4 h-4 text-violet-600" />
{t("analytics.chartFlow")}
</h3>
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
data={flowData}
cx="50%" cy="50%"
innerRadius={60} outerRadius={100}
paddingAngle={4}
dataKey="value"
label={(entry) => entry.name}
>
{flowData.map((_, i) => (
<Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />
))}
</Pie>
<Tooltip
contentStyle={{ borderRadius: "12px", border: "1px solid #e2e8f0", background: "rgba(255,255,255,0.95)" }}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="glass-card rounded-2xl p-6">
<h3 className="font-bold mb-4 flex items-center gap-2">
<Clock className="w-4 h-4 text-orange-600" />
{t("analytics.chartAvgWait")}
</h3>
<div className="flex items-center justify-center h-[280px]">
<div className="text-center">
<div className="font-black text-7xl gradient-text mb-2">{summary?.avgWaitMinutes ?? 0}</div>
<div className="text-slate-500 text-sm">{t("analytics.minutesOnAverage")}</div>
<div className="mt-6 grid grid-cols-2 gap-3">
<div className="p-3 rounded-xl bg-emerald-50 border border-emerald-100">
<div className="text-xs text-emerald-700 uppercase font-bold">{t("analytics.consultationLabel")}</div>
<div className="font-bold text-emerald-900 text-2xl mt-1">{summary?.avgConsultationMinutes ?? 0} {t("analytics.minShort")}</div>
</div>
<div className="p-3 rounded-xl bg-cyan-50 border border-cyan-100">
<div className="text-xs text-cyan-700 uppercase font-bold">{t("analytics.totalLabel")}</div>
<div className="font-bold text-cyan-900 text-2xl mt-1">{(summary?.avgWaitMinutes ?? 0) + (summary?.avgConsultationMinutes ?? 0)} {t("analytics.minShort")}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</>
)}

View file

@ -21,6 +21,11 @@ export default function DisplayScreen() {
{ 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);
@ -78,6 +83,13 @@ export default function DisplayScreen() {
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 (
@ -162,6 +174,18 @@ export default function DisplayScreen() {
{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 }}