mirror of
https://github.com/temetro/temetro.git
synced 2026-08-28 11:27:40 +00:00
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>
This commit is contained in:
@@ -17,6 +17,7 @@ export const statements = {
|
||||
patient: ["read", "write", "delete"],
|
||||
appointment: ["read", "write", "delete"],
|
||||
prescription: ["read", "write", "delete"],
|
||||
invoice: ["read", "write", "delete"],
|
||||
inventory: ["read", "write", "delete"],
|
||||
task: ["read", "write", "delete"],
|
||||
lab: ["read", "write"],
|
||||
@@ -40,6 +41,7 @@ export const owner = ac.newRole({
|
||||
patient: ["read", "write", "delete"],
|
||||
appointment: ["read", "write", "delete"],
|
||||
prescription: ["read", "write", "delete"],
|
||||
invoice: ["read", "write", "delete"],
|
||||
inventory: ["read", "write", "delete"],
|
||||
task: ["read", "write", "delete"],
|
||||
lab: ["read", "write"],
|
||||
@@ -50,6 +52,7 @@ export const admin = ac.newRole({
|
||||
patient: ["read", "write", "delete"],
|
||||
appointment: ["read", "write", "delete"],
|
||||
prescription: ["read", "write", "delete"],
|
||||
invoice: ["read", "write", "delete"],
|
||||
inventory: ["read", "write", "delete"],
|
||||
task: ["read", "write", "delete"],
|
||||
lab: ["read", "write"],
|
||||
@@ -61,6 +64,7 @@ export const member = ac.newRole({
|
||||
patient: ["read", "write"],
|
||||
appointment: ["read", "write", "delete"],
|
||||
prescription: ["read", "write", "delete"],
|
||||
invoice: ["read", "write", "delete"],
|
||||
inventory: ["read", "write", "delete"],
|
||||
task: ["read", "write", "delete"],
|
||||
lab: ["read", "write"],
|
||||
@@ -74,6 +78,7 @@ export const doctor = ac.newRole({
|
||||
patient: ["read", "write"],
|
||||
appointment: ["read", "write", "delete"],
|
||||
prescription: ["read", "write", "delete"],
|
||||
invoice: ["read", "write", "delete"],
|
||||
inventory: ["read", "write", "delete"],
|
||||
task: ["read", "write", "delete"],
|
||||
lab: ["read", "write"],
|
||||
@@ -88,6 +93,7 @@ export const reception = ac.newRole({
|
||||
...memberAc.statements,
|
||||
patient: ["read", "write"],
|
||||
appointment: ["read", "write", "delete"],
|
||||
invoice: ["read", "write", "delete"],
|
||||
task: ["read", "write"],
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { initialsFromName } from "./initials.js";
|
||||
|
||||
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD.");
|
||||
|
||||
export const invoiceLineItemSchema = z.object({
|
||||
description: z.string().trim().min(1, "Description is required.").max(300),
|
||||
quantity: z.coerce.number().min(0).max(100_000).default(1),
|
||||
unitPrice: z.coerce.number().min(0).max(10_000_000).default(0),
|
||||
});
|
||||
|
||||
export const invoiceInstallmentSchema = z.object({
|
||||
label: z.string().trim().max(120).default(""),
|
||||
amount: z.coerce.number().min(0).max(10_000_000).default(0),
|
||||
dueAt: isoDate.nullable().default(null),
|
||||
paid: z.boolean().default(false),
|
||||
});
|
||||
|
||||
// Payload accepted by POST/PUT /api/invoices. `number` and `issuedAt` are filled
|
||||
// server-side on create when omitted; initials are derived from the name.
|
||||
export const invoiceInputSchema = z
|
||||
.object({
|
||||
fileNumber: z.string().trim().default(""),
|
||||
name: z.string().trim().min(1, "Patient name is required.").max(200),
|
||||
initials: z.string().trim().max(4).default(""),
|
||||
number: z.string().trim().max(60).optional(),
|
||||
issuedAt: isoDate.optional(),
|
||||
dueAt: isoDate.nullish(),
|
||||
status: z.enum(["draft", "sent", "paid", "void"]).default("draft"),
|
||||
lineItems: z.array(invoiceLineItemSchema).default([]),
|
||||
installments: z.array(invoiceInstallmentSchema).default([]),
|
||||
notes: z.string().max(5000).nullish(),
|
||||
source: z.enum(["manual", "ai"]).default("manual"),
|
||||
})
|
||||
.transform((v) => ({
|
||||
...v,
|
||||
initials: v.initials || initialsFromName(v.name),
|
||||
}));
|
||||
|
||||
export type InvoiceInput = z.infer<typeof invoiceInputSchema>;
|
||||
Reference in New Issue
Block a user