GRO-505: Use paginated invoices API, eliminate over-fetching #241

Merged
groombook-engineer[bot] merged 2 commits from fleaflicker/gro-505-paginated-invoices into main 2026-04-07 21:49:17 +00:00
4 changed files with 123 additions and 46 deletions
+14
View File
@@ -188,6 +188,19 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push Reset image
uses: docker/build-push-action@v6
with:
context: .
file: apps/api/Dockerfile
target: reset
push: true
tags: |
ghcr.io/groombook/reset:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/reset:latest' || '' }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push Web image
uses: docker/build-push-action@v6
with:
@@ -356,6 +369,7 @@ jobs:
yq -i '(.images[] | select(.name == "ghcr.io/groombook/web")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/reset")).newTag = env(TAG)' "$DEV_KUST"
# Update migrate Job name to include short SHA (immutable template fix)
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
+4
View File
@@ -43,3 +43,7 @@ CMD ["pnpm", "db:migrate"]
# Seed stage — populates the database with test data
FROM builder AS seed
CMD ["pnpm", "db:seed"]
# Reset stage — drops all tables, re-runs migrations, and re-seeds
FROM builder AS reset
CMD ["pnpm", "db:reset"]
+103 -44
View File
@@ -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<string | null>(null);
@@ -99,6 +104,8 @@ function CreateFromAppointmentForm({
}
}
if (loading) return <Modal onClose={onClose}><p style={{ padding: "1rem" }}>Loading</p></Modal>;
return (
<Modal onClose={onClose}>
<h2 style={{ marginTop: 0 }}>Create Invoice from Appointment</h2>
@@ -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<string | null>(null);
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash");
@@ -259,6 +271,8 @@ function InvoiceDetailModal({
}
}
if (loading) return <Modal onClose={onClose}><p style={{ padding: "1rem" }}>Loading</p></Modal>;
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<T> {
data: T[];
total: number;
}
export function InvoicesPage() {
const [invoiceList, setInvoiceList] = useState<InvoiceWithClient[]>([]);
const [clients, setClients] = useState<Client[]>([]);
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [services, setServices] = useState<Service[]>([]);
const [allStaff, setAllStaff] = useState<Staff[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [selectedInvoice, setSelectedInvoice] = useState<Invoice | null>(null);
const [statusFilter, setStatusFilter] = useState<string>("");
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<Invoice[]>,
clientRes.json() as Promise<Client[]>,
apptRes.json() as Promise<Appointment[]>,
svcRes.json() as Promise<Service[]>,
staffRes.json() as Promise<Staff[]>,
]);
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<Invoice>;
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 <p style={{ padding: "1rem" }}>Loading</p>;
@@ -551,6 +578,7 @@ export function InvoicesPage() {
No invoices yet. Create one from a completed appointment.
</p>
) : (
<>
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", overflow: "hidden", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
<thead>
@@ -584,16 +612,41 @@ export function InvoicesPage() {
</tbody>
</table>
</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: "0.75rem" }}>
<span style={{ fontSize: 13, color: "#6b7280" }}>
{offset + 1}{Math.min(offset + LIMIT, total)} of {total} invoices
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button
onClick={() => { setLoading(true); loadInvoices(Math.max(0, offset - LIMIT)).finally(() => setLoading(false)); }}
disabled={offset === 0 || loading}
style={btnStyle}
>
Previous
</button>
<button
onClick={() => { setLoading(true); loadInvoices(offset + LIMIT).finally(() => setLoading(false)); }}
disabled={offset + LIMIT >= total || loading}
style={btnStyle}
>
Next
</button>
</div>
</div>
</>
)}
{showCreate && (
<CreateFromAppointmentForm
appointments={appointments}
clients={clients}
services={services}
appointments={createData?.appointments ?? []}
clients={createData?.clients ?? []}
services={createData?.services ?? []}
loading={createLoading}
onOpen={() => loadCreateData()}
onCreated={() => {
setShowCreate(false);
loadAll().catch(() => {});
setCreateData(null);
loadInvoices(0).catch(() => {});
}}
onClose={() => setShowCreate(false)}
/>
@@ -602,12 +655,18 @@ export function InvoicesPage() {
{selectedInvoice && (
<InvoiceDetailModal
invoice={selectedInvoice}
allStaff={allStaff}
allAppointments={appointments}
onClose={() => 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(() => {});
}}
/>
)}
+2 -2
View File
@@ -16,8 +16,8 @@ async function reset() {
process.exit(1);
}
if (process.env.NODE_ENV === "production") {
console.error("[FATAL] db:reset must not be run in production.");
if (process.env.NODE_ENV === "production" && process.env.ALLOW_RESET !== "true") {
console.error("[FATAL] db:reset must not be run in production without ALLOW_RESET=true.");
process.exit(1);
}