Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions apps/web/app/admin/_components/AdminActions.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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 (
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-950/40 p-4">
<div className="w-full max-w-lg rounded-xl bg-white p-6 shadow-xl">
<h2 className="text-lg font-bold text-slate-950">Confirm action</h2>
<p className="mt-2 text-sm text-slate-600">{action.confirm ?? `Run ${action.label}?`}</p>
{action.preview ? (
<div className="mt-4 grid grid-cols-2 gap-3 rounded-lg bg-slate-50 p-3 text-sm">
<div><span className="text-slate-500">Submitted players</span><p className="font-semibold">{action.preview.submitted}</p></div>
<div><span className="text-slate-500">Missing decisions</span><p className="font-semibold">{action.preview.missing}</p></div>
<div><span className="text-slate-500">Current round</span><p className="font-semibold">{action.preview.currentRound}</p></div>
<div><span className="text-slate-500">Expected next round</span><p className="font-semibold">{action.preview.nextRound}</p></div>
</div>
) : null}
<div className="mt-6 flex justify-end gap-2">
<button className="rounded-lg border border-slate-300 px-4 py-2 text-sm font-semibold" onClick={onClose} disabled={busy}>Cancel</button>
<button className={`rounded-lg px-4 py-2 text-sm font-semibold text-white ${action.destructive ? "bg-red-600" : "bg-slate-950"}`} onClick={onConfirm} disabled={busy}>{busy ? "Working…" : "Confirm"}</button>
</div>
</div>
</div>
);
}

export function AdminActions({ actions }: { actions: Action[] }) {
const router = useRouter();
const [pending, setPending] = useState<Action | null>(null);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<string | null>(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 (
<div>
<div className="flex flex-wrap gap-2">
{actions.map((action) => <button key={action.label} className={`rounded-lg px-3 py-2 text-sm font-semibold text-white ${action.destructive ? "bg-red-600" : "bg-slate-950"}`} onClick={() => action.confirm || action.destructive || action.preview ? setPending(action) : void run(action)}>{action.label}</button>)}
</div>
{message ? <p className="mt-3 text-sm text-slate-600">{message}</p> : null}
<ConfirmDialog action={pending} busy={busy} onClose={() => setPending(null)} onConfirm={() => pending && void run(pending)} />
</div>
);
}
71 changes: 71 additions & 0 deletions apps/web/app/admin/_components/AdminCharts.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string | number>;

export function LineMetricChart({ data, lines }: { data: Point[]; lines: string[] }) {
return (
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ left: 8, right: 8, top: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="round" />
<YAxis />
<Tooltip />
<Legend />
{lines.map((line, index) => <Line key={line} type="monotone" dataKey={line} stroke={["#0f172a", "#2563eb", "#059669", "#d97706"][index % 4]} strokeWidth={2} />)}
</LineChart>
</ResponsiveContainer>
</div>
);
}

export function BarMetricChart({ data, bars }: { data: Point[]; bars: string[] }) {
return (
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ left: 8, right: 8, top: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="round" />
<YAxis />
<Tooltip />
<Legend />
{bars.map((bar, index) => <Bar key={bar} dataKey={bar} fill={["#0f172a", "#2563eb", "#059669", "#d97706"][index % 4]} />)}
</BarChart>
</ResponsiveContainer>
</div>
);
}

export function DistributionChart({ data }: { data: Array<{ name: string; value: number }> }) {
return (
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ left: 8, right: 8, top: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Bar dataKey="value" fill="#2563eb" />
</BarChart>
</ResponsiveContainer>
</div>
);
}

export function PieMetricChart({ data }: { data: Array<{ name: string; value: number }> }) {
return (
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Tooltip />
<Legend />
<Pie data={data} dataKey="value" nameKey="name" outerRadius={95} label>
{data.map((_, index) => <Cell key={index} fill={["#0f172a", "#2563eb", "#059669", "#d97706", "#dc2626"][index % 5]} />)}
</Pie>
</PieChart>
</ResponsiveContainer>
</div>
);
}
34 changes: 34 additions & 0 deletions apps/web/app/admin/_components/AdminShell.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="grid gap-6 lg:grid-cols-[220px_1fr]">
<aside className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm lg:sticky lg:top-6 lg:h-fit">
<div className="mb-4">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Parcel Society</p>
<p className="text-lg font-bold text-slate-950">Admin</p>
</div>
<nav className="space-y-1">
{links.map(([href, label]) => (
<Link key={href} href={href} className={`block rounded-lg px-3 py-2 text-sm font-medium ${pathname === href || (href !== "/admin" && pathname.startsWith(href)) ? "bg-slate-950 text-white" : "text-slate-600 hover:bg-slate-100"}`}>{label}</Link>
))}
</nav>
<Link href="/admin/login" className="mt-4 block rounded-lg border border-slate-300 px-3 py-2 text-center text-sm font-medium text-slate-700">API login</Link>
</aside>
<div className="min-w-0">{children}</div>
</div>
);
}
44 changes: 44 additions & 0 deletions apps/web/app/admin/_components/DataTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"use client";

