From 94764d853273a63795180b9f8d76a60848657509 Mon Sep 17 00:00:00 2001
From: Flea Flicker
Date: Tue, 7 Apr 2026 20:11:24 +0000
Subject: [PATCH] Frontend: use paginated invoices API, eliminate over-fetching
- Replace loadAll() with single GET /api/invoices?limit=50&offset=0
- Remove parallel fetches of clients/appointments/services/staff from list load
- Use clientName from API response instead of client-side enrichment
- Add offset-based pagination controls with Previous/Next buttons
- Lazy-load staff/appointments only when opening invoice detail modal
- Lazy-load clients/appointments/services only when opening create form
- Filter changes only re-fetch invoices, not all endpoints
Co-Authored-By: Paperclip
---
apps/web/src/pages/Invoices.tsx | 147 ++++++++++++++++++++++----------
1 file changed, 103 insertions(+), 44 deletions(-)
diff --git a/apps/web/src/pages/Invoices.tsx b/apps/web/src/pages/Invoices.tsx
index 5039dd3..8363695 100644
--- a/apps/web/src/pages/Invoices.tsx
+++ b/apps/web/src/pages/Invoices.tsx
@@ -52,6 +52,8 @@ interface CreateFromApptProps {
appointments: Appointment[];
clients: Client[];
services: Service[];
+ loading: boolean;
+ onOpen: () => void;
onCreated: () => void;
onClose: () => void;
}
@@ -60,10 +62,13 @@ function CreateFromAppointmentForm({
appointments,
clients,
services,
+ loading,
+ onOpen,
onCreated,
onClose,
}: CreateFromApptProps) {
const [selectedApptId, setSelectedApptId] = useState("");
+ useEffect(() => { onOpen(); }, [onOpen]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
@@ -99,6 +104,8 @@ function CreateFromAppointmentForm({
}
}
+ if (loading) return Loading…
;
+
return (
Create Invoice from Appointment
@@ -148,16 +155,21 @@ function InvoiceDetailModal({
invoice,
allStaff,
allAppointments,
+ loading,
+ onOpen,
onClose,
onUpdated,
}: {
invoice: Invoice;
allStaff: Staff[];
allAppointments: Appointment[];
+ loading: boolean;
+ onOpen: () => void;
onClose: () => void;
onUpdated: () => void;
}) {
const [saving, setSaving] = useState(false);
+ useEffect(() => { onOpen(); }, [onOpen]);
const [error, setError] = useState(null);
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
const [paymentMethod, setPaymentMethod] = useState(invoice.paymentMethod ?? "cash");
@@ -259,6 +271,8 @@ function InvoiceDetailModal({
}
}
+ if (loading) return Loading…
;
+
const tipCentsCalc = Math.round(parseFloat(tipStr) * 100) || 0;
const newTotal = invoice.subtotalCents + invoice.taxCents + tipCentsCalc;
@@ -460,64 +474,77 @@ function SummaryRow({ label, value, bold }: { label: string; value: string; bold
// ─── Main Page ────────────────────────────────────────────────────────────────
+interface PaginatedResponse {
+ data: T[];
+ total: number;
+}
+
export function InvoicesPage() {
const [invoiceList, setInvoiceList] = useState([]);
- const [clients, setClients] = useState([]);
- const [appointments, setAppointments] = useState([]);
- const [services, setServices] = useState([]);
- const [allStaff, setAllStaff] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showCreate, setShowCreate] = useState(false);
const [selectedInvoice, setSelectedInvoice] = useState(null);
const [statusFilter, setStatusFilter] = useState("");
+ const [total, setTotal] = useState(0);
+ const [offset, setOffset] = useState(0);
+ const [createData, setCreateData] = useState<{ clients: Client[]; appointments: Appointment[]; services: Service[] } | null>(null);
+ const [createLoading, setCreateLoading] = useState(false);
+ const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null);
+ const [detailLoading, setDetailLoading] = useState(false);
- async function loadAll() {
- const [invRes, clientRes, apptRes, svcRes, staffRes] = await Promise.all([
- fetch("/api/invoices" + (statusFilter ? `?status=${statusFilter}` : "")),
- fetch("/api/clients"),
- fetch("/api/appointments"),
- fetch("/api/services?includeInactive=true"),
- fetch("/api/staff"),
- ]);
+ const LIMIT = 50;
- if (!invRes.ok || !clientRes.ok || !apptRes.ok || !svcRes.ok || !staffRes.ok) {
- throw new Error("Failed to load data");
- }
-
- const [invData, clientData, apptData, svcData, staffData] = await Promise.all([
- invRes.json() as Promise,
- clientRes.json() as Promise,
- apptRes.json() as Promise,
- svcRes.json() as Promise,
- staffRes.json() as Promise,
- ]);
-
- const clientMap = new Map(clientData.map((c) => [c.id, c.name]));
- const enriched: InvoiceWithClient[] = invData.map((inv) => ({
- ...inv,
- clientName: clientMap.get(inv.clientId),
- }));
-
- setInvoiceList(enriched);
- setClients(clientData);
- setAppointments(apptData);
- setServices(svcData);
- setAllStaff(staffData);
+ async function loadInvoices(newOffset: number) {
+ const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) });
+ if (statusFilter) params.set("status", statusFilter);
+ const res = await fetch(`/api/invoices?${params}`);
+ if (!res.ok) throw new Error("Failed to load invoices");
+ const page = (await res.json()) as PaginatedResponse;
+ setInvoiceList(page.data);
+ setTotal(page.total);
+ setOffset(newOffset);
}
useEffect(() => {
setLoading(true);
- loadAll()
+ loadInvoices(0)
.catch((e: unknown) => setError(e instanceof Error ? e.message : "Unknown error"))
.finally(() => setLoading(false));
}, [statusFilter]);
+ function loadCreateData() {
+ if (createData) return Promise.resolve();
+ setCreateLoading(true);
+ return Promise.all([
+ fetch("/api/clients"),
+ fetch("/api/appointments"),
+ fetch("/api/services?includeInactive=true"),
+ ])
+ .then(([c, a, s]) => Promise.all([c.json(), a.json(), s.json()]))
+ .then(([clients, appointments, services]) => {
+ setCreateData({ clients, appointments, services });
+ })
+ .finally(() => setCreateLoading(false));
+ }
+
+ function loadDetailData() {
+ if (detailData) return Promise.resolve();
+ setDetailLoading(true);
+ return Promise.all([fetch("/api/staff"), fetch("/api/appointments")])
+ .then(([s, a]) => Promise.all([s.json(), a.json()]))
+ .then(([staff, appointments]) => {
+ setDetailData({ staff, appointments });
+ })
+ .finally(() => setDetailLoading(false));
+ }
+
async function openInvoiceDetail(inv: InvoiceWithClient) {
const res = await fetch(`/api/invoices/${inv.id}`);
if (!res.ok) return;
const data = (await res.json()) as Invoice;
setSelectedInvoice(data);
+ loadDetailData();
}
if (loading) return Loading…
;
@@ -551,6 +578,7 @@ export function InvoicesPage() {
No invoices yet. Create one from a completed appointment.
) : (
+ <>
@@ -584,16 +612,41 @@ export function InvoicesPage() {
+
+
+ {offset + 1}–{Math.min(offset + LIMIT, total)} of {total} invoices
+
+
+
+
+
+
+ >
)}
{showCreate && (
loadCreateData()}
onCreated={() => {
setShowCreate(false);
- loadAll().catch(() => {});
+ setCreateData(null);
+ loadInvoices(0).catch(() => {});
}}
onClose={() => setShowCreate(false)}
/>
@@ -602,12 +655,18 @@ export function InvoicesPage() {
{selectedInvoice && (
setSelectedInvoice(null)}
+ allStaff={detailData?.staff ?? []}
+ allAppointments={detailData?.appointments ?? []}
+ loading={detailLoading}
+ onOpen={() => loadDetailData()}
+ onClose={() => {
+ setSelectedInvoice(null);
+ setDetailData(null);
+ }}
onUpdated={() => {
setSelectedInvoice(null);
- loadAll().catch(() => {});
+ setDetailData(null);
+ loadInvoices(offset).catch(() => {});
}}
/>
)}