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