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
@@ -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>
);
}
+91 -1
View File
@@ -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">
+36
View File
@@ -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>
);
}