feat: computed analytics endpoint, rework Analysis page

Add GET /api/analytics returning real aggregates over the clinic's
patients/appointments/prescriptions/tasks, and rebuild the Analysis page
to render them. Drops the fabricated revenue/profit cards — temetro has no
billing data source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-07 19:49:50 +03:00
parent 75940313a4
commit 48378ebc5e
6 changed files with 237 additions and 144 deletions
+44 -144
View File
@@ -1,56 +1,23 @@
"use client";
import { TrendingDown, TrendingUp } from "lucide-react";
import type { ReactNode } from "react";
import { type ReactNode, useEffect, useState } from "react";
import { Sparkline } from "@/components/chat/sparkline";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { type Analytics, getAnalytics } from "@/lib/analytics";
// All figures here are mock/placeholder data — there is no analytics backend.
// They illustrate the dashboard layout (clinic profits, patient volume, etc.).
// Clinic analytics computed on the server from real data (patients,
// appointments, prescriptions, tasks). No fabricated financials — temetro has no
// billing data source.
type Metric = {
label: string;
value: string;
// % change vs the previous period; sign drives the up/down badge.
delta?: number;
points?: number[];
// Tailwind text-color class tinting the sparkline (via currentColor).
tone?: string;
};
type Metric = { label: string; value: string };
function DeltaBadge({ delta }: { delta: number }) {
const up = delta >= 0;
return (
<Badge variant={up ? "secondary" : "destructive"}>
{up ? (
<TrendingUp className="size-3" />
) : (
<TrendingDown className="size-3" />
)}
{up ? "+" : ""}
{delta}%
</Badge>
);
}
function StatCard({ label, value, delta, points, tone }: Metric) {
function StatCard({ label, value }: Metric) {
return (
<Card className="gap-3 p-4">
<div className="flex items-start justify-between gap-2">
<span className="text-muted-foreground text-sm">{label}</span>
{typeof delta === "number" && <DeltaBadge delta={delta} />}
</div>
<span className="text-muted-foreground text-sm">{label}</span>
<div className="font-semibold text-2xl text-foreground tracking-tight">
{value}
</div>
{points && (
<div className={cn("h-10", tone ?? "text-primary")}>
<Sparkline points={points} />
</div>
)}
</Card>
);
}
@@ -77,128 +44,61 @@ function Section({
);
}
const revenue: Metric[] = [
{
label: "Revenue (this month)",
value: "$48.2k",
delta: 12,
points: [31, 34, 33, 38, 41, 44, 48.2],
tone: "text-emerald-500",
},
{
label: "Profit margin",
value: "32%",
delta: 4,
points: [24, 26, 25, 28, 30, 31, 32],
tone: "text-emerald-500",
},
{
label: "Outstanding balances",
value: "$6.4k",
delta: -8,
points: [9.1, 8.4, 8.8, 7.6, 7.0, 6.7, 6.4],
tone: "text-amber-500",
},
];
const volume: Metric[] = [
{
label: "New patients",
value: "38",
delta: 9,
points: [22, 27, 25, 30, 33, 35, 38],
tone: "text-sky-500",
},
{
label: "Returning patients",
value: "212",
delta: 3,
points: [188, 196, 201, 199, 205, 209, 212],
tone: "text-sky-500",
},
{
label: "Active patients",
value: "1,284",
delta: 2,
points: [1190, 1210, 1230, 1242, 1260, 1271, 1284],
tone: "text-sky-500",
},
];
const appointments: Metric[] = [
{
label: "Appointments this week",
value: "146",
delta: 6,
points: [120, 128, 131, 134, 139, 142, 146],
tone: "text-violet-500",
},
{ label: "No-show rate", value: "4.1%", delta: -2 },
{ label: "Schedule utilization", value: "87%", delta: 5 },
];
const operations: Metric[] = [
{
label: "Avg. wait time",
value: "14 min",
delta: -11,
points: [22, 21, 19, 18, 17, 15, 14],
tone: "text-amber-500",
},
{
label: "Prescriptions issued",
value: "318",
delta: 7,
points: [270, 281, 290, 297, 305, 312, 318],
tone: "text-primary",
},
{ label: "Top diagnosis", value: "Hypertension" },
];
export function AnalysisView() {
const [data, setData] = useState<Analytics | null>(null);
useEffect(() => {
let active = true;
getAnalytics()
.then((a) => {
if (active) setData(a);
})
.catch(() => {
/* api-client redirects on 401; otherwise leave it loading */
});
return () => {
active = false;
};
}, []);
const n = (v: number | undefined) => String(v ?? 0);
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-10 px-6 py-10">
<div>
<h1 className="font-semibold text-2xl tracking-tight">Analysis</h1>
<p className="text-muted-foreground text-sm">
Clinic performance at a glance. Figures are sample data.
Clinic performance at a glance, computed from your clinic&apos;s data.
</p>
</div>
<Section
description="Earnings, margin and receivables"
title="Revenue & profit"
>
{revenue.map((m) => (
<StatCard key={m.label} {...m} />
))}
</Section>
<Section
description="New, returning and active patients"
description="New, active and total patients"
title="Patient volume"
>
{volume.map((m) => (
<StatCard key={m.label} {...m} />
))}
<StatCard label="Total patients" value={n(data?.patients.total)} />
<StatCard label="New this month" value={n(data?.patients.newThisMonth)} />
<StatCard label="Active patients" value={n(data?.patients.active)} />
</Section>
<Section
description="Bookings, attendance and capacity"
description="Bookings, attendance and what's coming up"
title="Appointments & schedule"
>
{appointments.map((m) => (
<StatCard key={m.label} {...m} />
))}
<StatCard label="This week" value={n(data?.appointments.thisWeek)} />
<StatCard label="Upcoming" value={n(data?.appointments.upcoming)} />
<StatCard label="Completed" value={n(data?.appointments.completed)} />
<StatCard label="Cancelled" value={n(data?.appointments.cancelled)} />
</Section>
<Section
description="Throughput, prescribing and case mix"
title="Clinic operations"
>
{operations.map((m) => (
<StatCard key={m.label} {...m} />
))}
<Section description="Medications prescribed" title="Prescriptions">
<StatCard label="Total issued" value={n(data?.prescriptions.total)} />
<StatCard label="Active" value={n(data?.prescriptions.active)} />
</Section>
<Section description="Care-team workload" title="Tasks">
<StatCard label="Open" value={n(data?.tasks.open)} />
<StatCard label="Completed" value={n(data?.tasks.done)} />
</Section>
</div>
);
+29
View File
@@ -0,0 +1,29 @@
import { apiFetch } from "@/lib/api-client";
// Server-computed clinic analytics. Mirrors the backend `src/types/analytics.ts`.
// All figures are aggregates over the active clinic's real data.
export type Analytics = {
patients: {
total: number;
newThisMonth: number;
active: number;
};
appointments: {
thisWeek: number;
completed: number;
cancelled: number;
upcoming: number;
};
prescriptions: {
total: number;
active: number;
};
tasks: {
open: number;
done: number;
};
};
export function getAnalytics(): Promise<Analytics> {
return apiFetch<Analytics>("/api/analytics");
}