Files
kickkingsadmin/src/components/admin-ledger.tsx
T
2026-07-12 17:55:44 +00:00

1125 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { ClickableUserId } from "@/components/clickable-user-id";
import { buildDashboardHref } from "@/lib/dashboard-search-url";
import {
formatRcBalanceWithCoins,
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 { addSystemSupply } from "@/app/actions/add-system-supply";
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 {
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 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";
}
type Props = {
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;
supplyError: boolean;
readOnly: boolean;
};
export function AdminLedger({
ledgerGlobalSummary,
transactionsScoped,
ledgerTableRows,
ledgerTotalRowsInRange,
ledgerPage,
ledgerPageSize,
ledgerTotalPages,
ledgerSort,
ledgerOrder,
users,
error,
highlightId,
participantRaw,
ledgerFrom,
ledgerTo,
supplyError,
readOnly,
}: 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 systemAccount = useMemo(() => {
for (const u of users) {
if (Number(u.id) === 1) return u;
}
return null;
}, [users]);
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(transactionsScoped),
[transactionsScoped],
);
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,
});
}
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) {
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">
<ClickableUserId id={id} />
{un != null && un !== "" ? (
<span className="text-zinc-600 dark:text-zinc-400"> ({un})</span>
) : null}
</span>
);
}
const feeRcLabelGlobal = formatRcLabelFromCoinsBigInt(
integrityGlobal.entryFeeCoinsTotal,
);
const purchaseRcLabelGlobal = formatRcLabelFromCoinsBigInt(
integrityGlobal.purchaseCoinsTotal,
);
const entryHoldRcLabelGlobal = formatRcLabelFromCoinsBigInt(
integrityGlobal.entryHoldCoinsTotal,
);
const feeRcLabelScoped = formatRcLabelFromCoinsBigInt(
integrityScoped.entryFeeCoinsTotal,
);
const purchaseRcLabelScoped = formatRcLabelFromCoinsBigInt(
integrityScoped.purchaseCoinsTotal,
);
const entryHoldRcLabelScoped = formatRcLabelFromCoinsBigInt(
integrityScoped.entryHoldCoinsTotal,
);
const holdRewardMismatch =
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 [supplyModalOpen, setSupplyModalOpen] = useState(false);
useEffect(() => {
if (supplyError) {
setSupplyModalOpen(true);
}
}, [supplyError]);
useEffect(() => {
if (!supplyModalOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setSupplyModalOpen(false);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [supplyModalOpen]);
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 (
<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>
{" — "}
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{" "}
<span className="font-medium">every</span> transaction row. The same
metrics for the date range only appear under the filter.
</p>
{error ? (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
) : null}
<div className="grid gap-4 lg:grid-cols-3">
<div className="flex flex-col gap-3 lg:col-span-1">
<div
className={[
"rounded-xl border p-4 shadow-sm",
integrityGlobal.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(" ")}
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-zinc-600 dark:text-zinc-400">
Σ user nets vs net issued
</p>
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
All transactions
</p>
</div>
{!readOnly ? (
<button
type="button"
onClick={() => setSupplyModalOpen(true)}
className="shrink-0 rounded-md border border-zinc-300 bg-white/90 px-2.5 py-1 text-xs font-medium text-zinc-800 shadow-sm hover:bg-white dark:border-zinc-600 dark:bg-zinc-900/90 dark:text-zinc-100 dark:hover:bg-zinc-900"
>
Add supply
</button>
) : null}
</div>
<div className="mt-2 text-sm tabular-nums text-zinc-900 dark:text-zinc-100">
<p className="font-mono">
{formatBigAmount(integrityGlobal.sumUserNets)}
</p>
<p className="font-mono text-zinc-600 dark:text-zinc-400">
={" "}
{formatBigAmount(
integrityGlobal.mintTotal - integrityGlobal.sinkTotal,
)}
</p>
</div>
<p className="mt-2 text-xs font-medium text-zinc-700 dark:text-zinc-300">
{integrityGlobal.balanced
? "Balanced ✓"
: "Mismatch — inspect data"}
</p>
</div>
<div className="grid gap-2">
<div className="rounded-lg border border-zinc-200 bg-white p-3 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="text-[10px] font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Fee collected (entry_fee)
</p>
<p className="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">
All-time
</p>
<p
className="mt-1 text-lg font-semibold tabular-nums text-violet-800 dark:text-violet-200"
title={`${formatBigAmount(integrityGlobal.entryFeeCoinsTotal)} coins`}
>
{feeRcLabelGlobal}
</p>
<p className="mt-0.5 text-[11px] tabular-nums text-zinc-500 dark:text-zinc-400">
{formatBigAmount(integrityGlobal.entryFeeCoinsTotal)} coins
</p>
</div>
<div className="rounded-lg border border-zinc-200 bg-white p-3 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="text-[10px] font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Total purchase
</p>
<p className="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">
All-time
</p>
<p
className="mt-1 text-lg font-semibold tabular-nums text-sky-800 dark:text-sky-200"
title={`${formatBigAmount(integrityGlobal.purchaseCoinsTotal)} coins`}
>
{purchaseRcLabelGlobal}
</p>
<p className="mt-0.5 text-[11px] tabular-nums text-zinc-500 dark:text-zinc-400">
{formatBigAmount(integrityGlobal.purchaseCoinsTotal)} coins
</p>
</div>
<div className="rounded-lg border border-zinc-200 bg-white p-3 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="text-[10px] font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Total entry_hold / reward
</p>
<p className="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">
All-time
</p>
<p
className="mt-1 text-lg font-semibold tabular-nums text-amber-800 dark:text-amber-200"
title={`${formatBigAmount(integrityGlobal.entryHoldCoinsTotal)} coins`}
>
{entryHoldRcLabelGlobal}
</p>
<p className="mt-0.5 text-[11px] tabular-nums text-zinc-500 dark:text-zinc-400">
{formatBigAmount(integrityGlobal.entryHoldCoinsTotal)} coins
</p>
</div>
</div>
</div>
<div className="overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900 lg:col-span-2">
<div className="border-b border-zinc-200 bg-zinc-50 px-4 py-2 dark:border-zinc-800 dark:bg-zinc-800/50">
<h2 className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">
User net (credits debits)
</h2>
<p className="text-xs text-zinc-500 dark:text-zinc-400">
All transactions
</p>
</div>
<div className="max-h-[min(24rem,50vh)] overflow-y-auto overflow-x-auto">
<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">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>
</div>
<form
method="get"
action="/"
className="flex flex-wrap items-end gap-3 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
>
<input type="hidden" name="tab" value="ledger" />
{highlightId ? (
<input type="hidden" name="highlight" value={highlightId} />
) : null}
{participantRaw ? (
<input type="hidden" name="participant" value={participantRaw} />
) : null}
<input type="hidden" name="lsort" value={ledgerSort} />
<input type="hidden" name="lorder" value={ledgerOrder} />
{ledgerPageSize !== DEFAULT_LEDGER_PAGE_SIZE ? (
<input type="hidden" name="lsize" value={String(ledgerPageSize)} />
) : null}
<div className="flex min-w-[10rem] flex-col gap-1">
<label
htmlFor="ledger-lfrom"
className="text-xs font-medium text-zinc-500 dark:text-zinc-400"
>
From (UTC)
</label>
<input
id="ledger-lfrom"
name="lfrom"
type="date"
defaultValue={ledgerFrom}
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/>
</div>
<div className="flex min-w-[10rem] flex-col gap-1">
<label
htmlFor="ledger-lto"
className="text-xs font-medium text-zinc-500 dark:text-zinc-400"
>
To (UTC)
</label>
<input
id="ledger-lto"
name="lto"
type="date"
defaultValue={ledgerTo}
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/>
</div>
<button
type="submit"
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
Apply
</button>
<Link
href={buildDashboardHref({
tab: "ledger",
highlightId,
participantRaw,
ledgerPageSize,
})}
scroll={false}
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 shadow-sm hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
Reset range
</Link>
</form>
<div>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
In selected range (
<span className="font-mono normal-case">
{ledgerFrom}{ledgerTo}
</span>{" "}
UTC)
</h3>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<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(integrityScoped.entryFeeCoinsTotal)} coins`}
>
{feeRcLabelScoped}
</p>
<p className="mt-0.5 text-xs tabular-nums text-zinc-500 dark:text-zinc-400">
{formatBigAmount(integrityScoped.entryFeeCoinsTotal)} coins
</p>
<LedgerRangeCumulativeChart
dates={scopedDailyCumulative.dates}
cumulativeCoins={scopedDailyCumulative.entryFeeCumulative}
ariaLabel="Cumulative entry fee coins by UTC day in the selected range"
className="text-violet-600 dark:text-violet-300"
/>
</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(integrityScoped.purchaseCoinsTotal)} coins`}
>
{purchaseRcLabelScoped}
</p>
<p className="mt-0.5 text-xs tabular-nums text-zinc-500 dark:text-zinc-400">
{formatBigAmount(integrityScoped.purchaseCoinsTotal)} coins
</p>
<LedgerRangeCumulativeChart
dates={scopedDailyCumulative.dates}
cumulativeCoins={scopedDailyCumulative.purchaseCumulative}
ariaLabel="Cumulative purchase coins by UTC day in the selected range"
className="text-sky-600 dark:text-sky-300"
/>
</div>
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900 sm:col-span-2 lg:col-span-1">
<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(integrityScoped.entryHoldCoinsTotal)} coins`}
>
{entryHoldRcLabelScoped}
</p>
<p className="mt-0.5 text-xs tabular-nums text-zinc-500 dark:text-zinc-400">
{formatBigAmount(integrityScoped.entryHoldCoinsTotal)} coins
</p>
<LedgerRangeCumulativeChart
dates={scopedDailyCumulative.dates}
cumulativeCoins={scopedDailyCumulative.entryHoldCumulative}
ariaLabel="Cumulative entry hold coins by UTC day in the selected range"
className="text-amber-600 dark:text-amber-300"
/>
</div>
</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(integrityScoped.entryHoldCoinsTotal)} coins) does not
equal{" "}
<span className="font-mono">reward</span> total (
{formatBigAmount(integrityScoped.rewardCoinsTotal)} coins).
</div>
) : null}
{!integrityScoped.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(
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).
</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">
{ledgerTotalRowsInRange.toLocaleString("en-US")} rows in range{" "}
<span className="font-mono">
{ledgerFrom}{ledgerTo}
</span>{" "}
UTC (cap 10,000) · default sort newest first; click a column header to
sort
</p>
<p className="mb-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-zinc-600 dark:text-zinc-400">
<span className="font-medium text-zinc-700 dark:text-zinc-300">
Rows per page
</span>
{LEDGER_PAGE_SIZE_OPTIONS.map((n) =>
n === ledgerPageSize ? (
<span
key={n}
className="rounded px-2 py-0.5 font-semibold tabular-nums text-zinc-900 dark:text-zinc-100"
>
{n}
</span>
) : (
<Link
key={n}
href={ledgerHref({ ledgerPage: 1, ledgerPageSize: n })}
scroll={false}
className="rounded px-2 py-0.5 font-medium tabular-nums 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"
>
{n}
</Link>
),
)}
</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={`text-left px-4 py-3 ${sortHeaderClass("id")}`}>
<Link
href={sortToggleHref("id")}
scroll={false}
className="inline-flex items-baseline gap-1 hover:underline"
>
ID
{ledgerSort === "id" ? (
<span className="text-xs font-normal" aria-hidden>
{ledgerOrder === "asc" ? "▲" : "▼"}
</span>
) : null}
</Link>
</th>
<th
className={`text-left px-4 py-3 ${sortHeaderClass("created_at")}`}
>
<Link
href={sortToggleHref("created_at")}
scroll={false}
className="inline-flex items-baseline gap-1 hover:underline"
>
Created
{ledgerSort === "created_at" ? (
<span className="text-xs font-normal" aria-hidden>
{ledgerOrder === "asc" ? "▲" : "▼"}
</span>
) : null}
</Link>
</th>
<th className={`text-left px-4 py-3 ${sortHeaderClass("from")}`}>
<Link
href={sortToggleHref("from")}
scroll={false}
className="inline-flex items-baseline gap-1 hover:underline"
>
From
{ledgerSort === "from" ? (
<span className="text-xs font-normal" aria-hidden>
{ledgerOrder === "asc" ? "▲" : "▼"}
</span>
) : null}
</Link>
</th>
<th className={`text-left px-4 py-3 ${sortHeaderClass("to")}`}>
<Link
href={sortToggleHref("to")}
scroll={false}
className="inline-flex items-baseline gap-1 hover:underline"
>
To
{ledgerSort === "to" ? (
<span className="text-xs font-normal" aria-hidden>
{ledgerOrder === "asc" ? "▲" : "▼"}
</span>
) : null}
</Link>
</th>
<th
className={`text-right px-4 py-3 ${sortHeaderClass("amount")}`}
>
<Link
href={sortToggleHref("amount")}
scroll={false}
className="inline-flex w-full items-baseline justify-end gap-1 hover:underline"
>
Amount
{ledgerSort === "amount" ? (
<span className="text-xs font-normal" aria-hidden>
{ledgerOrder === "asc" ? "▲" : "▼"}
</span>
) : null}
</Link>
</th>
<th className={`text-right px-4 py-3 ${sortHeaderClass("rc")}`}>
<Link
href={sortToggleHref("rc")}
scroll={false}
className="inline-flex w-full items-baseline justify-end gap-1 hover:underline"
>
RC
{ledgerSort === "rc" ? (
<span className="text-xs font-normal" aria-hidden>
{ledgerOrder === "asc" ? "▲" : "▼"}
</span>
) : null}
</Link>
</th>
<th
className={`text-left px-4 py-3 ${sortHeaderClass("remarks")}`}
>
<Link
href={sortToggleHref("remarks")}
scroll={false}
className="inline-flex items-baseline gap-1 hover:underline"
>
Remarks
{ledgerSort === "remarks" ? (
<span className="text-xs font-normal" aria-hidden>
{ledgerOrder === "asc" ? "▲" : "▼"}
</span>
) : null}
</Link>
</th>
<th
className={`text-left px-4 py-3 ${sortHeaderClass("match_id")}`}
>
<Link
href={sortToggleHref("match_id")}
scroll={false}
className="inline-flex items-baseline gap-1 hover:underline"
>
Match
{ledgerSort === "match_id" ? (
<span className="text-xs font-normal" aria-hidden>
{ledgerOrder === "asc" ? "▲" : "▼"}
</span>
) : null}
</Link>
</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
{ledgerTotalRowsInRange === 0 ? (
<tr>
<td
colSpan={8}
className="px-4 py-8 text-center text-zinc-500"
>
No transactions loaded.
</td>
</tr>
) : (
ledgerTableRows.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>
{ledgerTotalRowsInRange > 0 ? (
<div className="mt-3 space-y-2 text-xs text-zinc-600 dark:text-zinc-400">
<p>
Rows {pageStart.toLocaleString("en-US")}
{pageEnd.toLocaleString("en-US")} of{" "}
{ledgerTotalRowsInRange.toLocaleString("en-US")} · Page{" "}
{ledgerPage.toLocaleString("en-US")} /{" "}
{ledgerTotalPages.toLocaleString("en-US")}
</p>
<nav
className="flex flex-wrap items-center gap-x-1 gap-y-1 font-mono tabular-nums"
aria-label="Ledger pages"
>
{ledgerPage > 1 ? (
<Link
href={ledgerHref({ ledgerPage: 1 })}
scroll={false}
className={navLinkClass}
>
First
</Link>
) : (
<span className={navMutedClass}>First</span>
)}
{ledgerPage > 1 ? (
<Link
href={ledgerHref({ ledgerPage: ledgerPage - 1 })}
scroll={false}
className={navLinkClass}
aria-label="Previous page"
>
{"<"}
</Link>
) : (
<span className={navMutedClass} aria-hidden>
{"<"}
</span>
)}
{pageNavItems.map((item, idx) =>
item === "ellipsis" ? (
<span
key={`e-${idx}`}
className="px-1 select-none text-zinc-400 dark:text-zinc-500"
>
...
</span>
) : ledgerPage === item ? (
<span
key={item}
className="rounded px-2 py-1 font-semibold text-zinc-900 dark:text-zinc-100"
>
{item}
</span>
) : (
<Link
key={item}
href={ledgerHref({ ledgerPage: item })}
scroll={false}
className={navLinkClass}
>
{item}
</Link>
),
)}
{ledgerPage < ledgerTotalPages ? (
<Link
href={ledgerHref({ ledgerPage: ledgerPage + 1 })}
scroll={false}
className={navLinkClass}
aria-label="Next page"
>
{">"}
</Link>
) : (
<span className={navMutedClass} aria-hidden>
{">"}
</span>
)}
{ledgerPage < ledgerTotalPages ? (
<Link
href={ledgerHref({ ledgerPage: ledgerTotalPages })}
scroll={false}
className={navLinkClass}
>
Last
</Link>
) : (
<span className={navMutedClass}>Last</span>
)}
</nav>
</div>
) : null}
</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}
{supplyModalOpen && !readOnly ? (
<div
className="fixed inset-0 z-[9998] flex items-center justify-center bg-black/40 p-4"
role="presentation"
onClick={() => setSupplyModalOpen(false)}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="supply-modal-title"
className="w-full max-w-md rounded-xl border border-zinc-200 bg-white p-6 shadow-lg dark:border-zinc-700 dark:bg-zinc-900"
onClick={(e) => e.stopPropagation()}
>
<h2
id="supply-modal-title"
className="text-lg font-semibold text-zinc-900 dark:text-zinc-50"
>
Add system supply
</h2>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Credits user id{" "}
<span className="font-mono">1</span>
{systemAccount?.username ? (
<span>
{" "}
(<span className="font-mono">{systemAccount.username}</span>)
</span>
) : null}
: mint row (<span className="font-mono">to = 1</span>,{" "}
<span className="font-mono">remarks = supply</span>), RC increases
by the same coin amount.
</p>
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-500">
Current RC:{" "}
<span className="font-mono text-zinc-800 dark:text-zinc-200">
{systemAccount
? formatRcBalanceWithCoins(systemAccount.rc)
: "— (user 1 not in loaded set)"}
</span>
</p>
{supplyError ? (
<p
className="mt-3 text-sm text-red-600 dark:text-red-400"
role="alert"
>
Could not add supply. Use a positive whole number of coin units
(no decimals), and ensure the database and service key are
available.
</p>
) : null}
<form
action={addSystemSupply}
className="mt-5 space-y-4"
>
<input type="hidden" name="tab" value="ledger" />
<input
type="hidden"
name="highlightId"
value={highlightId ?? ""}
/>
<input
type="hidden"
name="participantRaw"
value={participantRaw ?? ""}
/>
<input type="hidden" name="ledgerFrom" value={ledgerFrom} />
<input type="hidden" name="ledgerTo" value={ledgerTo} />
<input
type="hidden"
name="ledgerPage"
value={String(ledgerPage)}
/>
<input
type="hidden"
name="ledgerPageSize"
value={String(ledgerPageSize)}
/>
<input type="hidden" name="ledgerSort" value={ledgerSort} />
<input type="hidden" name="ledgerOrder" value={ledgerOrder} />
<div>
<label
htmlFor="supply-amount-modal"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Coin amount
</label>
<input
id="supply-amount-modal"
name="supplyAmount"
type="text"
inputMode="numeric"
autoComplete="off"
placeholder="e.g. 4000"
className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm tabular-nums text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={() => setSupplyModalOpen(false)}
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 shadow-sm hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
Cancel
</button>
<button
type="submit"
className="rounded-md border border-emerald-600 bg-emerald-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 dark:border-emerald-700 dark:bg-emerald-800 dark:hover:bg-emerald-700"
>
Submit
</button>
</div>
</form>
</div>
</div>
) : null}
</section>
);
}