feat(adminsite): staff operations dashboard

Four counts, each one work somebody has to do today: failed injections,
licences expiring inside 14 days, past-due subscriptions, and purchases
unlinked for more than 48 hours. No totals and no revenue -- nothing that
cannot be acted on. Every row links straight to the thing that needs doing.

An empty queue says "nothing to do here" rather than rendering a bare zero,
so a quiet dashboard reads as quiet rather than broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-25 21:10:48 +01:00
co-authored by Claude Opus 5
parent b583e9803f
commit 7fea321376
3 changed files with 164 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { API_BASE, NotConnected, api } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { Queue } from "@/components/Queue";
import { daysRemaining } from "@/lib/format";
const HOURS_48 = 48 * 3600_000;
export default function StaffDashboard() {
const injection = useQuery({ queryKey: ["injection"], queryFn: api.staff.injectionHealth });
const expiring = useQuery({
queryKey: ["instances", "expiring"],
queryFn: () => api.staff.instances({ expiring: "true" }),
});
const pastDue = useQuery({
queryKey: ["subs", "past_due"],
queryFn: () => api.staff.subscriptions("past_due"),
});
const unlinked = useQuery({
queryKey: ["instances", "awaiting_link"],
queryFn: () => api.staff.instances({ status: "awaiting_link" }),
});
if (injection.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
const stale = (unlinked.data ?? []).filter(
(i) => Date.now() - new Date(i.created_at).getTime() > HOURS_48,
);
return (
<div className="grid gap-6">
<h1 className="text-3xl">Operations</h1>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Queue
title="Failed injections"
tone="expired"
count={injection.data?.count ?? 0}
items={(injection.data?.failed ?? []).slice(0, 4).map((i) => ({
label: i.name || i.instance_id,
href: `/staff/instances/${i.instance_id}`,
meta: i.inject_failed_at
? new Date(i.inject_failed_at).toISOString().slice(11, 16)
: "",
}))}
/>
<Queue
title="Expiring ≤ 14 days"
tone="warn"
count={expiring.data?.length ?? 0}
items={(expiring.data ?? []).slice(0, 4).map((i) => ({
label: i.name || i.instance_id,
href: `/staff/instances/${i.instance_id}`,
meta: i.tier ?? "",
}))}
/>
<Queue
title="Past due"
tone="expired"
count={pastDue.data?.length ?? 0}
items={(pastDue.data ?? []).slice(0, 4).map((s) => ({
label: s.instance_id || s.account_id,
href: `/staff/accounts/${s.account_id}`,
meta: `${daysRemaining(s.current_period_end)}d`,
}))}
/>
<Queue
title="Unlinked > 48h"
tone="accent"
count={stale.length}
items={stale.slice(0, 4).map((i) => ({
label: i.name || i.instance_id,
href: `/staff/accounts/${i.account_id}`,
meta: `${Math.floor((Date.now() - new Date(i.created_at).getTime()) / 86_400_000)}d`,
}))}
/>
</div>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Queue } from "./Queue";
describe("Queue", () => {
it("shows the count and links every row to the work", () => {
render(
<Queue
title="Failed injections"
tone="expired"
count={2}
items={[
{ label: "Acme Production", href: "/staff/instances/i1", meta: "4h ago" },
{ label: "Globex EU", href: "/staff/instances/i2", meta: "26m ago" },
]}
/>,
);
expect(screen.getByText("Failed injections")).toBeInTheDocument();
expect(screen.getByText("2")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Acme Production" })).toHaveAttribute(
"href",
"/staff/instances/i1",
);
});
it("says so plainly when there is nothing to do", () => {
render(<Queue title="Past due" tone="expired" count={0} items={[]} />);
expect(screen.getByText(/nothing to do/i)).toBeInTheDocument();
});
});
+53
View File
@@ -0,0 +1,53 @@
import Link from "next/link";
import clsx from "clsx";
const TONE = {
expired: "border-l-expired text-expired",
warn: "border-l-warn text-warn",
accent: "border-l-accent text-accent",
} as const;
export function Queue({
title,
count,
tone,
items,
}: {
title: string;
count: number;
tone: keyof typeof TONE;
items: { label: string; href: string; meta: string }[];
}) {
return (
<section
className={clsx(
"grid gap-2 rounded border border-l-4 border-rule bg-panel p-4",
TONE[tone],
)}
>
<h2 className="font-mono text-[0.72rem] font-normal uppercase tracking-[0.1em] text-ink-3">
{title}
</h2>
<p className="text-3xl font-extrabold leading-none tabular-nums tracking-[-0.03em]">
{count}
</p>
{items.length === 0 ? (
<p className="text-[0.72rem] text-ink-3">Nothing to do here.</p>
) : (
<ul className="grid gap-1">
{items.map((i) => (
<li
key={i.href}
className="flex justify-between gap-2 font-mono text-[0.72rem] text-ink-2"
>
<Link href={i.href} className="text-accent underline">
{i.label}
</Link>
<span className="tabular-nums">{i.meta}</span>
</li>
))}
</ul>
)}
</section>
);
}