Files
temetro/frontend/lib/invoices.ts
T
Khalid Abdi d096c4fe9d feat: invoices — patient billing with installments + PDF export
Backend (new `invoice` RBAC resource, granted to clinicians + reception):
- invoices table (line items + installments as JSONB), types, zod validation,
  service (CRUD + splitIntoInstallments + auto invoice numbers), org-scoped
  REST routes mounted at /api/invoices, activity logging (migration 0015)

Frontend:
- lib/invoices.ts API client + money/date helpers
- /invoices page: list with KPIs and search, create/edit dialog (searchable
  patient combobox, inline line-item editor, live total), detail sheet to split
  a bill into equal monthly installments, delete, and Download PDF
- dependency-free PDF via a print-styled window (browser "Save as PDF")
- sidebar "Invoices" entry under the Patients group; "Added by AI" badge honored

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:58:08 +03:00

111 lines
2.6 KiB
TypeScript

import { apiFetch } from "@/lib/api-client";
// An invoice. Mirrors the backend `src/types/invoice.ts`. Scoped to the active
// clinic; `fileNumber` links back to a patient record.
export type InvoiceStatus = "draft" | "sent" | "paid" | "void";
export type InvoiceLineItem = {
description: string;
quantity: number;
unitPrice: number;
};
export type InvoiceInstallment = {
label: string;
amount: number;
dueAt: string | null;
paid: boolean;
};
export type Invoice = {
id: string;
fileNumber: string;
name: string;
initials: string;
number: string;
issuedAt: string; // YYYY-MM-DD
dueAt: string | null;
status: InvoiceStatus;
lineItems: InvoiceLineItem[];
installments: InvoiceInstallment[];
notes: string | null;
source: "manual" | "ai";
createdAt: string;
updatedAt: string;
};
// The fields the create/edit dialog collects; the backend fills the number and
// issuedAt on create when omitted.
export type InvoiceInput = {
fileNumber: string;
name: string;
initials: string;
number?: string;
issuedAt?: string;
dueAt?: string | null;
status?: InvoiceStatus;
lineItems: InvoiceLineItem[];
installments?: InvoiceInstallment[];
notes?: string | null;
source?: "manual" | "ai";
};
export function invoiceTotal(invoice: {
lineItems: InvoiceLineItem[];
}): number {
return invoice.lineItems.reduce(
(sum, li) => sum + li.quantity * li.unitPrice,
0,
);
}
const money = new Intl.NumberFormat(undefined, {
style: "currency",
currency: "USD",
});
export function formatMoney(amount: number): string {
return money.format(amount);
}
// "2026-06-05" -> "Jun 5, 2026"
export function formatInvoiceDate(iso: string): string {
return new Date(`${iso}T00:00:00`).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
export function listInvoices(): Promise<Invoice[]> {
return apiFetch<Invoice[]>("/api/invoices");
}
export function createInvoice(input: InvoiceInput): Promise<Invoice> {
return apiFetch<Invoice>("/api/invoices", {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateInvoice(
id: string,
input: InvoiceInput,
): Promise<Invoice> {
return apiFetch<Invoice>(`/api/invoices/${id}`, {
method: "PUT",
body: JSON.stringify(input),
});
}
export function splitInvoice(id: string, count: number): Promise<Invoice> {
return apiFetch<Invoice>(`/api/invoices/${id}/split`, {
method: "POST",
body: JSON.stringify({ count }),
});
}
export function deleteInvoice(id: string): Promise<void> {
return apiFetch<void>(`/api/invoices/${id}`, { method: "DELETE" });
}