From 906fc7f5cf196c1bf59292d3ff0373666a8964dd Mon Sep 17 00:00:00 2001 From: Spbd1 <148923621+Spbd1@users.noreply.github.com> Date: Sun, 10 May 2026 07:57:43 +0000 Subject: [PATCH] Build admin dashboard --- .../app/admin/_components/AdminActions.tsx | 92 +++++++++++++++++++ .../web/app/admin/_components/AdminCharts.tsx | 71 ++++++++++++++ apps/web/app/admin/_components/AdminShell.tsx | 34 +++++++ apps/web/app/admin/_components/DataTable.tsx | 44 +++++++++ apps/web/app/admin/_components/format.ts | 27 ++++++ apps/web/app/admin/_components/ui.tsx | 73 +++++++++++++++ apps/web/app/admin/audit-log/page.tsx | 4 + apps/web/app/admin/exports/page.tsx | 18 ++++ apps/web/app/admin/layout.tsx | 6 ++ apps/web/app/admin/login/page.tsx | 4 + apps/web/app/admin/page.tsx | 42 +++++++-- apps/web/app/admin/servers/ServersTable.tsx | 24 +++++ .../admin/servers/[serverId]/ServerTabs.tsx | 7 ++ .../servers/[serverId]/analytics/page.tsx | 9 ++ .../admin/servers/[serverId]/config/page.tsx | 6 ++ .../[serverId]/contracts/ContractsTable.tsx | 5 + .../servers/[serverId]/contracts/page.tsx | 8 ++ .../servers/[serverId]/events/EventsTable.tsx | 5 + .../admin/servers/[serverId]/events/page.tsx | 8 ++ .../web/app/admin/servers/[serverId]/page.tsx | 56 +++++++++++ .../servers/[serverId]/parcels/ParcelMap.tsx | 10 ++ .../admin/servers/[serverId]/parcels/page.tsx | 17 ++++ .../[serverId]/players/PlayersTable.tsx | 17 ++++ .../admin/servers/[serverId]/players/page.tsx | 15 +++ .../servers/[serverId]/rounds/RoundsTable.tsx | 5 + .../admin/servers/[serverId]/rounds/page.tsx | 8 ++ apps/web/app/admin/servers/new/actions.ts | 34 +++++++ apps/web/app/admin/servers/new/page.tsx | 30 ++++++ apps/web/app/admin/servers/page.tsx | 13 +++ apps/web/package.json | 3 +- 30 files changed, 687 insertions(+), 8 deletions(-) create mode 100644 apps/web/app/admin/_components/AdminActions.tsx create mode 100644 apps/web/app/admin/_components/AdminCharts.tsx create mode 100644 apps/web/app/admin/_components/AdminShell.tsx create mode 100644 apps/web/app/admin/_components/DataTable.tsx create mode 100644 apps/web/app/admin/_components/format.ts create mode 100644 apps/web/app/admin/_components/ui.tsx create mode 100644 apps/web/app/admin/audit-log/page.tsx create mode 100644 apps/web/app/admin/exports/page.tsx create mode 100644 apps/web/app/admin/layout.tsx create mode 100644 apps/web/app/admin/login/page.tsx create mode 100644 apps/web/app/admin/servers/ServersTable.tsx create mode 100644 apps/web/app/admin/servers/[serverId]/ServerTabs.tsx create mode 100644 apps/web/app/admin/servers/[serverId]/analytics/page.tsx create mode 100644 apps/web/app/admin/servers/[serverId]/config/page.tsx create mode 100644 apps/web/app/admin/servers/[serverId]/contracts/ContractsTable.tsx create mode 100644 apps/web/app/admin/servers/[serverId]/contracts/page.tsx create mode 100644 apps/web/app/admin/servers/[serverId]/events/EventsTable.tsx create mode 100644 apps/web/app/admin/servers/[serverId]/events/page.tsx create mode 100644 apps/web/app/admin/servers/[serverId]/page.tsx create mode 100644 apps/web/app/admin/servers/[serverId]/parcels/ParcelMap.tsx create mode 100644 apps/web/app/admin/servers/[serverId]/parcels/page.tsx create mode 100644 apps/web/app/admin/servers/[serverId]/players/PlayersTable.tsx create mode 100644 apps/web/app/admin/servers/[serverId]/players/page.tsx create mode 100644 apps/web/app/admin/servers/[serverId]/rounds/RoundsTable.tsx create mode 100644 apps/web/app/admin/servers/[serverId]/rounds/page.tsx create mode 100644 apps/web/app/admin/servers/new/actions.ts create mode 100644 apps/web/app/admin/servers/new/page.tsx create mode 100644 apps/web/app/admin/servers/page.tsx diff --git a/apps/web/app/admin/_components/AdminActions.tsx b/apps/web/app/admin/_components/AdminActions.tsx new file mode 100644 index 0000000..7c60513 --- /dev/null +++ b/apps/web/app/admin/_components/AdminActions.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useMemo, useState } from "react"; + +type ActionKind = "patch" | "post" | "export"; + +type Action = { + label: string; + confirm?: string; + url: string; + kind?: ActionKind; + body?: Record; + destructive?: boolean; + preview?: { submitted: number; missing: number; currentRound: number; nextRound: number }; +}; + +const authHeader = () => { + if (typeof window === "undefined") return {}; + const token = window.localStorage.getItem("parcel_admin_basic"); + return token ? { Authorization: `Basic ${token}` } : {}; +}; + +export function ConfirmDialog({ action, onClose, onConfirm, busy }: { action: Action | null; onClose: () => void; onConfirm: () => void; busy: boolean }) { + if (!action) return null; + return ( +
+
+

Confirm action

+

{action.confirm ?? `Run ${action.label}?`}

+ {action.preview ? ( +
+
Submitted players

{action.preview.submitted}

+
Missing decisions

{action.preview.missing}

+
Current round

{action.preview.currentRound}

+
Expected next round

{action.preview.nextRound}

+
+ ) : null} +
+ + +
+
+
+ ); +} + +export function AdminActions({ actions }: { actions: Action[] }) { + const router = useRouter(); + const [pending, setPending] = useState(null); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(null); + const headers = useMemo(() => ({ "Content-Type": "application/json", ...authHeader() }), []); + + const run = async (action: Action) => { + setBusy(true); + setMessage(null); + try { + const response = await fetch(action.url, { + method: action.kind === "patch" ? "PATCH" : "POST", + headers, + body: action.kind === "patch" || action.body ? JSON.stringify(action.body ?? {}) : undefined, + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data?.error?.message ?? data?.message ?? "Action failed"); + if (action.kind === "export") { + const blob = new Blob([JSON.stringify(data.data ?? data, null, 2)], { type: "application/json" }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = "parcel-society-export.json"; + link.click(); + } + setMessage(`${action.label} completed.`); + router.refresh(); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Action failed"); + } finally { + setBusy(false); + setPending(null); + } + }; + + return ( +
+
+ {actions.map((action) => )} +
+ {message ?

{message}

: null} + setPending(null)} onConfirm={() => pending && void run(pending)} /> +
+ ); +} diff --git a/apps/web/app/admin/_components/AdminCharts.tsx b/apps/web/app/admin/_components/AdminCharts.tsx new file mode 100644 index 0000000..608bf35 --- /dev/null +++ b/apps/web/app/admin/_components/AdminCharts.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { Bar, BarChart, CartesianGrid, Cell, Legend, Line, LineChart, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; + +type Point = Record; + +export function LineMetricChart({ data, lines }: { data: Point[]; lines: string[] }) { + return ( +
+ + + + + + + + {lines.map((line, index) => )} + + +
+ ); +} + +export function BarMetricChart({ data, bars }: { data: Point[]; bars: string[] }) { + return ( +
+ + + + + + + + {bars.map((bar, index) => )} + + +
+ ); +} + +export function DistributionChart({ data }: { data: Array<{ name: string; value: number }> }) { + return ( +
+ + + + + + + + + +
+ ); +} + +export function PieMetricChart({ data }: { data: Array<{ name: string; value: number }> }) { + return ( +
+ + + + + + {data.map((_, index) => )} + + + +
+ ); +} diff --git a/apps/web/app/admin/_components/AdminShell.tsx b/apps/web/app/admin/_components/AdminShell.tsx new file mode 100644 index 0000000..6637b09 --- /dev/null +++ b/apps/web/app/admin/_components/AdminShell.tsx @@ -0,0 +1,34 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import type { ReactNode } from "react"; + +const links = [ + ["/admin", "Dashboard"], + ["/admin/servers", "Servers"], + ["/admin/exports", "Exports"], + ["/admin/audit-log", "Audit log"], +]; + +export function AdminShell({ children }: { children: ReactNode }) { + const pathname = usePathname(); + if (pathname === "/admin/login") return <>{children}; + return ( +
+ +
{children}
+
+ ); +} diff --git a/apps/web/app/admin/_components/DataTable.tsx b/apps/web/app/admin/_components/DataTable.tsx new file mode 100644 index 0000000..59777b7 --- /dev/null +++ b/apps/web/app/admin/_components/DataTable.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useMemo, useState, type ReactNode } from "react"; + +export type Column = { + key: string; + header: string; + accessor?: (row: T) => ReactNode; + searchValue?: (row: T) => string; +}; + +export function DataTable({ rows, columns, placeholder = "Search…" }: { rows: T[]; columns: Column[]; placeholder?: string }) { + const [query, setQuery] = useState(""); + const filtered = useMemo(() => { + const needle = query.trim().toLowerCase(); + if (!needle) return rows; + return rows.filter((row) => + columns.some((column) => (column.searchValue?.(row) ?? String(column.accessor?.(row) ?? "")).toLowerCase().includes(needle)), + ); + }, [columns, query, rows]); + + return ( +
+
+ setQuery(event.target.value)} placeholder={placeholder} /> +

Showing {filtered.length} of {rows.length} rows

+
+
+ + + {columns.map((column) => )} + + + {filtered.map((row, index) => ( + + {columns.map((column) => )} + + ))} + +
{column.header}
{column.accessor?.(row) ?? "—"}
+
+
+ ); +} diff --git a/apps/web/app/admin/_components/format.ts b/apps/web/app/admin/_components/format.ts new file mode 100644 index 0000000..491566a --- /dev/null +++ b/apps/web/app/admin/_components/format.ts @@ -0,0 +1,27 @@ +export type DecimalValue = number | string | { toString(): string } | null | undefined; + +export const numberValue = (value: DecimalValue): number => Number(value ?? 0); + +export const formatNumber = (value: DecimalValue, digits = 0) => + numberValue(value).toLocaleString(undefined, { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); + +export const formatMoney = (value: DecimalValue) => `$${formatNumber(value, 2)}`; + +export const formatPercent = (value: DecimalValue) => `${(numberValue(value) * 100).toFixed(1)}%`; + +export const formatDate = (value: Date | string | null | undefined) => + value ? new Date(value).toLocaleString() : "—"; + +export const shortId = (id: string | null | undefined) => (id ? id.slice(0, 8) : "—"); + +export const gini = (values: number[]) => { + const sorted = values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b); + if (sorted.length === 0) return 0; + const sum = sorted.reduce((total, value) => total + value, 0); + if (sum === 0) return 0; + const weighted = sorted.reduce((total, value, index) => total + (index + 1) * value, 0); + return (2 * weighted) / (sorted.length * sum) - (sorted.length + 1) / sorted.length; +}; diff --git a/apps/web/app/admin/_components/ui.tsx b/apps/web/app/admin/_components/ui.tsx new file mode 100644 index 0000000..fb663d5 --- /dev/null +++ b/apps/web/app/admin/_components/ui.tsx @@ -0,0 +1,73 @@ +import Link from "next/link"; +import type { ReactNode } from "react"; + +const statusStyles: Record = { + DRAFT: "bg-slate-100 text-slate-700 ring-slate-200", + WAITING: "bg-amber-100 text-amber-800 ring-amber-200", + ACTIVE: "bg-emerald-100 text-emerald-800 ring-emerald-200", + COMPLETED: "bg-blue-100 text-blue-800 ring-blue-200", + ARCHIVED: "bg-zinc-200 text-zinc-700 ring-zinc-300", +}; + +const conditionStyles: Record = { + LOW: "bg-cyan-50 text-cyan-800 ring-cyan-200", + HIGH: "bg-fuchsia-50 text-fuchsia-800 ring-fuchsia-200", + STABLE: "bg-green-50 text-green-800 ring-green-200", + UNCERTAIN: "bg-orange-50 text-orange-800 ring-orange-200", +}; + +export function Card({ children, className = "" }: { children: ReactNode; className?: string }) { + return
{children}
; +} + +export function StatCard({ label, value, hint }: { label: string; value: ReactNode; hint?: ReactNode }) { + return ( + +

{label}

+
{value}
+ {hint ?

{hint}

: null} +
+ ); +} + +export function ServerStatusBadge({ status }: { status: string }) { + return {status}; +} + +export function ConditionBadge({ value }: { value: string }) { + return {value}; +} + +export function AdminPageHeader({ title, description, actions }: { title: string; description?: string; actions?: ReactNode }) { + return ( +
+
+

{title}

+ {description ?

{description}

: null} +
+ {actions ?
{actions}
: null} +
+ ); +} + +export function EmptyState({ title, description, actionHref, actionLabel }: { title: string; description?: string; actionHref?: string; actionLabel?: string }) { + return ( + +

{title}

+ {description ?

{description}

: null} + {actionHref && actionLabel ? {actionLabel} : null} +
+ ); +} + +export function LoadingState() { + return

Loading admin data…

; +} + +export function ErrorState({ message }: { message: string }) { + return

{message}

; +} + +export function ButtonLink({ href, children, variant = "primary" }: { href: string; children: ReactNode; variant?: "primary" | "secondary" }) { + return {children}; +} diff --git a/apps/web/app/admin/audit-log/page.tsx b/apps/web/app/admin/audit-log/page.tsx new file mode 100644 index 0000000..94f3d68 --- /dev/null +++ b/apps/web/app/admin/audit-log/page.tsx @@ -0,0 +1,4 @@ +import { prisma } from "@parcel-society/db"; +import { AdminPageHeader, Card } from "../_components/ui"; +import { formatDate, shortId } from "../_components/format"; +export default async function AuditLogPage() { const logs=await prisma.auditLog.findMany({ orderBy:{createdAt:"desc"}, include:{admin:true}, take:100 }); return <>
{logs.map(log=>)}
TimestampAdminActionEntityBefore JSONAfter JSON
{formatDate(log.createdAt)}{log.admin?.email ?? "System"}{log.action}{log.entityType}:{shortId(log.entityId)}
{JSON.stringify(log.before, null, 2)}
{JSON.stringify(log.after, null, 2)}
{logs.length===0?

No audit events recorded yet.

:null}
; } diff --git a/apps/web/app/admin/exports/page.tsx b/apps/web/app/admin/exports/page.tsx new file mode 100644 index 0000000..30780a6 --- /dev/null +++ b/apps/web/app/admin/exports/page.tsx @@ -0,0 +1,18 @@ +import Link from "next/link"; +import { prisma } from "@parcel-society/db"; +import { AdminPageHeader, Card, EmptyState } from "../_components/ui"; +import { formatDate, shortId } from "../_components/format"; + +export default async function ExportsPage() { + const [servers, jobs] = await Promise.all([ + prisma.server.findMany({ orderBy: { createdAt: "desc" }, select: { id: true, name: true, status: true } }), + prisma.exportJob.findMany({ orderBy: { createdAt: "desc" }, include: { server: { select: { name: true } }, requestedBy: { select: { email: true, anonymousId: true } } }, take: 50 }), + ]); + return <> +
+

Export all data

Use server-specific downloads below for normalized CSV payloads. Full export job generation can be wired to background storage through the ExportJob table.

Download server inventory JSON
+

Server-specific CSV downloads

{servers.map(s=>

{s.name}

{s.status} · {shortId(s.id)}

Download CSVs
)}
+
+

Generated export jobs

{jobs.length === 0 ? :
{jobs.map(j=>)}
JobServerStatusFileErrorCreated
{shortId(j.id)}{j.server?.name ?? "All data"}{j.status}{j.filePath ?? "—"}{j.error ?? "—"}{formatDate(j.createdAt)}
}
+ ; +} diff --git a/apps/web/app/admin/layout.tsx b/apps/web/app/admin/layout.tsx new file mode 100644 index 0000000..61e5152 --- /dev/null +++ b/apps/web/app/admin/layout.tsx @@ -0,0 +1,6 @@ +import type { ReactNode } from "react"; +import { AdminShell } from "./_components/AdminShell"; + +export default function AdminLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/apps/web/app/admin/login/page.tsx b/apps/web/app/admin/login/page.tsx new file mode 100644 index 0000000..5c93b65 --- /dev/null +++ b/apps/web/app/admin/login/page.tsx @@ -0,0 +1,4 @@ +"use client"; +import { useState } from "react"; +import { AdminPageHeader, Card } from "../_components/ui"; +export default function AdminLoginPage() { const [email,setEmail]=useState(""); const [password,setPassword]=useState(""); const [saved,setSaved]=useState(false); return <>
{e.preventDefault(); window.localStorage.setItem("parcel_admin_basic", btoa(`${email}:${password}`)); setSaved(true);}} className="max-w-md space-y-4">{saved?

Credentials saved in this browser.

:null}
; } diff --git a/apps/web/app/admin/page.tsx b/apps/web/app/admin/page.tsx index aeaee8b..df8de06 100644 --- a/apps/web/app/admin/page.tsx +++ b/apps/web/app/admin/page.tsx @@ -1,10 +1,38 @@ -export default function AdminPage() { +import Link from "next/link"; +import { prisma } from "@parcel-society/db"; +import { AdminPageHeader, Card, StatCard, ButtonLink } from "./_components/ui"; +import { formatDate, formatNumber } from "./_components/format"; + +export default async function AdminPage() { + const [servers, totalPlayers, activePlayers, exitedPlayers, totalDecisions, recentEvents] = await Promise.all([ + prisma.server.groupBy({ by: ["status"], _count: { _all: true } }), + prisma.player.count(), + prisma.player.count({ where: { exited: false } }), + prisma.player.count({ where: { exited: true } }), + prisma.decision.count(), + prisma.serverEvent.findMany({ take: 8, orderBy: { createdAt: "desc" }, include: { server: { select: { id: true, name: true } } } }), + ]); + const count = (status?: string) => status ? servers.find((row) => row.status === status)?._count._all ?? 0 : servers.reduce((total, row) => total + row._count._all, 0); return ( -
-

Admin Dashboard

-

- Admin tools will be implemented later. -

-
+ <> + Create server} /> +
+ + + + + + + + +
+ +

Recent server events

View servers
+
+ {recentEvents.map((event) =>

{event.eventType} round {event.roundNumber}

{event.server.name}

{formatDate(event.createdAt)}

)} + {recentEvents.length === 0 ?

No events have been recorded yet.

: null} +
+
+ ); } diff --git a/apps/web/app/admin/servers/ServersTable.tsx b/apps/web/app/admin/servers/ServersTable.tsx new file mode 100644 index 0000000..7b2cb48 --- /dev/null +++ b/apps/web/app/admin/servers/ServersTable.tsx @@ -0,0 +1,24 @@ +"use client"; + +import Link from "next/link"; +import { DataTable, type Column } from "../_components/DataTable"; +import { ConditionBadge, ServerStatusBadge } from "../_components/ui"; +import { formatDate } from "../_components/format"; + +export type ServerRow = { + id: string; name: string; status: string; inequalityCondition: string; uncertaintyCondition: string; maxPlayers: number; playersJoined: number; currentRound: number; createdAt: string; +}; + +export function ServersTable({ servers }: { servers: ServerRow[] }) { + const columns: Column[] = [ + { key: "name", header: "Name", accessor: (server) => {server.name}, searchValue: (server) => server.name }, + { key: "status", header: "Status", accessor: (server) => , searchValue: (server) => server.status }, + { key: "ineq", header: "Inequality", accessor: (server) => , searchValue: (server) => server.inequalityCondition }, + { key: "uncert", header: "Uncertainty", accessor: (server) => , searchValue: (server) => server.uncertaintyCondition }, + { key: "players", header: "Players joined", accessor: (server) => `${server.playersJoined}/${server.maxPlayers}` }, + { key: "round", header: "Current round", accessor: (server) => server.currentRound }, + { key: "created", header: "Created date", accessor: (server) => formatDate(server.createdAt) }, + { key: "actions", header: "Actions", accessor: (server) => Manage }, + ]; + return ; +} diff --git a/apps/web/app/admin/servers/[serverId]/ServerTabs.tsx b/apps/web/app/admin/servers/[serverId]/ServerTabs.tsx new file mode 100644 index 0000000..d717dee --- /dev/null +++ b/apps/web/app/admin/servers/[serverId]/ServerTabs.tsx @@ -0,0 +1,7 @@ +import Link from "next/link"; + +const tabs = ["config", "players", "parcels", "rounds", "contracts", "events", "analytics"]; + +export function ServerTabs({ serverId }: { serverId: string }) { + return
Overview{tabs.map((tab) => {tab})}
; +} diff --git a/apps/web/app/admin/servers/[serverId]/analytics/page.tsx b/apps/web/app/admin/servers/[serverId]/analytics/page.tsx new file mode 100644 index 0000000..2f463ac --- /dev/null +++ b/apps/web/app/admin/servers/[serverId]/analytics/page.tsx @@ -0,0 +1,9 @@ +import { notFound } from "next/navigation"; +import { ActionType, ContractType, prisma } from "@parcel-society/db"; +import { BarMetricChart, DistributionChart, LineMetricChart } from "../../../_components/AdminCharts"; +import { AdminPageHeader, Card } from "../../../_components/ui"; +import { numberValue } from "../../../_components/format"; +import { ServerTabs } from "../ServerTabs"; +type Props = { params: Promise<{ serverId: string }> }; +const share = (part: number, total: number) => total ? part / total : 0; +export default async function AnalyticsPage({ params }: Props) { const { serverId } = await params; const server=await prisma.server.findUnique({ where:{id:serverId}, include:{players:true} }); if(!server) notFound(); const [states, treasury, decisions, contracts] = await Promise.all([prisma.playerRoundState.groupBy({ by:["roundNumber"], where:{serverId}, _avg:{wealth:true, productiveCapital:true, safeAsset:true}, _count:{_all:true} }), prisma.treasuryTransaction.groupBy({ by:["roundNumber"], where:{serverId}, _sum:{amount:true} }), prisma.decision.groupBy({ by:["roundNumber","actionType"], where:{serverId}, _sum:{amount:true}, _count:{_all:true} }), prisma.contract.groupBy({ by:["roundNumber","contractType","fulfilled"], where:{serverId}, _count:{_all:true} })]); const rounds=[...new Set([...states.map(s=>s.roundNumber),...decisions.map(d=>d.roundNumber),...contracts.map(c=>c.roundNumber)])].sort((a,b)=>a-b); const byAction=(r:number,a:ActionType)=>decisions.find(d=>d.roundNumber===r&&d.actionType===a); const actionTotal=(r:number)=>decisions.filter(d=>d.roundNumber===r).reduce((t,d)=>t+d._count._all,0); const chart=rounds.map(round=>{ const total=actionTotal(round); const formal=contracts.filter(c=>c.roundNumber===round&&c.contractType===ContractType.FORMAL).reduce((t,c)=>t+c._count._all,0); const informal=contracts.filter(c=>c.roundNumber===round&&c.contractType===ContractType.INFORMAL).reduce((t,c)=>t+c._count._all,0); const fulfilled=contracts.filter(c=>c.roundNumber===round&&c.fulfilled).reduce((t,c)=>t+c._count._all,0); const state=states.find(s=>s.roundNumber===round)?._avg; return { round, wealth:numberValue(state?.wealth), treasury:numberValue(treasury.find(t=>t.roundNumber===round)?._sum.amount), productiveInvestmentShare:share(byAction(round,ActionType.PRODUCTIVE_INVESTMENT)?._count._all??0,total), publicContributionShare:share(byAction(round,ActionType.PUBLIC_CONTRIBUTION)?._count._all??0,total), formalContracts:formal, informalContracts:informal, exitCount:byAction(round,ActionType.EXIT)?._count._all??0, lobbyingShare:share(byAction(round,ActionType.LOBBYING)?._count._all??0,total), contractReliability:share(fulfilled, formal+informal) }; }); const wealthBins=Array.from({length:8},(_,i)=>({name:`${i*50}-${(i+1)*50}`, value:server.players.filter(p=>numberValue(p.wealth)>=i*50&&numberValue(p.wealth)<(i+1)*50).length})); return <>

Wealth over rounds

Treasury over rounds

Productive investment share

Public contribution share

Informal vs formal contracts

Exit count by round

Lobbying share

Contract reliability

Final wealth distribution

; } diff --git a/apps/web/app/admin/servers/[serverId]/config/page.tsx b/apps/web/app/admin/servers/[serverId]/config/page.tsx new file mode 100644 index 0000000..22cc59c --- /dev/null +++ b/apps/web/app/admin/servers/[serverId]/config/page.tsx @@ -0,0 +1,6 @@ +import { notFound } from "next/navigation"; +import { prisma } from "@parcel-society/db"; +import { AdminPageHeader, Card } from "../../../_components/ui"; +import { ServerTabs } from "../ServerTabs"; +type Props = { params: Promise<{ serverId: string }> }; +export default async function ConfigPage({ params }: Props) { const { serverId } = await params; const server = await prisma.server.findUnique({ where: { id: serverId }, include: { serverConfigs: true } }); if (!server) notFound(); return <>
{JSON.stringify({ server: { maxPlayers: server.maxPlayers, seasonLength: server.seasonLength, randomSeed: server.randomSeed, treasury: server.treasury.toString(), status: server.status, currentRound: server.currentRound, inequalityCondition: server.inequalityCondition, uncertaintyCondition: server.uncertaintyCondition, config: server.config }, configRows: server.serverConfigs }, null, 2)}
; } diff --git a/apps/web/app/admin/servers/[serverId]/contracts/ContractsTable.tsx b/apps/web/app/admin/servers/[serverId]/contracts/ContractsTable.tsx new file mode 100644 index 0000000..f4429f4 --- /dev/null +++ b/apps/web/app/admin/servers/[serverId]/contracts/ContractsTable.tsx @@ -0,0 +1,5 @@ +"use client"; +import { DataTable, type Column } from "../../../_components/DataTable"; +import { formatMoney, shortId } from "../../../_components/format"; +export type ContractRow = { sender: string; receiver: string; type: string; value: number; fee: number; fulfilled: boolean | null; defaulted: boolean | null; round: number }; +export function ContractsTable({ contracts }: { contracts: ContractRow[] }) { const columns: Column[] = [{key:"sender",header:"Sender",accessor:c=>shortId(c.sender),searchValue:c=>c.sender},{key:"receiver",header:"Receiver",accessor:c=>shortId(c.receiver),searchValue:c=>c.receiver},{key:"type",header:"Type",accessor:c=>c.type,searchValue:c=>c.type},{key:"value",header:"Value",accessor:c=>formatMoney(c.value)},{key:"fee",header:"Fee",accessor:c=>formatMoney(c.fee)},{key:"fulfilled",header:"Fulfilled/defaulted",accessor:c=>`${c.fulfilled === null ? "pending" : c.fulfilled ? "fulfilled" : "not fulfilled"}/${c.defaulted === null ? "pending" : c.defaulted ? "defaulted" : "not defaulted"}`, searchValue:c=>`${c.fulfilled} ${c.defaulted}`},{key:"round",header:"Round",accessor:c=>c.round}]; return ; } diff --git a/apps/web/app/admin/servers/[serverId]/contracts/page.tsx b/apps/web/app/admin/servers/[serverId]/contracts/page.tsx new file mode 100644 index 0000000..ecf80cf --- /dev/null +++ b/apps/web/app/admin/servers/[serverId]/contracts/page.tsx @@ -0,0 +1,8 @@ +import { notFound } from "next/navigation"; +import { prisma } from "@parcel-society/db"; +import { AdminPageHeader } from "../../../_components/ui"; +import { numberValue } from "../../../_components/format"; +import { ServerTabs } from "../ServerTabs"; +import { ContractsTable } from "./ContractsTable"; +type Props = { params: Promise<{ serverId: string }> }; +export default async function ContractsPage({ params }: Props) { const { serverId } = await params; const server = await prisma.server.findUnique({ where:{id:serverId}, select:{name:true} }); if(!server) notFound(); const contracts=await prisma.contract.findMany({ where:{serverId}, include:{sender:true, receiver:true}, orderBy:[{roundNumber:"asc"},{createdAt:"asc"}] }); return <>({ sender:c.sender.id, receiver:c.receiver.id, type:c.contractType, value:numberValue(c.value), fee:numberValue(c.fee), fulfilled:c.fulfilled, defaulted:c.defaulted, round:c.roundNumber }))} />; } diff --git a/apps/web/app/admin/servers/[serverId]/events/EventsTable.tsx b/apps/web/app/admin/servers/[serverId]/events/EventsTable.tsx new file mode 100644 index 0000000..69ad2ba --- /dev/null +++ b/apps/web/app/admin/servers/[serverId]/events/EventsTable.tsx @@ -0,0 +1,5 @@ +"use client"; +import { DataTable, type Column } from "../../../_components/DataTable"; +import { formatDate } from "../../../_components/format"; +export type EventRow = { type: string; category: string; round: number; value: string; createdAt: string }; +export function EventsTable({ events }: { events: EventRow[] }) { const columns: Column[] = [{key:"type",header:"Type",accessor:e=>e.type,searchValue:e=>e.type},{key:"category",header:"Category",accessor:e=>e.category,searchValue:e=>e.category},{key:"round",header:"Round",accessor:e=>e.round},{key:"value",header:"Value",accessor:e=>{e.value},searchValue:e=>e.value},{key:"created",header:"Timestamp",accessor:e=>formatDate(e.createdAt)}]; return ; } diff --git a/apps/web/app/admin/servers/[serverId]/events/page.tsx b/apps/web/app/admin/servers/[serverId]/events/page.tsx new file mode 100644 index 0000000..8890ec1 --- /dev/null +++ b/apps/web/app/admin/servers/[serverId]/events/page.tsx @@ -0,0 +1,8 @@ +import { notFound } from "next/navigation"; +import { prisma } from "@parcel-society/db"; +import { AdminPageHeader } from "../../../_components/ui"; +import { ServerTabs } from "../ServerTabs"; +import { EventsTable } from "./EventsTable"; +type Props = { params: Promise<{ serverId: string }> }; +const category = (type: string) => type.includes("CHANGE") ? "Rule change" : type.includes("SHOCK") ? "Shock" : type.includes("TREASURY") ? "Treasury" : "Server event"; +export default async function EventsPage({ params }: Props) { const { serverId } = await params; const server = await prisma.server.findUnique({ where:{id:serverId}, select:{name:true} }); if(!server) notFound(); const events=await prisma.serverEvent.findMany({ where:{serverId}, orderBy:[{createdAt:"desc"}] }); return <>({ type:e.eventType, category:category(e.eventType), round:e.roundNumber, value:JSON.stringify(e.value), createdAt:e.createdAt.toISOString() }))} />; } diff --git a/apps/web/app/admin/servers/[serverId]/page.tsx b/apps/web/app/admin/servers/[serverId]/page.tsx new file mode 100644 index 0000000..31d85d3 --- /dev/null +++ b/apps/web/app/admin/servers/[serverId]/page.tsx @@ -0,0 +1,56 @@ +import { notFound } from "next/navigation"; +import { prisma } from "@parcel-society/db"; +import { AdminActions } from "../../_components/AdminActions"; +import { AdminPageHeader, Card, ConditionBadge, ServerStatusBadge, StatCard } from "../../_components/ui"; +import { formatMoney, formatNumber, gini, numberValue } from "../../_components/format"; +import { ServerTabs } from "./ServerTabs"; + +type Props = { params: Promise<{ serverId: string }> }; + +export default async function ServerDetailPage({ params }: Props) { + const { serverId } = await params; + const server = await prisma.server.findUnique({ + where: { id: serverId }, + include: { + players: { include: { parcel: true } }, + parcels: true, + rounds: { orderBy: { roundNumber: "desc" } }, + _count: { select: { players: true, decisions: true, parcels: true } }, + }, + }); + if (!server) notFound(); + const activeRound = server.rounds.find((round) => round.status === "ACTIVE"); + const submittedPlayers = activeRound ? new Set((await prisma.decision.findMany({ where: { serverId, roundNumber: activeRound.roundNumber }, select: { playerId: true } })).map((decision) => decision.playerId)).size : 0; + const activePlayers = server.players.filter((player) => !player.exited).length; + const missing = Math.max(activePlayers - submittedPlayers, 0); + const qualityGini = gini(server.parcels.map((parcel) => numberValue(parcel.quality))); + const config = server.config && typeof server.config === "object" && !Array.isArray(server.config) ? server.config as Record : {}; + return ( + <> + } /> + +
+ } hint={<> } /> + + + + + + + +
+
+

