mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
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:
@@ -6,6 +6,7 @@ import { auth } from "./auth.js";
|
||||
import { env } from "./env.js";
|
||||
import { errorHandler, notFound } from "./middleware/error.js";
|
||||
import { activityRouter } from "./routes/activity.js";
|
||||
import { analyticsRouter } from "./routes/analytics.js";
|
||||
import { appointmentsRouter } from "./routes/appointments.js";
|
||||
import { notesRouter } from "./routes/notes.js";
|
||||
import { patientsRouter } from "./routes/patients.js";
|
||||
@@ -53,6 +54,7 @@ app.use("/api/appointments", appointmentsRouter);
|
||||
app.use("/api/prescriptions", prescriptionsRouter);
|
||||
app.use("/api/tasks", tasksRouter);
|
||||
app.use("/api/activity", activityRouter);
|
||||
app.use("/api/analytics", analyticsRouter);
|
||||
|
||||
app.use(notFound);
|
||||
app.use(errorHandler);
|
||||
@@ -66,4 +68,5 @@ app.listen(env.PORT, () => {
|
||||
console.log(` • rx: /api/prescriptions`);
|
||||
console.log(` • tasks: /api/tasks`);
|
||||
console.log(` • activity: /api/activity`);
|
||||
console.log(` • stats: /api/analytics`);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Router } from "express";
|
||||
|
||||
import { requireAuth, requireOrg } from "../middleware/auth.js";
|
||||
import * as service from "../services/analytics.js";
|
||||
|
||||
export const analyticsRouter = Router();
|
||||
|
||||
// Clinic analytics are readable by any member of the active clinic.
|
||||
analyticsRouter.use(requireAuth, requireOrg);
|
||||
|
||||
analyticsRouter.get("/", async (req, res, next) => {
|
||||
try {
|
||||
res.json(await service.getAnalytics(req.organizationId!));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { and, count, eq, gte, lte, type SQL } from "drizzle-orm";
|
||||
import type { PgTable } from "drizzle-orm/pg-core";
|
||||
|
||||
import { db } from "../db/index.js";
|
||||
import { appointments } from "../db/schema/appointments.js";
|
||||
import { patients } from "../db/schema/patients.js";
|
||||
import { prescriptions } from "../db/schema/prescriptions.js";
|
||||
import { tasks } from "../db/schema/tasks.js";
|
||||
import type { Analytics } from "../types/analytics.js";
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const keyOf = (d: Date) =>
|
||||
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
|
||||
async function countWhere(table: PgTable, where: SQL): Promise<number> {
|
||||
const [row] = await db.select({ value: count() }).from(table).where(where);
|
||||
return row?.value ?? 0;
|
||||
}
|
||||
|
||||
export async function getAnalytics(orgId: string): Promise<Analytics> {
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const startOfWeek = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate() - now.getDay(),
|
||||
);
|
||||
const endOfWeek = new Date(startOfWeek);
|
||||
endOfWeek.setDate(startOfWeek.getDate() + 6);
|
||||
const weekStartKey = keyOf(startOfWeek);
|
||||
const weekEndKey = keyOf(endOfWeek);
|
||||
const todayKey = keyOf(now);
|
||||
|
||||
const [
|
||||
patientsTotal,
|
||||
patientsNew,
|
||||
patientsActive,
|
||||
apptWeek,
|
||||
apptCompleted,
|
||||
apptCancelled,
|
||||
apptUpcoming,
|
||||
rxTotal,
|
||||
rxActive,
|
||||
tasksOpen,
|
||||
tasksDone,
|
||||
] = await Promise.all([
|
||||
countWhere(patients, eq(patients.organizationId, orgId)),
|
||||
countWhere(
|
||||
patients,
|
||||
and(
|
||||
eq(patients.organizationId, orgId),
|
||||
gte(patients.createdAt, startOfMonth),
|
||||
)!,
|
||||
),
|
||||
countWhere(
|
||||
patients,
|
||||
and(eq(patients.organizationId, orgId), eq(patients.status, "active"))!,
|
||||
),
|
||||
countWhere(
|
||||
appointments,
|
||||
and(
|
||||
eq(appointments.organizationId, orgId),
|
||||
gte(appointments.date, weekStartKey),
|
||||
lte(appointments.date, weekEndKey),
|
||||
)!,
|
||||
),
|
||||
countWhere(
|
||||
appointments,
|
||||
and(
|
||||
eq(appointments.organizationId, orgId),
|
||||
eq(appointments.status, "completed"),
|
||||
)!,
|
||||
),
|
||||
countWhere(
|
||||
appointments,
|
||||
and(
|
||||
eq(appointments.organizationId, orgId),
|
||||
eq(appointments.status, "cancelled"),
|
||||
)!,
|
||||
),
|
||||
countWhere(
|
||||
appointments,
|
||||
and(
|
||||
eq(appointments.organizationId, orgId),
|
||||
gte(appointments.date, todayKey),
|
||||
)!,
|
||||
),
|
||||
countWhere(prescriptions, eq(prescriptions.organizationId, orgId)),
|
||||
countWhere(
|
||||
prescriptions,
|
||||
and(
|
||||
eq(prescriptions.organizationId, orgId),
|
||||
eq(prescriptions.status, "active"),
|
||||
)!,
|
||||
),
|
||||
countWhere(
|
||||
tasks,
|
||||
and(eq(tasks.organizationId, orgId), eq(tasks.done, false))!,
|
||||
),
|
||||
countWhere(
|
||||
tasks,
|
||||
and(eq(tasks.organizationId, orgId), eq(tasks.done, true))!,
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
patients: {
|
||||
total: patientsTotal,
|
||||
newThisMonth: patientsNew,
|
||||
active: patientsActive,
|
||||
},
|
||||
appointments: {
|
||||
thisWeek: apptWeek,
|
||||
completed: apptCompleted,
|
||||
cancelled: apptCancelled,
|
||||
upcoming: apptUpcoming,
|
||||
},
|
||||
prescriptions: { total: rxTotal, active: rxActive },
|
||||
tasks: { open: tasksOpen, done: tasksDone },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Server-computed clinic analytics returned by GET /api/analytics. All figures
|
||||
// are aggregates over the active clinic's real data (no fabricated financials).
|
||||
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;
|
||||
};
|
||||
};
|
||||
@@ -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'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>
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user