feat: clinic-wide AI (analytics/earnings/inventory + invoice-from-file),

real Live card, persistent chat history

Analytics & earnings:
- Analytics now carries real money computed from invoices (billed/paid/
  outstanding + by-month); new Earnings section on the Analysis page drawn with
  the project's Bklit chart components (shared EarningsChart).

AI agent reaches the whole clinic:
- new read tools getClinicInfo / getAnalytics / listInventory render clinic,
  analytics (with a Bklit earnings chart) and inventory cards in chat
- proposeInvoice turns an uploaded purchase/medication list into an invoice
  (new "invoice" action-preview kind → createInvoice); invoices/appointments
  auto-create/link a patient (ensurePatient) so they hit the Patients page

Live card:
- plots real data — patients checked in today — via GET /api/analytics/live
  (polled); value pill clamped and margins widened so nothing spills the card

Persistent AI chat history (Claude-style):
- ai_chat_threads + ai_chat_messages (migration 0016); per-user, org-scoped
  thread CRUD under /api/chat/threads
- chat panel owns a thread id, loads /?thread=<id>, and auto-saves after each
  exchange; sidebar lists past chats (open/delete), "New chat" starts fresh

Verified with backend typecheck + frontend tsc + next build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-14 22:25:15 +03:00
parent b946dc9226
commit 3aa699aefe
27 changed files with 4274 additions and 61 deletions
+44
View File
@@ -0,0 +1,44 @@
import type { TemetroUIMessage } from "@/lib/ai-chat";
import { apiFetch } from "@/lib/api-client";
// Persisted AI-chat threads (Claude-style history). Threads are per-user within
// the active clinic; the thread id is generated on the client.
export type ThreadSummary = { id: string; title: string; updatedAt: string };
type StoredMessage = { role: string; parts: unknown };
export function listThreads(): Promise<ThreadSummary[]> {
return apiFetch<ThreadSummary[]>("/api/chat/threads");
}
export function getThread(
id: string,
): Promise<{ id: string; title: string; messages: StoredMessage[] }> {
return apiFetch(`/api/chat/threads/${id}`);
}
export function saveThread(
id: string,
messages: TemetroUIMessage[],
title: string,
): Promise<{ ok: boolean }> {
return apiFetch(`/api/chat/threads/${id}`, {
method: "PUT",
body: JSON.stringify({
messages: messages.map((m) => ({ role: m.role, parts: m.parts })),
title,
}),
});
}
export function deleteThread(id: string): Promise<void> {
return apiFetch<void>(`/api/chat/threads/${id}`, { method: "DELETE" });
}
// Fired after a thread is saved/deleted so the sidebar history can refresh.
export const THREADS_CHANGED_EVENT = "temetro:threads-changed";
export function notifyThreadsChanged() {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(THREADS_CHANGED_EVENT));
}
}
+18 -1
View File
@@ -1,6 +1,8 @@
import type { UIMessage } from "ai";
import type { Analytics } from "@/lib/analytics";
import type { Appointment } from "@/lib/appointments";
import type { InventoryItem } from "@/lib/inventory";
import type { Lab, Patient, Trend } from "@/lib/patients";
import type { Prescription } from "@/lib/prescriptions";
import type { Task } from "@/lib/tasks";
@@ -39,10 +41,22 @@ export type StepData = {
export type AppointmentListData = { appointments: Appointment[] };
export type TaskListData = { tasks: Task[] };
export type PrescriptionListData = { prescriptions: Prescription[] };
export type InventoryListData = { items: InventoryItem[] };
// Clinic-wide read cards.
export type ClinicCardData = {
name: string;
slug: string | null;
createdAt: string | null;
};
// An add proposed by the agent, awaiting one-click clinician approval. `record`
// is the validated, ready-to-commit input for the matching create endpoint.
export type ActionPreviewKind = "appointment" | "task" | "prescription";
export type ActionPreviewKind =
| "appointment"
| "task"
| "prescription"
| "invoice";
export type ActionPreviewData = {
token: string;
kind: ActionPreviewKind;
@@ -61,6 +75,9 @@ export type TemetroDataParts = {
appointmentList: AppointmentListData;
taskList: TaskListData;
prescriptionList: PrescriptionListData;
inventoryList: InventoryListData;
clinicCard: ClinicCardData;
analyticsCard: Analytics;
actionPreview: ActionPreviewData;
};
+13
View File
@@ -3,6 +3,7 @@ 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 TrendPoint = { label: string; count: number };
export type EarningsPoint = { label: string; billed: number; paid: number };
export type Analytics = {
patients: {
@@ -24,6 +25,12 @@ export type Analytics = {
open: number;
done: number;
};
earnings: {
totalBilled: number;
totalPaid: number;
totalOutstanding: number;
byMonth: EarningsPoint[];
};
trends: {
patientsByMonth: TrendPoint[];
appointmentsByWeekday: TrendPoint[];
@@ -33,3 +40,9 @@ export type Analytics = {
export function getAnalytics(): Promise<Analytics> {
return apiFetch<Analytics>("/api/analytics");
}
// Current "in the building now" count — checked-in appointments today. Polled by
// the Analysis Live card.
export function getLiveMetric(): Promise<{ value: number }> {
return apiFetch<{ value: number }>("/api/analytics/live");
}
+36 -3
View File
@@ -646,6 +646,14 @@
"newThisMonth": "New this month",
"active": "Active patients"
},
"earnings": {
"title": "Earnings",
"description": "Billed, paid and outstanding — from invoices",
"billed": "Total billed",
"paid": "Total paid",
"outstanding": "Outstanding",
"byMonth": "Billed vs paid, by month"
},
"appointments": {
"title": "Appointments & schedule",
"description": "Bookings, attendance and what's coming up",
@@ -773,6 +781,12 @@
"thinking": "Thinking…",
"steps": "Steps",
"reasoning": "Reasoning",
"history": {
"title": "Chats",
"untitled": "New chat",
"empty": "No saved chats yet.",
"delete": "Delete chat"
},
"suggestions": {
"schedule": "Show today's schedule",
"tasks": "List open tasks",
@@ -795,12 +809,14 @@
"title": {
"appointment": "Proposed appointment",
"task": "Proposed task",
"prescription": "Proposed prescription"
"prescription": "Proposed prescription",
"invoice": "Proposed invoice"
},
"kind": {
"appointment": "Appointment added.",
"task": "Task added.",
"prescription": "Prescription added."
"prescription": "Prescription added.",
"invoice": "Invoice added."
},
"approve": "Add",
"adding": "Adding…",
@@ -832,7 +848,24 @@
"done": "Done",
"noAppointments": "No appointments.",
"noTasks": "No tasks.",
"noPrescriptions": "No prescriptions."
"noPrescriptions": "No prescriptions.",
"inventory": "Inventory",
"noInventory": "No inventory items."
},
"clinicCard": {
"title": "Clinic",
"since": "Since {{date}}"
},
"analyticsCard": {
"title": "Clinic analytics",
"patients": "Patients",
"appointmentsThisWeek": "Appts this week",
"activePrescriptions": "Active Rx",
"openTasks": "Open tasks",
"billed": "Billed",
"paid": "Paid",
"outstanding": "Outstanding",
"byMonth": "Billed vs paid, by month"
},
"labCard": {
"flags": {