Config summary

{JSON.stringify({ maxPlayers: server.maxPlayers, seasonLength: server.seasonLength, randomSeed: server.randomSeed, ...config }, null, 2)}
+

Actions

+
+ + ); +} diff --git a/apps/web/app/admin/servers/[serverId]/parcels/ParcelMap.tsx b/apps/web/app/admin/servers/[serverId]/parcels/ParcelMap.tsx new file mode 100644 index 0000000..5f539d2 --- /dev/null +++ b/apps/web/app/admin/servers/[serverId]/parcels/ParcelMap.tsx @@ -0,0 +1,10 @@ +"use client"; +import { useMemo, useState } from "react"; +import { formatNumber, shortId } from "../../../_components/format"; +export type ParcelRow = { id: string; x: number; y: number; soil: number; water: number; marketAccess: number; risk: number; quality: number; ownerId: string | null; ownerPlayerId: string | null }; +export function ParcelMap({ parcels }: { parcels: ParcelRow[] }) { + const [selected, setSelected] = useState(null); + const maxQuality = Math.max(...parcels.map((p) => p.quality), 1); + const byCoord = useMemo(() => new Map(parcels.map((p) => [`${p.x}:${p.y}`, p])), [parcels]); + return
{Array.from({ length: 100 }).map((_, i) => { const x = i % 10; const y = Math.floor(i / 10); const parcel = byCoord.get(`${x}:${y}`); const intensity = parcel ? parcel.quality / maxQuality : 0; return ; })}

Selected parcel

{selected ?
Coordinates
{selected.x}, {selected.y}
Quality
{formatNumber(selected.quality, 4)}
Soil
{formatNumber(selected.soil, 4)}
Water
{formatNumber(selected.water, 4)}
Market access
{formatNumber(selected.marketAccess, 4)}
Risk
{formatNumber(selected.risk, 4)}
Owner
{shortId(selected.ownerPlayerId ?? selected.ownerId)}
:

Click a parcel to inspect quality, attributes, and owner.

}
; +} diff --git a/apps/web/app/admin/servers/[serverId]/parcels/page.tsx b/apps/web/app/admin/servers/[serverId]/parcels/page.tsx new file mode 100644 index 0000000..0483dd5 --- /dev/null +++ b/apps/web/app/admin/servers/[serverId]/parcels/page.tsx @@ -0,0 +1,17 @@ +import { notFound } from "next/navigation"; +import { prisma } from "@parcel-society/db"; +import { DistributionChart } from "../../../_components/AdminCharts"; +import { AdminPageHeader, Card, StatCard } from "../../../_components/ui"; +import { gini, numberValue } from "../../../_components/format"; +import { ServerTabs } from "../ServerTabs"; +import { ParcelMap } from "./ParcelMap"; + +type Props = { params: Promise<{ serverId: string }> }; +export default async function ParcelsPage({ params }: Props) { + const { serverId } = await params; + const server = await prisma.server.findUnique({ where: { id: serverId }, include: { parcels: { include: { owner: true }, orderBy: [{ y: "asc" }, { x: "asc" }] } } }); + if (!server) notFound(); + const parcels = server.parcels.map((p) => ({ id: p.id, x: p.x, y: p.y, soil: numberValue(p.soil), water: numberValue(p.water), marketAccess: numberValue(p.marketAccess), risk: numberValue(p.risk), quality: numberValue(p.quality), ownerId: p.ownerId, ownerPlayerId: p.owner?.id ?? null })); + const hist = Array.from({ length: 10 }, (_, i) => ({ name: `${i / 10}-${(i + 1) / 10}`, value: parcels.filter((p) => p.quality >= i / 10 && p.quality < (i + 1) / 10).length })); + return <>
p.quality)).toFixed(3)} /> p.ownerPlayerId).length} />

