1125 lines
44 KiB
TypeScript
1125 lines
44 KiB
TypeScript
"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>
|
||
);
|
||
}
|