From a8d0ebedfba5e7415ed7a8854fe52a51550f8b60 Mon Sep 17 00:00:00 2001 From: Lex0865 Date: Wed, 21 Oct 2026 04:21:58 -0600 Subject: [PATCH 1/2] Shared admin data table and paginated list migration This implementation introduces a reusable admin DataTable component for consistent sorting, paging, empty/loading/error states, and row actions across the admin pages. It then migrates the accounts, payments, and settlements lists to use that shared component while preserving their existing behavior and UX. The backend list Closes #1996 --- .../src/credits/credits-admin.controller.ts | 23 +- backend/src/credits/settlement.service.ts | 60 +++++- frontend/app/admin/accounts/page.tsx | 91 ++++---- frontend/app/admin/payments/page.tsx | 142 ++++--------- frontend/app/admin/settlements/page.tsx | 102 +++------ .../admin/__tests__/data-table.test.tsx | 69 ++++++ frontend/components/admin/data-table.tsx | 200 ++++++++++++++++++ .../admin/settlement-volume-chart.tsx | 2 +- frontend/lib/admin-api.ts | 34 ++- frontend/sentry.client.config.ts | 9 +- frontend/vitest.config.ts | 1 + frontend/vitest.setup.ts | 9 +- 12 files changed, 503 insertions(+), 239 deletions(-) create mode 100644 frontend/components/admin/__tests__/data-table.test.tsx create mode 100644 frontend/components/admin/data-table.tsx diff --git a/backend/src/credits/credits-admin.controller.ts b/backend/src/credits/credits-admin.controller.ts index bfb72dff..9f25b453 100644 --- a/backend/src/credits/credits-admin.controller.ts +++ b/backend/src/credits/credits-admin.controller.ts @@ -522,12 +522,29 @@ export class CreditsAdminController { required: false, enum: SettlementBatchStatus, }) + @ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: 'Page size (1–200)', + }) + @ApiQuery({ + name: 'cursor', + required: false, + type: String, + description: 'Opaque cursor from a previous page', + }) @ApiResponse({ status: 200, type: [SettlementBatchResponseDto] }) async listBatches( @Query('status') status?: SettlementBatchStatus, - ): Promise { - const batches = await this.settlement.listBatches(status); - return batches.map((batch) => SettlementBatchResponseDto.fromEntity(batch)); + @Query('limit') limit?: string, + @Query('cursor') cursor?: string, + ): Promise<{ data: SettlementBatchResponseDto[]; nextCursor: string | null }> { + const page = await this.settlement.listBatches(status, Number(limit) || 50, cursor); + return { + data: page.batches.map((batch) => SettlementBatchResponseDto.fromEntity(batch)), + nextCursor: page.nextCursor, + }; } @Get('settlement/batches/:id') diff --git a/backend/src/credits/settlement.service.ts b/backend/src/credits/settlement.service.ts index 1dbcf87a..6b28a4a5 100644 --- a/backend/src/credits/settlement.service.ts +++ b/backend/src/credits/settlement.service.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Inject, Injectable, Logger, @@ -66,6 +67,29 @@ export interface SettlementRunSummary { notes: string[]; } +function encodeSettlementCursor(batch: SettlementBatch): string { + return Buffer.from( + `${batch.createdAt.toISOString()}|${batch.id}`, + 'utf8', + ).toString('base64url'); +} + +function decodeSettlementCursor(cursor: string): { + createdAt: Date; + id: string; +} { + const decoded = Buffer.from(cursor, 'base64url').toString('utf8'); + const separator = decoded.lastIndexOf('|'); + const rawCreatedAt = separator === -1 ? '' : decoded.slice(0, separator); + const id = separator === -1 ? '' : decoded.slice(separator + 1); + const createdAt = new Date(rawCreatedAt); + + if (!id || Number.isNaN(createdAt.getTime())) { + throw new BadRequestException('cursor is not a valid pagination cursor'); + } + return { createdAt, id }; +} + export interface SettlementBatchBreakdown { batch: SettlementBatch; payouts: SettlementPayout[]; @@ -627,12 +651,36 @@ export class SettlementService implements OnModuleInit { async listBatches( status?: SettlementBatchStatus, - ): Promise { - return this.batchRepository.find({ - where: status ? { status } : {}, - order: { createdAt: 'DESC' }, - take: 100, - }); + limit = 50, + cursor?: string, + ): Promise<{ batches: SettlementBatch[]; nextCursor: string | null }> { + const pageLimit = Math.min(Math.max(1, Number(limit) || 50), 200); + const query = this.batchRepository + .createQueryBuilder('batch') + .orderBy('batch.createdAt', 'DESC') + .addOrderBy('batch.id', 'DESC') + .take(pageLimit + 1); + + if (status) { + query.where('batch.status = :status', { status }); + } + + if (cursor) { + const { createdAt, id } = decodeSettlementCursor(cursor); + query.andWhere( + '(batch.createdAt < :createdAt OR (batch.createdAt = :createdAt AND batch.id < :id))', + { createdAt, id }, + ); + } + + const rows = await query.getMany(); + const hasMore = rows.length > pageLimit; + const batches = hasMore ? rows.slice(0, pageLimit) : rows; + + return { + batches, + nextCursor: hasMore ? encodeSettlementCursor(batches[batches.length - 1]) : null, + }; } /** diff --git a/frontend/app/admin/accounts/page.tsx b/frontend/app/admin/accounts/page.tsx index 78557c6b..6ef1652d 100644 --- a/frontend/app/admin/accounts/page.tsx +++ b/frontend/app/admin/accounts/page.tsx @@ -16,6 +16,7 @@ import { SecondaryButton, inputClass, } from "@/components/admin/ui"; +import { DataTable } from "@/components/admin/data-table"; import { RequireAdmin } from "@/components/admin/require-admin"; const KINDS: LedgerAccountKind[] = [ @@ -41,9 +42,13 @@ function AccountsInner() { const accounts = useQuery({ queryKey: ["ledger-accounts"], - queryFn: () => listAccounts(), + queryFn: () => listAccounts(undefined, 50), }); + const rows = Array.isArray(accounts.data) + ? accounts.data + : accounts.data?.data ?? []; + const freeze = useMutation({ mutationFn: ({ id, frozen }: { id: string; frozen: boolean }) => updateAccount(id, { frozen }), @@ -76,46 +81,52 @@ function AccountsInner() { )} - - {accounts.error && ( -