Parcel-quality distribution

; +} diff --git a/apps/web/app/admin/servers/[serverId]/players/PlayersTable.tsx b/apps/web/app/admin/servers/[serverId]/players/PlayersTable.tsx new file mode 100644 index 0000000..250baef --- /dev/null +++ b/apps/web/app/admin/servers/[serverId]/players/PlayersTable.tsx @@ -0,0 +1,17 @@ +"use client"; +import { DataTable, type Column } from "../../../_components/DataTable"; +import { formatMoney, formatNumber, shortId } from "../../../_components/format"; +export type PlayerRow = { id: string; wealth: number; productiveCapital: number; safeAsset: number; parcelQuality: number; exited: boolean; roundExited: number | null; actionsSubmittedCount: number }; +export function PlayersTable({ players }: { players: PlayerRow[] }) { + const columns: Column[] = [ + { key: "id", header: "Player", accessor: (p) => shortId(p.id), searchValue: (p) => p.id }, + { key: "wealth", header: "Wealth", accessor: (p) => formatMoney(p.wealth) }, + { key: "capital", header: "Productive capital", accessor: (p) => formatMoney(p.productiveCapital) }, + { key: "safe", header: "Safe asset", accessor: (p) => formatMoney(p.safeAsset) }, + { key: "quality", header: "Parcel quality", accessor: (p) => formatNumber(p.parcelQuality, 3) }, + { key: "exited", header: "Exited", accessor: (p) => p.exited ? "Yes" : "No", searchValue: (p) => p.exited ? "exited yes" : "active no" }, + { key: "roundExited", header: "Round exited", accessor: (p) => p.roundExited ?? "—" }, + { key: "actions", header: "Actions submitted", accessor: (p) => p.actionsSubmittedCount }, + ]; + return ; +} diff --git a/apps/web/app/admin/servers/[serverId]/players/page.tsx b/apps/web/app/admin/servers/[serverId]/players/page.tsx new file mode 100644 index 0000000..8c44f84 --- /dev/null +++ b/apps/web/app/admin/servers/[serverId]/players/page.tsx @@ -0,0 +1,15 @@ +import { notFound } from "next/navigation"; +import { prisma } from "@parcel-society/db"; +import { AdminPageHeader } from "../../../_components/ui"; +import { numberValue } from "../../../_components/format"; +import { ServerTabs } from "../ServerTabs"; +import { PlayersTable } from "./PlayersTable"; + +type Props = { params: Promise<{ serverId: string }> }; +export default async function PlayersPage({ params }: Props) { + const { serverId } = await params; + const server = await prisma.server.findUnique({ where: { id: serverId }, select: { name: true } }); + if (!server) notFound(); + const players = await prisma.player.findMany({ where: { serverId }, include: { parcel: true, _count: { select: { decisions: true } } }, orderBy: { createdAt: "asc" } }); + return <> ({ id: p.id, wealth: numberValue(p.wealth), productiveCapital: numberValue(p.productiveCapital), safeAsset: numberValue(p.safeAsset), parcelQuality: numberValue(p.parcel.quality), exited: p.exited, roundExited: p.roundExited, actionsSubmittedCount: p._count.decisions }))} />; +} diff --git a/apps/web/app/admin/servers/[serverId]/rounds/RoundsTable.tsx b/apps/web/app/admin/servers/[serverId]/rounds/RoundsTable.tsx new file mode 100644 index 0000000..8d262e1 --- /dev/null +++ b/apps/web/app/admin/servers/[serverId]/rounds/RoundsTable.tsx @@ -0,0 +1,5 @@ +"use client"; +import { DataTable, type Column } from "../../../_components/DataTable"; +import { formatDate, formatMoney } from "../../../_components/format"; +export type RoundRow = { roundNumber: number; status: string; decisionsCount: number; eventsCount: number; treasuryChanges: number; aggregateOutcomes: string; startsAt: string; endsAt: string }; +export function RoundsTable({ rounds }: { rounds: RoundRow[] }) { const columns: Column[] = [{key:"round",header:"Round",accessor:r=>r.roundNumber},{key:"status",header:"Status",accessor:r=>r.status,searchValue:r=>r.status},{key:"decisions",header:"Decisions",accessor:r=>r.decisionsCount},{key:"events",header:"Events",accessor:r=>r.eventsCount},{key:"treasury",header:"Treasury changes",accessor:r=>formatMoney(r.treasuryChanges)},{key:"outcomes",header:"Aggregate outcomes",accessor:r=>r.aggregateOutcomes,searchValue:r=>r.aggregateOutcomes},{key:"starts",header:"Starts",accessor:r=>formatDate(r.startsAt)},{key:"ends",header:"Ends",accessor:r=>formatDate(r.endsAt)}]; return ; } diff --git a/apps/web/app/admin/servers/[serverId]/rounds/page.tsx b/apps/web/app/admin/servers/[serverId]/rounds/page.tsx new file mode 100644 index 0000000..ff087ea --- /dev/null +++ b/apps/web/app/admin/servers/[serverId]/rounds/page.tsx @@ -0,0 +1,8 @@ +import { notFound } from "next/navigation"; +import { prisma } from "@parcel-society/db"; +import { AdminPageHeader } from "../../../_components/ui"; +import { numberValue } from "../../../_components/format"; +import { ServerTabs } from "../ServerTabs"; +import { RoundsTable } from "./RoundsTable"; +type Props = { params: Promise<{ serverId: string }> }; +export default async function RoundsPage({ params }: Props) { const { serverId } = await params; const server = await prisma.server.findUnique({ where: { id: serverId }, select: { name: true } }); if (!server) notFound(); const [rounds, decisions, events, treasury, states] = await Promise.all([prisma.round.findMany({ where: { serverId }, orderBy: { roundNumber: "asc" } }), prisma.decision.groupBy({ by: ["roundNumber"], where: { serverId }, _count: { _all: true } }), prisma.serverEvent.groupBy({ by: ["roundNumber"], where: { serverId }, _count: { _all: true } }), prisma.treasuryTransaction.groupBy({ by: ["roundNumber"], where: { serverId }, _sum: { amount: true } }), prisma.playerRoundState.groupBy({ by: ["roundNumber"], where: { serverId }, _avg: { wealth: true, productiveCapital: true, safeAsset: true } })]); type CountRow = { roundNumber: number; _count: { _all: number } }; const count=(arr:CountRow[],n:number)=>arr.find(x=>x.roundNumber===n)?._count._all??0; const sum=(n:number)=>numberValue(treasury.find(x=>x.roundNumber===n)?._sum.amount); const avg=(n:number)=>states.find(x=>x.roundNumber===n)?._avg; return <>({ roundNumber:r.roundNumber, status:r.status, decisionsCount:count(decisions,r.roundNumber), eventsCount:count(events,r.roundNumber), treasuryChanges:sum(r.roundNumber), aggregateOutcomes: JSON.stringify(avg(r.roundNumber) ?? {}), startsAt:r.startsAt.toISOString(), endsAt:r.endsAt.toISOString() }))} />; } diff --git a/apps/web/app/admin/servers/new/actions.ts b/apps/web/app/admin/servers/new/actions.ts new file mode 100644 index 0000000..cb784bc --- /dev/null +++ b/apps/web/app/admin/servers/new/actions.ts @@ -0,0 +1,34 @@ +"use server"; + +import { randomUUID } from "node:crypto"; +import { redirect } from "next/navigation"; +import { InequalityCondition, prisma, ServerStatus, UncertaintyCondition } from "@parcel-society/db"; +import { createServerMap } from "../../../../lib/services/game"; + +const num = (formData: FormData, key: string, fallback = 0) => Number(formData.get(key) || fallback); + +export async function createServer(formData: FormData) { + const generateMap = formData.get("intent") === "generate"; + const config = { + taxRate: num(formData, "taxRate", 0.15), + formalContractFee: num(formData, "formalContractFee", 2), + informalContractFee: num(formData, "informalContractFee", 0), + shockProbability: num(formData, "shockProbability", 0.1), + }; + const server = await prisma.server.create({ + data: { + name: String(formData.get("name") || "Untitled server"), + description: String(formData.get("description") || ""), + maxPlayers: num(formData, "maxPlayers", 20), + seasonLength: num(formData, "seasonLength", 7), + inequalityCondition: String(formData.get("inequalityCondition")) as InequalityCondition, + uncertaintyCondition: String(formData.get("uncertaintyCondition")) as UncertaintyCondition, + randomSeed: String(formData.get("randomSeed") || randomUUID()), + treasury: num(formData, "initialTreasury", 0), + config, + status: ServerStatus.DRAFT, + }, + }); + if (generateMap) await createServerMap({ serverId: server.id, width: 10, height: 10 }); + redirect(`/admin/servers/${server.id}`); +} diff --git a/apps/web/app/admin/servers/new/page.tsx b/apps/web/app/admin/servers/new/page.tsx new file mode 100644 index 0000000..4ef08a4 --- /dev/null +++ b/apps/web/app/admin/servers/new/page.tsx @@ -0,0 +1,30 @@ +import { AdminPageHeader } from "../../_components/ui"; +import { createServer } from "./actions"; + +export default function NewServerPage() { + return ( + <> + +
+
+ +