From 48378ebc5ee296910552a33810cfafe7c9893033 Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Sun, 7 Jun 2026 19:49:50 +0300 Subject: [PATCH] feat: computed analytics endpoint, rework Analysis page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/src/index.ts | 3 + backend/src/routes/analytics.ts | 17 ++ backend/src/services/analytics.ts | 121 +++++++++++ backend/src/types/analytics.ts | 23 +++ .../components/analysis/analysis-view.tsx | 188 ++++-------------- frontend/lib/analytics.ts | 29 +++ 6 files changed, 237 insertions(+), 144 deletions(-) create mode 100644 backend/src/routes/analytics.ts create mode 100644 backend/src/services/analytics.ts create mode 100644 backend/src/types/analytics.ts create mode 100644 frontend/lib/analytics.ts diff --git a/backend/src/index.ts b/backend/src/index.ts index ddfce99..e75e123 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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`); }); diff --git a/backend/src/routes/analytics.ts b/backend/src/routes/analytics.ts new file mode 100644 index 0000000..77b5b18 --- /dev/null +++ b/backend/src/routes/analytics.ts @@ -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); + } +}); diff --git a/backend/src/services/analytics.ts b/backend/src/services/analytics.ts new file mode 100644 index 0000000..6c048c9 --- /dev/null +++ b/backend/src/services/analytics.ts @@ -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 { + const [row] = await db.select({ value: count() }).from(table).where(where); + return row?.value ?? 0; +} + +export async function getAnalytics(orgId: string): Promise { + 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 }, + }; +} diff --git a/backend/src/types/analytics.ts b/backend/src/types/analytics.ts new file mode 100644 index 0000000..e5d499b --- /dev/null +++ b/backend/src/types/analytics.ts @@ -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; + }; +}; diff --git a/frontend/components/analysis/analysis-view.tsx b/frontend/components/analysis/analysis-view.tsx index 59fdd6e..8ccd6de 100644 --- a/frontend/components/analysis/analysis-view.tsx +++ b/frontend/components/analysis/analysis-view.tsx @@ -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 ( - - {up ? ( - - ) : ( - - )} - {up ? "+" : ""} - {delta}% - - ); -} - -function StatCard({ label, value, delta, points, tone }: Metric) { +function StatCard({ label, value }: Metric) { return ( -
- {label} - {typeof delta === "number" && } -
+ {label}
{value}
- {points && ( -
- -
- )}
); } @@ -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(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 (

Analysis

- Clinic performance at a glance. Figures are sample data. + Clinic performance at a glance, computed from your clinic's data.

- {revenue.map((m) => ( - - ))} -
- -
- {volume.map((m) => ( - - ))} + + +
- {appointments.map((m) => ( - - ))} + + + +
-
- {operations.map((m) => ( - - ))} +
+ + +
+ +
+ +
); diff --git a/frontend/lib/analytics.ts b/frontend/lib/analytics.ts new file mode 100644 index 0000000..5b8cec1 --- /dev/null +++ b/frontend/lib/analytics.ts @@ -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 { + return apiFetch("/api/analytics"); +}