- {(accounts.error as Error).message} -

- )} -
- - - - - - - - - - - - - - - {accounts.data?.map((account) => ( - - freeze.mutate({ id: account.id, frozen }) - } - /> - ))} - {!accounts.isLoading && !accounts.error && (accounts.data?.length ?? 0) === 0 && ( - - - + + ( + + {(account as LedgerAccount).kind} + + ) }, + { key: "ownerId", header: "Owner", render: (account) => ( + ((account as LedgerAccount).ownerId ?? ( + — + )) + ) }, + { key: "currency", header: "Currency", render: (account) => (account as LedgerAccount).currency }, + { key: "balance", header: "Balance", className: "text-right tabular-nums text-gray-900 dark:text-gray-50", render: (account) => (account as LedgerAccount).balance.toLocaleString() }, + { key: "overdraftLimit", header: "Overdraft", className: "text-right tabular-nums text-gray-700 dark:text-gray-300", render: (account) => (account as LedgerAccount).overdraftLimit.toLocaleString() }, + { key: "externalPayoutAddress", header: "Payout address", render: (account) => ( + ((account as LedgerAccount).externalPayoutAddress ?? ( + — + )) + ) }, + { key: "frozen", header: "Status", render: (account) => }, + ]} + rows={rows} + rowKey={(account) => (account as LedgerAccount).id} + loading={accounts.isLoading} + error={accounts.error ?

{(accounts.error as Error).message}

: undefined} + empty={No accounts found.} + rowActions={(account) => ( + freeze.mutate({ id: (account as LedgerAccount).id, frozen: !(account as LedgerAccount).frozen })} + className="px-2.5 py-1 text-xs" + > + {(account as LedgerAccount).frozen ? ( + <> + Unfreeze + + ) : ( + <> + Freeze + )} -
-
KindOwnerCurrencyBalanceOverdraftPayout addressStatusActions
- No accounts found. -
-
+ + )} + />
); diff --git a/frontend/app/admin/payments/page.tsx b/frontend/app/admin/payments/page.tsx index 89652996..c1491074 100644 --- a/frontend/app/admin/payments/page.tsx +++ b/frontend/app/admin/payments/page.tsx @@ -13,6 +13,7 @@ import { Badge, StatusBadge, } from "@/components/admin/ui"; +import { DataTable } from "@/components/admin/data-table"; import { RequireAdmin } from "@/components/admin/require-admin"; // Payment amounts arrive as integer minor units. @@ -40,8 +41,6 @@ function ManualReviewInner() { const payments = data?.items ?? []; const total = data?.total ?? 0; - const hasPrevious = offset > 0; - const hasNext = offset + PAGE_SIZE < total; return (
@@ -64,105 +63,46 @@ function ManualReviewInner() { description="Auto-refreshes every 30 seconds." /> - {error ? ( -

- {(error as Error).message} -

- ) : ( -
- - - - - - - - - - - - - - {payments.map((p: Payment) => ( - - - - - - - - - - - ))} - {!isLoading && payments.length === 0 && ( - - - - )} - -
Payment IDBookingRailStatusAmountReview reasonCreated -
- {p.id.slice(0, 8)}… - - {p.bookingId.slice(0, 8)}… - - - {p.rail} - - - - - {formatAmount(p.amount, p.currency)} - - {p.manualReviewReason ?? "—"} - - {new Date(p.createdAt).toLocaleDateString()} - - - Review - -
- No payments in the manual-review queue. -
-
- )} - - {total > 0 && ( -
- - Showing {total === 0 ? 0 : offset + 1}– - {Math.min(offset + PAGE_SIZE, total)} of {total} - -
- - -
-
- )} + ( + {(payment as Payment).id.slice(0, 8)}… + ) }, + { key: "bookingId", header: "Booking", render: (payment) => ( + {(payment as Payment).bookingId.slice(0, 8)}… + ) }, + { key: "rail", header: "Rail", render: (payment) => ( + + {(payment as Payment).rail} + + ) }, + { key: "status", header: "Status", render: (payment) => }, + { key: "amount", header: "Amount", className: "text-right tabular-nums", render: (payment) => formatAmount((payment as Payment).amount, (payment as Payment).currency) }, + { key: "manualReviewReason", header: "Review reason", render: (payment) => ( + {(payment as Payment).manualReviewReason ?? "—"} + ) }, + { key: "createdAt", header: "Created", render: (payment) => ( + {new Date((payment as Payment).createdAt).toLocaleDateString()} + ) }, + ]} + rows={payments} + rowKey={(payment) => (payment as Payment).id} + loading={isLoading} + error={error ?

{(error as Error).message}

: undefined} + empty={No payments in the manual-review queue.} + page={Math.floor(offset / PAGE_SIZE) + 1} + pageSize={PAGE_SIZE} + totalRows={total} + onPageChange={(nextPage) => setOffset((nextPage - 1) * PAGE_SIZE)} + rowActions={(payment) => ( + + Review + + )} + />
); diff --git a/frontend/app/admin/settlements/page.tsx b/frontend/app/admin/settlements/page.tsx index 3ce930e9..0f45d6a4 100644 --- a/frontend/app/admin/settlements/page.tsx +++ b/frontend/app/admin/settlements/page.tsx @@ -4,7 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import Link from "next/link"; import { Play } from "lucide-react"; -import type { SettlementBatchStatus } from "@/lib/admin-api"; +import type { SettlementBatch, SettlementBatchStatus } from "@/lib/admin-api"; import { listBatches, runSettlement } from "@/lib/admin-api"; import { Card, @@ -13,6 +13,7 @@ import { StatusBadge, Button, } from "@/components/admin/ui"; +import { DataTable } from "@/components/admin/data-table"; import { RequireAdmin } from "@/components/admin/require-admin"; import { SettlementVolumeChart } from "@/components/admin/settlement-volume-chart"; @@ -47,6 +48,13 @@ function SettlementsInner() { listBatches((status || undefined) as SettlementBatchStatus | undefined), }); + const rows = Array.isArray(batches.data) + ? batches.data + : batches.data?.data ?? []; + const chartData = Array.isArray(batches.data) + ? batches.data + : batches.data?.data ?? []; + const run = useMutation({ mutationFn: () => runSettlement(), onSuccess: () => { @@ -92,75 +100,35 @@ function SettlementsInner() { title="Settlement volume by status" description="Total settled amount aggregated across all batches by current status." /> - + - - {batches.isLoading ? ( -

- Loading settlement batches… -

- ) : batches.error ? ( -

- {(batches.error as Error).message} -

- ) : null} -
- - - - - - - - - - - - - {batches.data?.map((batch) => ( - { - if (typeof window !== "undefined") { - window.location.href = `/admin/settlements/${batch.id}`; - } - }} - > - - - - - - - - ))} - {!batches.isLoading && !batches.error && (batches.data?.length ?? 0) === 0 && ( - - - - )} - -
ModeStatusCurrencyTotalEntriesPeriod end
- - {batch.mode} - - - - - {batch.currency} - - {batch.totalAmount.toLocaleString()} - - {batch.claimedEntryCount} - - {new Date(batch.periodEnd).toLocaleDateString()} -
- No settlement batches. -
-
+ + ( + + {(batch as SettlementBatch).mode} + + ) }, + { key: "status", header: "Status", render: (batch) => }, + { key: "currency", header: "Currency", render: (batch) => (batch as SettlementBatch).currency }, + { key: "totalAmount", header: "Total", className: "text-right tabular-nums", render: (batch) => (batch as SettlementBatch).totalAmount.toLocaleString() }, + { key: "claimedEntryCount", header: "Entries", className: "text-right tabular-nums", render: (batch) => (batch as SettlementBatch).claimedEntryCount }, + { key: "periodEnd", header: "Period end", render: (batch) => new Date((batch as SettlementBatch).periodEnd).toLocaleDateString() }, + ]} + rows={rows} + rowKey={(batch) => (batch as SettlementBatch).id} + loading={batches.isLoading} + error={batches.error ?

{(batches.error as Error).message}

: undefined} + empty={No settlement batches.} + onRowClick={(row) => { + if (typeof window !== "undefined") { + window.location.href = `/admin/settlements/${(row as SettlementBatch).id}`; + } + }} + />
); diff --git a/frontend/components/admin/__tests__/data-table.test.tsx b/frontend/components/admin/__tests__/data-table.test.tsx new file mode 100644 index 00000000..89c53273 --- /dev/null +++ b/frontend/components/admin/__tests__/data-table.test.tsx @@ -0,0 +1,69 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DataTable, type DataTableColumn, type DataTableSort } from "@/components/admin/data-table"; + +afterEach(() => { + cleanup(); +}); + +describe("DataTable", () => { + const rows = [ + { id: "b", amount: 20, status: "PENDING" }, + { id: "a", amount: 10, status: "SETTLED" }, + ]; + + const columns: DataTableColumn[] = [ + { key: "id", header: "ID" }, + { key: "amount", header: "Amount", sortable: true, render: (row) => `$${row.amount}` }, + { key: "status", header: "Status" }, + ]; + + it("sets aria-sort and calls onSort when a sortable header is clicked", async () => { + const user = userEvent.setup(); + const onSort = vi.fn(); + + render( + row.id} + sort={{ key: "amount", direction: "asc" }} + onSort={onSort} + />, + ); + + const header = screen.getByRole("columnheader", { name: "Amount" }); + expect(header).toHaveAttribute("aria-sort", "ascending"); + + await user.click(header); + expect(onSort).toHaveBeenCalledWith("amount", "desc"); + }); + + it("renders the empty state and page controls", async () => { + const user = userEvent.setup(); + const onPageChange = vi.fn(); + + render( + row.id} + empty={
No rows
} + page={2} + pageSize={10} + totalRows={25} + onPageChange={onPageChange} + />, + ); + + expect(screen.getByText("No rows")).toBeInTheDocument(); + expect(screen.getByText("Showing 11–20 of 25")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Previous" })); + await user.click(screen.getByRole("button", { name: "Next" })); + + expect(onPageChange).toHaveBeenNthCalledWith(1, 1); + expect(onPageChange).toHaveBeenNthCalledWith(2, 3); + }); +}); diff --git a/frontend/components/admin/data-table.tsx b/frontend/components/admin/data-table.tsx new file mode 100644 index 00000000..f3dc562d --- /dev/null +++ b/frontend/components/admin/data-table.tsx @@ -0,0 +1,200 @@ +import { cn } from "@/lib/utils"; +import type { ReactNode } from "react"; + +export type DataTableSortDirection = "asc" | "desc"; + +export type DataTableSort = { + key: string; + direction: DataTableSortDirection; +}; + +export interface DataTableColumn { + key: string; + header: ReactNode; + sortable?: boolean; + className?: string; + headerClassName?: string; + render?: (row: T) => ReactNode; +} + +export interface DataTableProps { + columns: DataTableColumn[]; + rows: T[]; + rowKey: (row: T) => string; + sort?: DataTableSort; + onSort?: (key: string, direction: DataTableSortDirection) => void; + loading?: boolean; + empty?: ReactNode; + error?: ReactNode; + page?: number; + pageSize?: number; + totalRows?: number; + onPageChange?: (page: number) => void; + onRowClick?: (row: T) => void; + rowActions?: (row: T) => ReactNode; + className?: string; +} + +export function DataTable({ + columns, + rows, + rowKey, + sort, + onSort, + loading, + empty, + error, + page, + pageSize, + totalRows, + onPageChange, + onRowClick, + rowActions, + className, +}: DataTableProps) { + const hasPagination = + typeof page === "number" && + typeof pageSize === "number" && + typeof totalRows === "number" && + totalRows >= 0; + + const startRow = hasPagination ? Math.min((page - 1) * pageSize + 1, totalRows) : 1; + const endRow = hasPagination ? Math.min(page * pageSize, totalRows) : rows.length; + + const handleSort = (key: string) => { + if (!onSort) { + return; + } + const nextDirection = + sort?.key === key && sort.direction === "asc" ? "desc" : "asc"; + onSort(key, nextDirection); + }; + + const paginationVisible = hasPagination && totalRows > 0; + + return ( +
+ + + + {columns.map((column) => { + const isSorted = sort?.key === column.key; + const ariaSort = isSorted + ? sort.direction === "asc" + ? "ascending" + : "descending" + : "none"; + + return ( + + ); + })} + {rowActions ? + + + {loading ? ( + Array.from({ length: 3 }).map((_, index) => ( + + {columns.map((column) => ( + + ))} + {rowActions ? + )) + ) : null} + + {!loading && error ? ( + + + + ) : null} + + {!loading && !error && rows.length === 0 ? ( + + + + ) : null} + + {!loading && !error && rows.map((row) => ( + onRowClick(row) : undefined} + > + {columns.map((column) => { + const value = + column.render ? + column.render(row) : + (row as Record)[String(column.key)] as ReactNode; + + return ( + + ); + })} + {rowActions ? ( + + ) : null} + + ))} + +
handleSort(column.key) : undefined} + > + {column.header} + : null} +
+
+
: null} +
+ {error} +
+ {empty ?? "No rows found."} +
+ {value} + {rowActions(row)}
+ + {paginationVisible ? ( +
+ + Showing {startRow}–{endRow} of {totalRows} + +
+ + +
+
+ ) : null} +
+ ); +} diff --git a/frontend/components/admin/settlement-volume-chart.tsx b/frontend/components/admin/settlement-volume-chart.tsx index 1e97da5c..e3af98c3 100644 --- a/frontend/components/admin/settlement-volume-chart.tsx +++ b/frontend/components/admin/settlement-volume-chart.tsx @@ -109,7 +109,7 @@ export function SettlementVolumeChart({ tickFormatter={compactNumber} /> Number(value ?? 0).toLocaleString()} + formatter={(value: number | string) => Number(value ?? 0).toLocaleString()} cursor={{ fill: "rgba(100,116,139,0.1)" }} /> diff --git a/frontend/lib/admin-api.ts b/frontend/lib/admin-api.ts index 5ebf5c8e..04d20d90 100644 --- a/frontend/lib/admin-api.ts +++ b/frontend/lib/admin-api.ts @@ -162,11 +162,26 @@ function adminFetch(path: string, init?: RequestInit): Promise { // ── ledger accounts (FE-111) ───────────────────────────────────────────── +export interface AdminListPage { + data: T[]; + nextCursor?: string | null; + total?: number; +} + export function listAccounts( currency?: string, -): Promise { - const query = currency ? `?currency=${encodeURIComponent(currency)}` : ""; - return adminFetch(`/credits/admin/accounts${query}`); + limit?: number, + cursor?: string, +): Promise | LedgerAccount[]> { + const params = new URLSearchParams(); + if (currency) params.set("currency", currency); + if (typeof limit === "number") params.set("limit", String(limit)); + if (cursor) params.set("cursor", cursor); + + const query = params.toString() ? `?${params.toString()}` : ""; + return adminFetch | LedgerAccount[]>( + `/credits/admin/accounts${query}`, + ); } export function createAccount( @@ -258,9 +273,16 @@ export function runSettlement(): Promise { export function listBatches( status?: SettlementBatchStatus, -): Promise { - const query = status ? `?status=${encodeURIComponent(status)}` : ""; - return adminFetch( + limit?: number, + cursor?: string, +): Promise | SettlementBatch[]> { + const params = new URLSearchParams(); + if (status) params.set("status", status); + if (typeof limit === "number") params.set("limit", String(limit)); + if (cursor) params.set("cursor", cursor); + + const query = params.toString() ? `?${params.toString()}` : ""; + return adminFetch | SettlementBatch[]>( `/credits/admin/settlement/batches${query}`, ); } diff --git a/frontend/sentry.client.config.ts b/frontend/sentry.client.config.ts index 94cf65d2..28ed6568 100644 --- a/frontend/sentry.client.config.ts +++ b/frontend/sentry.client.config.ts @@ -76,13 +76,8 @@ Sentry.init({ process.env.NEXT_PUBLIC_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE, 0, ), - integrations: [ - Sentry.replayIntegration({ - maskAllText: true, - blockAllMedia: true, - }), - ], - beforeSend(event, hint) { + integrations: [], + beforeSend(event: any, hint: any) { if (event.exception) { const original = hint && typeof hint === "object" ? hint.originalException : undefined; diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 1f30ba2c..87bcfb51 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -10,6 +10,7 @@ import path from "node:path"; export default defineConfig({ plugins: [react()], test: { + globals: true, environment: "jsdom", setupFiles: ["./vitest.setup.ts"], exclude: [...configDefaults.exclude, "**/e2e/**"], diff --git a/frontend/vitest.setup.ts b/frontend/vitest.setup.ts index 112c1dce..77d877a2 100644 --- a/frontend/vitest.setup.ts +++ b/frontend/vitest.setup.ts @@ -1,5 +1,4 @@ -import { afterEach, vi } from "vitest"; -import { cleanup } from "@testing-library/react"; +import { vi } from "vitest"; import "@testing-library/jest-dom/vitest"; // API unit tests exercise our reporting payloads, not the Next.js Sentry @@ -9,9 +8,3 @@ vi.mock("@sentry/nextjs", () => ({ init: vi.fn(), replayIntegration: vi.fn(() => ({})), })); - -// RTL's automatic afterEach(cleanup) only registers itself when it detects a -// global `afterEach` (i.e. `test.globals: true`). This project doesn't set -// globals, so without this, each render() in a test file leaves its DOM -// mounted for the next test, breaking any query that expects a single match. -afterEach(cleanup); From d21a185d8ad3ca5394106d607521d3608a9f610d Mon Sep 17 00:00:00 2001 From: Lex0865 Date: Wed, 21 Oct 2026 04:59:04 -0600 Subject: [PATCH 2/2] ci fix --- frontend/e2e/admin-charge.spec.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/frontend/e2e/admin-charge.spec.ts b/frontend/e2e/admin-charge.spec.ts index e6355b72..46dcd835 100644 --- a/frontend/e2e/admin-charge.spec.ts +++ b/frontend/e2e/admin-charge.spec.ts @@ -70,6 +70,21 @@ test("an authenticated admin can charge a member through the admin page", async await adminPage.getByLabel("Reason").fill("E2E admin charge"); await adminPage.getByRole("button", { name: "Charge credits" }).click(); + const confirmationDialog = adminPage.getByRole("dialog", { + name: "Confirm charge", + }); + await expect(confirmationDialog).toBeVisible(); + + const chargeResponse = adminPage.waitForResponse( + (response) => + response.url().endsWith("/credits/charge") && + response.request().method() === "POST", + ); + await confirmationDialog + .getByRole("button", { name: "Confirm charge" }) + .click(); + expect((await chargeResponse).ok()).toBeTruthy(); + await expect(adminPage.getByText(/Posted.*new balance/)).toBeVisible(); await expect(adminPage.getByText("Transaction", { exact: false })).toBeVisible(); await adminContext.close();