mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
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:
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE "ai_chat_messages" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"thread_id" text NOT NULL,
|
||||
"position" integer NOT NULL,
|
||||
"role" text NOT NULL,
|
||||
"parts" jsonb NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ai_chat_threads" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"title" text DEFAULT 'New chat' NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "ai_chat_messages" ADD CONSTRAINT "ai_chat_messages_thread_id_ai_chat_threads_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."ai_chat_threads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "ai_chat_threads" ADD CONSTRAINT "ai_chat_threads_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "ai_chat_threads" ADD CONSTRAINT "ai_chat_threads_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "ai_messages_thread_idx" ON "ai_chat_messages" USING btree ("thread_id","position");--> statement-breakpoint
|
||||
CREATE INDEX "ai_threads_org_user_idx" ON "ai_chat_threads" USING btree ("organization_id","user_id");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -113,6 +113,13 @@
|
||||
"when": 1781455848772,
|
||||
"tag": "0015_polite_captain_marvel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1781464772532,
|
||||
"tag": "0016_past_maestro",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import { organization, user } from "./auth.js";
|
||||
|
||||
// Persisted AI-chat threads (Claude-style history), per user within a clinic.
|
||||
// The thread id is client-generated (nanoid) so the frontend can start saving a
|
||||
// fresh chat without a round-trip. Messages store the full UIMessage `parts`
|
||||
// (text + custom record cards) as JSONB so a reopened thread renders exactly as
|
||||
// it did live.
|
||||
export const aiChatThreads = pgTable(
|
||||
"ai_chat_threads",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
title: text("title").notNull().default("New chat"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(t) => [index("ai_threads_org_user_idx").on(t.organizationId, t.userId)],
|
||||
);
|
||||
|
||||
export const aiChatMessages = pgTable(
|
||||
"ai_chat_messages",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
threadId: text("thread_id")
|
||||
.notNull()
|
||||
.references(() => aiChatThreads.id, { onDelete: "cascade" }),
|
||||
position: integer("position").notNull(),
|
||||
role: text("role").notNull(),
|
||||
parts: jsonb("parts").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
},
|
||||
(t) => [index("ai_messages_thread_idx").on(t.threadId, t.position)],
|
||||
);
|
||||
@@ -11,3 +11,4 @@ export * from "./messaging.js";
|
||||
export * from "./notifications.js";
|
||||
export * from "./settings.js";
|
||||
export * from "./ai.js";
|
||||
export * from "./ai-chat.js";
|
||||
|
||||
@@ -15,3 +15,12 @@ analyticsRouter.get("/", async (req, res, next) => {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Lightweight real-time metric polled by the Live card: patients checked in today.
|
||||
analyticsRouter.get("/live", async (req, res, next) => {
|
||||
try {
|
||||
res.json({ value: await service.getLiveMetric(req.organizationId!) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,13 +10,16 @@ import {
|
||||
type UIMessage,
|
||||
} from "ai";
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { HttpError } from "../lib/http-error.js";
|
||||
import {
|
||||
requireAuth,
|
||||
requireOrg,
|
||||
requirePermission,
|
||||
} from "../middleware/auth.js";
|
||||
import { recordActivity } from "../services/activity.js";
|
||||
import * as aiChat from "../services/ai-chat.js";
|
||||
import { getAiSettings } from "../services/ai/config.js";
|
||||
import { resolveModel } from "../services/ai/provider.js";
|
||||
import { createChatTools } from "../services/ai/tools.js";
|
||||
@@ -43,12 +46,19 @@ function systemPrompt(veilActive: boolean, providerLabel: string): string {
|
||||
"- listAppointments: when asked to see the schedule / upcoming visits.",
|
||||
"- listTasks: when asked to see open tasks / to-dos.",
|
||||
"- listPrescriptions: when asked to see prescriptions.",
|
||||
"- getClinicInfo: the clinic's name / basic info (e.g. 'what's my clinic called?').",
|
||||
"- getAnalytics: clinic KPIs AND earnings (money billed / paid / outstanding, by month). Use for analytics, earnings, revenue, or performance questions.",
|
||||
"- listInventory: stock levels / low-stock / reorder questions.",
|
||||
"",
|
||||
"Add tools (propose only — these NEVER write):",
|
||||
"- proposeAppointment / proposeTask / proposePrescription: when the clinician",
|
||||
" asks to add/book/create one. They show an approval card; the record is only",
|
||||
" written after the clinician clicks Add. NEVER say you added/booked/created",
|
||||
" something — say you've drafted it for their approval.",
|
||||
"- proposeInvoice: when the clinician wants to bill someone — e.g. they upload",
|
||||
" a list of purchased medications/items. Parse it into line items",
|
||||
" {description, quantity, unitPrice} (use the prices in the document) and call",
|
||||
" proposeInvoice with the patient/client name.",
|
||||
"- previewImport: when the clinician wants to import/migrate an existing",
|
||||
" patient database file, or add a single patient. Parse the uploaded content",
|
||||
" into our patient shape and call previewImport.",
|
||||
@@ -167,3 +177,66 @@ chatRouter.post("/", async (req, res, next) => {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Persisted conversation history (Claude-style) --------------------------
|
||||
// Threads are per-user within the clinic. The client owns the thread id (nanoid)
|
||||
// and saves a snapshot of the conversation after each exchange.
|
||||
|
||||
chatRouter.get("/threads", async (req, res, next) => {
|
||||
try {
|
||||
res.json(await aiChat.listThreads(req.organizationId!, req.user!.id));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
chatRouter.get("/threads/:id", async (req, res, next) => {
|
||||
try {
|
||||
const thread = await aiChat.getThread(
|
||||
req.organizationId!,
|
||||
req.user!.id,
|
||||
req.params.id as string,
|
||||
);
|
||||
if (!thread) throw new HttpError(404, "Conversation not found.");
|
||||
res.json(thread);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const saveThreadSchema = z.object({
|
||||
messages: z
|
||||
.array(z.object({ role: z.string(), parts: z.unknown() }))
|
||||
.max(500),
|
||||
title: z.string().trim().max(120).default("New chat"),
|
||||
});
|
||||
|
||||
chatRouter.put("/threads/:id", async (req, res, next) => {
|
||||
try {
|
||||
const { messages, title } = saveThreadSchema.parse(req.body);
|
||||
await aiChat.saveThread(
|
||||
req.organizationId!,
|
||||
req.user!.id,
|
||||
req.params.id as string,
|
||||
messages,
|
||||
title || "New chat",
|
||||
);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
chatRouter.delete("/threads/:id", async (req, res, next) => {
|
||||
try {
|
||||
const ok = await aiChat.deleteThread(
|
||||
req.organizationId!,
|
||||
req.user!.id,
|
||||
req.params.id as string,
|
||||
);
|
||||
if (!ok) throw new HttpError(404, "Conversation not found.");
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { and, asc, desc, eq } from "drizzle-orm";
|
||||
|
||||
import { db } from "../db/index.js";
|
||||
import { aiChatMessages, aiChatThreads } from "../db/schema/ai-chat.js";
|
||||
import { HttpError } from "../lib/http-error.js";
|
||||
|
||||
export type StoredMessage = { role: string; parts: unknown };
|
||||
|
||||
export type ThreadSummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export async function listThreads(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
): Promise<ThreadSummary[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: aiChatThreads.id,
|
||||
title: aiChatThreads.title,
|
||||
updatedAt: aiChatThreads.updatedAt,
|
||||
})
|
||||
.from(aiChatThreads)
|
||||
.where(
|
||||
and(
|
||||
eq(aiChatThreads.organizationId, orgId),
|
||||
eq(aiChatThreads.userId, userId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(aiChatThreads.updatedAt))
|
||||
.limit(50);
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getThread(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
threadId: string,
|
||||
): Promise<{ id: string; title: string; messages: StoredMessage[] } | null> {
|
||||
const [thread] = await db
|
||||
.select()
|
||||
.from(aiChatThreads)
|
||||
.where(
|
||||
and(
|
||||
eq(aiChatThreads.id, threadId),
|
||||
eq(aiChatThreads.organizationId, orgId),
|
||||
eq(aiChatThreads.userId, userId),
|
||||
),
|
||||
);
|
||||
if (!thread) return null;
|
||||
const rows = await db
|
||||
.select({ role: aiChatMessages.role, parts: aiChatMessages.parts })
|
||||
.from(aiChatMessages)
|
||||
.where(eq(aiChatMessages.threadId, threadId))
|
||||
.orderBy(asc(aiChatMessages.position));
|
||||
return {
|
||||
id: thread.id,
|
||||
title: thread.title,
|
||||
messages: rows.map((r) => ({ role: r.role, parts: r.parts })),
|
||||
};
|
||||
}
|
||||
|
||||
// Upsert a thread and replace its messages with the supplied snapshot. The
|
||||
// thread id is client-generated; ownership is enforced (you can only write your
|
||||
// own threads within your clinic).
|
||||
export async function saveThread(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
threadId: string,
|
||||
messages: StoredMessage[],
|
||||
title: string,
|
||||
): Promise<void> {
|
||||
await db.transaction(async (tx) => {
|
||||
const [existing] = await tx
|
||||
.select({ userId: aiChatThreads.userId })
|
||||
.from(aiChatThreads)
|
||||
.where(eq(aiChatThreads.id, threadId));
|
||||
if (existing && existing.userId !== userId) {
|
||||
throw new HttpError(403, "Not your conversation.");
|
||||
}
|
||||
if (existing) {
|
||||
await tx
|
||||
.update(aiChatThreads)
|
||||
.set({ title, updatedAt: new Date() })
|
||||
.where(eq(aiChatThreads.id, threadId));
|
||||
await tx
|
||||
.delete(aiChatMessages)
|
||||
.where(eq(aiChatMessages.threadId, threadId));
|
||||
} else {
|
||||
await tx
|
||||
.insert(aiChatThreads)
|
||||
.values({ id: threadId, organizationId: orgId, userId, title });
|
||||
}
|
||||
if (messages.length > 0) {
|
||||
await tx.insert(aiChatMessages).values(
|
||||
messages.map((m, i) => ({
|
||||
id: randomUUID(),
|
||||
threadId,
|
||||
position: i,
|
||||
role: m.role,
|
||||
parts: m.parts,
|
||||
})),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteThread(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
threadId: string,
|
||||
): Promise<boolean> {
|
||||
const deleted = await db
|
||||
.delete(aiChatThreads)
|
||||
.where(
|
||||
and(
|
||||
eq(aiChatThreads.id, threadId),
|
||||
eq(aiChatThreads.organizationId, orgId),
|
||||
eq(aiChatThreads.userId, userId),
|
||||
),
|
||||
)
|
||||
.returning({ id: aiChatThreads.id });
|
||||
return deleted.length > 0;
|
||||
}
|
||||
@@ -2,11 +2,19 @@ import { tool } from "ai";
|
||||
import type { UIMessageStreamWriter } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
import { db } from "../../db/index.js";
|
||||
import { organization } from "../../db/schema/auth.js";
|
||||
import { appointmentInputSchema } from "../../lib/appointment-validation.js";
|
||||
import { initialsFromName } from "../../lib/initials.js";
|
||||
import { invoiceInputSchema } from "../../lib/invoice-validation.js";
|
||||
import { patientInputSchema } from "../../lib/patient-validation.js";
|
||||
import { prescriptionInputSchema } from "../../lib/prescription-validation.js";
|
||||
import { taskInputSchema } from "../../lib/task-validation.js";
|
||||
import * as analytics from "../analytics.js";
|
||||
import * as appointments from "../appointments.js";
|
||||
import * as inventory from "../inventory.js";
|
||||
import * as patients from "../patients.js";
|
||||
import * as prescriptions from "../prescriptions.js";
|
||||
import * as tasks from "../tasks.js";
|
||||
@@ -407,6 +415,123 @@ export function createChatTools(ctx: ToolContext) {
|
||||
},
|
||||
}),
|
||||
|
||||
// --- Clinic-wide reads (aggregates / non-PHI — safe to return to model) ---
|
||||
|
||||
getClinicInfo: tool({
|
||||
description:
|
||||
"Get the clinic's name and basic info. Use when the clinician asks about their clinic/organization (e.g. 'what's my clinic called?').",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
step("Loading clinic info");
|
||||
const [org] = await db
|
||||
.select({
|
||||
name: organization.name,
|
||||
slug: organization.slug,
|
||||
createdAt: organization.createdAt,
|
||||
})
|
||||
.from(organization)
|
||||
.where(eq(organization.id, orgId));
|
||||
const info = {
|
||||
name: org?.name ?? "",
|
||||
slug: org?.slug ?? null,
|
||||
createdAt: org?.createdAt ? org.createdAt.toISOString() : null,
|
||||
};
|
||||
writer.write({ type: "data-clinicCard", data: info });
|
||||
return info;
|
||||
},
|
||||
}),
|
||||
|
||||
getAnalytics: tool({
|
||||
description:
|
||||
"Retrieve the clinic's analytics AND earnings — patient/appointment/prescription/task counts plus money billed, paid, and outstanding (from invoices), with a by-month earnings trend. Use for KPIs, earnings, revenue, or performance questions.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
step("Loading clinic analytics");
|
||||
const data = await analytics.getAnalytics(orgId);
|
||||
writer.write({ type: "data-analyticsCard", data });
|
||||
return data; // aggregates only, no PHI
|
||||
},
|
||||
}),
|
||||
|
||||
listInventory: tool({
|
||||
description:
|
||||
"List the clinic's inventory (medications/supplies, stock levels, reorder thresholds). Use for stock, low-stock, or reorder questions.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
step("Loading inventory");
|
||||
const items = await inventory.listInventory(orgId);
|
||||
writer.write({ type: "data-inventoryList", data: { items } });
|
||||
return {
|
||||
count: items.length,
|
||||
items: items.map((i) => ({
|
||||
name: i.name,
|
||||
form: i.form,
|
||||
strength: i.strength,
|
||||
stock: i.stockQuantity,
|
||||
reorderThreshold: i.reorderThreshold,
|
||||
})),
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
proposeInvoice: tool({
|
||||
description:
|
||||
"Propose a new invoice for the clinician to approve — e.g. parse an uploaded list of purchased medications into billable line items. Does NOT save; it shows an approval card the clinician confirms. Provide the patient/client name (a file number if known) and line items {description, quantity, unitPrice}; prices come from the uploaded document.",
|
||||
inputSchema: z.object({
|
||||
name: z.string().describe("Patient/client name (may be a token)"),
|
||||
fileNumber: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Patient file number / MRN if known"),
|
||||
lineItems: z
|
||||
.array(
|
||||
z.object({
|
||||
description: z.string(),
|
||||
quantity: z.number(),
|
||||
unitPrice: z.number(),
|
||||
}),
|
||||
)
|
||||
.describe("Billed items, e.g. each purchased medication"),
|
||||
notes: z.string().nullish(),
|
||||
}),
|
||||
execute: async ({ name, fileNumber, lineItems, notes }) => {
|
||||
step(`Drafting invoice for ${fileNumber ?? name}`);
|
||||
const patient = fileNumber ? await resolvePatient(fileNumber) : null;
|
||||
const resolvedName = patient?.name ?? (name ? veil.rehydrate(name) : "");
|
||||
if (!resolvedName) {
|
||||
return { ok: false as const, reason: "patient_not_found" as const };
|
||||
}
|
||||
const candidate = {
|
||||
fileNumber: patient?.fileNumber ?? "",
|
||||
name: resolvedName,
|
||||
initials: patient?.initials ?? initialsFromName(resolvedName),
|
||||
lineItems,
|
||||
notes: notes ?? null,
|
||||
source: "ai" as const,
|
||||
};
|
||||
const parsed = invoiceInputSchema.safeParse(candidate);
|
||||
const issues = parsed.success
|
||||
? []
|
||||
: parsed.error.issues.map(
|
||||
(i) => `${i.path.join(".") || "(root)"}: ${i.message}`,
|
||||
);
|
||||
writer.write({
|
||||
type: "data-actionPreview",
|
||||
data: {
|
||||
token: `invoice-${stepSeq}`,
|
||||
kind: "invoice" as const,
|
||||
record: parsed.success ? parsed.data : candidate,
|
||||
issues,
|
||||
},
|
||||
});
|
||||
return {
|
||||
ok: parsed.success,
|
||||
issues,
|
||||
note: "Preview only — awaiting clinician approval before any write.",
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
// Migration: validate parsed records WITHOUT writing. The model parses an
|
||||
// uploaded export into our patient shape and calls this; the result drives
|
||||
// an approval card. Nothing is inserted until the clinician approves and the
|
||||
|
||||
@@ -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"),
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { db } from "../db/index.js";
|
||||
import { invoices } from "../db/schema/invoices.js";
|
||||
import type { InvoiceInput } from "../lib/invoice-validation.js";
|
||||
import type { Invoice, InvoiceInstallment } from "../types/invoice.js";
|
||||
import * as patients from "./patients.js";
|
||||
|
||||
type InvoiceRow = typeof invoices.$inferSelect;
|
||||
|
||||
@@ -104,9 +105,17 @@ export async function createInvoice(
|
||||
input: InvoiceInput,
|
||||
): Promise<Invoice> {
|
||||
const number = input.number || (await generateInvoiceNumber(orgId));
|
||||
// Link to a patient — creating one when the invoice has no file number (e.g.
|
||||
// an AI invoice built from an uploaded purchase list), so the client shows up
|
||||
// on the Patients page.
|
||||
const fileNumber = await patients.ensurePatient(orgId, userId, {
|
||||
fileNumber: input.fileNumber,
|
||||
name: input.name,
|
||||
initials: input.initials,
|
||||
});
|
||||
const [row] = await db
|
||||
.insert(invoices)
|
||||
.values(columns(orgId, { ...input, number }, userId))
|
||||
.values(columns(orgId, { ...input, number, fileNumber }, userId))
|
||||
.returning();
|
||||
return toInvoice(row!);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// A single bar/point in a time-series chart (e.g. one month or one weekday).
|
||||
export type TrendPoint = { label: string; count: number };
|
||||
|
||||
// One month of billing, in currency units (computed from invoices).
|
||||
export type EarningsPoint = { label: string; billed: number; paid: number };
|
||||
|
||||
// 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 = {
|
||||
@@ -23,6 +26,14 @@ export type Analytics = {
|
||||
open: number;
|
||||
done: number;
|
||||
};
|
||||
// Real money, computed from invoices: billed = sum of line items; paid =
|
||||
// invoices marked paid; outstanding = draft + sent. `void` is excluded.
|
||||
earnings: {
|
||||
totalBilled: number;
|
||||
totalPaid: number;
|
||||
totalOutstanding: number;
|
||||
byMonth: EarningsPoint[];
|
||||
};
|
||||
// Time-series for charts: new patients over the last 6 months, and
|
||||
// appointments per day across the current week.
|
||||
trends: {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { EarningsChart } from "@/components/analysis/earnings-chart";
|
||||
import { LiveHospitalChart } from "@/components/analysis/live-hospital-chart";
|
||||
import { Area, AreaChart } from "@/components/charts/area-chart";
|
||||
import { Bar } from "@/components/charts/bar";
|
||||
@@ -13,6 +14,7 @@ import { ChartTooltip } from "@/components/charts/tooltip";
|
||||
import { XAxis } from "@/components/charts/x-axis";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { type Analytics, getAnalytics } from "@/lib/analytics";
|
||||
import { formatMoney } from "@/lib/invoices";
|
||||
|
||||
// Clinic analytics computed on the server from real data (patients,
|
||||
// appointments, prescriptions, tasks). No fabricated financials — temetro has no
|
||||
@@ -216,6 +218,37 @@ export function AnalysisView() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg tracking-tight">
|
||||
{t("analysis.earnings.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("analysis.earnings.description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<StatCard
|
||||
label={t("analysis.earnings.billed")}
|
||||
value={formatMoney(data?.earnings.totalBilled ?? 0)}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("analysis.earnings.paid")}
|
||||
value={formatMoney(data?.earnings.totalPaid ?? 0)}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("analysis.earnings.outstanding")}
|
||||
value={formatMoney(data?.earnings.totalOutstanding ?? 0)}
|
||||
/>
|
||||
</div>
|
||||
<Card className="gap-3 p-4">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("analysis.earnings.byMonth")}
|
||||
</span>
|
||||
<EarningsChart data={data?.earnings.byMonth ?? []} />
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<Section
|
||||
columns={4}
|
||||
description={t("analysis.appointments.description")}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { Bar } from "@/components/charts/bar";
|
||||
import { BarChart } from "@/components/charts/bar-chart";
|
||||
import { BarXAxis } from "@/components/charts/bar-x-axis";
|
||||
import { Grid } from "@/components/charts/grid";
|
||||
import { ChartTooltip } from "@/components/charts/tooltip";
|
||||
import type { EarningsPoint } from "@/lib/analytics";
|
||||
|
||||
// Earnings by month — billed vs paid — drawn with the project's Bklit chart
|
||||
// components (the vendored visx chart family under components/charts). Shared by
|
||||
// the Analysis page and the chat analytics card.
|
||||
export function EarningsChart({
|
||||
data,
|
||||
aspectRatio = "2 / 1",
|
||||
}: {
|
||||
data: EarningsPoint[];
|
||||
aspectRatio?: string;
|
||||
}) {
|
||||
const chartData = data.map((d) => ({
|
||||
name: d.label,
|
||||
billed: d.billed,
|
||||
paid: d.paid,
|
||||
}));
|
||||
return (
|
||||
<BarChart aspectRatio={aspectRatio} data={chartData}>
|
||||
<Grid horizontal />
|
||||
<Bar dataKey="billed" fill="var(--chart-line-primary)" />
|
||||
<Bar dataKey="paid" fill="var(--chart-2)" />
|
||||
<BarXAxis />
|
||||
<ChartTooltip showDatePill={false} />
|
||||
</BarChart>
|
||||
);
|
||||
}
|
||||
@@ -12,51 +12,63 @@ import { LiveLine } from "@/components/charts/live-line";
|
||||
import { LiveYAxis } from "@/components/charts/live-y-axis";
|
||||
import { ChartTooltip } from "@/components/charts/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getLiveMetric } from "@/lib/analytics";
|
||||
|
||||
// temetro has no real-time telemetry feed yet, so the "Live" panel simulates a
|
||||
// hospital signal (patients currently in the building) as a bounded random walk
|
||||
// that updates once a second. Swap `tick()` for a WebSocket/poll when a real
|
||||
// feed exists — the chart contract (append { time, value }) stays the same.
|
||||
//
|
||||
// The simulation (a 1s interval + an rAF animation loop) only runs while the
|
||||
// clinician has explicitly toggled it on, so the Analysis page stays idle by
|
||||
// default instead of animating in the background.
|
||||
const BASELINE = 48;
|
||||
const MIN = 24;
|
||||
const MAX = 90;
|
||||
// The "Live" panel plots a REAL clinic signal — patients checked in today — by
|
||||
// polling GET /api/analytics/live. It only runs while the clinician toggles it
|
||||
// on, so the Analysis page stays idle by default. The metric changes slowly
|
||||
// (only as people check in), so the line scrolls smoothly using the last value
|
||||
// and refreshes from the server every few seconds.
|
||||
const WINDOW_SECONDS = 30;
|
||||
const REFETCH_EVERY_TICKS = 5; // poll the server every 5s (1s render ticks)
|
||||
|
||||
export function LiveHospitalChart() {
|
||||
const { t } = useTranslation();
|
||||
const [live, setLive] = useState(false);
|
||||
const [data, setData] = useState<LiveLinePoint[]>([]);
|
||||
const [value, setValue] = useState(BASELINE);
|
||||
const valueRef = useRef(BASELINE);
|
||||
const [value, setValue] = useState(0);
|
||||
const valueRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!live) return;
|
||||
// Seed a short history so the line is drawn immediately on start.
|
||||
const now = Date.now() / 1000;
|
||||
valueRef.current = BASELINE;
|
||||
setValue(BASELINE);
|
||||
setData(
|
||||
Array.from({ length: WINDOW_SECONDS }, (_, i) => ({
|
||||
time: now - (WINDOW_SECONDS - i),
|
||||
value: BASELINE,
|
||||
})),
|
||||
);
|
||||
let active = true;
|
||||
|
||||
const refetch = () =>
|
||||
getLiveMetric()
|
||||
.then((r) => {
|
||||
if (!active) return;
|
||||
valueRef.current = r.value;
|
||||
setValue(r.value);
|
||||
})
|
||||
.catch(() => {
|
||||
/* keep the last value on a transient error */
|
||||
});
|
||||
|
||||
// Seed a short flat history from the first reading so the line draws at once.
|
||||
refetch().finally(() => {
|
||||
if (!active) return;
|
||||
const now = Date.now() / 1000;
|
||||
setData(
|
||||
Array.from({ length: WINDOW_SECONDS }, (_, i) => ({
|
||||
time: now - (WINDOW_SECONDS - i),
|
||||
value: valueRef.current,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
let tick = 0;
|
||||
const id = setInterval(() => {
|
||||
const drift = (Math.random() - 0.5) * 5;
|
||||
const next = Math.max(MIN, Math.min(MAX, valueRef.current + drift));
|
||||
valueRef.current = next;
|
||||
setValue(next);
|
||||
tick += 1;
|
||||
if (tick % REFETCH_EVERY_TICKS === 0) void refetch();
|
||||
setData((prev) => [
|
||||
...prev.slice(-500),
|
||||
{ time: Date.now() / 1000, value: next },
|
||||
{ time: Date.now() / 1000, value: valueRef.current },
|
||||
]);
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [live]);
|
||||
|
||||
return (
|
||||
@@ -86,7 +98,7 @@ export function LiveHospitalChart() {
|
||||
<div className="h-56 w-full">
|
||||
<LiveLineChart
|
||||
data={data}
|
||||
margin={{ left: 44, right: 28 }}
|
||||
margin={{ left: 52, right: 48 }}
|
||||
value={value}
|
||||
window={WINDOW_SECONDS}
|
||||
>
|
||||
|
||||
@@ -290,30 +290,38 @@ export function LiveLine({
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* Badge — use popover vars so text is never white-on-white */}
|
||||
{badge && (
|
||||
<g transform={`translate(${liveDotX + 12},${liveDotY})`}>
|
||||
<rect
|
||||
fill="var(--popover)"
|
||||
height={24}
|
||||
opacity={0.95}
|
||||
rx={6}
|
||||
width={formatValue(liveValue).length * 7.5 + 16}
|
||||
x={0}
|
||||
y={-12}
|
||||
/>
|
||||
<text
|
||||
fill="var(--popover-foreground)"
|
||||
fontFamily="SF Mono, Menlo, Monaco, monospace"
|
||||
fontSize={11}
|
||||
fontWeight={500}
|
||||
x={8}
|
||||
y={4}
|
||||
>
|
||||
{formatValue(liveValue)}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
{/* Badge — use popover vars so text is never white-on-white. Flip it to
|
||||
the left of the dot when it would overflow the right edge, so the
|
||||
value never spills outside the card. */}
|
||||
{badge &&
|
||||
(() => {
|
||||
const badgeWidth = formatValue(liveValue).length * 7.5 + 16;
|
||||
const flip = liveDotX + 12 + badgeWidth > innerWidth;
|
||||
const badgeX = flip ? liveDotX - 12 - badgeWidth : liveDotX + 12;
|
||||
return (
|
||||
<g transform={`translate(${badgeX},${liveDotY})`}>
|
||||
<rect
|
||||
fill="var(--popover)"
|
||||
height={24}
|
||||
opacity={0.95}
|
||||
rx={6}
|
||||
width={badgeWidth}
|
||||
x={0}
|
||||
y={-12}
|
||||
/>
|
||||
<text
|
||||
fill="var(--popover-foreground)"
|
||||
fontFamily="SF Mono, Menlo, Monaco, monospace"
|
||||
fontSize={11}
|
||||
fontWeight={500}
|
||||
x={8}
|
||||
y={4}
|
||||
>
|
||||
{formatValue(liveValue)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})()}
|
||||
</motion.g>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, CalendarPlus, Check, ClipboardList, Pill, X } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CalendarPlus,
|
||||
Check,
|
||||
ClipboardList,
|
||||
Pill,
|
||||
Receipt,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -8,6 +16,12 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { ActionPreviewData } from "@/lib/ai-chat";
|
||||
import { type AppointmentInput, createAppointment } from "@/lib/appointments";
|
||||
import {
|
||||
createInvoice,
|
||||
formatMoney,
|
||||
type InvoiceInput,
|
||||
type InvoiceLineItem,
|
||||
} from "@/lib/invoices";
|
||||
import { type PrescriptionInput, createPrescription } from "@/lib/prescriptions";
|
||||
import { type TaskInput, createTask } from "@/lib/tasks";
|
||||
import { notify } from "@/lib/toast";
|
||||
@@ -18,6 +32,7 @@ export const ACTION_ICONS = {
|
||||
appointment: CalendarPlus,
|
||||
task: ClipboardList,
|
||||
prescription: Pill,
|
||||
invoice: Receipt,
|
||||
} as const;
|
||||
|
||||
const ICONS = ACTION_ICONS;
|
||||
@@ -38,6 +53,14 @@ export function summarize(data: ActionPreviewData): string[] {
|
||||
[r.assignee, r.due, r.priority].filter(Boolean).join(" · "),
|
||||
].filter(Boolean);
|
||||
}
|
||||
if (data.kind === "invoice") {
|
||||
const items = (r.lineItems as InvoiceLineItem[] | undefined) ?? [];
|
||||
const total = items.reduce((s, li) => s + li.quantity * li.unitPrice, 0);
|
||||
return [
|
||||
String(r.name ?? ""),
|
||||
`${items.length} item${items.length === 1 ? "" : "s"} · ${formatMoney(total)}`,
|
||||
].filter(Boolean);
|
||||
}
|
||||
// prescription
|
||||
return [
|
||||
[r.medication, r.dose].filter(Boolean).join(" "),
|
||||
@@ -56,6 +79,8 @@ export async function commitAction(data: ActionPreviewData): Promise<void> {
|
||||
});
|
||||
} else if (data.kind === "task") {
|
||||
await createTask(data.record as TaskInput);
|
||||
} else if (data.kind === "invoice") {
|
||||
await createInvoice({ ...(data.record as InvoiceInput), source: "ai" });
|
||||
} else {
|
||||
await createPrescription({
|
||||
...(data.record as PrescriptionInput),
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart3 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { EarningsChart } from "@/components/analysis/earnings-chart";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { Analytics } from "@/lib/analytics";
|
||||
import { formatMoney } from "@/lib/invoices";
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-muted-foreground text-xs">{label}</span>
|
||||
<span className="font-semibold text-foreground text-sm tabular-nums">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The agent's analytics card: clinic KPIs + earnings, with a Bklit earnings
|
||||
// chart (reused from the Analysis page).
|
||||
export function AnalyticsCard({ data }: { data: Analytics }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card className="w-full gap-3 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">
|
||||
{t("chat.analyticsCard.title")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Stat
|
||||
label={t("chat.analyticsCard.patients")}
|
||||
value={String(data.patients.total)}
|
||||
/>
|
||||
<Stat
|
||||
label={t("chat.analyticsCard.appointmentsThisWeek")}
|
||||
value={String(data.appointments.thisWeek)}
|
||||
/>
|
||||
<Stat
|
||||
label={t("chat.analyticsCard.activePrescriptions")}
|
||||
value={String(data.prescriptions.active)}
|
||||
/>
|
||||
<Stat
|
||||
label={t("chat.analyticsCard.openTasks")}
|
||||
value={String(data.tasks.open)}
|
||||
/>
|
||||
<Stat
|
||||
label={t("chat.analyticsCard.billed")}
|
||||
value={formatMoney(data.earnings.totalBilled)}
|
||||
/>
|
||||
<Stat
|
||||
label={t("chat.analyticsCard.paid")}
|
||||
value={formatMoney(data.earnings.totalPaid)}
|
||||
/>
|
||||
<Stat
|
||||
label={t("chat.analyticsCard.outstanding")}
|
||||
value={formatMoney(data.earnings.totalOutstanding)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("chat.analyticsCard.byMonth")}
|
||||
</span>
|
||||
<EarningsChart data={data.earnings.byMonth} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -43,8 +43,11 @@ import {
|
||||
ToolOutput,
|
||||
} from "@/components/ai-elements/tool";
|
||||
import { ActionPreviewCard } from "@/components/chat/action-preview-card";
|
||||
import { AnalyticsCard } from "@/components/chat/analytics-card";
|
||||
import { BatchActionPreviewCard } from "@/components/chat/batch-action-preview-card";
|
||||
import { ChatInput } from "@/components/chat/chat-input";
|
||||
import { ClinicCard } from "@/components/chat/clinic-card";
|
||||
import { InventoryListCard } from "@/components/chat/inventory-list-card";
|
||||
import { ImportPreviewCard } from "@/components/chat/import-preview-card";
|
||||
import { LabChartCard } from "@/components/chat/lab-chart-card";
|
||||
import { PatientResult } from "@/components/chat/patient-cards";
|
||||
@@ -67,6 +70,11 @@ import {
|
||||
getModel,
|
||||
} from "@/lib/ai-models";
|
||||
import type { ActionPreviewData, TemetroUIMessage } from "@/lib/ai-chat";
|
||||
import {
|
||||
getThread,
|
||||
notifyThreadsChanged,
|
||||
saveThread,
|
||||
} from "@/lib/ai-chat-history";
|
||||
import { getAiConfig } from "@/lib/ai-settings";
|
||||
import { API_BASE_URL } from "@/lib/api-client";
|
||||
import { getPatient } from "@/lib/patients";
|
||||
@@ -90,6 +98,15 @@ export function ChatPanel() {
|
||||
// (or waiting on the Veil gate) wait here and auto-send when it goes idle.
|
||||
const [queued, setQueued] = useState<string[]>([]);
|
||||
|
||||
// Persisted conversation: a client-owned thread id (a fresh one per new chat),
|
||||
// saved to the server after each exchange so history survives reloads.
|
||||
const [threadId, setThreadId] = useState<string>(() => nanoid());
|
||||
const threadIdRef = useRef(threadId);
|
||||
threadIdRef.current = threadId;
|
||||
// Skip the auto-save that would otherwise fire right after loading a thread
|
||||
// (which would needlessly bump it to the top of the history).
|
||||
const justLoadedRef = useRef(false);
|
||||
|
||||
const transport = useMemo(
|
||||
() =>
|
||||
new DefaultChatTransport<TemetroUIMessage>({
|
||||
@@ -135,7 +152,10 @@ export function ChatPanel() {
|
||||
// Run the LLM agent for a message (after any Veil gate) on a given model.
|
||||
const runAgentWith = useCallback(
|
||||
(text: string, modelId: string) => {
|
||||
sendMessage({ text }, { body: { model: modelId, effort } });
|
||||
sendMessage(
|
||||
{ text },
|
||||
{ body: { model: modelId, effort, threadId: threadIdRef.current } },
|
||||
);
|
||||
},
|
||||
[sendMessage, effort],
|
||||
);
|
||||
@@ -239,6 +259,67 @@ export function ChatPanel() {
|
||||
}
|
||||
}, [requestedPatient, send]);
|
||||
|
||||
// Open a saved thread from `/?thread=<id>` (sidebar history); a bare `/` starts
|
||||
// a fresh chat. Driven by the URL so the sidebar links and "New chat" work.
|
||||
const requestedThread = searchParams.get("thread");
|
||||
useEffect(() => {
|
||||
if (requestedThread) {
|
||||
if (requestedThread === threadIdRef.current) return; // already open
|
||||
let active = true;
|
||||
getThread(requestedThread)
|
||||
.then((thread) => {
|
||||
if (!active) return;
|
||||
justLoadedRef.current = true;
|
||||
setThreadId(thread.id);
|
||||
setMessages(
|
||||
thread.messages.map(
|
||||
(m) =>
|
||||
({
|
||||
id: nanoid(),
|
||||
role: m.role,
|
||||
parts: m.parts,
|
||||
}) as TemetroUIMessage,
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
/* missing/forbidden thread → leave the current chat as-is */
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}
|
||||
// No ?thread → fresh chat (e.g. after "New chat").
|
||||
setThreadId(nanoid());
|
||||
setMessages([]);
|
||||
}, [requestedThread, setMessages]);
|
||||
|
||||
// Auto-save the conversation a moment after it settles (covers both LLM and
|
||||
// the `/patient` fast path). Skips the redundant save right after a load.
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) return;
|
||||
if (status === "submitted" || status === "streaming") return;
|
||||
if (justLoadedRef.current) {
|
||||
justLoadedRef.current = false;
|
||||
return;
|
||||
}
|
||||
const id = setTimeout(() => {
|
||||
const firstUser = messages.find((m) => m.role === "user");
|
||||
const textPart = firstUser?.parts.find((p) => p.type === "text") as
|
||||
| { text?: string }
|
||||
| undefined;
|
||||
const title =
|
||||
(textPart?.text ?? "").trim().slice(0, 60) ||
|
||||
t("chat.history.untitled");
|
||||
saveThread(threadIdRef.current, messages, title)
|
||||
.then(notifyThreadsChanged)
|
||||
.catch(() => {
|
||||
/* a failed save shouldn't disrupt the chat */
|
||||
});
|
||||
}, 800);
|
||||
return () => clearTimeout(id);
|
||||
}, [messages, status, t]);
|
||||
|
||||
const promptInput = (
|
||||
<ChatInput
|
||||
effort={effort}
|
||||
@@ -447,6 +528,15 @@ export function ChatPanel() {
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (part.type === "data-inventoryList") {
|
||||
return <InventoryListCard items={part.data.items} key={key} />;
|
||||
}
|
||||
if (part.type === "data-clinicCard") {
|
||||
return <ClinicCard data={part.data} key={key} />;
|
||||
}
|
||||
if (part.type === "data-analyticsCard") {
|
||||
return <AnalyticsCard data={part.data} key={key} />;
|
||||
}
|
||||
if (part.type === "data-veilNotice") {
|
||||
return (
|
||||
<Badge className="gap-1 self-start" key={key} variant="secondary">
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { Building2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { ClinicCardData } from "@/lib/ai-chat";
|
||||
|
||||
// Small card the agent shows for getClinicInfo.
|
||||
export function ClinicCard({ data }: { data: ClinicCardData }) {
|
||||
const { t } = useTranslation();
|
||||
const since = data.createdAt
|
||||
? new Date(data.createdAt).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})
|
||||
: null;
|
||||
return (
|
||||
<Card className="w-full gap-1 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="size-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("chat.clinicCard.title")}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-semibold text-foreground text-lg tracking-tight">
|
||||
{data.name}
|
||||
</span>
|
||||
{since ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("chat.clinicCard.since", { date: since })}
|
||||
</span>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { Boxes } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { InventoryItem } from "@/lib/inventory";
|
||||
|
||||
// Read-only inventory card the agent shows for listInventory; low-stock items
|
||||
// (at or below their reorder threshold) get a destructive badge.
|
||||
export function InventoryListCard({ items }: { items: InventoryItem[] }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card className="w-full gap-0 overflow-hidden p-0">
|
||||
<div className="flex items-center gap-2 border-b px-4 py-3">
|
||||
<Boxes className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">{t("chat.lists.inventory")}</span>
|
||||
<Badge className="ml-auto" variant="secondary">
|
||||
{items.length}
|
||||
</Badge>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-muted-foreground text-sm">
|
||||
{t("chat.lists.noInventory")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-72 divide-y divide-border overflow-y-auto">
|
||||
{items.map((it) => {
|
||||
const low = it.stockQuantity <= it.reorderThreshold;
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2.5" key={it.id}>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate font-medium text-foreground text-sm">
|
||||
{it.name}
|
||||
</span>
|
||||
<span className="truncate text-muted-foreground text-xs">
|
||||
{[it.strength, it.form].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant={low ? "destructive" : "outline"}>
|
||||
{it.stockQuantity} {it.unit}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import Image from "next/image";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Route } from "./nav-main";
|
||||
import DashboardNavigation from "@/components/sidebar-02/nav-main";
|
||||
import { NavChatHistory } from "@/components/sidebar-02/nav-chat-history";
|
||||
import { NotificationsPopover } from "@/components/sidebar-02/nav-notifications";
|
||||
import { NavUser } from "@/components/sidebar-02/nav-user";
|
||||
|
||||
@@ -91,6 +92,7 @@ export function DashboardSidebar() {
|
||||
</SidebarHeader>
|
||||
<SidebarContent className="gap-4 px-2 py-4">
|
||||
<DashboardNavigation routes={dashboardRoutes} />
|
||||
<NavChatHistory />
|
||||
</SidebarContent>
|
||||
<SidebarFooter className="p-2">
|
||||
<NavUser />
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { type MouseEvent, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import {
|
||||
deleteThread,
|
||||
listThreads,
|
||||
THREADS_CHANGED_EVENT,
|
||||
type ThreadSummary,
|
||||
} from "@/lib/ai-chat-history";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Claude-style list of saved AI chats in the sidebar. Refreshes when a chat is
|
||||
// saved/deleted (via the THREADS_CHANGED_EVENT). Hidden when collapsed or empty.
|
||||
export function NavChatHistory() {
|
||||
const { t } = useTranslation();
|
||||
const { state } = useSidebar();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const activeThread = searchParams.get("thread");
|
||||
const [threads, setThreads] = useState<ThreadSummary[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => {
|
||||
listThreads()
|
||||
.then(setThreads)
|
||||
.catch(() => {
|
||||
/* not signed in / no clinic — just show nothing */
|
||||
});
|
||||
};
|
||||
refresh();
|
||||
window.addEventListener(THREADS_CHANGED_EVENT, refresh);
|
||||
return () => window.removeEventListener(THREADS_CHANGED_EVENT, refresh);
|
||||
}, []);
|
||||
|
||||
if (state === "collapsed" || threads.length === 0) return null;
|
||||
|
||||
const remove = async (event: MouseEvent, id: string) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setThreads((prev) => prev.filter((x) => x.id !== id));
|
||||
await deleteThread(id).catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-col gap-0.5 overflow-y-auto px-2">
|
||||
<span className="px-2 py-1 font-medium text-muted-foreground text-xs">
|
||||
{t("chat.history.title")}
|
||||
</span>
|
||||
{threads.map((thread) => {
|
||||
const active = pathname === "/" && activeThread === thread.id;
|
||||
return (
|
||||
<Link
|
||||
className={cn(
|
||||
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent",
|
||||
active ? "bg-accent text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
href={`/?thread=${thread.id}`}
|
||||
key={thread.id}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{thread.title}</span>
|
||||
<button
|
||||
aria-label={t("chat.history.delete")}
|
||||
className="shrink-0 opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100"
|
||||
onClick={(event) => remove(event, thread.id)}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user