add supply
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||
import { applyCoinsDeltaToRcBalance } from "@/lib/coins-rc";
|
||||
import {
|
||||
buildDashboardHref,
|
||||
type AdminDashboardTab,
|
||||
} from "@/lib/dashboard-search-url";
|
||||
import { createAdminSupabase } from "@/lib/supabase/admin";
|
||||
import { normalizeLedgerPageSize } from "@/lib/ledger-table-view";
|
||||
|
||||
const SYSTEM_ACCOUNT_ID = 1;
|
||||
|
||||
function parsePositiveCoins(raw: FormDataEntryValue | null): bigint | null {
|
||||
if (raw == null) return null;
|
||||
const s = String(raw).trim();
|
||||
if (!/^[1-9]\d*$/.test(s)) return null;
|
||||
try {
|
||||
return BigInt(s);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function addSystemSupply(formData: FormData) {
|
||||
const cookieStore = await cookies();
|
||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const tabRaw = String(formData.get("tab") ?? "");
|
||||
const tab: AdminDashboardTab =
|
||||
tabRaw === "matches"
|
||||
? "matches"
|
||||
: tabRaw === "players"
|
||||
? "players"
|
||||
: tabRaw === "matchmaker"
|
||||
? "matchmaker"
|
||||
: tabRaw === "ledger"
|
||||
? "ledger"
|
||||
: "dashboard";
|
||||
|
||||
const highlightRaw = String(formData.get("highlightId") ?? "").trim();
|
||||
const participantRaw = String(formData.get("participantRaw") ?? "").trim();
|
||||
const highlightId = highlightRaw === "" ? null : highlightRaw;
|
||||
const participant =
|
||||
participantRaw === "" ? null : participantRaw;
|
||||
|
||||
const ledgerFromRaw = String(formData.get("ledgerFrom") ?? "").trim();
|
||||
const ledgerToRaw = String(formData.get("ledgerTo") ?? "").trim();
|
||||
const ledgerPageRaw = String(formData.get("ledgerPage") ?? "").trim();
|
||||
const ledgerPageSizeRaw = String(
|
||||
formData.get("ledgerPageSize") ?? "",
|
||||
).trim();
|
||||
const ledgerSortRaw = String(formData.get("ledgerSort") ?? "").trim();
|
||||
const ledgerOrderRaw = String(formData.get("ledgerOrder") ?? "").trim();
|
||||
const ledgerPageNum =
|
||||
ledgerPageRaw === "" ? NaN : Number.parseInt(ledgerPageRaw, 10);
|
||||
|
||||
const base = {
|
||||
tab,
|
||||
highlightId,
|
||||
participantRaw: participant,
|
||||
...(tab === "ledger"
|
||||
? {
|
||||
ledgerFrom: ledgerFromRaw || null,
|
||||
ledgerTo: ledgerToRaw || null,
|
||||
ledgerPage:
|
||||
Number.isInteger(ledgerPageNum) && ledgerPageNum > 1
|
||||
? ledgerPageNum
|
||||
: null,
|
||||
ledgerSort: ledgerSortRaw || null,
|
||||
ledgerOrder:
|
||||
ledgerOrderRaw === "desc"
|
||||
? ("desc" as const)
|
||||
: ledgerOrderRaw === "asc"
|
||||
? ("asc" as const)
|
||||
: null,
|
||||
ledgerPageSize: normalizeLedgerPageSize(
|
||||
ledgerPageSizeRaw === "" ? null : ledgerPageSizeRaw,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const parsedCoins = parsePositiveCoins(formData.get("supplyAmount"));
|
||||
if (parsedCoins == null) {
|
||||
redirect(
|
||||
buildDashboardHref({
|
||||
...base,
|
||||
supplyError: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const coins = parsedCoins;
|
||||
|
||||
const supabase = createAdminSupabase();
|
||||
if (!supabase) {
|
||||
redirect(
|
||||
buildDashboardHref({
|
||||
...base,
|
||||
supplyError: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const { data: sysRow, error: fetchErr } = await supabase
|
||||
.from("users")
|
||||
.select("id, rc")
|
||||
.eq("id", SYSTEM_ACCOUNT_ID)
|
||||
.maybeSingle();
|
||||
|
||||
if (fetchErr || !sysRow) {
|
||||
redirect(
|
||||
buildDashboardHref({
|
||||
...base,
|
||||
supplyError: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const currentRc = sysRow.rc as number | null;
|
||||
|
||||
const { error: insertErr } = await supabase.from("transactions").insert({
|
||||
from: null,
|
||||
to: SYSTEM_ACCOUNT_ID,
|
||||
amount: coins.toString(),
|
||||
remarks: "supply",
|
||||
match_id: null,
|
||||
});
|
||||
|
||||
if (insertErr) {
|
||||
redirect(
|
||||
buildDashboardHref({
|
||||
...base,
|
||||
supplyError: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const newRc = applyCoinsDeltaToRcBalance(currentRc, coins);
|
||||
|
||||
const { error: updateErr } = await supabase
|
||||
.from("users")
|
||||
.update({ rc: newRc })
|
||||
.eq("id", SYSTEM_ACCOUNT_ID);
|
||||
|
||||
if (updateErr) {
|
||||
redirect(
|
||||
buildDashboardHref({
|
||||
...base,
|
||||
supplyError: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
redirect(buildDashboardHref(base));
|
||||
}
|
||||
@@ -55,6 +55,7 @@ export default async function Home({
|
||||
participant?: string | string[];
|
||||
edit?: string | string[];
|
||||
saveError?: string | string[];
|
||||
supplyErr?: string | string[];
|
||||
mklog?: string | string[];
|
||||
lfrom?: string | string[];
|
||||
lto?: string | string[];
|
||||
@@ -80,6 +81,7 @@ export default async function Home({
|
||||
const participantRaw = firstSearchParam(sp.participant);
|
||||
const editRaw = firstSearchParam(sp.edit);
|
||||
const saveError = firstSearchParam(sp.saveError) === "1";
|
||||
const supplyError = firstSearchParam(sp.supplyErr) === "1";
|
||||
const editIdNum =
|
||||
editRaw != null && editRaw !== "" ? Number(editRaw) : NaN;
|
||||
|
||||
@@ -272,6 +274,7 @@ export default async function Home({
|
||||
ledgerOrder={ledgerSortOrderView}
|
||||
ledgerFrom={ledgerFrom}
|
||||
ledgerTo={ledgerTo}
|
||||
supplyError={supplyError}
|
||||
/>
|
||||
{editUser ? (
|
||||
<EditUserCcRcOverlay
|
||||
|
||||
@@ -215,6 +215,8 @@ type Props = {
|
||||
matchmakerSource: MatchmakerLogSource;
|
||||
matchmakerContent: string;
|
||||
matchmakerError: string | null;
|
||||
/** Ledger: add-system-supply action failed (URL `supplyErr=1`). */
|
||||
supplyError: boolean;
|
||||
};
|
||||
|
||||
function StatCard({
|
||||
@@ -264,6 +266,7 @@ export function AdminDashboard({
|
||||
matchmakerSource,
|
||||
matchmakerContent,
|
||||
matchmakerError,
|
||||
supplyError,
|
||||
}: Props) {
|
||||
const [hideNoWinner, setHideNoWinner] = useState(true);
|
||||
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("wins");
|
||||
@@ -735,6 +738,7 @@ export function AdminDashboard({
|
||||
participantRaw={participantRaw}
|
||||
ledgerFrom={ledgerFrom}
|
||||
ledgerTo={ledgerTo}
|
||||
supplyError={supplyError}
|
||||
/>
|
||||
) : (
|
||||
<section className="space-y-3">
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ClickableUserId } from "@/components/clickable-user-id";
|
||||
import { buildDashboardHref } from "@/lib/dashboard-search-url";
|
||||
import { formatRcLabelFromCoinsBigInt } from "@/lib/coins-rc";
|
||||
import {
|
||||
formatRcBalanceWithCoins,
|
||||
formatRcLabelFromCoinsBigInt,
|
||||
} from "@/lib/coins-rc";
|
||||
import { auditMatchEconomics } from "@/lib/ledger-match-audit";
|
||||
import {
|
||||
computeLedgerIntegrity,
|
||||
@@ -21,6 +24,7 @@ import {
|
||||
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";
|
||||
|
||||
@@ -86,6 +90,7 @@ type Props = {
|
||||
participantRaw: string | null;
|
||||
ledgerFrom: string;
|
||||
ledgerTo: string;
|
||||
supplyError: boolean;
|
||||
};
|
||||
|
||||
export function AdminLedger({
|
||||
@@ -104,6 +109,7 @@ export function AdminLedger({
|
||||
participantRaw,
|
||||
ledgerFrom,
|
||||
ledgerTo,
|
||||
supplyError,
|
||||
}: Props) {
|
||||
const usernameById = useMemo(() => {
|
||||
const m = new Map<number, string | null>();
|
||||
@@ -113,6 +119,13 @@ export function AdminLedger({
|
||||
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 {
|
||||
@@ -240,6 +253,25 @@ export function AdminLedger({
|
||||
);
|
||||
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";
|
||||
@@ -275,12 +307,23 @@ export function AdminLedger({
|
||||
: "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>
|
||||
<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>
|
||||
</div>
|
||||
<div className="mt-2 text-sm tabular-nums text-zinc-900 dark:text-zinc-100">
|
||||
<p className="font-mono">
|
||||
{formatBigAmount(integrityGlobal.sumUserNets)}
|
||||
@@ -956,6 +999,122 @@ export function AdminLedger({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{supplyModalOpen ? (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,3 +70,25 @@ export function formatRcBalanceWithCoins(rc: number | null): string {
|
||||
}
|
||||
return `${rc.toFixed(1)} RC`;
|
||||
}
|
||||
|
||||
/** Maps total coin units to `users.rc` display (whole + tenths/10). */
|
||||
export function coinsBigIntToRcNumber(coins: bigint): number {
|
||||
let c = coins;
|
||||
if (c < BigInt(0)) c = BigInt(0);
|
||||
const whole = c / BigInt(4);
|
||||
const tenths = c % BigInt(4);
|
||||
return Number(whole) + Number(tenths) / 10;
|
||||
}
|
||||
|
||||
/** Adds coin units to an existing RC balance (same encoding as `users.rc`). */
|
||||
export function applyCoinsDeltaToRcBalance(
|
||||
currentRc: number | null,
|
||||
deltaCoins: bigint,
|
||||
): number {
|
||||
const existingCoins =
|
||||
currentRc != null && Number.isFinite(currentRc)
|
||||
? rcToCoins(currentRc) ?? 0
|
||||
: 0;
|
||||
const total = BigInt(existingCoins) + deltaCoins;
|
||||
return coinsBigIntToRcNumber(total);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ export type DashboardUrlQuery = {
|
||||
participantRaw: string | null;
|
||||
editId?: number | null;
|
||||
saveError?: boolean;
|
||||
/** Ledger: failed system supply action */
|
||||
supplyError?: boolean;
|
||||
/** When tab is matchmaker: `raw` selects matchmaker.log; omit or processed → history.log */
|
||||
matchmakerSource?: MatchmakerLogSource | null;
|
||||
/** Ledger tab: UTC date-only bounds (`lfrom` / `lto` query params). */
|
||||
@@ -62,6 +64,7 @@ export function buildDashboardHref(q: DashboardUrlQuery): string {
|
||||
p.set("edit", String(q.editId));
|
||||
}
|
||||
if (q.saveError) p.set("saveError", "1");
|
||||
if (q.supplyError) p.set("supplyErr", "1");
|
||||
const s = p.toString();
|
||||
return s ? `/?${s}` : "/";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user