diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fbafc46..69b8800 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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" diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index e7a0882..1a89f85 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -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"] 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(() => {}); }} /> )} diff --git a/packages/db/src/reset.ts b/packages/db/src/reset.ts index c390f1c..41c3ce8 100644 --- a/packages/db/src/reset.ts +++ b/packages/db/src/reset.ts @@ -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); }