import { useMemo, useState, type ReactNode } from "react";

export type Column<T> = {
key: string;
header: string;
accessor?: (row: T) => ReactNode;
searchValue?: (row: T) => string;
};

export function DataTable<T>({ rows, columns, placeholder = "Search…" }: { rows: T[]; columns: Column<T>[]; 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 (
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-200 p-4">
<input className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none focus:border-slate-500" value={query} onChange={(event) => setQuery(event.target.value)} placeholder={placeholder} />
<p className="mt-2 text-xs text-slate-500">Showing {filtered.length} of {rows.length} rows</p>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-200 text-sm">
<thead className="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr>{columns.map((column) => <th className="px-4 py-3" key={column.key}>{column.header}</th>)}</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filtered.map((row, index) => (
<tr className="align-top hover:bg-slate-50" key={index}>
{columns.map((column) => <td className="whitespace-nowrap px-4 py-3 text-slate-700" key={column.key}>{column.accessor?.(row) ?? "—"}</td>)}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
27 changes: 27 additions & 0 deletions apps/web/app/admin/_components/format.ts
Original file line number Diff line number Diff line change
@@ -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;
};
73 changes: 73 additions & 0 deletions apps/web/app/admin/_components/ui.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import Link from "next/link";
import type { ReactNode } from "react";

const statusStyles: Record<string, string> = {
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<string, string> = {
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 <section className={`rounded-xl border border-slate-200 bg-white p-5 shadow-sm ${className}`}>{children}</section>;
}

export function StatCard({ label, value, hint }: { label: string; value: ReactNode; hint?: ReactNode }) {
return (
<Card>
<p className="text-sm font-medium text-slate-500">{label}</p>
<div className="mt-2 text-3xl font-bold tracking-tight text-slate-950">{value}</div>
{hint ? <p className="mt-2 text-xs text-slate-500">{hint}</p> : null}
</Card>
);
}

export function ServerStatusBadge({ status }: { status: string }) {
return <span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ring-1 ${statusStyles[status] ?? statusStyles.DRAFT}`}>{status}</span>;
}

export function ConditionBadge({ value }: { value: string }) {
return <span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ring-1 ${conditionStyles[value] ?? conditionStyles.STABLE}`}>{value}</span>;
}

export function AdminPageHeader({ title, description, actions }: { title: string; description?: string; actions?: ReactNode }) {
return (
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-950">{title}</h1>
{description ? <p className="mt-2 max-w-3xl text-sm text-slate-600">{description}</p> : null}
</div>
{actions ? <div className="flex flex-wrap gap-2">{actions}</div> : null}
</div>
);
}

export function EmptyState({ title, description, actionHref, actionLabel }: { title: string; description?: string; actionHref?: string; actionLabel?: string }) {
return (
<Card className="py-12 text-center">
<h2 className="text-lg font-semibold text-slate-950">{title}</h2>
{description ? <p className="mx-auto mt-2 max-w-xl text-sm text-slate-600">{description}</p> : null}
{actionHref && actionLabel ? <Link className="mt-4 inline-flex rounded-lg bg-slate-950 px-4 py-2 text-sm font-semibold text-white" href={actionHref}>{actionLabel}</Link> : null}
</Card>
);
}

export function LoadingState() {
return <Card><p className="animate-pulse text-sm text-slate-600">Loading admin data…</p></Card>;
}

export function ErrorState({ message }: { message: string }) {
return <Card className="border-red-200 bg-red-50"><p className="text-sm font-medium text-red-800">{message}</p></Card>;
}

export function ButtonLink({ href, children, variant = "primary" }: { href: string; children: ReactNode; variant?: "primary" | "secondary" }) {
return <Link className={variant === "primary" ? "rounded-lg bg-slate-950 px-4 py-2 text-sm font-semibold text-white" : "rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700"} href={href}>{children}</Link>;
}
4 changes: 4 additions & 0 deletions apps/web/app/admin/audit-log/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <><AdminPageHeader title="Audit log" description="Admin actions with before/after JSON and timestamps." /><Card><div className="overflow-x-auto"><table className="min-w-full text-sm"><thead><tr className="text-left text-xs uppercase text-slate-500"><th className="p-2">Timestamp</th><th className="p-2">Admin</th><th className="p-2">Action</th><th className="p-2">Entity</th><th className="p-2">Before JSON</th><th className="p-2">After JSON</th></tr></thead><tbody>{logs.map(log=><tr className="border-t align-top" key={log.id}><td className="p-2 whitespace-nowrap">{formatDate(log.createdAt)}</td><td className="p-2">{log.admin?.email ?? "System"}</td><td className="p-2 font-medium">{log.action}</td><td className="p-2">{log.entityType}:{shortId(log.entityId)}</td><td className="p-2"><pre className="max-w-xs overflow-auto rounded bg-slate-50 p-2 text-xs">{JSON.stringify(log.before, null, 2)}</pre></td><td className="p-2"><pre className="max-w-xs overflow-auto rounded bg-slate-50 p-2 text-xs">{JSON.stringify(log.after, null, 2)}</pre></td></tr>)}</tbody></table>{logs.length===0?<p className="p-4 text-sm text-slate-500">No audit events recorded yet.</p>:null}</div></Card></>; }
18 changes: 18 additions & 0 deletions apps/web/app/admin/exports/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <><AdminPageHeader title="Exports" description="Export all data, download server-specific CSV payloads, and review generated export jobs." />
<div className="grid gap-6 lg:grid-cols-2">
<Card><h2 className="text-lg font-semibold">Export all data</h2><p className="mt-2 text-sm text-slate-600">Use server-specific downloads below for normalized CSV payloads. Full export job generation can be wired to background storage through the ExportJob table.</p><a className="mt-4 inline-flex rounded-lg border border-slate-300 px-4 py-2 text-sm font-semibold" href="/api/admin/servers">Download server inventory JSON</a></Card>
<Card><h2 className="text-lg font-semibold">Server-specific CSV downloads</h2><div className="mt-4 max-h-96 space-y-2 overflow-auto">{servers.map(s=><div className="flex items-center justify-between gap-3 rounded-lg border border-slate-100 p-3" key={s.id}><div><p className="font-medium">{s.name}</p><p className="text-xs text-slate-500">{s.status} · {shortId(s.id)}</p></div><Link className="rounded-lg bg-slate-950 px-3 py-2 text-xs font-semibold text-white" href={`/api/admin/servers/${s.id}/export`}>Download CSVs</Link></div>)}</div></Card>
</div>
<Card className="mt-6"><h2 className="mb-4 text-lg font-semibold">Generated export jobs</h2>{jobs.length === 0 ? <EmptyState title="No export jobs yet" description="Generated jobs will appear here with status, file path, and errors." /> : <div className="overflow-x-auto"><table className="min-w-full text-sm"><thead><tr className="text-left text-xs uppercase text-slate-500"><th className="p-2">Job</th><th className="p-2">Server</th><th className="p-2">Status</th><th className="p-2">File</th><th className="p-2">Error</th><th className="p-2">Created</th></tr></thead><tbody>{jobs.map(j=><tr className="border-t" key={j.id}><td className="p-2">{shortId(j.id)}</td><td className="p-2">{j.server?.name ?? "All data"}</td><td className="p-2">{j.status}</td><td className="p-2">{j.filePath ?? "—"}</td><td className="p-2">{j.error ?? "—"}</td><td className="p-2">{formatDate(j.createdAt)}</td></tr>)}</tbody></table></div>}</Card>
</>;
}
6 changes: 6 additions & 0 deletions apps/web/app/admin/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { ReactNode } from "react";
import { AdminShell } from "./_components/AdminShell";

export default function AdminLayout({ children }: { children: ReactNode }) {
return <AdminShell>{children}</AdminShell>;
}
Loading