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
+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",