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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
formatInvoiceDate,
|
||||
formatMoney,
|
||||
type Invoice,
|
||||
invoiceTotal,
|
||||
} from "@/lib/invoices";
|
||||
|
||||
function esc(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user