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/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(); 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);