src/components/queue-pane.tsx
Linked repository file from this build.
"use client";
import { useEffect, useState } from "react";
import { NavigationLink as Link } from "@/components/navigation-link";
import { usePathname } from "next/navigation";
import { ElapsedTime } from "./elapsed-time";
import { useReviewNavigation } from "@/features/reviews/provider";
import { useSession } from "@/lib/auth/client";
import { workflowLabels, type Actor } from "@/features/reviews/types";
import { fetchJson } from "@/lib/fetch-json";
import { useRemoteLoad } from "@/lib/use-remote-load";
import type { InboxResponse, InboxReview } from "@/features/inbox/types";
type QueueResponse = Pick<
InboxResponse,
"section" | "newReviews" | "lastTouched" | "awaitingReview" | "totals"
>;
type Tab = "new" | "queue" | "recent";
const PAGE = 14;
interface QueueCache {
actorKey: string;
tab: Tab;
page: number;
data: QueueResponse;
refreshedAt: string | null;
}
let queueCache: QueueCache | null = null;
function actorKey(actor: Actor) {
return `${actor.role}:${actor.staffId}:${actor.program}:${(actor.subordinateStaffIds ?? []).join(",")}`;
}
function tabSection(tab: Tab) {
return tab === "recent" ? "touched" : tab;
}
function mergeQueue(previous: QueueResponse | null, result: QueueResponse): QueueResponse {
if (!previous || result.section === "all") return result;
if (result.section === "new") {
return {
...previous,
newReviews: result.newReviews,
totals: { ...previous.totals, newReviews: result.totals.newReviews },
};
}
if (result.section === "touched") {
return {
...previous,
lastTouched: result.lastTouched,
totals: { ...previous.totals, lastTouched: result.totals.lastTouched },
};
}
return {
...previous,
awaitingReview: result.awaitingReview,
totals: { ...previous.totals, awaitingReview: result.totals.awaitingReview },
};
}
/**
* Desk layout: the actor's review queues stay docked beside every page so reviews can be
* opened one after another without returning to the Inbox.
*/
export function QueuePane() {
const { actor } = useSession();
const { navigate } = useReviewNavigation();
const pathname = usePathname();
const key = actorKey(actor);
const cached = queueCache?.actorKey === key ? queueCache : null;
const [tab, setTab] = useState<Tab>(cached?.tab ?? "new");
const [page, setPage] = useState(cached?.page ?? 0);
// Data is stored with its actor key, so switching accounts never shows the old queues.
const [stored, setStored] = useState(cached ? { key, data: cached.data } : null);
const data = stored?.key === key ? stored.data : null;
const [refresh, setRefresh] = useState(0);
const activeReview = decodeURIComponent(pathname.match(/^\/reviews\/([^/]+)/)?.[1] ?? "");
const { error, refreshedAt } = useRemoteLoad(
async (signal) => {
const section = page === 0 ? "all" : tabSection(tab);
const pageParam = tab === "new" ? "newPage" : tab === "recent" ? "touchedPage" : "queuePage";
const result = await fetchJson<QueueResponse>(
`/api/inbox?pageSize=${PAGE}§ion=${section}&${pageParam}=${page}`,
{ signal, fallback: "Queues are unavailable." },
);
if (signal.aborted) return;
const at = new Date().toISOString();
setStored((previous) => {
const base =
previous?.key === key
? previous.data
: queueCache?.actorKey === key
? queueCache.data
: null;
const next = mergeQueue(base, result);
queueCache = { actorKey: key, tab, page, data: next, refreshedAt: at };
return { key, data: next };
});
},
[key, refresh, tab, page],
{ initialRefreshedAt: cached?.refreshedAt ?? null },
);
useEffect(() => {
const bump = () => {
if (!document.hidden) setRefresh((value) => value + 1);
};
const timer = setInterval(bump, 300_000);
return () => clearInterval(timer);
}, []);
const queueTitle =
actor.role === "examiner"
? "With reviewers"
: actor.role === "supervisor" || actor.role === "pec"
? "Awaiting me"
: "Awaiting";
const tabs: { id: Tab; label: string; total: number; rows: InboxReview[] }[] = [
{ id: "new", label: "New", total: data?.totals.newReviews ?? 0, rows: data?.newReviews ?? [] },
{
id: "queue",
label: queueTitle,
total: data?.totals.awaitingReview ?? 0,
rows: data?.awaitingReview ?? [],
},
{
id: "recent",
label: "Recent",
total: data?.totals.lastTouched ?? 0,
rows: data?.lastTouched ?? [],
},
];
const current = tabs.find((entry) => entry.id === tab) ?? tabs[0];
return (
<aside className="app-queue" aria-label="Review queues">
<div className="flex items-center justify-between gap-2 border-b border-sidebar-border px-3 py-2">
<h2 className="text-xs font-semibold tracking-wide uppercase opacity-80">Queues</h2>
<ElapsedTime at={data ? refreshedAt : null} compact />
</div>
<div role="tablist" aria-label="Queue" className="segmented grid grid-cols-3 gap-1 m-2 p-1">
{tabs.map((entry) => (
<button
key={entry.id}
role="tab"
type="button"
aria-selected={entry.id === tab}
onClick={() => {
setTab(entry.id);
setPage(0);
if (queueCache?.actorKey === key) {
queueCache = { ...queueCache, tab: entry.id, page: 0 };
}
}}
className={`flex flex-col items-center rounded-md px-1 py-1.5 text-xs leading-tight ${entry.id === tab ? "bg-sidebar-accent font-semibold text-sidebar-accent-foreground" : "opacity-75 hover:bg-sidebar-accent/60"}`}
>
<span className="truncate">{entry.label}</span>
<span className="tabular-nums text-[0.7rem] opacity-70">{entry.total}</span>
</button>
))}
</div>
<div className="min-h-0 flex-1 overflow-auto px-2 pb-2">
{error ? (
<p role="alert" className="px-2 py-3 text-xs text-destructive">
{error}
</p>
) : !data ? (
<p role="status" className="px-2 py-3 text-xs opacity-70">
Loading queues…
</p>
) : current.rows.length === 0 ? (
<p className="px-2 py-3 text-xs opacity-70">Nothing here right now.</p>
) : (
<ul className="space-y-0.5">
{current.rows.map((row) => {
const active = row.reviewKey === activeReview;
return (
<li key={row.reviewKey}>
<Link
prefetch={false}
href={`/reviews/${row.reviewKey}`}
aria-current={active ? "page" : undefined}
onClick={(event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
event.preventDefault();
void navigate(`/reviews/${row.reviewKey}`);
}}
className={`block rounded-md px-2 py-1.5 text-xs leading-tight hover:bg-sidebar-accent/70 ${active ? "bg-sidebar-accent text-sidebar-accent-foreground shadow-xs" : ""}`}
>
<span className="flex items-center justify-between gap-2">
<span className="truncate font-medium">
{row.clientName || "Unnamed client"}
</span>
<span className="shrink-0 font-mono tabular-nums opacity-70">
{row.reviewNumber}
</span>
</span>
<span className="mt-0.5 flex items-center gap-1.5 opacity-70">
<span
aria-hidden
className={`size-1.5 rounded-full ${row.state === "needs_changes" ? "bg-destructive" : row.state === "released_to_reporting" ? "bg-success" : "bg-current opacity-50"}`}
/>
<span className="truncate">
FFY {row.fiscalYear} · {workflowLabels[row.state]}
</span>
</span>
</Link>
</li>
);
})}
</ul>
)}
</div>
<div className="flex items-center justify-between gap-2 px-3 py-2 text-xs">
<button
type="button"
disabled={page === 0}
onClick={() => setPage((value) => value - 1)}
className="rounded px-2 py-1 hover:bg-accent disabled:opacity-40"
>
Previous
</button>
<span>
{current.total
? `${page * PAGE + 1}–${Math.min((page + 1) * PAGE, current.total)} of ${current.total}`
: "0 reviews"}
</span>
<button
type="button"
disabled={(page + 1) * PAGE >= current.total}
onClick={() => setPage((value) => value + 1)}
className="rounded px-2 py-1 hover:bg-accent disabled:opacity-40"
>
Next
</button>
</div>
</aside>
);
}