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
+23
View File
@@ -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
+7
View File
@@ -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
}
]
}
+50
View File
@@ -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)],
);
+1
View File
@@ -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";
+9
View File
@@ -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);
}
});
+73
View File
@@ -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);
}
});
+132
View File
@@ -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;
}
+125
View File
@@ -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
+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"),
)!,
);
}
+10 -1
View File
@@ -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!);
}
+11
View File
@@ -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}
>
+32 -24
View File
@@ -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>
);
}
+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>
);
}
@@ -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>
);
}
+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": {