From 7fea3213760a54af5b98bf8a059e8e3556d8f4c1 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Sat, 25 Jul 2026 21:10:48 +0100 Subject: [PATCH] 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 --- adminsite/app/(staff)/staff/page.tsx | 81 ++++++++++++++++++++++++++++ adminsite/components/Queue.test.tsx | 30 +++++++++++ adminsite/components/Queue.tsx | 53 ++++++++++++++++++ 3 files changed, 164 insertions(+) create mode 100644 adminsite/app/(staff)/staff/page.tsx create mode 100644 adminsite/components/Queue.test.tsx create mode 100644 adminsite/components/Queue.tsx diff --git a/adminsite/app/(staff)/staff/page.tsx b/adminsite/app/(staff)/staff/page.tsx new file mode 100644 index 0000000..3ef0370 --- /dev/null +++ b/adminsite/app/(staff)/staff/page.tsx @@ -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 ; + + const stale = (unlinked.data ?? []).filter( + (i) => Date.now() - new Date(i.created_at).getTime() > HOURS_48, + ); + + return ( +
+

Operations

+
+ ({ + 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) + : "", + }))} + /> + ({ + label: i.name || i.instance_id, + href: `/staff/instances/${i.instance_id}`, + meta: i.tier ?? "", + }))} + /> + ({ + label: s.instance_id || s.account_id, + href: `/staff/accounts/${s.account_id}`, + meta: `${daysRemaining(s.current_period_end)}d`, + }))} + /> + ({ + 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`, + }))} + /> +
+
+ ); +} diff --git a/adminsite/components/Queue.test.tsx b/adminsite/components/Queue.test.tsx new file mode 100644 index 0000000..ed0bb7a --- /dev/null +++ b/adminsite/components/Queue.test.tsx @@ -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( + , + ); + 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(); + expect(screen.getByText(/nothing to do/i)).toBeInTheDocument(); + }); +}); diff --git a/adminsite/components/Queue.tsx b/adminsite/components/Queue.tsx new file mode 100644 index 0000000..cb58231 --- /dev/null +++ b/adminsite/components/Queue.tsx @@ -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 ( +
+

+ {title} +

+

+ {count} +

+ {items.length === 0 ? ( +

Nothing to do here.

+ ) : ( +
    + {items.map((i) => ( +
  • + + {i.label} + + {i.meta} +
  • + ))} +
+ )} +
+ ); +}