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
+57
View File
@@ -3,10 +3,12 @@ import type { PgTable } from "drizzle-orm/pg-core";
import { db } from "../db/index.js";
import { appointments } from "../db/schema/appointments.js";
import { invoices } from "../db/schema/invoices.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";
import { invoiceTotal } from "./invoices.js";
const pad = (n: number) => String(n).padStart(2, "0");
const keyOf = (d: Date) =>
@@ -178,6 +180,41 @@ export async function getAnalytics(orgId: string): Promise<Analytics> {
count: dayCounts.get(d.key) ?? 0,
}));
// Earnings from invoices (real money). `void` invoices are excluded entirely.
const invoiceRows = await db
.select({
lineItems: invoices.lineItems,
status: invoices.status,
issuedAt: invoices.issuedAt,
})
.from(invoices)
.where(eq(invoices.organizationId, orgId));
let totalBilled = 0;
let totalPaid = 0;
let totalOutstanding = 0;
const billedByMonth = new Map(months.map((m) => [m.key, 0]));
const paidByMonth = new Map(months.map((m) => [m.key, 0]));
for (const inv of invoiceRows) {
if (inv.status === "void") continue;
const amount = invoiceTotal({ lineItems: inv.lineItems });
totalBilled += amount;
if (inv.status === "paid") totalPaid += amount;
else totalOutstanding += amount; // draft + sent
// issuedAt is a YYYY-MM-DD string; its YYYY-MM prefix is the month key.
const monthKey = inv.issuedAt.slice(0, 7);
if (billedByMonth.has(monthKey)) {
billedByMonth.set(monthKey, billedByMonth.get(monthKey)! + amount);
if (inv.status === "paid") {
paidByMonth.set(monthKey, paidByMonth.get(monthKey)! + amount);
}
}
}
const earningsByMonth = months.map((m) => ({
label: m.label,
billed: billedByMonth.get(m.key) ?? 0,
paid: paidByMonth.get(m.key) ?? 0,
}));
return {
patients: {
total: patientsTotal,
@@ -192,6 +229,26 @@ export async function getAnalytics(orgId: string): Promise<Analytics> {
},
prescriptions: { total: rxTotal, active: rxActive },
tasks: { open: tasksOpen, done: tasksDone },
earnings: {
totalBilled,
totalPaid,
totalOutstanding,
byMonth: earningsByMonth,
},
trends: { patientsByMonth, appointmentsByWeekday },
};
}
// "In the building now" — today's appointments that are checked in. Cheap query
// for the Analysis Live card to poll.
export async function getLiveMetric(orgId: string): Promise<number> {
const todayKey = keyOf(new Date());
return countWhere(
appointments,
and(
eq(appointments.organizationId, orgId),
eq(appointments.date, todayKey),
eq(appointments.status, "checked-in"),
)!,
);
}