ledger
This commit is contained in:
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -63,7 +63,11 @@ export function EditUserCcRcOverlay({
|
||||
? "matches"
|
||||
: tab === "players"
|
||||
? "players"
|
||||
: "dashboard"
|
||||
: tab === "matchmaker"
|
||||
? "matchmaker"
|
||||
: tab === "ledger"
|
||||
? "ledger"
|
||||
: "dashboard"
|
||||
}
|
||||
/>
|
||||
<input
|
||||
|
||||
Reference in New Issue
Block a user