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:
parent
d495cfc033
commit
5d1ea1acc3
4 changed files with 149 additions and 70 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue