GRO-505: Use paginated invoices API, eliminate over-fetching #241
@@ -188,6 +188,19 @@ jobs:
|
|||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
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
|
- name: Build and push Web image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
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/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/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/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)
|
# Update migrate Job name to include short SHA (immutable template fix)
|
||||||
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
|
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
|
||||||
|
|||||||
@@ -43,3 +43,7 @@ CMD ["pnpm", "db:migrate"]
|
|||||||
# Seed stage — populates the database with test data
|
# Seed stage — populates the database with test data
|
||||||
FROM builder AS seed
|
FROM builder AS seed
|
||||||
CMD ["pnpm", "db: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
@@ -52,6 +52,8 @@ interface CreateFromApptProps {
|
|||||||
appointments: Appointment[];
|
appointments: Appointment[];
|
||||||
clients: Client[];
|
clients: Client[];
|
||||||
services: Service[];
|
services: Service[];
|
||||||
|
loading: boolean;
|
||||||
|
onOpen: () => void;
|
||||||
onCreated: () => void;
|
onCreated: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
@@ -60,10 +62,13 @@ function CreateFromAppointmentForm({
|
|||||||
appointments,
|
appointments,
|
||||||
clients,
|
clients,
|
||||||
services,
|
services,
|
||||||
|
loading,
|
||||||
|
onOpen,
|
||||||
onCreated,
|
onCreated,
|
||||||
onClose,
|
onClose,
|
||||||
}: CreateFromApptProps) {
|
}: CreateFromApptProps) {
|
||||||
const [selectedApptId, setSelectedApptId] = useState("");
|
const [selectedApptId, setSelectedApptId] = useState("");
|
||||||
|
useEffect(() => { onOpen(); }, [onOpen]);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 (
|
return (
|
||||||
<Modal onClose={onClose}>
|
<Modal onClose={onClose}>
|
||||||
<h2 style={{ marginTop: 0 }}>Create Invoice from Appointment</h2>
|
<h2 style={{ marginTop: 0 }}>Create Invoice from Appointment</h2>
|
||||||
@@ -148,16 +155,21 @@ function InvoiceDetailModal({
|
|||||||
invoice,
|
invoice,
|
||||||
allStaff,
|
allStaff,
|
||||||
allAppointments,
|
allAppointments,
|
||||||
|
loading,
|
||||||
|
onOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onUpdated,
|
onUpdated,
|
||||||
}: {
|
}: {
|
||||||
invoice: Invoice;
|
invoice: Invoice;
|
||||||
allStaff: Staff[];
|
allStaff: Staff[];
|
||||||
allAppointments: Appointment[];
|
allAppointments: Appointment[];
|
||||||
|
loading: boolean;
|
||||||
|
onOpen: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onUpdated: () => void;
|
onUpdated: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
useEffect(() => { onOpen(); }, [onOpen]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
|
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
|
||||||
const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash");
|
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 tipCentsCalc = Math.round(parseFloat(tipStr) * 100) || 0;
|
||||||
const newTotal = invoice.subtotalCents + invoice.taxCents + tipCentsCalc;
|
const newTotal = invoice.subtotalCents + invoice.taxCents + tipCentsCalc;
|
||||||
|
|
||||||
@@ -460,64 +474,77 @@ function SummaryRow({ label, value, bold }: { label: string; value: string; bold
|
|||||||
|
|
||||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface PaginatedResponse<T> {
|
||||||
|
data: T[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function InvoicesPage() {
|
export function InvoicesPage() {
|
||||||
const [invoiceList, setInvoiceList] = useState<InvoiceWithClient[]>([]);
|
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 [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [selectedInvoice, setSelectedInvoice] = useState<Invoice | null>(null);
|
const [selectedInvoice, setSelectedInvoice] = useState<Invoice | null>(null);
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("");
|
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 LIMIT = 50;
|
||||||
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"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!invRes.ok || !clientRes.ok || !apptRes.ok || !svcRes.ok || !staffRes.ok) {
|
async function loadInvoices(newOffset: number) {
|
||||||
throw new Error("Failed to load data");
|
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) });
|
||||||
}
|
if (statusFilter) params.set("status", statusFilter);
|
||||||
|
const res = await fetch(`/api/invoices?${params}`);
|
||||||
const [invData, clientData, apptData, svcData, staffData] = await Promise.all([
|
if (!res.ok) throw new Error("Failed to load invoices");
|
||||||
invRes.json() as Promise<Invoice[]>,
|
const page = (await res.json()) as PaginatedResponse<Invoice>;
|
||||||
clientRes.json() as Promise<Client[]>,
|
setInvoiceList(page.data);
|
||||||
apptRes.json() as Promise<Appointment[]>,
|
setTotal(page.total);
|
||||||
svcRes.json() as Promise<Service[]>,
|
setOffset(newOffset);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
loadAll()
|
loadInvoices(0)
|
||||||
.catch((e: unknown) => setError(e instanceof Error ? e.message : "Unknown error"))
|
.catch((e: unknown) => setError(e instanceof Error ? e.message : "Unknown error"))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [statusFilter]);
|
}, [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) {
|
async function openInvoiceDetail(inv: InvoiceWithClient) {
|
||||||
const res = await fetch(`/api/invoices/${inv.id}`);
|
const res = await fetch(`/api/invoices/${inv.id}`);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = (await res.json()) as Invoice;
|
const data = (await res.json()) as Invoice;
|
||||||
setSelectedInvoice(data);
|
setSelectedInvoice(data);
|
||||||
|
loadDetailData();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <p style={{ padding: "1rem" }}>Loading…</p>;
|
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.
|
No invoices yet. Create one from a completed appointment.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", overflow: "hidden", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
|
<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 }}>
|
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -584,16 +612,41 @@ export function InvoicesPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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 && (
|
{showCreate && (
|
||||||
<CreateFromAppointmentForm
|
<CreateFromAppointmentForm
|
||||||
appointments={appointments}
|
appointments={createData?.appointments ?? []}
|
||||||
clients={clients}
|
clients={createData?.clients ?? []}
|
||||||
services={services}
|
services={createData?.services ?? []}
|
||||||
|
loading={createLoading}
|
||||||
|
onOpen={() => loadCreateData()}
|
||||||
onCreated={() => {
|
onCreated={() => {
|
||||||
setShowCreate(false);
|
setShowCreate(false);
|
||||||
loadAll().catch(() => {});
|
setCreateData(null);
|
||||||
|
loadInvoices(0).catch(() => {});
|
||||||
}}
|
}}
|
||||||
onClose={() => setShowCreate(false)}
|
onClose={() => setShowCreate(false)}
|
||||||
/>
|
/>
|
||||||
@@ -602,12 +655,18 @@ export function InvoicesPage() {
|
|||||||
{selectedInvoice && (
|
{selectedInvoice && (
|
||||||
<InvoiceDetailModal
|
<InvoiceDetailModal
|
||||||
invoice={selectedInvoice}
|
invoice={selectedInvoice}
|
||||||
allStaff={allStaff}
|
allStaff={detailData?.staff ?? []}
|
||||||
allAppointments={appointments}
|
allAppointments={detailData?.appointments ?? []}
|
||||||
onClose={() => setSelectedInvoice(null)}
|
loading={detailLoading}
|
||||||
|
onOpen={() => loadDetailData()}
|
||||||
|
onClose={() => {
|
||||||
|
setSelectedInvoice(null);
|
||||||
|
setDetailData(null);
|
||||||
|
}}
|
||||||
onUpdated={() => {
|
onUpdated={() => {
|
||||||
setSelectedInvoice(null);
|
setSelectedInvoice(null);
|
||||||
loadAll().catch(() => {});
|
setDetailData(null);
|
||||||
|
loadInvoices(offset).catch(() => {});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ async function reset() {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "production") {
|
if (process.env.NODE_ENV === "production" && process.env.ALLOW_RESET !== "true") {
|
||||||
console.error("[FATAL] db:reset must not be run in production.");
|
console.error("[FATAL] db:reset must not be run in production without ALLOW_RESET=true.");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user