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[];
|
participant?: string | string[];
|
||||||
edit?: string | string[];
|
edit?: string | string[];
|
||||||
saveError?: string | string[];
|
saveError?: string | string[];
|
||||||
|
supplyErr?: string | string[];
|
||||||
mklog?: string | string[];
|
mklog?: string | string[];
|
||||||
lfrom?: string | string[];
|
lfrom?: string | string[];
|
||||||
lto?: string | string[];
|
lto?: string | string[];
|
||||||
@@ -80,6 +81,7 @@ export default async function Home({
|
|||||||
const participantRaw = firstSearchParam(sp.participant);
|
const participantRaw = firstSearchParam(sp.participant);
|
||||||
const editRaw = firstSearchParam(sp.edit);
|
const editRaw = firstSearchParam(sp.edit);
|
||||||
const saveError = firstSearchParam(sp.saveError) === "1";
|
const saveError = firstSearchParam(sp.saveError) === "1";
|
||||||
|
const supplyError = firstSearchParam(sp.supplyErr) === "1";
|
||||||
const editIdNum =
|
const editIdNum =
|
||||||
editRaw != null && editRaw !== "" ? Number(editRaw) : NaN;
|
editRaw != null && editRaw !== "" ? Number(editRaw) : NaN;
|
||||||
|
|
||||||
@@ -272,6 +274,7 @@ export default async function Home({
|
|||||||
ledgerOrder={ledgerSortOrderView}
|
ledgerOrder={ledgerSortOrderView}
|
||||||
ledgerFrom={ledgerFrom}
|
ledgerFrom={ledgerFrom}
|
||||||
ledgerTo={ledgerTo}
|
ledgerTo={ledgerTo}
|
||||||
|
supplyError={supplyError}
|
||||||
/>
|
/>
|
||||||
{editUser ? (
|
{editUser ? (
|
||||||
<EditUserCcRcOverlay
|
<EditUserCcRcOverlay
|
||||||
|
|||||||
@@ -215,6 +215,8 @@ type Props = {
|
|||||||
matchmakerSource: MatchmakerLogSource;
|
matchmakerSource: MatchmakerLogSource;
|
||||||
matchmakerContent: string;
|
matchmakerContent: string;
|
||||||
matchmakerError: string | null;
|
matchmakerError: string | null;
|
||||||
|
/** Ledger: add-system-supply action failed (URL `supplyErr=1`). */
|
||||||
|
supplyError: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function StatCard({
|
function StatCard({
|
||||||
@@ -264,6 +266,7 @@ export function AdminDashboard({
|
|||||||
matchmakerSource,
|
matchmakerSource,
|
||||||
matchmakerContent,
|
matchmakerContent,
|
||||||
matchmakerError,
|
matchmakerError,
|
||||||
|
supplyError,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [hideNoWinner, setHideNoWinner] = useState(true);
|
const [hideNoWinner, setHideNoWinner] = useState(true);
|
||||||
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("wins");
|
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("wins");
|
||||||
@@ -735,6 +738,7 @@ export function AdminDashboard({
|
|||||||
participantRaw={participantRaw}
|
participantRaw={participantRaw}
|
||||||
ledgerFrom={ledgerFrom}
|
ledgerFrom={ledgerFrom}
|
||||||
ledgerTo={ledgerTo}
|
ledgerTo={ledgerTo}
|
||||||
|
supplyError={supplyError}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useMemo } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { ClickableUserId } from "@/components/clickable-user-id";
|
import { ClickableUserId } from "@/components/clickable-user-id";
|
||||||
import { buildDashboardHref } from "@/lib/dashboard-search-url";
|
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 { auditMatchEconomics } from "@/lib/ledger-match-audit";
|
||||||
import {
|
import {
|
||||||
computeLedgerIntegrity,
|
computeLedgerIntegrity,
|
||||||
@@ -21,6 +24,7 @@ import {
|
|||||||
type LedgerSortOrder,
|
type LedgerSortOrder,
|
||||||
} from "@/lib/ledger-table-view";
|
} from "@/lib/ledger-table-view";
|
||||||
import { buildLedgerScopedDailyCumulativeSeries } from "@/lib/ledger-scoped-daily-series";
|
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 { LedgerRangeCumulativeChart } from "@/components/ledger-range-cumulative-chart";
|
||||||
import type { DbTransaction, DbUser } from "@/types/database";
|
import type { DbTransaction, DbUser } from "@/types/database";
|
||||||
|
|
||||||
@@ -86,6 +90,7 @@ type Props = {
|
|||||||
participantRaw: string | null;
|
participantRaw: string | null;
|
||||||
ledgerFrom: string;
|
ledgerFrom: string;
|
||||||
ledgerTo: string;
|
ledgerTo: string;
|
||||||
|
supplyError: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AdminLedger({
|
export function AdminLedger({
|
||||||
@@ -104,6 +109,7 @@ export function AdminLedger({
|
|||||||
participantRaw,
|
participantRaw,
|
||||||
ledgerFrom,
|
ledgerFrom,
|
||||||
ledgerTo,
|
ledgerTo,
|
||||||
|
supplyError,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const usernameById = useMemo(() => {
|
const usernameById = useMemo(() => {
|
||||||
const m = new Map<number, string | null>();
|
const m = new Map<number, string | null>();
|
||||||
@@ -113,6 +119,13 @@ export function AdminLedger({
|
|||||||
return m;
|
return m;
|
||||||
}, [users]);
|
}, [users]);
|
||||||
|
|
||||||
|
const systemAccount = useMemo(() => {
|
||||||
|
for (const u of users) {
|
||||||
|
if (Number(u.id) === 1) return u;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [users]);
|
||||||
|
|
||||||
const { integrityGlobal, userNets } = useMemo(() => {
|
const { integrityGlobal, userNets } = useMemo(() => {
|
||||||
if (ledgerGlobalSummary == null) {
|
if (ledgerGlobalSummary == null) {
|
||||||
return {
|
return {
|
||||||
@@ -240,6 +253,25 @@ export function AdminLedger({
|
|||||||
);
|
);
|
||||||
const pageNavItems = ledgerPaginationItems(ledgerPage, ledgerTotalPages);
|
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 =
|
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";
|
"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";
|
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",
|
: "border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/40",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-zinc-600 dark:text-zinc-400">
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
Σ user nets vs net issued
|
<div className="min-w-0 flex-1">
|
||||||
</p>
|
<p className="text-xs font-medium uppercase tracking-wide text-zinc-600 dark:text-zinc-400">
|
||||||
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
|
Σ user nets vs net issued
|
||||||
All transactions
|
</p>
|
||||||
</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">
|
<div className="mt-2 text-sm tabular-nums text-zinc-900 dark:text-zinc-100">
|
||||||
<p className="font-mono">
|
<p className="font-mono">
|
||||||
{formatBigAmount(integrityGlobal.sumUserNets)}
|
{formatBigAmount(integrityGlobal.sumUserNets)}
|
||||||
@@ -956,6 +999,122 @@ export function AdminLedger({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,3 +70,25 @@ export function formatRcBalanceWithCoins(rc: number | null): string {
|
|||||||
}
|
}
|
||||||
return `${rc.toFixed(1)} RC`;
|
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;
|
participantRaw: string | null;
|
||||||
editId?: number | null;
|
editId?: number | null;
|
||||||
saveError?: boolean;
|
saveError?: boolean;
|
||||||
|
/** Ledger: failed system supply action */
|
||||||
|
supplyError?: boolean;
|
||||||
/** When tab is matchmaker: `raw` selects matchmaker.log; omit or processed → history.log */
|
/** When tab is matchmaker: `raw` selects matchmaker.log; omit or processed → history.log */
|
||||||
matchmakerSource?: MatchmakerLogSource | null;
|
matchmakerSource?: MatchmakerLogSource | null;
|
||||||
/** Ledger tab: UTC date-only bounds (`lfrom` / `lto` query params). */
|
/** 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));
|
p.set("edit", String(q.editId));
|
||||||
}
|
}
|
||||||
if (q.saveError) p.set("saveError", "1");
|
if (q.saveError) p.set("saveError", "1");
|
||||||
|
if (q.supplyError) p.set("supplyErr", "1");
|
||||||
const s = p.toString();
|
const s = p.toString();
|
||||||
return s ? `/?${s}` : "/";
|
return s ? `/?${s}` : "/";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user