mirror of
https://github.com/temetro/temetro.git
synced 2026-08-18 06:13: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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user