diff --git a/client/src/locales/en.json b/client/src/locales/en.json index bd2395f..05f7e4a 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -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", diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json index 362a98f..0ec76f0 100644 --- a/client/src/locales/fr.json +++ b/client/src/locales/fr.json @@ -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", diff --git a/client/src/pages/Analytics.tsx b/client/src/pages/Analytics.tsx index c2f4098..c37fa54 100644 --- a/client/src/pages/Analytics.tsx +++ b/client/src/pages/Analytics.tsx @@ -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 (
@@ -130,13 +142,32 @@ export default function Analytics() { ) : ( <> {/* KPI cards */} -
+
{[ - { 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() { - {summary && summary.peakHour >= 0 && ( + {advancedPeakHour >= 0 && (

- {t("analytics.peakHour")} {summary.peakHour}{t("analytics.hourSuffix")} + {t("analytics.peakHour")} {advancedPeakHour}{t("analytics.hourSuffix")}

)}

- - {t("analytics.chartByDay")} + + {t("analytics.chartWaitTrend")}

- + @@ -219,69 +250,75 @@ export default function Analytics() { + + + [`${v} ${t("analytics.minShort")}`, t("analytics.kpiAvgWait")]} + /> + + + + {waitTrendData.length === 0 && ( +

{t("analytics.noTrendData")}

+ )} +
+ +
+

+ + {t("analytics.chartNoShow")} +

+ + + `${entry.name}: ${entry.value}%`} + > + + + + [`${v}%`, ""]} + /> + + + +
+ +
+

+ + {t("analytics.chartByDay")} +

+ + + + + + + + + - - + + - {summary && summary.peakDay >= 0 && ( + {advancedBusiestDay >= 0 && (

- {t("analytics.peakDay")} {DAY_NAMES[summary.peakDay]} + {t("analytics.peakDay")} {DAY_NAMES[advancedBusiestDay]}

)}
- -
-

- - {t("analytics.chartFlow")} -

- - - entry.name} - > - {flowData.map((_, i) => ( - - ))} - - - - -
- -
-

- - {t("analytics.chartAvgWait")} -

-
-
-
{summary?.avgWaitMinutes ?? 0}
-
{t("analytics.minutesOnAverage")}
-
-
-
{t("analytics.consultationLabel")}
-
{summary?.avgConsultationMinutes ?? 0} {t("analytics.minShort")}
-
-
-
{t("analytics.totalLabel")}
-
{(summary?.avgWaitMinutes ?? 0) + (summary?.avgConsultationMinutes ?? 0)} {t("analytics.minShort")}
-
-
-
-
-
)} diff --git a/client/src/pages/DisplayScreen.tsx b/client/src/pages/DisplayScreen.tsx index ee6d8c8..e2236df 100644 --- a/client/src/pages/DisplayScreen.tsx +++ b/client/src/pages/DisplayScreen.tsx @@ -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}
)} + {calledPractitioner && ( +
+
+ )}