From 49d9f31182325288f23f753b7215aae06edf7c39 Mon Sep 17 00:00:00 2001 From: NextJS Date: Mon, 11 May 2026 06:34:15 +0000 Subject: [PATCH] ledger revanced --- package.json | 4 +- src/app/actions/update-user-cc-rc.ts | 38 +- src/app/page.tsx | 113 +- src/components/admin-dashboard.tsx | 41 +- src/components/admin-ledger.tsx | 986 +++++++++++++----- src/components/edit-user-cc-rc-overlay.tsx | 37 + .../ledger-range-cumulative-chart.tsx | 194 ++++ src/lib/dashboard-search-url.ts | 29 + src/lib/ledger-global-summary-server.ts | 48 + src/lib/ledger-integrity.ts | 210 ++++ src/lib/ledger-scoped-daily-series.ts | 78 ++ src/lib/ledger-table-view.ts | 176 ++++ src/lib/ledger-utc-date-range.ts | 67 ++ 13 files changed, 1719 insertions(+), 302 deletions(-) create mode 100644 src/components/ledger-range-cumulative-chart.tsx create mode 100644 src/lib/ledger-global-summary-server.ts create mode 100644 src/lib/ledger-integrity.ts create mode 100644 src/lib/ledger-scoped-daily-series.ts create mode 100644 src/lib/ledger-table-view.ts create mode 100644 src/lib/ledger-utc-date-range.ts diff --git a/package.json b/package.json index cc0cd03..87d27ec 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,9 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev -p 2613", + "dev": "next dev -p 2614", "build": "next build", - "start": "next start -p 2613", + "start": "next start -p 2614", "lint": "eslint" }, "dependencies": { diff --git a/src/app/actions/update-user-cc-rc.ts b/src/app/actions/update-user-cc-rc.ts index 107aff0..e2150d5 100644 --- a/src/app/actions/update-user-cc-rc.ts +++ b/src/app/actions/update-user-cc-rc.ts @@ -8,6 +8,7 @@ import { type AdminDashboardTab, } from "@/lib/dashboard-search-url"; import { createAdminSupabase } from "@/lib/supabase/admin"; +import { normalizeLedgerPageSize } from "@/lib/ledger-table-view"; function parseScoreField(raw: FormDataEntryValue | null): number | null { if (raw == null) return null; @@ -41,7 +42,42 @@ export async function updateUserCcRc(formData: FormData) { const participant = participantRaw === "" ? null : participantRaw; - const base = { tab, highlightId, participantRaw: participant }; + const ledgerFromRaw = String(formData.get("ledgerFrom") ?? "").trim(); + const ledgerToRaw = String(formData.get("ledgerTo") ?? "").trim(); + const ledgerPageRaw = String(formData.get("ledgerPage") ?? "").trim(); + const ledgerPageSizeRaw = String( + formData.get("ledgerPageSize") ?? "", + ).trim(); + const ledgerSortRaw = String(formData.get("ledgerSort") ?? "").trim(); + const ledgerOrderRaw = String(formData.get("ledgerOrder") ?? "").trim(); + const ledgerPageNum = + ledgerPageRaw === "" ? NaN : Number.parseInt(ledgerPageRaw, 10); + + const base = { + tab, + highlightId, + participantRaw: participant, + ...(tab === "ledger" + ? { + ledgerFrom: ledgerFromRaw || null, + ledgerTo: ledgerToRaw || null, + ledgerPage: + Number.isInteger(ledgerPageNum) && ledgerPageNum > 1 + ? ledgerPageNum + : null, + ledgerSort: ledgerSortRaw || null, + ledgerOrder: + ledgerOrderRaw === "desc" + ? ("desc" as const) + : ledgerOrderRaw === "asc" + ? ("asc" as const) + : null, + ledgerPageSize: normalizeLedgerPageSize( + ledgerPageSizeRaw === "" ? null : ledgerPageSizeRaw, + ), + } + : {}), + }; if (!Number.isInteger(userId) || userId < 1) { redirect(buildDashboardHref(base)); diff --git a/src/app/page.tsx b/src/app/page.tsx index be518ba..a9d0b4e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,6 +2,15 @@ import { AdminDashboard } from "@/components/admin-dashboard"; import { AdminHeader } from "@/components/admin-header"; import { EditUserCcRcOverlay } from "@/components/edit-user-cc-rc-overlay"; import { loadDashboardStatsBundle } from "@/lib/dashboard-stats"; +import { + normalizeLedgerDateRange, + utcLast30DaysDateRange, +} from "@/lib/ledger-utc-date-range"; +import { fetchLedgerGlobalSummary } from "@/lib/ledger-global-summary-server"; +import { + serializeLedgerGlobalSummary, + type SerializedLedgerGlobalSummary, +} from "@/lib/ledger-integrity"; import { createAdminSupabase } from "@/lib/supabase/admin"; import type { AdminDashboardTab } from "@/lib/dashboard-search-url"; import { @@ -10,6 +19,16 @@ import { } from "@/lib/matchmaker-log-source"; import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server"; import type { DbMatch, DbTransaction, DbUser } from "@/types/database"; +import { + normalizeLedgerPage, + normalizeLedgerPageSize, + normalizeLedgerSortKey, + normalizeLedgerSortOrder, + sliceLedgerPage, + sortLedgerTransactions, + type LedgerSortKey, + type LedgerSortOrder, +} from "@/lib/ledger-table-view"; export const dynamic = "force-dynamic"; @@ -31,6 +50,12 @@ export default async function Home({ edit?: string | string[]; saveError?: string | string[]; mklog?: string | string[]; + lfrom?: string | string[]; + lto?: string | string[]; + lpage?: string | string[]; + lsize?: string | string[]; + lsort?: string | string[]; + lorder?: string | string[]; }>; }) { const sp = await searchParams; @@ -58,8 +83,18 @@ export default async function Home({ let configError: string | null = null; let usersError: string | null = null; let matchesError: string | null = null; - let transactions: DbTransaction[] = []; let transactionsError: string | null = null; + let ledgerGlobalSummary: SerializedLedgerGlobalSummary | null = null; + let ledgerTransactionsFiltered: DbTransaction[] = []; + let ledgerTableRows: DbTransaction[] = []; + let ledgerTotalRowsInRange = 0; + let ledgerPageView = 1; + let ledgerPageSizeView = normalizeLedgerPageSize(null); + let ledgerTotalPagesView = 1; + let ledgerSortKeyView: LedgerSortKey = "created_at"; + let ledgerSortOrderView: LedgerSortOrder = "desc"; + let ledgerFrom = utcLast30DaysDateRange().from; + let ledgerTo = utcLast30DaysDateRange().to; let statsBundle: Awaited> | null = null; @@ -108,15 +143,61 @@ export default async function Home({ statsBundle = await loadDashboardStatsBundle(supabase); if (tab === "ledger") { - const txRes = await supabase + const lfromRaw = firstSearchParam(sp.lfrom); + const ltoRaw = firstSearchParam(sp.lto); + const range = normalizeLedgerDateRange(lfromRaw, ltoRaw); + ledgerFrom = range.from; + ledgerTo = range.to; + const rangeStartIso = `${ledgerFrom}T00:00:00.000Z`; + const rangeEndIso = `${ledgerTo}T23:59:59.999Z`; + + const filteredQuery = supabase .from("transactions") .select("id, created_at, from, to, amount, remarks, match_id") - .order("id", { ascending: false }) - .limit(5000); - if (txRes.error) { - transactionsError = txRes.error.message; + .gte("created_at", rangeStartIso) + .lte("created_at", rangeEndIso) + .order("created_at", { ascending: true }) + .order("id", { ascending: true }) + .limit(10000); + + const [globalRes, filteredRes] = await Promise.all([ + fetchLedgerGlobalSummary(supabase), + filteredQuery, + ]); + + if (!globalRes.ok) { + transactionsError = globalRes.message; } else { - transactions = (txRes.data ?? []) as DbTransaction[]; + ledgerGlobalSummary = serializeLedgerGlobalSummary(globalRes.data); + } + if (filteredRes.error) { + if (!transactionsError) { + transactionsError = filteredRes.error.message; + } + } else { + ledgerTransactionsFiltered = (filteredRes.data ?? + []) as DbTransaction[]; + const pageRaw = normalizeLedgerPage(firstSearchParam(sp.lpage)); + ledgerPageSizeView = normalizeLedgerPageSize( + firstSearchParam(sp.lsize), + ); + ledgerSortKeyView = normalizeLedgerSortKey( + firstSearchParam(sp.lsort), + ); + ledgerSortOrderView = normalizeLedgerSortOrder( + firstSearchParam(sp.lorder), + ledgerSortKeyView, + ); + const sorted = sortLedgerTransactions( + ledgerTransactionsFiltered, + ledgerSortKeyView, + ledgerSortOrderView, + ); + const slice = sliceLedgerPage(sorted, pageRaw, ledgerPageSizeView); + ledgerTableRows = slice.rows; + ledgerTotalRowsInRange = slice.totalRows; + ledgerPageView = slice.page; + ledgerTotalPagesView = slice.totalPages; } } } @@ -145,7 +226,6 @@ export default async function Home({ matches={matches} usersError={usersError} matchesError={matchesError} - transactions={transactions} transactionsError={transactionsError} statsBundle={statsBundle} tab={tab} @@ -154,6 +234,17 @@ export default async function Home({ matchmakerSource={matchmakerSource} matchmakerContent={matchmakerContent} matchmakerError={matchmakerError} + ledgerGlobalSummary={ledgerGlobalSummary} + ledgerTransactionsFiltered={ledgerTransactionsFiltered} + ledgerTableRows={ledgerTableRows} + ledgerTotalRowsInRange={ledgerTotalRowsInRange} + ledgerPage={ledgerPageView} + ledgerPageSize={ledgerPageSizeView} + ledgerTotalPages={ledgerTotalPagesView} + ledgerSort={ledgerSortKeyView} + ledgerOrder={ledgerSortOrderView} + ledgerFrom={ledgerFrom} + ledgerTo={ledgerTo} /> {editUser ? ( ) : null} diff --git a/src/components/admin-dashboard.tsx b/src/components/admin-dashboard.tsx index a1bd353..e8d06c4 100644 --- a/src/components/admin-dashboard.tsx +++ b/src/components/admin-dashboard.tsx @@ -12,6 +12,8 @@ import { } from "@/lib/dashboard-search-url"; import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source"; import type { DbMatch, DbTransaction, DbUser } from "@/types/database"; +import type { SerializedLedgerGlobalSummary } from "@/lib/ledger-integrity"; +import type { LedgerSortKey, LedgerSortOrder } from "@/lib/ledger-table-view"; /** Bigint columns often arrive as strings from PostgREST / JSON. */ function matchHasRecordedWinner(winnerId: DbMatch["winner_id"]): boolean { @@ -59,8 +61,21 @@ type Props = { matches: DbMatch[]; usersError: string | null; matchesError: string | null; - transactions: DbTransaction[]; transactionsError: string | null; + /** Ledger tab: full-database totals + user nets (server scan). */ + ledgerGlobalSummary: SerializedLedgerGlobalSummary | null; + /** Ledger tab: rows in the selected UTC date range (full set for audits). */ + ledgerTransactionsFiltered: DbTransaction[]; + /** Ledger tab: current page of the ledger table (sorted + paginated). */ + ledgerTableRows: DbTransaction[]; + ledgerTotalRowsInRange: number; + ledgerPage: number; + ledgerPageSize: number; + ledgerTotalPages: number; + ledgerSort: LedgerSortKey; + ledgerOrder: LedgerSortOrder; + ledgerFrom: string; + ledgerTo: string; statsBundle: DashboardStatsBundle | null; /** From server `searchParams` so SSR and client markup match (do not use `useSearchParams` here). */ tab: AdminDashboardTab; @@ -99,8 +114,18 @@ export function AdminDashboard({ matches, usersError, matchesError, - transactions, transactionsError, + ledgerGlobalSummary, + ledgerTransactionsFiltered, + ledgerTableRows, + ledgerTotalRowsInRange, + ledgerPage, + ledgerPageSize, + ledgerTotalPages, + ledgerSort, + ledgerOrder, + ledgerFrom, + ledgerTo, statsBundle, tab, highlightId, @@ -503,11 +528,21 @@ export function AdminDashboard({ ) : tab === "ledger" ? ( ) : (
diff --git a/src/components/admin-ledger.tsx b/src/components/admin-ledger.tsx index 7c1378b..caeeea3 100644 --- a/src/components/admin-ledger.tsx +++ b/src/components/admin-ledger.tsx @@ -5,8 +5,41 @@ import { useMemo } from "react"; import { buildDashboardHref } from "@/lib/dashboard-search-url"; import { formatRcLabelFromCoinsBigInt } from "@/lib/coins-rc"; import { auditMatchEconomics } from "@/lib/ledger-match-audit"; +import { + computeLedgerIntegrity, + parseSerializedLedgerGlobalSummary, + txAmountToBigInt, + type LedgerIntegrity, + type SerializedLedgerGlobalSummary, +} from "@/lib/ledger-integrity"; +import { + DEFAULT_LEDGER_PAGE_SIZE, + LEDGER_PAGE_SIZE_OPTIONS, + ledgerPaginationItems, + type LedgerSortKey, + type LedgerSortOrder, +} from "@/lib/ledger-table-view"; +import { buildLedgerScopedDailyCumulativeSeries } from "@/lib/ledger-scoped-daily-series"; +import { LedgerRangeCumulativeChart } from "@/components/ledger-range-cumulative-chart"; import type { DbTransaction, DbUser } from "@/types/database"; +function emptyIntegrity(): LedgerIntegrity { + const z = BigInt(0); + return { + mintTotal: z, + sinkTotal: z, + entryFeeCoinsTotal: z, + purchaseCoinsTotal: z, + entryHoldCoinsTotal: z, + rewardCoinsTotal: z, + sumUserNets: z, + balanced: true, + totalDebitVolume: z, + totalCreditVolume: z, + volumeBalanced: true, + }; +} + function formatTsUtc(value: string | null): string { if (!value) return "—"; try { @@ -18,20 +51,6 @@ function formatTsUtc(value: string | null): string { } } -function txAmountToBigInt(raw: number | string): bigint { - if (typeof raw === "number") { - if (!Number.isFinite(raw)) return BigInt(0); - return BigInt(Math.trunc(raw)); - } - const t = String(raw).trim(); - if (t === "" || !/^-?\d+$/.test(t)) return BigInt(0); - try { - return BigInt(t); - } catch { - return BigInt(0); - } -} - function formatBigAmount(n: bigint): string { return n.toLocaleString("en-US"); } @@ -50,102 +69,40 @@ function remarkClass(remarks: string | null): string { return "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200"; } -function isEntryFeeRemark(remarks: string | null): boolean { - return (remarks ?? "").toLowerCase().trim() === "entry_fee"; -} - -export type LedgerIntegrity = { - mintTotal: bigint; - /** Outflows with no payee (kept for internal checks only). */ - sinkTotal: bigint; - entryFeeCoinsTotal: bigint; - purchaseCoinsTotal: bigint; - entryHoldCoinsTotal: bigint; - rewardCoinsTotal: bigint; - sumUserNets: bigint; - balanced: boolean; - totalDebitVolume: bigint; - totalCreditVolume: bigint; - volumeBalanced: boolean; -}; - -function computeIntegrity(rows: DbTransaction[]): LedgerIntegrity { - let mintTotal = BigInt(0); - let sinkTotal = BigInt(0); - let entryFeeCoinsTotal = BigInt(0); - let purchaseCoinsTotal = BigInt(0); - let entryHoldCoinsTotal = BigInt(0); - let rewardCoinsTotal = BigInt(0); - const net = new Map(); - let totalDebitVolume = BigInt(0); - let totalCreditVolume = BigInt(0); - - for (const row of rows) { - const a = txAmountToBigInt(row.amount); - if (a === BigInt(0)) continue; - if (isEntryFeeRemark(row.remarks)) { - entryFeeCoinsTotal += a; - } - const remark = (row.remarks ?? "").toLowerCase().trim(); - if (remark === "purchase") purchaseCoinsTotal += a; - if (remark === "entry_hold") entryHoldCoinsTotal += a; - if (remark === "reward") rewardCoinsTotal += a; - if (row.from == null) { - mintTotal += a; - } else { - totalDebitVolume += a; - const prev = net.get(row.from) ?? BigInt(0); - net.set(row.from, prev - a); - } - if (row.to == null) { - sinkTotal += a; - } else { - totalCreditVolume += a; - const prev = net.get(row.to) ?? BigInt(0); - net.set(row.to, prev + a); - } - } - - let sumUserNets = BigInt(0); - for (const v of net.values()) { - sumUserNets += v; - } - - const netIssued = mintTotal - sinkTotal; - const balanced = sumUserNets === netIssued; - - const volumeBalanced = - totalDebitVolume + mintTotal === totalCreditVolume + sinkTotal; - - return { - mintTotal, - sinkTotal, - entryFeeCoinsTotal, - purchaseCoinsTotal, - entryHoldCoinsTotal, - rewardCoinsTotal, - sumUserNets, - balanced, - totalDebitVolume, - totalCreditVolume, - volumeBalanced, - }; -} - type Props = { - transactions: DbTransaction[]; + ledgerGlobalSummary: SerializedLedgerGlobalSummary | null; + transactionsScoped: DbTransaction[]; + ledgerTableRows: DbTransaction[]; + ledgerTotalRowsInRange: number; + ledgerPage: number; + ledgerPageSize: number; + ledgerTotalPages: number; + ledgerSort: LedgerSortKey; + ledgerOrder: LedgerSortOrder; users: DbUser[]; error: string | null; highlightId: string | null; participantRaw: string | null; + ledgerFrom: string; + ledgerTo: string; }; export function AdminLedger({ - transactions, + ledgerGlobalSummary, + transactionsScoped, + ledgerTableRows, + ledgerTotalRowsInRange, + ledgerPage, + ledgerPageSize, + ledgerTotalPages, + ledgerSort, + ledgerOrder, users, error, highlightId, participantRaw, + ledgerFrom, + ledgerTo, }: Props) { const usernameById = useMemo(() => { const m = new Map(); @@ -155,42 +112,81 @@ export function AdminLedger({ return m; }, [users]); - const integrity = useMemo( - () => computeIntegrity(transactions), - [transactions], + const { integrityGlobal, userNets } = useMemo(() => { + if (ledgerGlobalSummary == null) { + return { + integrityGlobal: emptyIntegrity(), + userNets: [] as [number, bigint][], + }; + } + const { integrity, userNets } = + parseSerializedLedgerGlobalSummary(ledgerGlobalSummary); + return { integrityGlobal: integrity, userNets }; + }, [ledgerGlobalSummary]); + + const integrityScoped = useMemo( + () => computeLedgerIntegrity(transactionsScoped), + [transactionsScoped], + ); + + const scopedDailyCumulative = useMemo( + () => + buildLedgerScopedDailyCumulativeSeries( + transactionsScoped, + ledgerFrom, + ledgerTo, + ), + [transactionsScoped, ledgerFrom, ledgerTo], ); const matchChecks = useMemo( - () => auditMatchEconomics(transactions), - [transactions], + () => auditMatchEconomics(transactionsScoped), + [transactionsScoped], ); - const userNets = useMemo(() => { - const net = new Map(); - for (const row of transactions) { - const a = txAmountToBigInt(row.amount); - if (a === BigInt(0)) continue; - if (row.from != null) { - net.set(row.from, (net.get(row.from) ?? BigInt(0)) - a); - } - if (row.to != null) { - net.set(row.to, (net.get(row.to) ?? BigInt(0)) + a); - } - } - return [...net.entries()] - .filter(([, v]) => v !== BigInt(0)) - .sort((a, b) => { - const da = a[1] < BigInt(0) ? -a[1] : a[1]; - const db = b[1] < BigInt(0) ? -b[1] : b[1]; - if (da === db) return a[0] - b[0]; - return db > da ? 1 : -1; - }); - }, [transactions]); + function ledgerHref( + override: Partial<{ + ledgerPage: number; + ledgerSort: LedgerSortKey; + ledgerOrder: LedgerSortOrder; + ledgerPageSize: number; + }>, + ): string { + return buildDashboardHref({ + tab: "ledger", + highlightId, + participantRaw, + ledgerFrom, + ledgerTo, + ledgerPage: override.ledgerPage ?? ledgerPage, + ledgerSort: override.ledgerSort ?? ledgerSort, + ledgerOrder: override.ledgerOrder ?? ledgerOrder, + ledgerPageSize: override.ledgerPageSize ?? ledgerPageSize, + }); + } - const ledgerRowsChronological = useMemo( - () => [...transactions].sort((a, b) => a.id - b.id), - [transactions], - ); + function sortToggleHref(column: LedgerSortKey): string { + const nextOrder: LedgerSortOrder = + ledgerSort === column + ? ledgerOrder === "asc" + ? "desc" + : "asc" + : "asc"; + return ledgerHref({ + ledgerSort: column, + ledgerOrder: nextOrder, + ledgerPage: 1, + }); + } + + function sortHeaderClass(column: LedgerSortKey): string { + return [ + "whitespace-nowrap px-4 py-3 font-medium transition", + ledgerSort === column + ? "text-sky-800 dark:text-sky-200" + : "text-zinc-600 dark:text-zinc-400", + ].join(" "); + } function userCell(id: number | null, role: "from" | "to") { if (id == null) { @@ -221,17 +217,41 @@ export function AdminLedger({ ); } - const feeRcLabel = formatRcLabelFromCoinsBigInt( - integrity.entryFeeCoinsTotal, + const feeRcLabelGlobal = formatRcLabelFromCoinsBigInt( + integrityGlobal.entryFeeCoinsTotal, ); - const purchaseRcLabel = formatRcLabelFromCoinsBigInt( - integrity.purchaseCoinsTotal, + const purchaseRcLabelGlobal = formatRcLabelFromCoinsBigInt( + integrityGlobal.purchaseCoinsTotal, ); - const entryHoldRcLabel = formatRcLabelFromCoinsBigInt( - integrity.entryHoldCoinsTotal, + const entryHoldRcLabelGlobal = formatRcLabelFromCoinsBigInt( + integrityGlobal.entryHoldCoinsTotal, + ); + const feeRcLabelScoped = formatRcLabelFromCoinsBigInt( + integrityScoped.entryFeeCoinsTotal, + ); + const purchaseRcLabelScoped = formatRcLabelFromCoinsBigInt( + integrityScoped.purchaseCoinsTotal, + ); + const entryHoldRcLabelScoped = formatRcLabelFromCoinsBigInt( + integrityScoped.entryHoldCoinsTotal, ); const holdRewardMismatch = - integrity.entryHoldCoinsTotal !== integrity.rewardCoinsTotal; + integrityScoped.entryHoldCoinsTotal !== + integrityScoped.rewardCoinsTotal; + + const pageStart = + ledgerTotalRowsInRange === 0 + ? 0 + : (ledgerPage - 1) * ledgerPageSize + 1; + const pageEnd = Math.min( + ledgerPage * ledgerPageSize, + ledgerTotalRowsInRange, + ); + const pageNavItems = ledgerPaginationItems(ledgerPage, ledgerTotalPages); + + const navLinkClass = + "rounded px-2 py-1 font-medium text-sky-700 underline decoration-sky-400/50 underline-offset-2 hover:bg-sky-50 dark:text-sky-300 dark:hover:bg-sky-950/40"; + const navMutedClass = "rounded px-2 py-1 text-zinc-400 dark:text-zinc-500"; return (
@@ -243,121 +263,333 @@ export function AdminLedger({ Ledger book {" — "} - double-entry view by date range (default last month UTC). + The ledger table and match checks use the selected UTC range (default + last 30 days). User nets, Σ issuance, and the three all-time fee / + purchase / hold cards are computed from{" "} + every transaction row. The same + metrics for the date range only appear under the filter.

{error ? (

{error}

) : null} -
-
-

- Rows loaded -

-

- {transactions.length.toLocaleString("en-US")} -

-
-
-

- Mint total (∅ from) -

-

- {formatBigAmount(integrity.mintTotal)} -

-
-
-

- Fee collected (entry_fee) -

-

+

+
- {feeRcLabel} -

-

- {formatBigAmount(integrity.entryFeeCoinsTotal)} coins -

-
-
-

- Total purchase -

-

- {purchaseRcLabel} -

-

- {formatBigAmount(integrity.purchaseCoinsTotal)} coins -

-
-
-

- Total entry_hold / reward -

-

- {entryHoldRcLabel} -

-

- {formatBigAmount(integrity.entryHoldCoinsTotal)} coins -

-
-
-

- Σ user nets vs net issued -

-
-

- {formatBigAmount(integrity.sumUserNets)} +

+ Σ user nets vs net issued

-

- ={" "} - {formatBigAmount( - integrity.mintTotal - integrity.sinkTotal, - )} +

+ All transactions +

+
+

+ {formatBigAmount(integrityGlobal.sumUserNets)} +

+

+ ={" "} + {formatBigAmount( + integrityGlobal.mintTotal - integrityGlobal.sinkTotal, + )} +

+
+

+ {integrityGlobal.balanced + ? "Balanced ✓" + : "Mismatch — inspect data"}

-

- {integrity.balanced ? "Balanced ✓" : "Mismatch — inspect data"} -

+ +
+
+

+ Fee collected (entry_fee) +

+

+ All-time +

+

+ {feeRcLabelGlobal} +

+

+ {formatBigAmount(integrityGlobal.entryFeeCoinsTotal)} coins +

+
+
+

+ Total purchase +

+

+ All-time +

+

+ {purchaseRcLabelGlobal} +

+

+ {formatBigAmount(integrityGlobal.purchaseCoinsTotal)} coins +

+
+
+

+ Total entry_hold / reward +

+

+ All-time +

+

+ {entryHoldRcLabelGlobal} +

+

+ {formatBigAmount(integrityGlobal.entryHoldCoinsTotal)} coins +

+
+
+
+ +
+
+

+ User net (credits − debits) +

+

+ All transactions +

+
+
+ + + + + + + + + + {userNets.length === 0 ? ( + + + + ) : ( + userNets.map(([uid, bal]) => ( + + + + + + )) + )} + +
User + Net (coins) + Net (RC)
+ No non-zero balances in loaded rows. +
+ {userCell(uid, "from")} + BigInt(0) + ? "text-emerald-700 dark:text-emerald-300" + : bal < BigInt(0) + ? "text-rose-700 dark:text-rose-300" + : "", + ].join(" ")} + > + {bal > BigInt(0) ? "+" : ""} + {formatBigAmount(bal)} + + {bal < BigInt(0) + ? `−${formatRcLabelFromCoinsBigInt(-bal)}` + : formatRcLabelFromCoinsBigInt(bal)} +
+
+ +
+ + {highlightId ? ( + + ) : null} + {participantRaw ? ( + + ) : null} + + + {ledgerPageSize !== DEFAULT_LEDGER_PAGE_SIZE ? ( + + ) : null} +
+ + +
+
+ + +
+ + + Reset range + +
+ +
+

+ In selected range ( + + {ledgerFrom}–{ledgerTo} + {" "} + UTC) +

+
+
+

+ Fee collected (entry_fee) +

+

+ {feeRcLabelScoped} +

+

+ {formatBigAmount(integrityScoped.entryFeeCoinsTotal)} coins +

+ +
+
+

+ Total purchase +

+

+ {purchaseRcLabelScoped} +

+

+ {formatBigAmount(integrityScoped.purchaseCoinsTotal)} coins +

+ +
+
+

+ Total entry_hold / reward +

+

+ {entryHoldRcLabelScoped} +

+

+ {formatBigAmount(integrityScoped.entryHoldCoinsTotal)} coins +

+ +
+
+
+ {holdRewardMismatch ? (
Data mismatch: entry_hold total ( - {formatBigAmount(integrity.entryHoldCoinsTotal)} coins) does not equal{" "} + {formatBigAmount(integrityScoped.entryHoldCoinsTotal)} coins) does not + equal{" "} reward total ( - {formatBigAmount(integrity.rewardCoinsTotal)} coins). + {formatBigAmount(integrityScoped.rewardCoinsTotal)} coins).
) : null} - {!integrity.volumeBalanced ? ( + {!integrityScoped.volumeBalanced ? (
Debit / credit volume: debits + mint ( - {formatBigAmount(integrity.totalDebitVolume + integrity.mintTotal)}) - ≠ credits + null-{`to`} outflows ( {formatBigAmount( - integrity.totalCreditVolume + integrity.sinkTotal, + integrityScoped.totalDebitVolume + integrityScoped.mintTotal, + )} + ) ≠ credits + null-{`to`} outflows ( + {formatBigAmount( + integrityScoped.totalCreditVolume + integrityScoped.sinkTotal, )} ). This usually means inconsistent rows (e.g. both {`from`} and{" "} {`to`} null with non-zero amount). @@ -369,25 +601,165 @@ export function AdminLedger({ Ledger

- All {ledgerRowsChronological.length.toLocaleString("en-US")} rows · - oldest first + {ledgerTotalRowsInRange.toLocaleString("en-US")} rows in range{" "} + + {ledgerFrom}–{ledgerTo} + {" "} + UTC (cap 10,000) · default sort newest first; click a column header to + sort +

+

+ + Rows per page + + {LEDGER_PAGE_SIZE_OPTIONS.map((n) => + n === ledgerPageSize ? ( + + {n} + + ) : ( + + {n} + + ), + )}

- - - - - - - - + + + + + + + + - {ledgerRowsChronological.length === 0 ? ( + {ledgerTotalRowsInRange === 0 ? ( ) : ( - ledgerRowsChronological.map((row) => { + ledgerTableRows.map((row) => { const amt = txAmountToBigInt(row.amount); return (
IDCreatedFromToAmountRCRemarksMatch + + ID + {ledgerSort === "id" ? ( + + {ledgerOrder === "asc" ? "▲" : "▼"} + + ) : null} + + + + Created + {ledgerSort === "created_at" ? ( + + {ledgerOrder === "asc" ? "▲" : "▼"} + + ) : null} + + + + From + {ledgerSort === "from" ? ( + + {ledgerOrder === "asc" ? "▲" : "▼"} + + ) : null} + + + + To + {ledgerSort === "to" ? ( + + {ledgerOrder === "asc" ? "▲" : "▼"} + + ) : null} + + + + Amount + {ledgerSort === "amount" ? ( + + {ledgerOrder === "asc" ? "▲" : "▼"} + + ) : null} + + + + RC + {ledgerSort === "rc" ? ( + + {ledgerOrder === "asc" ? "▲" : "▼"} + + ) : null} + + + + Remarks + {ledgerSort === "remarks" ? ( + + {ledgerOrder === "asc" ? "▲" : "▼"} + + ) : null} + + + + Match + {ledgerSort === "match_id" ? ( + + {ledgerOrder === "asc" ? "▲" : "▼"} + + ) : null} + +
+ {ledgerTotalRowsInRange > 0 ? ( +
+

+ Rows {pageStart.toLocaleString("en-US")}– + {pageEnd.toLocaleString("en-US")} of{" "} + {ledgerTotalRowsInRange.toLocaleString("en-US")} · Page{" "} + {ledgerPage.toLocaleString("en-US")} /{" "} + {ledgerTotalPages.toLocaleString("en-US")} +

+ +
+ ) : null}
{matchChecks.length > 0 ? ( @@ -501,60 +965,6 @@ export function AdminLedger({
) : null} -
-

- User net (credits − debits) -

-
- - - - - - - - - - {userNets.length === 0 ? ( - - - - ) : ( - userNets.map(([uid, bal]) => ( - - - - - - )) - )} - -
UserNet (coins)Net (RC)
- No non-zero balances in loaded rows. -
- {userCell(uid, "from")} - BigInt(0) - ? "text-emerald-700 dark:text-emerald-300" - : bal < BigInt(0) - ? "text-rose-700 dark:text-rose-300" - : "", - ].join(" ")} - > - {bal > BigInt(0) ? "+" : ""} - {formatBigAmount(bal)} - - {bal < BigInt(0) - ? `−${formatRcLabelFromCoinsBigInt(-bal)}` - : formatRcLabelFromCoinsBigInt(bal)} -
-
-
); } diff --git a/src/components/edit-user-cc-rc-overlay.tsx b/src/components/edit-user-cc-rc-overlay.tsx index f974907..1dba805 100644 --- a/src/components/edit-user-cc-rc-overlay.tsx +++ b/src/components/edit-user-cc-rc-overlay.tsx @@ -4,6 +4,7 @@ import { buildDashboardHref, type AdminDashboardTab, } from "@/lib/dashboard-search-url"; +import type { LedgerSortKey, LedgerSortOrder } from "@/lib/ledger-table-view"; import type { DbUser } from "@/types/database"; type Props = { @@ -12,6 +13,12 @@ type Props = { highlightId: string | null; participantRaw: string | null; saveError: boolean; + ledgerFrom: string; + ledgerTo: string; + ledgerPage: number; + ledgerPageSize: number; + ledgerSort: LedgerSortKey; + ledgerOrder: LedgerSortOrder; }; export function EditUserCcRcOverlay({ @@ -20,11 +27,27 @@ export function EditUserCcRcOverlay({ highlightId, participantRaw, saveError, + ledgerFrom, + ledgerTo, + ledgerPage, + ledgerPageSize, + ledgerSort, + ledgerOrder, }: Props) { const cancelHref = buildDashboardHref({ tab, highlightId, participantRaw, + ...(tab === "ledger" + ? { + ledgerFrom, + ledgerTo, + ledgerPage, + ledgerPageSize, + ledgerSort, + ledgerOrder, + } + : {}), }); return ( @@ -80,6 +103,20 @@ export function EditUserCcRcOverlay({ name="participantRaw" value={participantRaw ?? ""} /> + {tab === "ledger" ? ( + <> + + + + + + + + ) : null}