mirror of
https://github.com/temetro/temetro.git
synced 2026-08-12 19:46:53 +00:00
3aa699aefe
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>
181 lines
5.6 KiB
TypeScript
181 lines
5.6 KiB
TypeScript
import { and, desc, eq, sql } from "drizzle-orm";
|
|
|
|
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;
|
|
|
|
// Postgres throws on a malformed uuid; treat non-uuid ids as "not found".
|
|
const UUID_RE =
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
|
|
function toInvoice(row: InvoiceRow): Invoice {
|
|
return {
|
|
id: row.id,
|
|
fileNumber: row.patientFileNumber,
|
|
name: row.patientName,
|
|
initials: row.patientInitials,
|
|
number: row.number,
|
|
issuedAt: row.issuedAt,
|
|
dueAt: row.dueAt,
|
|
status: row.status,
|
|
lineItems: row.lineItems,
|
|
installments: row.installments,
|
|
notes: row.notes,
|
|
source: row.source,
|
|
createdAt: row.createdAt.toISOString(),
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
function columns(orgId: string, input: InvoiceInput, createdBy?: string) {
|
|
return {
|
|
organizationId: orgId,
|
|
patientFileNumber: input.fileNumber,
|
|
patientName: input.name,
|
|
patientInitials: input.initials,
|
|
number: input.number ?? "",
|
|
status: input.status,
|
|
lineItems: input.lineItems,
|
|
installments: input.installments,
|
|
notes: input.notes ?? null,
|
|
source: input.source,
|
|
...(input.issuedAt ? { issuedAt: input.issuedAt } : {}),
|
|
dueAt: input.dueAt ?? null,
|
|
...(createdBy ? { createdBy } : {}),
|
|
};
|
|
}
|
|
|
|
export function invoiceTotal(input: {
|
|
lineItems: { quantity: number; unitPrice: number }[];
|
|
}): number {
|
|
return input.lineItems.reduce(
|
|
(sum, li) => sum + li.quantity * li.unitPrice,
|
|
0,
|
|
);
|
|
}
|
|
|
|
// Next human invoice number for an org: "INV-" + (max existing numeric suffix +
|
|
// 1, floored at 1000). Falls back cleanly when there are no prior invoices.
|
|
async function generateInvoiceNumber(orgId: string): Promise<string> {
|
|
const [r] = await db
|
|
.select({
|
|
max: sql<number>`coalesce(max(nullif(regexp_replace(${invoices.number}, '\\D', '', 'g'), '')::bigint), 999)`,
|
|
})
|
|
.from(invoices)
|
|
.where(eq(invoices.organizationId, orgId));
|
|
return `INV-${Number(r?.max ?? 999) + 1}`;
|
|
}
|
|
|
|
// Add `months` calendar months to an ISO (YYYY-MM-DD) date, returning an ISO
|
|
// date. Used to stagger installment due dates from the issue date.
|
|
function addMonths(iso: string, months: number): string {
|
|
const [y, m, d] = iso.split("-").map(Number);
|
|
const base = new Date(Date.UTC(y!, (m! - 1) + months, d!));
|
|
return base.toISOString().slice(0, 10);
|
|
}
|
|
|
|
export async function listInvoices(orgId: string): Promise<Invoice[]> {
|
|
const rows = await db
|
|
.select()
|
|
.from(invoices)
|
|
.where(eq(invoices.organizationId, orgId))
|
|
.orderBy(desc(invoices.issuedAt), desc(invoices.createdAt));
|
|
return rows.map(toInvoice);
|
|
}
|
|
|
|
export async function getInvoice(
|
|
orgId: string,
|
|
id: string,
|
|
): Promise<Invoice | null> {
|
|
if (!UUID_RE.test(id)) return null;
|
|
const [row] = await db
|
|
.select()
|
|
.from(invoices)
|
|
.where(and(eq(invoices.id, id), eq(invoices.organizationId, orgId)));
|
|
return row ? toInvoice(row) : null;
|
|
}
|
|
|
|
export async function createInvoice(
|
|
orgId: string,
|
|
userId: string,
|
|
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, fileNumber }, userId))
|
|
.returning();
|
|
return toInvoice(row!);
|
|
}
|
|
|
|
export async function updateInvoice(
|
|
orgId: string,
|
|
id: string,
|
|
input: InvoiceInput,
|
|
): Promise<Invoice | null> {
|
|
if (!UUID_RE.test(id)) return null;
|
|
const [row] = await db
|
|
.update(invoices)
|
|
.set(columns(orgId, input))
|
|
.where(and(eq(invoices.id, id), eq(invoices.organizationId, orgId)))
|
|
.returning();
|
|
return row ? toInvoice(row) : null;
|
|
}
|
|
|
|
export async function deleteInvoice(
|
|
orgId: string,
|
|
id: string,
|
|
): Promise<boolean> {
|
|
if (!UUID_RE.test(id)) return false;
|
|
const deleted = await db
|
|
.delete(invoices)
|
|
.where(and(eq(invoices.id, id), eq(invoices.organizationId, orgId)))
|
|
.returning({ id: invoices.id });
|
|
return deleted.length > 0;
|
|
}
|
|
|
|
// Split an invoice's total into `count` roughly-equal installments, staggered
|
|
// one month apart from the issue date. Amounts are computed in cents so they
|
|
// always sum back to the exact total (any remainder lands on the first slice).
|
|
export async function splitIntoInstallments(
|
|
orgId: string,
|
|
id: string,
|
|
count: number,
|
|
): Promise<Invoice | null> {
|
|
const invoice = await getInvoice(orgId, id);
|
|
if (!invoice) return null;
|
|
const n = Math.max(1, Math.min(36, Math.floor(count)));
|
|
const totalCents = Math.round(invoiceTotal(invoice) * 100);
|
|
const base = Math.floor(totalCents / n);
|
|
const remainder = totalCents - base * n;
|
|
|
|
const installments: InvoiceInstallment[] = Array.from(
|
|
{ length: n },
|
|
(_, i) => ({
|
|
label: `${i + 1} of ${n}`,
|
|
amount: (base + (i < remainder ? 1 : 0)) / 100,
|
|
dueAt: addMonths(invoice.issuedAt, i),
|
|
paid: false,
|
|
}),
|
|
);
|
|
|
|
const [row] = await db
|
|
.update(invoices)
|
|
.set({ installments })
|
|
.where(and(eq(invoices.id, id), eq(invoices.organizationId, orgId)))
|
|
.returning();
|
|
return row ? toInvoice(row) : null;
|
|
}
|