mirror of
https://github.com/temetro/temetro.git
synced 2026-08-08 17:53:15 +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,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: {
|
||||
|
||||
Reference in New Issue
Block a user