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:
Khalid Abdi
2026-06-14 19:58:08 +03:00
parent 9eb6353e00
commit d096c4fe9d
21 changed files with 4734 additions and 0 deletions
@@ -0,0 +1,23 @@
CREATE TABLE "invoices" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" text NOT NULL,
"patient_file_number" text DEFAULT '' NOT NULL,
"patient_name" text NOT NULL,
"patient_initials" text NOT NULL,
"number" text NOT NULL,
"issued_at" date DEFAULT now() NOT NULL,
"due_at" date,
"status" text NOT NULL,
"line_items" jsonb NOT NULL,
"installments" jsonb NOT NULL,
"notes" text,
"source" text DEFAULT 'manual' NOT NULL,
"created_by" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "invoices_org_idx" ON "invoices" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "invoices_org_file_idx" ON "invoices" USING btree ("organization_id","patient_file_number");
File diff suppressed because it is too large Load Diff
+7
View File
@@ -106,6 +106,13 @@
"when": 1781454115877,
"tag": "0014_youthful_aqueduct",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1781455848772,
"tag": "0015_polite_captain_marvel",
"breakpoints": true
}
]
}
+1
View File
@@ -3,6 +3,7 @@ export * from "./patients.js";
export * from "./notes.js";
export * from "./appointments.js";
export * from "./prescriptions.js";
export * from "./invoices.js";
export * from "./inventory.js";
export * from "./tasks.js";
export * from "./activity.js";
+55
View File
@@ -0,0 +1,55 @@
import {
date,
index,
jsonb,
pgTable,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
import type {
InvoiceInstallment,
InvoiceLineItem,
InvoiceStatus,
} from "../../types/invoice.js";
import { organization, user } from "./auth.js";
// One row per patient invoice, scoped to a clinic (organization). Patient
// identity is denormalized (name/initials/file number) so the ledger renders
// without joining. Line items and installments are stored as JSONB. `issuedAt`
// defaults to today.
export const invoices = pgTable(
"invoices",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
patientFileNumber: text("patient_file_number").notNull().default(""),
patientName: text("patient_name").notNull(),
patientInitials: text("patient_initials").notNull(),
number: text("number").notNull(),
issuedAt: date("issued_at").defaultNow().notNull(),
dueAt: date("due_at"),
status: text("status").$type<InvoiceStatus>().notNull(),
lineItems: jsonb("line_items").$type<InvoiceLineItem[]>().notNull(),
installments: jsonb("installments")
.$type<InvoiceInstallment[]>()
.notNull(),
notes: text("notes"),
source: text("source").$type<"manual" | "ai">().notNull().default("manual"),
createdBy: text("created_by").references(() => user.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
},
(t) => [
index("invoices_org_idx").on(t.organizationId),
index("invoices_org_file_idx").on(t.organizationId, t.patientFileNumber),
],
);
+2
View File
@@ -15,6 +15,7 @@ import { appointmentsRouter } from "./routes/appointments.js";
import { chatRouter } from "./routes/chat.js";
import { conversationsRouter } from "./routes/conversations.js";
import { inventoryRouter } from "./routes/inventory.js";
import { invoicesRouter } from "./routes/invoices.js";
import { notesRouter } from "./routes/notes.js";
import { notificationsRouter } from "./routes/notifications.js";
import { patientsRouter } from "./routes/patients.js";
@@ -63,6 +64,7 @@ app.use("/api/notes", notesRouter);
app.use("/api/appointments", appointmentsRouter);
app.use("/api/prescriptions", prescriptionsRouter);
app.use("/api/inventory", inventoryRouter);
app.use("/api/invoices", invoicesRouter);
app.use("/api/tasks", tasksRouter);
app.use("/api/staff", staffRouter);
app.use("/api/activity", activityRouter);
+6
View File
@@ -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"],
});
+41
View File
@@ -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>;
+138
View File
@@ -0,0 +1,138 @@
import { Router } from "express";
import { z } from "zod";
import { HttpError } from "../lib/http-error.js";
import { invoiceInputSchema } from "../lib/invoice-validation.js";
import {
requireAuth,
requireOrg,
requirePermission,
} from "../middleware/auth.js";
import { recordActivity } from "../services/activity.js";
import * as service from "../services/invoices.js";
export const invoicesRouter = Router();
// Invoices are clinic-wide billing records, gated by the caller's role.
invoicesRouter.use(requireAuth, requireOrg);
invoicesRouter.get(
"/",
requirePermission({ invoice: ["read"] }),
async (req, res, next) => {
try {
res.json(await service.listInvoices(req.organizationId!));
} catch (err) {
next(err);
}
},
);
invoicesRouter.post(
"/",
requirePermission({ invoice: ["write"] }),
async (req, res, next) => {
try {
const input = invoiceInputSchema.parse(req.body);
const created = await service.createInvoice(
req.organizationId!,
req.user!.id,
input,
);
await recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: `Created invoice ${created.number} for ${created.name}`,
entityType: "invoice",
entityId: created.id,
patientName: created.name,
patientFileNumber: created.fileNumber || null,
});
res.status(201).json(created);
} catch (err) {
next(err);
}
},
);
invoicesRouter.put(
"/:id",
requirePermission({ invoice: ["write"] }),
async (req, res, next) => {
try {
const input = invoiceInputSchema.parse(req.body);
const updated = await service.updateInvoice(
req.organizationId!,
req.params.id as string,
input,
);
if (!updated) throw new HttpError(404, "Invoice not found.");
await recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: `Updated invoice ${updated.number}`,
entityType: "invoice",
entityId: updated.id,
patientName: updated.name,
patientFileNumber: updated.fileNumber || null,
});
res.json(updated);
} catch (err) {
next(err);
}
},
);
const splitSchema = z.object({ count: z.coerce.number().int().min(1).max(36) });
invoicesRouter.post(
"/:id/split",
requirePermission({ invoice: ["write"] }),
async (req, res, next) => {
try {
const { count } = splitSchema.parse(req.body);
const updated = await service.splitIntoInstallments(
req.organizationId!,
req.params.id as string,
count,
);
if (!updated) throw new HttpError(404, "Invoice not found.");
await recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: `Split invoice ${updated.number} into ${count} installments`,
entityType: "invoice",
entityId: updated.id,
patientName: updated.name,
patientFileNumber: updated.fileNumber || null,
});
res.json(updated);
} catch (err) {
next(err);
}
},
);
invoicesRouter.delete(
"/:id",
requirePermission({ invoice: ["delete"] }),
async (req, res, next) => {
try {
const ok = await service.deleteInvoice(
req.organizationId!,
req.params.id as string,
);
if (!ok) throw new HttpError(404, "Invoice not found.");
await recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: "Deleted invoice",
entityType: "invoice",
entityId: req.params.id as string,
});
res.status(204).end();
} catch (err) {
next(err);
}
},
);
+171
View File
@@ -0,0 +1,171 @@
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";
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));
const [row] = await db
.insert(invoices)
.values(columns(orgId, { ...input, number }, 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;
}
+1
View File
@@ -6,6 +6,7 @@ export type ActivityEntityType =
| "note"
| "appointment"
| "prescription"
| "invoice"
| "inventory"
| "task";
+36
View File
@@ -0,0 +1,36 @@
// The canonical Invoice shape returned by the API. Mirrors the frontend
// `lib/invoices.ts` Invoice type. Scoped to the active clinic; patient fields
// are denormalized for display and `fileNumber` links to a patient record.
export type InvoiceStatus = "draft" | "sent" | "paid" | "void";
export type InvoiceLineItem = {
description: string;
quantity: number;
unitPrice: number;
};
// One slice of a split bill. `amount` is the installment total; `dueAt` is an
// optional ISO date; `paid` tracks settlement.
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; // human invoice number, e.g. "INV-1001"
issuedAt: string; // YYYY-MM-DD
dueAt: string | null; // YYYY-MM-DD
status: InvoiceStatus;
lineItems: InvoiceLineItem[];
installments: InvoiceInstallment[];
notes: string | null;
source: "manual" | "ai";
createdAt: string;
updatedAt: string;
};
+10
View File
@@ -0,0 +1,10 @@
import { InvoicesView } from "@/components/invoices/invoices-view";
import { SidebarInset } from "@/components/ui/sidebar";
export default function InvoicesPage() {
return (
<SidebarInset className="flex flex-1 flex-col overflow-y-auto">
<InvoicesView />
</SidebarInset>
);
}
@@ -0,0 +1,277 @@
"use client";
import { Download, Pencil, Split, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AiBadge } from "@/components/ai-badge";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Sheet,
SheetFooter,
SheetHeader,
SheetPanel,
SheetPopup,
SheetTitle,
} from "@/components/ui/sheet";
import { downloadInvoicePdf } from "@/lib/invoice-pdf";
import {
deleteInvoice,
formatInvoiceDate,
formatMoney,
type Invoice,
type InvoiceStatus,
invoiceTotal,
splitInvoice,
} from "@/lib/invoices";
import { notify } from "@/lib/toast";
const statusVariant: Record<
InvoiceStatus,
"default" | "secondary" | "destructive" | "outline"
> = {
draft: "secondary",
sent: "default",
paid: "outline",
void: "destructive",
};
export function InvoiceDetailSheet({
invoice,
open,
onOpenChange,
onChanged,
onDeleted,
onEdit,
}: {
invoice: Invoice | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onChanged: (invoice: Invoice) => void;
onDeleted: (id: string) => void;
onEdit: (invoice: Invoice) => void;
}) {
const { t } = useTranslation();
const [count, setCount] = useState(3);
const [busy, setBusy] = useState(false);
useEffect(() => {
setCount(3);
}, [invoice?.id]);
if (!invoice) {
return (
<Sheet onOpenChange={onOpenChange} open={open}>
<SheetPopup className="sm:max-w-md" side="right">
<SheetHeader>
<SheetTitle>{t("invoices.sheet.fallbackTitle")}</SheetTitle>
</SheetHeader>
</SheetPopup>
</Sheet>
);
}
const total = invoiceTotal(invoice);
const split = async () => {
setBusy(true);
try {
const updated = await splitInvoice(invoice.id, count);
onChanged(updated);
notify.success(
t("invoices.sheet.splitTitle"),
`${invoice.number} · ${count}`,
);
} catch {
notify.error(
t("invoices.sheet.splitFailedTitle"),
t("invoices.sheet.splitFailedBody"),
);
} finally {
setBusy(false);
}
};
const remove = async () => {
setBusy(true);
try {
await deleteInvoice(invoice.id);
onDeleted(invoice.id);
notify.success(t("invoices.sheet.deletedTitle"), invoice.number);
onOpenChange(false);
} catch {
notify.error(
t("invoices.sheet.deleteFailedTitle"),
t("invoices.sheet.deleteFailedBody"),
);
} finally {
setBusy(false);
}
};
return (
<Sheet onOpenChange={onOpenChange} open={open}>
<SheetPopup className="sm:max-w-lg" side="right">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
{invoice.number}
<AiBadge source={invoice.source} />
<Badge className="ml-auto" variant={statusVariant[invoice.status]}>
{t(`invoices.status.${invoice.status}`)}
</Badge>
</SheetTitle>
</SheetHeader>
<SheetPanel className="min-h-0 flex-1">
<div className="flex flex-col gap-5">
<div>
<p className="font-medium text-foreground text-sm">
{invoice.name}
</p>
<p className="text-muted-foreground text-xs">
{t("invoices.dialog.fileNumber", {
number: invoice.fileNumber || "—",
})}
</p>
</div>
<dl className="grid grid-cols-[7rem_1fr] gap-x-3 gap-y-2 text-sm">
<dt className="text-muted-foreground">
{t("invoices.sheet.issued")}
</dt>
<dd className="text-foreground">
{formatInvoiceDate(invoice.issuedAt)}
</dd>
<dt className="text-muted-foreground">
{t("invoices.sheet.due")}
</dt>
<dd className="text-foreground">
{invoice.dueAt ? formatInvoiceDate(invoice.dueAt) : "—"}
</dd>
</dl>
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs">
{t("invoices.sheet.lineItems")}
</span>
<div className="divide-y divide-border overflow-hidden rounded-2xl border">
{invoice.lineItems.map((li, i) => (
<div
className="flex items-center justify-between gap-3 px-3 py-2 text-sm"
// biome-ignore lint/suspicious/noArrayIndexKey: positional
key={i}
>
<span className="min-w-0 flex-1 truncate text-foreground">
{li.description}
</span>
<span className="shrink-0 text-muted-foreground text-xs tabular-nums">
{li.quantity} × {formatMoney(li.unitPrice)}
</span>
<span className="w-20 shrink-0 text-right font-medium text-foreground tabular-nums">
{formatMoney(li.quantity * li.unitPrice)}
</span>
</div>
))}
<div className="flex items-center justify-between px-3 py-2 text-sm">
<span className="text-muted-foreground">
{t("invoices.sheet.total")}
</span>
<span className="font-semibold text-foreground tabular-nums">
{formatMoney(total)}
</span>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-muted-foreground text-xs">
{t("invoices.sheet.installments")}
</span>
{invoice.installments.length > 0 ? (
<div className="divide-y divide-border overflow-hidden rounded-2xl border">
{invoice.installments.map((it, i) => (
<div
className="flex items-center justify-between gap-3 px-3 py-2 text-sm"
// biome-ignore lint/suspicious/noArrayIndexKey: positional
key={i}
>
<span className="text-foreground">{it.label}</span>
<span className="text-muted-foreground text-xs">
{it.dueAt ? formatInvoiceDate(it.dueAt) : "—"}
</span>
<span className="font-medium text-foreground tabular-nums">
{formatMoney(it.amount)}
</span>
</div>
))}
</div>
) : (
<p className="text-muted-foreground text-sm">
{t("invoices.sheet.noInstallments")}
</p>
)}
<div className="flex items-end gap-2">
<label className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs">
{t("invoices.sheet.splitCount")}
</span>
<Input
className="w-20"
max={36}
min={1}
onChange={(e) => setCount(Number(e.target.value) || 1)}
type="number"
value={count}
/>
</label>
<Button
disabled={busy}
onClick={split}
type="button"
variant="outline"
>
<Split className="size-4" />
{t("invoices.sheet.split")}
</Button>
</div>
</div>
{invoice.notes ? (
<p className="whitespace-pre-wrap text-foreground text-sm leading-relaxed">
{invoice.notes}
</p>
) : null}
</div>
</SheetPanel>
<SheetFooter className="flex-row flex-wrap justify-between gap-2">
<Button
disabled={busy}
onClick={remove}
type="button"
variant="destructive"
>
<Trash2 className="size-4" />
{t("invoices.sheet.delete")}
</Button>
<div className="flex items-center gap-2">
<Button
onClick={() => downloadInvoicePdf(invoice)}
type="button"
variant="outline"
>
<Download className="size-4" />
{t("invoices.sheet.download")}
</Button>
<Button onClick={() => onEdit(invoice)} type="button">
<Pencil className="size-4" />
{t("invoices.sheet.edit")}
</Button>
</div>
</SheetFooter>
</SheetPopup>
</Sheet>
);
}
@@ -0,0 +1,451 @@
"use client";
import { CalendarDays, Plus, X } from "lucide-react";
import {
type FormEvent,
type ReactNode,
useEffect,
useMemo,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import {
Dialog,
DialogClose,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Popover, PopoverPopup, PopoverTrigger } from "@/components/ui/popover";
import {
createInvoice,
formatMoney,
type Invoice,
type InvoiceLineItem,
type InvoiceStatus,
updateInvoice,
} from "@/lib/invoices";
import { listPatients, type Patient } from "@/lib/patients";
import { notify } from "@/lib/toast";
const STATUSES: InvoiceStatus[] = ["draft", "sent", "paid", "void"];
const controlClass =
"h-9 w-full rounded-3xl border border-transparent bg-input/50 px-3 text-sm text-foreground outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30";
const keyOf = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(
d.getDate(),
).padStart(2, "0")}`;
function emptyLine(): InvoiceLineItem {
return { description: "", quantity: 1, unitPrice: 0 };
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs">{label}</span>
{children}
</label>
);
}
function DatePicker({
value,
onChange,
}: {
value: Date;
onChange: (d: Date) => void;
}) {
const [open, setOpen] = useState(false);
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger
render={
<Button
className="w-full justify-start font-normal"
type="button"
variant="outline"
>
<CalendarDays className="size-4" />
{value.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</Button>
}
/>
<PopoverPopup>
<Calendar
mode="single"
onSelect={(d) => {
if (d) {
onChange(d);
setOpen(false);
}
}}
selected={value}
/>
</PopoverPopup>
</Popover>
);
}
// Create or edit an invoice. The patient is chosen with a searchable combobox on
// create (locked on edit); line items are edited inline and the total updates
// live. Persists through the invoices API and hands the saved record back.
export function InvoiceFormDialog({
open,
onOpenChange,
mode,
invoice,
onSaved,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
mode: "create" | "edit";
invoice?: Invoice;
onSaved: (invoice: Invoice) => void;
}) {
const { t } = useTranslation();
const [patients, setPatients] = useState<Patient[]>([]);
const [selected, setSelected] = useState<Patient | null>(null);
// In edit mode the patient is fixed; keep the denormalized identity.
const fixedPatient =
mode === "edit" && invoice
? {
fileNumber: invoice.fileNumber,
name: invoice.name,
initials: invoice.initials,
}
: null;
const [issuedAt, setIssuedAt] = useState<Date>(() => new Date());
const [hasDue, setHasDue] = useState(false);
const [dueAt, setDueAt] = useState<Date>(() => new Date());
const [status, setStatus] = useState<InvoiceStatus>("draft");
const [notes, setNotes] = useState("");
const [lineItems, setLineItems] = useState<InvoiceLineItem[]>([emptyLine()]);
const [busy, setBusy] = useState(false);
// Seed the form when opening.
useEffect(() => {
if (!open) return;
if (mode === "edit" && invoice) {
setIssuedAt(new Date(`${invoice.issuedAt}T00:00:00`));
setHasDue(Boolean(invoice.dueAt));
setDueAt(new Date(`${invoice.dueAt ?? invoice.issuedAt}T00:00:00`));
setStatus(invoice.status);
setNotes(invoice.notes ?? "");
setLineItems(
invoice.lineItems.length ? invoice.lineItems : [emptyLine()],
);
} else {
setSelected(null);
setIssuedAt(new Date());
setHasDue(false);
setDueAt(new Date());
setStatus("draft");
setNotes("");
setLineItems([emptyLine()]);
}
}, [open, mode, invoice]);
// Load patients lazily for the create combobox.
useEffect(() => {
if (!open || mode !== "create") return;
let active = true;
listPatients()
.then((data) => active && setPatients(data))
.catch(() => {
/* search stays empty */
});
return () => {
active = false;
};
}, [open, mode]);
const patientOptions = useMemo<ComboboxOption[]>(
() =>
patients.map((p) => ({
value: p.fileNumber,
label: `${p.name} ${p.fileNumber}`,
node: (
<span className="flex w-full items-center justify-between gap-2">
<span className="truncate">{p.name}</span>
<span className="shrink-0 text-muted-foreground text-xs">
#{p.fileNumber}
</span>
</span>
),
})),
[patients],
);
const total = useMemo(
() =>
lineItems.reduce((sum, li) => sum + li.quantity * li.unitPrice, 0),
[lineItems],
);
const updateLine = (index: number, patch: Partial<InvoiceLineItem>) =>
setLineItems((prev) =>
prev.map((li, i) => (i === index ? { ...li, ...patch } : li)),
);
const submit = async (event: FormEvent) => {
event.preventDefault();
const patient = fixedPatient ?? selected;
if (!patient) {
notify.error(
t("invoices.dialog.pickPatientTitle"),
t("invoices.dialog.pickPatientBody"),
);
return;
}
const cleanLines = lineItems.filter((li) => li.description.trim());
setBusy(true);
try {
const payload = {
fileNumber: patient.fileNumber,
name: patient.name,
initials: patient.initials,
issuedAt: keyOf(issuedAt),
dueAt: hasDue ? keyOf(dueAt) : null,
status,
lineItems: cleanLines,
notes: notes.trim() || null,
};
const saved =
mode === "edit" && invoice
? await updateInvoice(invoice.id, {
...payload,
// Preserve fields the form doesn't edit.
number: invoice.number,
installments: invoice.installments,
})
: await createInvoice(payload);
onSaved(saved);
onOpenChange(false);
} catch {
notify.error(t("invoices.addFailedTitle"), t("invoices.addFailedBody"));
} finally {
setBusy(false);
}
};
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogPopup className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{mode === "edit"
? t("invoices.dialog.editTitle")
: t("invoices.dialog.createTitle")}
</DialogTitle>
<DialogDescription>
{t("invoices.dialog.description")}
</DialogDescription>
</DialogHeader>
<form className="contents" onSubmit={submit}>
<DialogPanel className="flex flex-col gap-4">
<Field label={t("invoices.dialog.patient")}>
{fixedPatient ? (
<div className="rounded-2xl border bg-input/30 px-3 py-2 text-sm">
<span className="font-medium text-foreground">
{fixedPatient.name}
</span>
<span className="text-muted-foreground">
{" "}
·{" "}
{t("invoices.dialog.fileNumber", {
number: fixedPatient.fileNumber || "—",
})}
</span>
</div>
) : selected ? (
<div className="flex items-center justify-between gap-2 rounded-2xl border bg-input/30 px-3 py-2">
<div className="flex min-w-0 flex-col">
<span className="truncate font-medium text-foreground text-sm">
{selected.name}
</span>
<span className="text-muted-foreground text-xs">
{t("invoices.dialog.fileNumber", {
number: selected.fileNumber,
})}
</span>
</div>
<Button
onClick={() => setSelected(null)}
size="sm"
type="button"
variant="ghost"
>
{t("invoices.dialog.change")}
</Button>
</div>
) : (
<Combobox
autoFocus
emptyText={t("invoices.dialog.noPatients")}
onSelect={(fileNumber) => {
const p = patients.find((x) => x.fileNumber === fileNumber);
if (p) setSelected(p);
}}
options={patientOptions}
placeholder={t("invoices.dialog.searchPlaceholder")}
/>
)}
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label={t("invoices.dialog.issued")}>
<DatePicker onChange={setIssuedAt} value={issuedAt} />
</Field>
<div className="flex flex-col gap-1.5">
<span className="flex items-center justify-between text-muted-foreground text-xs">
{t("invoices.dialog.due")}
<input
aria-label={t("invoices.dialog.due")}
checked={hasDue}
onChange={(e) => setHasDue(e.target.checked)}
type="checkbox"
/>
</span>
{hasDue ? (
<DatePicker onChange={setDueAt} value={dueAt} />
) : (
<div className="flex h-9 items-center rounded-3xl border border-dashed px-3 text-muted-foreground text-xs">
</div>
)}
</div>
</div>
<Field label={t("invoices.dialog.status")}>
<select
className={controlClass}
onChange={(e) => setStatus(e.target.value as InvoiceStatus)}
value={status}
>
{STATUSES.map((s) => (
<option key={s} value={s}>
{t(`invoices.status.${s}`)}
</option>
))}
</select>
</Field>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
{t("invoices.dialog.lineItems")}
</span>
<Button
onClick={() => setLineItems((prev) => [...prev, emptyLine()])}
size="sm"
type="button"
variant="ghost"
>
<Plus className="size-4" />
{t("invoices.dialog.addLine")}
</Button>
</div>
{lineItems.map((li, i) => (
<div
className="flex items-center gap-2"
// biome-ignore lint/suspicious/noArrayIndexKey: rows are positional
key={i}
>
<Input
aria-label={t("invoices.dialog.lineDescription")}
className="flex-1"
onChange={(e) =>
updateLine(i, { description: e.target.value })
}
placeholder={t("invoices.dialog.lineDescriptionPlaceholder")}
value={li.description}
/>
<Input
aria-label={t("invoices.dialog.qty")}
className="w-16"
min={0}
onChange={(e) =>
updateLine(i, { quantity: Number(e.target.value) || 0 })
}
type="number"
value={li.quantity}
/>
<Input
aria-label={t("invoices.dialog.unitPrice")}
className="w-24"
min={0}
onChange={(e) =>
updateLine(i, { unitPrice: Number(e.target.value) || 0 })
}
step="0.01"
type="number"
value={li.unitPrice}
/>
<Button
aria-label="remove"
disabled={lineItems.length === 1}
onClick={() =>
setLineItems((prev) => prev.filter((_, j) => j !== i))
}
size="icon"
type="button"
variant="ghost"
>
<X className="size-4" />
</Button>
</div>
))}
<div className="flex items-center justify-between border-t pt-2 text-sm">
<span className="text-muted-foreground">
{t("invoices.dialog.total")}
</span>
<span className="font-semibold text-foreground tabular-nums">
{formatMoney(total)}
</span>
</div>
</div>
<Field label={t("invoices.dialog.notes")}>
<textarea
className="min-h-16 w-full rounded-2xl border border-transparent bg-input/50 px-3 py-2 text-sm text-foreground outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30"
onChange={(e) => setNotes(e.target.value)}
placeholder={t("invoices.dialog.notesPlaceholder")}
value={notes}
/>
</Field>
</DialogPanel>
<DialogFooter>
<DialogClose render={<Button type="button" variant="outline" />}>
{t("invoices.dialog.cancel")}
</DialogClose>
<Button disabled={busy} type="submit">
{busy
? t("invoices.dialog.saving")
: mode === "edit"
? t("invoices.dialog.save")
: t("invoices.dialog.create")}
</Button>
</DialogFooter>
</form>
</DialogPopup>
</Dialog>
);
}
@@ -0,0 +1,225 @@
"use client";
import { CircleDollarSign, FileText, Plus, Search, Wallet } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { AiBadge } from "@/components/ai-badge";
import { InvoiceDetailSheet } from "@/components/invoices/invoice-detail-sheet";
import { InvoiceFormDialog } from "@/components/invoices/invoice-form-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
formatInvoiceDate,
formatMoney,
type Invoice,
type InvoiceStatus,
invoiceTotal,
listInvoices,
} from "@/lib/invoices";
const statusVariant: Record<
InvoiceStatus,
"default" | "secondary" | "destructive" | "outline"
> = {
draft: "secondary",
sent: "default",
paid: "outline",
void: "destructive",
};
export function InvoicesView() {
const { t } = useTranslation();
const [list, setList] = useState<Invoice[]>([]);
const [query, setQuery] = useState("");
const [loadError, setLoadError] = useState<string | null>(null);
const [selected, setSelected] = useState<Invoice | null>(null);
const [sheetOpen, setSheetOpen] = useState(false);
const [formOpen, setFormOpen] = useState(false);
const [formMode, setFormMode] = useState<"create" | "edit">("create");
const [editing, setEditing] = useState<Invoice | null>(null);
useEffect(() => {
let active = true;
listInvoices()
.then((data) => active && setList(data))
.catch((err) => {
if (active) {
setLoadError(
err instanceof Error ? err.message : t("invoices.loadError"),
);
}
});
return () => {
active = false;
};
}, [t]);
const search = query.trim().toLowerCase();
const filtered = useMemo(() => {
if (!search) return list;
return list.filter(
(inv) =>
inv.name.toLowerCase().includes(search) ||
inv.number.toLowerCase().includes(search) ||
inv.fileNumber.includes(search) ||
inv.status.toLowerCase().includes(search),
);
}, [list, search]);
const kpis = useMemo(() => {
const unpaid = list
.filter((i) => i.status === "draft" || i.status === "sent")
.reduce((sum, i) => sum + invoiceTotal(i), 0);
const paid = list
.filter((i) => i.status === "paid")
.reduce((sum, i) => sum + invoiceTotal(i), 0);
const drafts = list.filter((i) => i.status === "draft").length;
return [
{
label: t("invoices.kpi.outstanding"),
value: formatMoney(unpaid),
icon: CircleDollarSign,
},
{ label: t("invoices.kpi.paid"), value: formatMoney(paid), icon: Wallet },
{
label: t("invoices.kpi.drafts"),
value: String(drafts),
icon: FileText,
},
];
}, [list, t]);
const openInvoice = (inv: Invoice) => {
setSelected(inv);
setSheetOpen(true);
};
const upsert = (saved: Invoice) => {
setList((prev) => {
const exists = prev.some((i) => i.id === saved.id);
return exists
? prev.map((i) => (i.id === saved.id ? saved : i))
: [saved, ...prev];
});
setSelected((cur) => (cur?.id === saved.id ? saved : cur));
};
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-10 px-6 py-10">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="font-semibold text-2xl tracking-tight">
{t("invoices.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("invoices.subtitle")}
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative">
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
<Input
className="w-full pl-9 sm:w-64"
onChange={(event) => setQuery(event.target.value)}
placeholder={t("invoices.searchPlaceholder")}
value={query}
/>
</div>
<Button
className="rounded-3xl"
onClick={() => {
setFormMode("create");
setEditing(null);
setFormOpen(true);
}}
type="button"
>
<Plus className="size-4" />
{t("invoices.new")}
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{kpis.map((k) => (
<Card className="flex-row items-center gap-3 p-4" key={k.label}>
<div className="flex size-9 items-center justify-center rounded-lg border bg-background text-muted-foreground">
<k.icon className="size-4" />
</div>
<div className="flex flex-col">
<span className="text-muted-foreground text-xs">{k.label}</span>
<span className="font-semibold text-foreground text-lg tracking-tight tabular-nums">
{k.value}
</span>
</div>
</Card>
))}
</div>
<div className="divide-y divide-border overflow-hidden rounded-2xl border bg-card/30">
{filtered.map((inv) => (
<button
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/50"
key={inv.id}
onClick={() => openInvoice(inv)}
type="button"
>
<div className="flex min-w-0 flex-1 flex-col">
<span className="flex items-center gap-2 truncate font-medium text-foreground text-sm">
{inv.number}
<span className="font-normal text-muted-foreground">
· {inv.name}
</span>
<AiBadge source={inv.source} />
</span>
<span className="truncate text-muted-foreground text-xs">
{formatInvoiceDate(inv.issuedAt)}
</span>
</div>
<span className="shrink-0 font-medium text-foreground text-sm tabular-nums">
{formatMoney(invoiceTotal(inv))}
</span>
<Badge variant={statusVariant[inv.status]}>
{t(`invoices.status.${inv.status}`)}
</Badge>
</button>
))}
{filtered.length === 0 && (
<p className="p-6 text-center text-muted-foreground text-sm">
{loadError
? loadError
: search
? t("invoices.noMatches")
: t("invoices.empty")}
</p>
)}
</div>
<InvoiceFormDialog
invoice={editing ?? undefined}
mode={formMode}
onOpenChange={setFormOpen}
onSaved={upsert}
open={formOpen}
/>
<InvoiceDetailSheet
invoice={selected}
onChanged={upsert}
onDeleted={(id) => setList((prev) => prev.filter((i) => i.id !== id))}
onEdit={(inv) => {
setSheetOpen(false);
setFormMode("edit");
setEditing(inv);
setFormOpen(true);
}}
onOpenChange={setSheetOpen}
open={sheetOpen}
/>
</div>
);
}
+6
View File
@@ -13,6 +13,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"],
@@ -28,6 +29,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"],
@@ -38,6 +40,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"],
@@ -48,6 +51,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"],
@@ -60,6 +64,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"],
@@ -70,6 +75,7 @@ export const reception = ac.newRole({
...memberAc.statements,
patient: ["read", "write"],
appointment: ["read", "write", "delete"],
invoice: ["read", "write", "delete"],
task: ["read", "write"],
});
@@ -113,6 +113,7 @@
"newChat": "New chat",
"patients": "Patients",
"appointments": "Appointments",
"invoices": "Invoices",
"prescriptions": "Prescriptions",
"analysis": "Analysis",
"pharmacy": "Pharmacy",
@@ -276,6 +277,83 @@
"none": "No appointments on this day."
}
},
"invoices": {
"title": "Invoices",
"subtitle": "Patient billing — create, split, and export invoices.",
"searchPlaceholder": "Search patient, number, status",
"new": "New invoice",
"empty": "No invoices yet.",
"noMatches": "No invoices match your search.",
"loadError": "Couldn't load invoices.",
"recent": "Invoices",
"recentDescription": "Most recent first",
"addFailedTitle": "Couldn't save invoice",
"addFailedBody": "Please try again.",
"kpi": {
"outstanding": "Outstanding",
"paid": "Paid",
"drafts": "Drafts"
},
"status": {
"draft": "Draft",
"sent": "Sent",
"paid": "Paid",
"void": "Void"
},
"dialog": {
"createTitle": "New invoice",
"editTitle": "Edit invoice",
"description": "Pick a patient and add line items.",
"patient": "Patient",
"searchPlaceholder": "Search name or file number",
"noPatients": "No patients found.",
"fileNumber": "File #{{number}}",
"change": "Change",
"issued": "Issued",
"due": "Due date",
"status": "Status",
"notes": "Notes",
"notesPlaceholder": "Optional notes…",
"lineItems": "Line items",
"lineDescription": "Description",
"lineDescriptionPlaceholder": "e.g. Consultation",
"qty": "Qty",
"unitPrice": "Unit price",
"addLine": "Add line",
"total": "Total",
"cancel": "Cancel",
"create": "Create invoice",
"save": "Save changes",
"saving": "Saving…",
"pickPatientTitle": "Pick a patient",
"pickPatientBody": "Search and select a patient first."
},
"sheet": {
"fallbackTitle": "Invoice",
"issued": "Issued",
"due": "Due",
"status": "Status",
"total": "Total",
"lineItems": "Line items",
"installments": "Installments",
"noInstallments": "Not split into installments.",
"split": "Split into installments",
"splitTitle": "Split invoice",
"splitBody": "Divide the total into equal monthly installments.",
"splitCount": "Installments",
"splitConfirm": "Split",
"download": "Download PDF",
"edit": "Edit",
"delete": "Delete",
"deletedTitle": "Invoice deleted",
"splitFailedTitle": "Couldn't split invoice",
"splitFailedBody": "Please try again.",
"deleteFailedTitle": "Couldn't delete invoice",
"deleteFailedBody": "Please try again.",
"paid": "Paid",
"unpaid": "Unpaid"
}
},
"prescriptions": {
"title": "Prescriptions",
"subtitle": "Medications prescribed across the clinic.",
+111
View File
@@ -0,0 +1,111 @@
import {
formatInvoiceDate,
formatMoney,
type Invoice,
invoiceTotal,
} from "@/lib/invoices";
function esc(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
// Render an invoice into a clean, print-styled document in a new window and
// trigger the browser's print dialog, where the clinician can "Save as PDF".
// Dependency-free — no PDF library needed; swap for jsPDF later if a generated
// file is required.
export function downloadInvoicePdf(invoice: Invoice, clinicName = "temetro") {
const win = window.open("", "_blank", "width=820,height=1040");
if (!win) return;
const total = invoiceTotal(invoice);
const lineRows = invoice.lineItems
.map(
(li) => `<tr>
<td>${esc(li.description)}</td>
<td class="num">${li.quantity}</td>
<td class="num">${formatMoney(li.unitPrice)}</td>
<td class="num">${formatMoney(li.quantity * li.unitPrice)}</td>
</tr>`,
)
.join("");
const installmentRows = invoice.installments.length
? `<h2>Installments</h2>
<table>
<thead><tr><th>Installment</th><th class="num">Due</th><th class="num">Amount</th><th class="num">Status</th></tr></thead>
<tbody>${invoice.installments
.map(
(it) => `<tr>
<td>${esc(it.label)}</td>
<td class="num">${it.dueAt ? formatInvoiceDate(it.dueAt) : "—"}</td>
<td class="num">${formatMoney(it.amount)}</td>
<td class="num">${it.paid ? "Paid" : "Unpaid"}</td>
</tr>`,
)
.join("")}</tbody>
</table>`
: "";
const notes = invoice.notes
? `<h2>Notes</h2><p class="notes">${esc(invoice.notes)}</p>`
: "";
win.document.write(`<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>${esc(invoice.number)}</title>
<style>
* { box-sizing: border-box; }
body { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: #111; margin: 40px; }
header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 2px solid #111; padding-bottom: 16px; }
h1 { font-size: 22px; margin: 0; }
h2 { font-size: 14px; margin: 28px 0 8px; text-transform: uppercase; letter-spacing: .04em; color: #555; }
.meta { text-align: right; font-size: 13px; color: #444; }
.meta strong { color: #111; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid #e5e5e5; }
th { color: #555; font-weight: 600; }
.num { text-align: right; }
tfoot td { font-weight: 700; border-top: 2px solid #111; border-bottom: none; font-size: 15px; }
.patient { margin-top: 24px; font-size: 14px; }
.notes { font-size: 13px; color: #333; white-space: pre-wrap; }
@media print { body { margin: 0.6in; } }
</style>
</head>
<body>
<header>
<div>
<h1>${esc(clinicName)}</h1>
<div style="font-size:13px;color:#555;margin-top:4px;">Invoice</div>
</div>
<div class="meta">
<div><strong>${esc(invoice.number)}</strong></div>
<div>Issued ${formatInvoiceDate(invoice.issuedAt)}</div>
${invoice.dueAt ? `<div>Due ${formatInvoiceDate(invoice.dueAt)}</div>` : ""}
<div>Status: ${esc(invoice.status)}</div>
</div>
</header>
<div class="patient">
<strong>Bill to:</strong> ${esc(invoice.name)}${invoice.fileNumber ? ` · File #${esc(invoice.fileNumber)}` : ""}
</div>
<h2>Line items</h2>
<table>
<thead><tr><th>Description</th><th class="num">Qty</th><th class="num">Unit price</th><th class="num">Amount</th></tr></thead>
<tbody>${lineRows || `<tr><td colspan="4" style="color:#888;">No line items</td></tr>`}</tbody>
<tfoot><tr><td colspan="3" class="num">Total</td><td class="num">${formatMoney(total)}</td></tr></tfoot>
</table>
${installmentRows}
${notes}
<script>window.onload = function () { window.focus(); window.print(); };</script>
</body>
</html>`);
win.document.close();
}
+110
View File
@@ -0,0 +1,110 @@
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" });
}
+8
View File
@@ -11,6 +11,7 @@ import {
NotebookPen,
Pill,
Plus,
Receipt,
Settings,
Users,
} from "lucide-react";
@@ -69,6 +70,13 @@ export const navItems: NavItem[] = [
icon: CalendarClock,
link: "/appointments",
},
{
id: "invoices",
labelKey: "nav.invoices",
icon: Receipt,
link: "/invoices",
access: "clinical",
},
{
id: "prescriptions",
labelKey: "nav.prescriptions",