"use client"; import Link from "next/link"; import { useCallback, useEffect, useMemo, useState } from "react"; import { ClickableUserId } from "@/components/clickable-user-id"; import { AdminLedger } from "@/components/admin-ledger"; import { AdminSystemLogs } from "@/components/admin-system-logs"; import { LocalTimestamp } from "@/components/local-timestamp"; import { MatchHistoryBattleCard } from "@/components/match-history-battle-card"; import type { AuditLogEntry } from "@/lib/auth/audit-log"; import type { DashboardStatsBundle, LeaderboardRow } from "@/lib/dashboard-stats"; import { formatRcBalanceWithCoins } from "@/lib/coins-rc"; import { AdminMatchAnalysis } from "@/components/admin-match-analysis"; import type { GeolocationAnalyticsResult } from "@/lib/geolocation-analytics"; import type { PingAnalyticsResult } from "@/lib/ping-analytics"; import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal"; import type { MatchLogAnalysisResult } from "@/lib/match-log-parser"; import { buildDashboardHref, type AdminDashboardTab, } from "@/lib/dashboard-search-url"; import { localLast30DaysDateRange, } from "@/lib/local-date-range"; import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source"; import type { AdminMatchRow, DbMatch, DbTransaction, DbUser, } from "@/types/database"; import { txAmountToBigInt, type SerializedLedgerGlobalSummary, } from "@/lib/ledger-integrity"; import type { LedgerSortKey, LedgerSortOrder } from "@/lib/ledger-table-view"; /** Bigint columns often arrive as strings from PostgREST / JSON. */ function matchHasRecordedWinner(winnerId: DbMatch["winner_id"]): boolean { if (winnerId == null) return false; if (typeof winnerId === "number") { return Number.isInteger(winnerId) && winnerId > 0; } if (typeof winnerId === "string") { const t = winnerId.trim(); if (t === "" || !/^-?\d+$/.test(t)) return false; const n = Number(t); return Number.isFinite(n) && n > 0; } return false; } function participantInMatch(m: DbMatch, participantId: number): boolean { const red = m.user_red; const blue = m.user_blue; if (red != null && Number(red) === participantId) return true; if (blue != null && Number(blue) === participantId) return true; return false; } function coalesceUserId(v: number | string | null): number | null { if (v == null) return null; const n = Number(v); return Number.isFinite(n) ? Math.trunc(n) : null; } /** UTC ISO slice — identical on server and client (avoids hydration mismatch from `toLocaleString`). */ function formatTs(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 formatPrizeCcChip(v: DbMatch["prize_cc"]): string { if (v == null) return "—"; const b = txAmountToBigInt(v); try { return b.toLocaleString("en-US"); } catch { return String(v); } } type LeaderboardSortKey = "rank" | "player" | "rc" | "mmr" | "winRate" | "wins"; function playerSortKey(row: LeaderboardRow): string { const t = row.username?.trim(); if (t) return t.toLowerCase(); return "\uffff"; } function isEscrowUsername(username: string | null): boolean { return (username ?? "").toLowerCase().startsWith("match_escrow_"); } function leaderboardDefaultSortDir(key: LeaderboardSortKey): "asc" | "desc" { if (key === "player" || key === "rank") return "asc"; return "desc"; } function sortLeaderboardRows( rows: LeaderboardRow[], key: LeaderboardSortKey, dir: "asc" | "desc", ): LeaderboardRow[] { const out = [...rows]; out.sort((a, b) => { let cmp = 0; switch (key) { case "rank": cmp = a.winsLeaderboardRank - b.winsLeaderboardRank; break; case "wins": cmp = a.matchesWon - b.matchesWon; break; case "player": { const ka = playerSortKey(a); const kb = playerSortKey(b); cmp = ka.localeCompare(kb, "en", { sensitivity: "base" }); if (cmp === 0) cmp = a.userId - b.userId; break; } case "rc": { const aOk = a.rcBalance != null && Number.isFinite(a.rcBalance); const bOk = b.rcBalance != null && Number.isFinite(b.rcBalance); if (!aOk && !bOk) cmp = 0; else if (!aOk) cmp = 1; else if (!bOk) cmp = -1; else cmp = a.rcBalance! - b.rcBalance!; break; } case "mmr": { const aOk = a.mmr != null && Number.isFinite(a.mmr); const bOk = b.mmr != null && Number.isFinite(b.mmr); if (!aOk && !bOk) cmp = 0; else if (!aOk) cmp = 1; else if (!bOk) cmp = -1; else cmp = a.mmr! - b.mmr!; break; } case "winRate": { const aOk = a.winRatePercent != null && Number.isFinite(a.winRatePercent); const bOk = b.winRatePercent != null && Number.isFinite(b.winRatePercent); if (!aOk && !bOk) cmp = 0; else if (!aOk) cmp = 1; else if (!bOk) cmp = -1; else cmp = a.winRatePercent! - b.winRatePercent!; break; } default: break; } if (cmp !== 0) return dir === "asc" ? cmp : -cmp; return a.userId - b.userId; }); return out; } function LeaderboardSortTh({ label, sortKey, activeKey, activeDir, onActivate, align = "left", }: { label: string; sortKey: LeaderboardSortKey; activeKey: LeaderboardSortKey; activeDir: "asc" | "desc"; onActivate: (key: LeaderboardSortKey) => void; align?: "left" | "right"; }) { const active = activeKey === sortKey; const rowAlign = align === "right" ? "text-right" : "text-left"; const btnAlign = align === "right" ? "justify-end text-right" : "justify-start text-left"; return ( ); } type Props = { users: DbUser[]; matches: AdminMatchRow[]; usersError: string | null; matchesError: string | null; transactionsError: string | null; /** Ledger tab: full-database totals + user nets (server scan). */ ledgerGlobalSummary: SerializedLedgerGlobalSummary | null; /** Ledger tab: rows in the selected UTC date range (full set for audits). */ ledgerTransactionsFiltered: DbTransaction[]; /** Ledger tab: current page of the ledger table (sorted + paginated). */ ledgerTableRows: DbTransaction[]; ledgerTotalRowsInRange: number; ledgerPage: number; ledgerPageSize: number; ledgerTotalPages: number; ledgerSort: LedgerSortKey; ledgerOrder: LedgerSortOrder; ledgerFrom: string; ledgerTo: string; statsBundle: DashboardStatsBundle | null; /** From server `searchParams` so SSR and client markup match (do not use `useSearchParams` here). */ tab: AdminDashboardTab; highlightId: string | null; participantRaw: string | null; matchmakerSource: MatchmakerLogSource; matchmakerContent: string; matchmakerError: string | null; /** Ledger: add-system-supply action failed (URL `supplyErr=1`). */ supplyError: boolean; /** Matches tab: local date-only bounds + browser tz offset minutes. */ matchesFrom: string; matchesTo: string; matchesTzOffsetMinutes: number | null; /** True when `mfrom`/`mto` were present and valid in the URL. */ matchesRangeExplicit: boolean; analysisFrom: string; analysisTo: string; analysisPlayerIds: number[]; matchAnalysis: MatchLogAnalysisResult; pingAnalytics: PingAnalyticsResult; geolocationAnalytics: GeolocationAnalyticsResult; auditEntries: AuditLogEntry[]; auditError: string | null; canWritePlayers: boolean; canWriteLedger: boolean; /** Match IDs that have a replay JSON file on disk. */ matchIdsWithReplay: number[]; }; function StatCard({ title, value, }: { title: string; value: number | null; }) { const display = value == null ? "—" : value.toLocaleString("en-US"); return (

{title}

{display}

); } export function AdminDashboard({ users, matches, usersError, matchesError, transactionsError, ledgerGlobalSummary, ledgerTransactionsFiltered, ledgerTableRows, ledgerTotalRowsInRange, ledgerPage, ledgerPageSize, ledgerTotalPages, ledgerSort, ledgerOrder, ledgerFrom, ledgerTo, statsBundle, tab, highlightId, participantRaw, matchmakerSource, matchmakerContent, matchmakerError, supplyError, matchesFrom, matchesTo, matchesTzOffsetMinutes, matchesRangeExplicit, analysisFrom, analysisTo, analysisPlayerIds, matchAnalysis, pingAnalytics, geolocationAnalytics, auditEntries, auditError, canWritePlayers, canWriteLedger, matchIdsWithReplay, }: Props) { const replaySet = useMemo( () => new Set(matchIdsWithReplay), [matchIdsWithReplay], ); const [hideNoWinner, setHideNoWinner] = useState(true); const [playersSearch, setPlayersSearch] = useState(""); const [showEscrowAccounts, setShowEscrowAccounts] = useState(false); const [lbSortKey, setLbSortKey] = useState("mmr"); const [lbSortDir, setLbSortDir] = useState<"asc" | "desc">("desc"); /** Once we know the browser offset, reload with mtz so day bounds are local. */ useEffect(() => { if (tab !== "matches") return; if (matchesTzOffsetMinutes != null) return; const tz = new Date().getTimezoneOffset(); const range = matchesRangeExplicit ? { from: matchesFrom, to: matchesTo } : localLast30DaysDateRange(tz); const href = buildDashboardHref({ tab: "matches", highlightId, participantRaw, matchesFrom: range.from, matchesTo: range.to, matchesTzOffsetMinutes: tz, }); window.location.replace(href); }, [ tab, matchesTzOffsetMinutes, matchesRangeExplicit, highlightId, participantRaw, matchesFrom, matchesTo, ]); const lbRaw = statsBundle?.leaderboard; const sortedLeaderboard = useMemo(() => { const rows = lbRaw ?? []; if (rows.length === 0) return []; return sortLeaderboardRows(rows, lbSortKey, lbSortDir); }, [lbRaw, lbSortKey, lbSortDir]); const onLbSort = useCallback((key: LeaderboardSortKey) => { setLbSortKey((prevKey) => { if (key === prevKey) { setLbSortDir((d) => (d === "asc" ? "desc" : "asc")); return prevKey; } setLbSortDir(leaderboardDefaultSortDir(key)); return key; }); }, []); const userById = useMemo(() => { const m = new Map(); for (const u of users) { m.set(u.id, u); } return m; }, [users]); const usernameById = useMemo(() => { const m = new Map(); for (const u of users) { m.set(u.id, u.username); } return m; }, [users]); const participantId = useMemo(() => { if (!participantRaw) return null; const n = Number(participantRaw); return Number.isFinite(n) ? n : null; }, [participantRaw]); const filteredMatches = useMemo(() => { if (participantId == null) return matches; return matches.filter((m) => participantInMatch(m, participantId)); }, [matches, participantId]); const visibleMatches = useMemo(() => { if (!hideNoWinner) return filteredMatches; return filteredMatches.filter((m) => matchHasRecordedWinner(m.winner_id)); }, [filteredMatches, hideNoWinner]); const playersPool = useMemo(() => { if (showEscrowAccounts) return users; return users.filter((u) => !isEscrowUsername(u.username)); }, [users, showEscrowAccounts]); const filteredUsers = useMemo(() => { const q = playersSearch.trim().toLowerCase(); if (!q) return playersPool; return playersPool.filter((u) => { const haystack = [ String(u.id), u.username ?? "", u.email ?? "", u.cc == null ? "" : String(u.cc), u.rc == null ? "" : String(u.rc), u.mmr == null ? "" : String(u.mmr), formatTs(u.created_at), formatTs(u.last_logged_at), u.ip_address ?? "", ] .join(" ") .toLowerCase(); return haystack.includes(q); }); }, [playersPool, playersSearch]); useEffect(() => { if (tab !== "players" || !highlightId) return; const el = document.getElementById(`player-row-${highlightId}`); el?.scrollIntoView({ block: "center", behavior: "smooth" }); }, [tab, highlightId]); function editHref(u: DbUser): string { return buildDashboardHref({ tab, highlightId, participantRaw, editId: u.id, }); } const statusLabel = (status: number | null) => { if (status === 2) return "Final"; if (status === 1) return "Live"; if (status === 0) return "Waiting"; return status == null ? "Unknown" : `Status ${status}`; }; return (
{tab === "dashboard" ? (
{statsBundle?.error ? (
{statsBundle.error}
) : null}

Active players

Matches

Top players by match wins

{(statsBundle?.leaderboard ?? []).length === 0 ? ( ) : ( sortedLeaderboard.map((row, i) => ( )) )}
Email
No wins recorded yet (no rows with a winner).
{i + 1} {row.username != null && row.username !== "" ? ( {" "} ({row.username}) ) : ( (—) )} {row.email?.trim() ? row.email.trim() : "—"} {formatRcBalanceWithCoins(row.rcBalance)} {row.mmr == null ? "—" : row.mmr.toLocaleString("en-US")} {row.winRatePercent == null ? "—" : `${row.winRatePercent.toLocaleString("en-US", { maximumFractionDigits: 1, minimumFractionDigits: 0, })}%`} {row.matchesWon.toLocaleString("en-US")}
) : tab === "players" ? (
{usersError ? (

{usersError}

) : (
Showing {filteredUsers.length} of {playersPool.length}
{filteredUsers.length === 0 ? ( ) : ( filteredUsers.map((u) => { const isHi = highlightId != null && highlightId === String(u.id); return ( ); }) )}
ID Username Email CC RC MMR Created Last seen IP Actions
{playersPool.length === 0 ? users.length === 0 ? "No players yet." : "No players left after hiding escrow accounts." : "No players match this search."}
{u.username ?? "—"} {u.email?.trim() ? u.email.trim() : "—"} {u.cc ?? "—"} {u.rc ?? "—"} {u.mmr ?? "—"} {formatTs(u.created_at)} {formatTs(u.last_logged_at)} {u.ip_address?.trim() ? u.ip_address.trim() : "—"}
{canWritePlayers ? ( Edit ) : null} Show matches
)}
) : tab === "matchmaker" ? (

history.log {" "} is the processed summary;{" "} matchmaker.log {" "} is raw output. Toggle below or{" "} open fullscreen .

) : tab === "analysis" ? ( ) : tab === "ledger" ? ( ) : tab === "logs" ? ( ) : (
{ const form = e.currentTarget; const mtzInput = form.elements.namedItem( "mtz", ) as HTMLInputElement | null; if (mtzInput) { mtzInput.value = String(new Date().getTimezoneOffset()); } }} > {highlightId ? ( ) : null} {participantRaw ? ( ) : null}
Reset range

Showing {matches.length.toLocaleString("en-US")}{" "} {matches.length === 1 ? "match" : "matches"} created{" "} {matchesFrom}–{matchesTo} {" "} in your local timezone {matches.length >= 10000 ? " · capped at 10,000 rows" : null}

{participantId != null ? (
Showing matches where{" "} {participantId} {usernameById.get(participantId) != null && usernameById.get(participantId) !== "" ? ( {" "} ({usernameById.get(participantId)}) ) : null}{" "} is the Red or Blue user ( {filteredMatches.length} of {matches.length}) Clear filter
) : null} {matchesError ? (

{matchesError}

) : (
Showing {visibleMatches.length} of {filteredMatches.length}
{visibleMatches.length === 0 ? (
{filteredMatches.length === 0 ? "No matches in this date range." : hideNoWinner ? "No matches left after hiding no-winner matches." : "No matches for this filter."}
) : ( visibleMatches.map((m) => { const redId = coalesceUserId(m.user_red); const blueId = coalesceUserId(m.user_blue); const leftUser = redId != null ? userById.get(redId) : null; const rightUser = blueId != null ? userById.get(blueId) : null; return ( } entryFeeCoins={m.entryFeePerPlayerCoins} entryHoldPerPlayerCoins={m.entryHoldPerPlayerCoins} prizeCcLabel={formatPrizeCcChip(m.prize_cc)} winnerId={m.winner_id} hasReplay={replaySet.has(Number(m.id))} left={{ id: redId, username: leftUser?.username ?? null, cc: leftUser?.cc ?? null, rc: leftUser?.rc ?? null, color: "red", }} right={{ id: blueId, username: rightUser?.username ?? null, cc: rightUser?.cc ?? null, rc: rightUser?.rc ?? null, color: "blue", }} /> ); }) )}
)}
)}
); }