This commit is contained in:
NextJS
2026-05-11 04:30:39 +00:00
parent 0f6e979aac
commit b2be61190f
13 changed files with 1361 additions and 6 deletions
+25 -1
View File
@@ -2,6 +2,7 @@
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { AdminLedger } from "@/components/admin-ledger";
import { MatchHistoryBattleCard } from "@/components/match-history-battle-card";
import type { DashboardStatsBundle } from "@/lib/dashboard-stats";
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
@@ -10,7 +11,7 @@ import {
type AdminDashboardTab,
} from "@/lib/dashboard-search-url";
import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source";
import type { DbMatch, DbUser } from "@/types/database";
import type { DbMatch, DbTransaction, DbUser } from "@/types/database";
/** Bigint columns often arrive as strings from PostgREST / JSON. */
function matchHasRecordedWinner(winnerId: DbMatch["winner_id"]): boolean {
@@ -58,6 +59,8 @@ type Props = {
matches: DbMatch[];
usersError: string | null;
matchesError: string | null;
transactions: DbTransaction[];
transactionsError: string | null;
statsBundle: DashboardStatsBundle | null;
/** From server `searchParams` so SSR and client markup match (do not use `useSearchParams` here). */
tab: AdminDashboardTab;
@@ -96,6 +99,8 @@ export function AdminDashboard({
matches,
usersError,
matchesError,
transactions,
transactionsError,
statsBundle,
tab,
highlightId,
@@ -228,6 +233,17 @@ export function AdminDashboard({
>
Matchmaker
</Link>
<Link
href={buildDashboardHref({
tab: "ledger",
highlightId,
participantRaw,
})}
className={tabClass(tab === "ledger")}
scroll={false}
>
Ledger
</Link>
<Link
href="/settings"
className={tabClass(false)}
@@ -485,6 +501,14 @@ export function AdminDashboard({
errorMessage={matchmakerError}
/>
</section>
) : tab === "ledger" ? (
<AdminLedger
transactions={transactions}
users={users}
error={transactionsError}
highlightId={highlightId}
participantRaw={participantRaw}
/>
) : (
<section className="space-y-3">
{participantId != null ? (
+560
View File
@@ -0,0 +1,560 @@
"use client";
import Link from "next/link";
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 type { DbTransaction, DbUser } from "@/types/database";
function formatTsUtc(value: string | null): string {
if (!value) return "—";
try {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return d.toISOString().replace("T", " ").slice(0, 19) + " UTC";
} catch {
return value;
}
}
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");
}
function remarkClass(remarks: string | null): string {
const r = (remarks ?? "").toLowerCase();
if (r === "supply") return "bg-emerald-100 text-emerald-900 dark:bg-emerald-950/70 dark:text-emerald-100";
if (r === "purchase")
return "bg-sky-100 text-sky-900 dark:bg-sky-950/70 dark:text-sky-100";
if (r === "entry_hold")
return "bg-amber-100 text-amber-950 dark:bg-amber-950/60 dark:text-amber-100";
if (r === "entry_fee")
return "bg-violet-100 text-violet-900 dark:bg-violet-950/60 dark:text-violet-100";
if (r === "reward")
return "bg-rose-100 text-rose-900 dark:bg-rose-950/60 dark:text-rose-100";
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<number, bigint>();
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[];
users: DbUser[];
error: string | null;
highlightId: string | null;
participantRaw: string | null;
};
export function AdminLedger({
transactions,
users,
error,
highlightId,
participantRaw,
}: Props) {
const usernameById = useMemo(() => {
const m = new Map<number, string | null>();
for (const u of users) {
m.set(u.id, u.username);
}
return m;
}, [users]);
const integrity = useMemo(
() => computeIntegrity(transactions),
[transactions],
);
const matchChecks = useMemo(
() => auditMatchEconomics(transactions),
[transactions],
);
const userNets = useMemo(() => {
const net = new Map<number, bigint>();
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]);
const ledgerRowsChronological = useMemo(
() => [...transactions].sort((a, b) => a.id - b.id),
[transactions],
);
function userCell(id: number | null, role: "from" | "to") {
if (id == null) {
return (
<span className="text-zinc-400 italic dark:text-zinc-500">
{role === "from" ? "∅ mint" : "∅"}
</span>
);
}
const un = usernameById.get(id);
return (
<span className="font-mono text-xs">
<Link
href={buildDashboardHref({
tab: "players",
highlightId: String(id),
participantRaw,
})}
className="text-sky-700 underline decoration-sky-400/60 underline-offset-2 hover:text-sky-900 dark:text-sky-300 dark:hover:text-sky-100"
scroll={false}
>
{id}
</Link>
{un != null && un !== "" ? (
<span className="text-zinc-600 dark:text-zinc-400"> ({un})</span>
) : null}
</span>
);
}
const feeRcLabel = formatRcLabelFromCoinsBigInt(
integrity.entryFeeCoinsTotal,
);
const purchaseRcLabel = formatRcLabelFromCoinsBigInt(
integrity.purchaseCoinsTotal,
);
const entryHoldRcLabel = formatRcLabelFromCoinsBigInt(
integrity.entryHoldCoinsTotal,
);
const holdRewardMismatch =
integrity.entryHoldCoinsTotal !== integrity.rewardCoinsTotal;
return (
<section className="space-y-6">
<p className="text-sm text-zinc-600 dark:text-zinc-400">
<Link
href="/ledger-book"
className="font-medium text-sky-700 underline decoration-sky-400/60 underline-offset-2 hover:text-sky-900 dark:text-sky-300 dark:hover:text-sky-100"
>
Ledger book
</Link>
{" — "}
double-entry view by date range (default last month UTC).
</p>
{error ? (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
) : null}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-6">
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Rows loaded
</p>
<p className="mt-1 text-2xl font-semibold tabular-nums text-zinc-900 dark:text-zinc-50">
{transactions.length.toLocaleString("en-US")}
</p>
</div>
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Mint total ( from)
</p>
<p className="mt-1 text-2xl font-semibold tabular-nums text-emerald-800 dark:text-emerald-200">
{formatBigAmount(integrity.mintTotal)}
</p>
</div>
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Fee collected (entry_fee)
</p>
<p
className="mt-1 text-2xl font-semibold tabular-nums text-violet-800 dark:text-violet-200"
title={`${formatBigAmount(integrity.entryFeeCoinsTotal)} coins`}
>
{feeRcLabel}
</p>
<p className="mt-0.5 text-xs tabular-nums text-zinc-500 dark:text-zinc-400">
{formatBigAmount(integrity.entryFeeCoinsTotal)} coins
</p>
</div>
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Total purchase
</p>
<p
className="mt-1 text-2xl font-semibold tabular-nums text-sky-800 dark:text-sky-200"
title={`${formatBigAmount(integrity.purchaseCoinsTotal)} coins`}
>
{purchaseRcLabel}
</p>
<p className="mt-0.5 text-xs tabular-nums text-zinc-500 dark:text-zinc-400">
{formatBigAmount(integrity.purchaseCoinsTotal)} coins
</p>
</div>
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Total entry_hold / reward
</p>
<p
className="mt-1 text-2xl font-semibold tabular-nums text-amber-800 dark:text-amber-200"
title={`${formatBigAmount(integrity.entryHoldCoinsTotal)} coins`}
>
{entryHoldRcLabel}
</p>
<p className="mt-0.5 text-xs tabular-nums text-zinc-500 dark:text-zinc-400">
{formatBigAmount(integrity.entryHoldCoinsTotal)} coins
</p>
</div>
<div
className={[
"rounded-xl border p-4 shadow-sm",
integrity.balanced
? "border-emerald-200 bg-emerald-50 dark:border-emerald-900 dark:bg-emerald-950/40"
: "border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/40",
].join(" ")}
>
<p className="text-xs font-medium uppercase tracking-wide text-zinc-600 dark:text-zinc-400">
Σ user nets vs net issued
</p>
<div className="mt-1 text-sm tabular-nums text-zinc-900 dark:text-zinc-100">
<p className="font-mono">
{formatBigAmount(integrity.sumUserNets)}
</p>
<p className="font-mono text-zinc-600 dark:text-zinc-400">
={" "}
{formatBigAmount(
integrity.mintTotal - integrity.sinkTotal,
)}
</p>
</div>
<p className="mt-1 text-xs font-medium text-zinc-700 dark:text-zinc-300">
{integrity.balanced ? "Balanced ✓" : "Mismatch — inspect data"}
</p>
</div>
</div>
{holdRewardMismatch ? (
<div
className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900 dark:border-red-900 dark:bg-red-950/40 dark:text-red-100"
role="alert"
>
Data mismatch: <span className="font-mono">entry_hold</span> total (
{formatBigAmount(integrity.entryHoldCoinsTotal)} coins) does not equal{" "}
<span className="font-mono">reward</span> total (
{formatBigAmount(integrity.rewardCoinsTotal)} coins).
</div>
) : null}
{!integrity.volumeBalanced ? (
<div
className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"
role="status"
>
Debit / credit volume: debits + mint (
{formatBigAmount(integrity.totalDebitVolume + integrity.mintTotal)})
credits + null-{`to`} outflows (
{formatBigAmount(
integrity.totalCreditVolume + integrity.sinkTotal,
)}
). This usually means inconsistent rows (e.g. both {`from`} and{" "}
{`to`} null with non-zero amount).
</div>
) : null}
<div>
<h2 className="mb-2 text-lg font-semibold text-zinc-900 dark:text-zinc-50">
Ledger
</h2>
<p className="mb-3 text-xs text-zinc-500 dark:text-zinc-400">
All {ledgerRowsChronological.length.toLocaleString("en-US")} rows ·
oldest first
</p>
<div className="overflow-x-auto rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<table className="min-w-full text-left text-sm">
<thead className="sticky top-0 z-10 border-b border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-800 dark:bg-zinc-800/95 dark:text-zinc-400">
<tr>
<th className="px-4 py-3 font-medium">ID</th>
<th className="px-4 py-3 font-medium">Created</th>
<th className="px-4 py-3 font-medium">From</th>
<th className="px-4 py-3 font-medium">To</th>
<th className="px-4 py-3 font-medium text-right">Amount</th>
<th className="px-4 py-3 font-medium text-right">RC</th>
<th className="px-4 py-3 font-medium">Remarks</th>
<th className="px-4 py-3 font-medium">Match</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
{ledgerRowsChronological.length === 0 ? (
<tr>
<td
colSpan={8}
className="px-4 py-8 text-center text-zinc-500"
>
No transactions loaded.
</td>
</tr>
) : (
ledgerRowsChronological.map((row) => {
const amt = txAmountToBigInt(row.amount);
return (
<tr
key={row.id}
className="text-zinc-800 dark:text-zinc-200"
>
<td className="px-4 py-2 font-mono text-xs">{row.id}</td>
<td className="whitespace-nowrap px-4 py-2 text-xs text-zinc-600 dark:text-zinc-400">
{formatTsUtc(row.created_at)}
</td>
<td className="px-4 py-2">
{userCell(row.from, "from")}
</td>
<td className="px-4 py-2">{userCell(row.to, "to")}</td>
<td className="px-4 py-2 text-right font-mono tabular-nums">
{formatBigAmount(amt)}
</td>
<td className="whitespace-nowrap px-4 py-2 text-right font-mono text-xs tabular-nums text-zinc-600 dark:text-zinc-400">
{formatRcLabelFromCoinsBigInt(amt)}
</td>
<td className="px-4 py-2">
{row.remarks ? (
<span
className={[
"inline-block rounded px-2 py-0.5 text-xs font-medium",
remarkClass(row.remarks),
].join(" ")}
>
{row.remarks}
</span>
) : (
"—"
)}
</td>
<td className="px-4 py-2 font-mono text-xs">
{row.match_id != null ? (
<Link
href={`/match-logs/${row.match_id}`}
className="text-sky-700 underline decoration-sky-400/50 underline-offset-2 hover:text-sky-900 dark:text-sky-300"
>
{row.match_id}
</Link>
) : (
"—"
)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
{matchChecks.length > 0 ? (
<div>
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
By match_id (2 entry_hold + 2 entry_fee + 1 reward)
</h2>
<div className="flex flex-wrap gap-2">
{matchChecks.map((m) => (
<div
key={m.matchId}
className={[
"rounded-lg border px-3 py-2 text-xs shadow-sm",
m.balanced
? "border-emerald-200 bg-white dark:border-emerald-900/60 dark:bg-zinc-900"
: "border-amber-300 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/40",
].join(" ")}
>
<span className="font-mono font-semibold">#{m.matchId}</span>
<span className="mx-1 text-zinc-400">·</span>
<Link
href={`/match-logs/${m.matchId}`}
className="text-sky-700 underline decoration-sky-400/50 underline-offset-2 hover:text-sky-900 dark:text-sky-300"
>
log
</Link>
<span className="mx-1 text-zinc-400">·</span>
{m.balanced ? (
<span className="text-emerald-700 dark:text-emerald-300">
balanced
</span>
) : (
<span className="text-amber-800 dark:text-amber-200">
{m.rewardDeltaCoins > BigInt(0)
? `overspent +${formatBigAmount(m.rewardDeltaCoins)}`
: m.rewardDeltaCoins < BigInt(0)
? `short reward ${formatBigAmount(-m.rewardDeltaCoins)}`
: "structure mismatch"}
</span>
)}
<span className="mx-1 text-zinc-400">·</span>
<span className="font-mono text-zinc-600 dark:text-zinc-400">
h:{m.entryHoldCount} f:{m.entryFeeCount} r:{m.rewardCount}
</span>
</div>
))}
</div>
</div>
) : null}
<div>
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
User net (credits debits)
</h2>
<div className="overflow-x-auto rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<table className="min-w-full text-left text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-800 dark:bg-zinc-800/50 dark:text-zinc-400">
<tr>
<th className="px-4 py-3 font-medium">User</th>
<th className="px-4 py-3 font-medium text-right">Net (coins)</th>
<th className="px-4 py-3 font-medium text-right">Net (RC)</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
{userNets.length === 0 ? (
<tr>
<td
colSpan={3}
className="px-4 py-6 text-center text-zinc-500"
>
No non-zero balances in loaded rows.
</td>
</tr>
) : (
userNets.map(([uid, bal]) => (
<tr key={uid} className="text-zinc-800 dark:text-zinc-200">
<td className="px-4 py-2 font-mono text-xs">
{userCell(uid, "from")}
</td>
<td
className={[
"px-4 py-2 text-right font-mono tabular-nums",
bal > 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)}
</td>
<td className="px-4 py-2 text-right font-mono text-xs tabular-nums text-zinc-600 dark:text-zinc-400">
{bal < BigInt(0)
? `${formatRcLabelFromCoinsBigInt(-bal)}`
: formatRcLabelFromCoinsBigInt(bal)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</section>
);
}
+5 -1
View File
@@ -63,7 +63,11 @@ export function EditUserCcRcOverlay({
? "matches"
: tab === "players"
? "players"
: "dashboard"
: tab === "matchmaker"
? "matchmaker"
: tab === "ledger"
? "ledger"
: "dashboard"
}
/>
<input