"use client"; import * as React from "react"; import { CheckCircle2, XCircle } from "lucide-react"; import { TRANSACTION_TYPES } from "@spinza/shared"; import { useAdminQuery } from "@/components/admin/use-query"; import type { EconomyResponse } from "@/components/admin/types"; import { PageHeader, RefreshButton, Panel, DataTable, ErrorState, TileSkeleton, TableSkeleton, StatGrid, StatTile, SegmentedControl, Pill, type Column } from "@/components/admin/primitives"; import { Bars, ChartFrame, CHART, Histogram, type Series } from "@/components/admin/charts"; import { compact, int, isoDay, num, pct, sc, shortDate, signedSC } from "@/components/admin/format"; import { cn } from "@/lib/utils"; const DAYS = [7, 30, 90] as const; /** 9 ledger types folded into 5 legible series (the table below keeps every type). */ const GROUPS: { key: string; label: string; color: string; types: string[] }[] = [ { key: "bet", label: "Bets", color: CHART.grey2, types: ["BET"] }, { key: "win", label: "Wins", color: CHART.accent, types: ["WIN"] }, { key: "rewards", label: "Rewards", color: CHART.info, types: ["DAILY_REWARD", "ACHIEVEMENT", "MISSION", "LEVEL_UP"] }, { key: "grants", label: "Grants", color: CHART.success, types: ["INITIAL_GRANT", "RESCUE_CREDITS"] }, { key: "admin", label: "Admin", color: CHART.danger, types: ["ADMIN_ADJUSTMENT"] }, ]; const SERIES: Series[] = GROUPS.map((g) => ({ key: g.key, label: g.label, color: g.color })); const groupOf = (type: string) => GROUPS.find((g) => g.types.includes(type))?.key ?? "grants"; const TYPE_TONE: Record = { BET: "muted", WIN: "accent", DAILY_REWARD: "info", ACHIEVEMENT: "info", MISSION: "info", LEVEL_UP: "info", INITIAL_GRANT: "success", RESCUE_CREDITS: "success", ADMIN_ADJUSTMENT: "warn" }; interface TypeRow { type: string; n: number; total: number; } const typeCols: Column[] = [ { key: "type", header: "Type", render: (r) => {r.type} }, { key: "group", header: "Group", render: (r) => {GROUPS.find((g) => g.key === groupOf(r.type))?.label} }, { key: "n", header: "Transactions", align: "right", render: (r) => int(r.n) }, { key: "total", header: "Signed total", align: "right", render: (r) => 0 ? "text-success" : r.total < 0 ? "text-fg-2" : "text-fg-3")}>{signedSC(r.total)} }, { key: "avg", header: "Avg / txn", align: "right", render: (r) => (r.n ? signedSC(Math.round(r.total / r.n)) : "—") }, ]; export default function AdminEconomyPage() { const [days, setDays] = React.useState<(typeof DAYS)[number]>(30); const q = useAdminQuery(`/api/admin/economy?days=${days}`); const d = q.data; const byType = React.useMemo(() => { const m = new Map((d?.byType ?? []).map((r) => [r.type, { type: r.type, n: num(r.n), total: num(r.total) }])); return TRANSACTION_TYPES.map((t) => m.get(t) ?? { type: t, n: 0, total: 0 }); }, [d]); const anchor = q.updatedAt; const daily = React.useMemo(() => { const rows = new Map>(); for (let i = days - 1; i >= 0; i--) { const day = new Date(anchor - i * 86400_000).toISOString().slice(0, 10); rows.set(day, { day, bet: 0, win: 0, rewards: 0, grants: 0, admin: 0 }); } for (const r of d?.daily ?? []) { const day = isoDay(r.day); const row = rows.get(day); if (!row) continue; const k = groupOf(r.type); row[k] = num(row[k]) + num(r.total); } return Array.from(rows.values()); }, [d, days, anchor]); const net = byType.reduce((a, r) => a + r.total, 0); const mismatches = d ? num(d.invariant.mismatches) : 0; const distribution = React.useMemo(() => { const m = new Map((d?.balanceDistribution ?? []).map((r) => [num(r.bucket), num(r.n)])); return Array.from({ length: 11 }, (_, i) => { const b = i + 1; const label = b === 11 ? "100K+" : `${(b - 1) * 10}K–${b * 10}K`; return { bucket: b, label, n: m.get(b) ?? 0 }; }); }, [d]); return ( <> ({ value: v, label: `${v}d` }))} /> void q.refresh()} loading={q.refreshing} /> } /> {q.error && !d ? ( void q.refresh()} /> ) : !d ? (
) : (
{mismatches === 0 ? : } {mismatches === 0 ? "OK" : `${int(mismatches)} off`} } tone={mismatches === 0 ? "success" : "danger"} sub={mismatches === 0 ? "Σ ledger = balance for every wallet" : "wallets whose ledger ≠ balance"} />
shortDate(String(v))} yFormat={(v) => compact(v)} barSize={days > 30 ? 6 : 14} tooltipLabel={(l) => shortDate(String(l))} /> int(v)} />
a + r.n, 0))} transactions`} padded={false}> r.type} dense footer={ Net {int(byType.reduce((a, r) => a + r.n, 0))} = 0 ? "text-success" : "text-danger")}>{signedSC(net)} } />
)} ); }