src/features/catalog/work-catalog.tsx
Linked repository file from this build.
"use client";
import { fieldClass } from "@/components/ui/input";
import { ElapsedTime } from "@/components/elapsed-time";
import { cn } from "@/lib/utils";
import { LoadingPreview } from "@/components/loading-preview";
import { NavigationLink as Link } from "@/components/navigation-link";
import { useEffect, useRef, useState } from "react";
import { ArrowDownUp, ChevronDown, ChevronRight, Flag, RefreshCw } from "lucide-react";
import { useReview } from "@/features/reviews/provider";
import { demoRoster } from "@/lib/auth/roster";
import {
categoryLabels,
queueStates,
type CatalogBranch,
type CatalogResponse,
type CatalogReview,
} from "./query";
import { can } from "@/lib/auth/scope";
import { fetchJson } from "@/lib/fetch-json";
import { useRemoteLoad } from "@/lib/use-remote-load";
type Branch = { examiner?: string; year?: number; month?: string };
const buttonClass =
"inline-flex min-h-9 items-center justify-center gap-2 rounded-md border px-3 text-sm hover:bg-muted disabled:opacity-40";
const reviewColumns = ["Flag", "Review", "From", "Category", "Updated"];
const reviewRowRem = 4;
function reviewListStyle(pageSize: number) {
return { minHeight: `calc(${pageSize} * ${reviewRowRem}rem + 2.75rem)` };
}
const catalogSorts = [
{ value: "newest", label: "Newest to oldest" },
{ value: "oldest", label: "Oldest to newest" },
{ value: "sender", label: "Person it came from" },
{ value: "unread", label: "New and unread first" },
{ value: "flagged", label: "Flagged first" },
{ value: "category", label: "Category" },
] as const;
function nextCatalogSort(current: string) {
const index = catalogSorts.findIndex((option) => option.value === current);
return catalogSorts[(index + 1) % catalogSorts.length]!.value;
}
function staffName(value: string) {
return demoRoster.find((person) => person.staffId === value)?.name ?? value;
}
function monthName(value: string) {
return new Date(`${value}-15T12:00:00Z`).toLocaleDateString("en-US", {
month: "long",
year: "numeric",
timeZone: "UTC",
});
}
function branchKey(branch: Branch) {
return [branch.examiner ?? "", branch.year ?? "", branch.month ?? ""].join("/");
}
function branchTitle(branch: Branch) {
return (
[
branch.examiner ? staffName(branch.examiner) : "",
branch.year ? `FFY ${branch.year}` : "",
branch.month ? monthName(branch.month) : "",
]
.filter(Boolean)
.join(" / ") || "All reviews"
);
}
function queryString(
queue: boolean,
branch: Branch,
category: string,
sort: string,
page: number,
pageSize: number,
) {
const params = new URLSearchParams({
queue: String(queue),
category,
sort,
page: String(page),
pageSize: String(pageSize),
});
if (branch.examiner) params.set("examiner", branch.examiner);
if (branch.year) params.set("year", String(branch.year));
if (branch.month) params.set("month", branch.month);
return params.toString();
}
/** Loads one catalog page. Branch counts are cached per tree and reloaded only on Refresh. */
function useCatalog(
query: string,
treeKey: string,
refresh: number,
initial?: CatalogResponse,
preview = false,
) {
const [result, setResult] = useState<{ query: string; data: CatalogResponse } | undefined>(
initial ? { query, data: initial } : undefined,
);
const branchCache = useRef(
new Map<string, CatalogBranch[]>(initial ? [[treeKey, initial.branches]] : []),
);
const loadedRefresh = useRef(refresh);
const { loading, error, refreshedAt } = useRemoteLoad(
async (signal) => {
const request = new URLSearchParams(query);
const includeBranches =
refresh !== loadedRefresh.current || !branchCache.current.has(treeKey);
request.set("includeBranches", String(includeBranches));
const data = await fetchJson<CatalogResponse>(`/api/catalog?${request}`, {
signal,
cache: "no-store",
fallback: "Could not load reviews.",
});
if (signal.aborted) return;
if (includeBranches) branchCache.current.set(treeKey, data.branches);
loadedRefresh.current = refresh;
setResult({ query, data: { ...data, branches: branchCache.current.get(treeKey) ?? [] } });
},
[query, treeKey, refresh],
{
enabled: !preview,
skipFirstRun: Boolean(initial),
initialLoading: !initial,
initialRefreshedAt: initial ? new Date().toISOString() : null,
},
);
const stale = result != null && result.query !== query;
return {
data: result?.data,
branches: result?.data.branches ?? [],
error,
loading: !error && (!result || stale || loading),
refreshedAt,
};
}
function PageControls({
page,
total,
loading,
onPage,
pageSize,
onPageSize,
}: {
pageSize: number;
onPageSize: (size: number) => void;
page: number;
total: number;
loading: boolean;
onPage: (page: number) => void;
}) {
return (
<div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-2 text-xs text-muted-foreground">
<span>
{total ? Math.min(page * pageSize + 1, total) : 0}–{Math.min((page + 1) * pageSize, total)}{" "}
of {total}
</span>
<div className="flex flex-wrap items-center gap-2">
<label className="flex items-center gap-2">
Reviews per page
<select
value={pageSize}
disabled={loading}
onChange={(event) => onPageSize(Number(event.target.value))}
className={cn(fieldClass, "w-auto")}
>
{[5, 10, 25, 50].map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</label>
<button
type="button"
className={buttonClass}
disabled={!page || loading}
onClick={() => onPage(page - 1)}
>
Previous
</button>
<button
type="button"
className={buttonClass}
disabled={(page + 1) * pageSize >= total || loading}
onClick={() => onPage(page + 1)}
>
Next
</button>
</div>
</div>
);
}
function placeholderReviews(count: number): CatalogReview[] {
return Array.from({ length: count }, (_, i) => ({
reviewKey: `loading-${i}`,
fiscalYear: 2000,
reviewNumber: "00000",
clientName: "Loading client name",
caseNumber: "000000000",
examiner: "Loading examiner",
state: "new_active",
outcome: "",
updatedAt: "2000-01-01T12:00:00Z",
routedBy: "",
unread: false,
flagged: false,
}));
}
export function CatalogLoading({ queue = false, rows = 10 }: { queue?: boolean; rows?: number }) {
return (
<CatalogContent
queue={queue}
previewRows={rows}
initial={{
reviews: placeholderReviews(rows),
branches: Array.from({ length: 6 }, (_, i) => ({
examiner: `Loading examiner ${i}`,
year: 2000 - i,
month: "2000-01",
total: rows,
unread: 0,
})),
total: rows,
}}
/>
);
}
function ReviewRows({
rows,
queue,
onChange,
}: {
rows: CatalogReview[];
queue: boolean;
onChange: () => void;
}) {
const { actor, navigate } = useReview();
const [pending, setPending] = useState<string[]>([]);
const [error, setError] = useState("");
async function flag(row: CatalogReview) {
setPending((value) => [...value, row.reviewKey]);
setError("");
try {
const response = await fetch("/api/catalog/flag", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reviewKey: row.reviewKey, flagged: !row.flagged }),
});
if (!response.ok) throw new Error("Could not save this flag. Please retry.");
onChange();
} catch (cause) {
setError((cause as Error).message);
} finally {
setPending((value) => value.filter((key) => key !== row.reviewKey));
}
}
return (
<>
{error && (
<p role="alert" className="px-4 py-2 text-sm text-destructive">
{error}
</p>
)}
<div className="overflow-x-auto">
<table className="w-full text-left text-sm" aria-label="Reviews in branch">
<thead className="border-b bg-muted/40 text-xs text-muted-foreground">
<tr>
{reviewColumns.map((label) => (
<th key={label} scope="col" className="px-3 py-3 font-medium">
{label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.reviewKey}
className={`h-16 border-b last:border-0 hover:bg-muted/30 ${row.unread ? "bg-primary/[0.035]" : ""}`}
>
<td className="w-12 px-2 py-2">
<button
type="button"
className={`inline-flex size-9 items-center justify-center rounded-md hover:bg-muted ${row.flagged ? "text-warning" : "text-muted-foreground"}`}
aria-label={`Flag review ${row.reviewNumber}, FFY ${row.fiscalYear}`}
aria-pressed={row.flagged}
disabled={pending.includes(row.reviewKey)}
onClick={() => void flag(row)}
>
<Flag
className="size-4"
fill={row.flagged ? "currentColor" : "none"}
aria-hidden
/>
</button>
</td>
<td className="min-w-52 px-3 py-2">
<Link
href={`/reviews/${row.reviewKey}`}
prefetch={false}
className="block min-h-10 w-full text-left hover:underline"
onClick={async (event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
event.preventDefault();
if (!(await navigate(`/reviews/${row.reviewKey}`)))
setError("Save this review before opening another.");
}}
>
<span className={`block ${row.unread ? "font-semibold" : "font-medium"}`}>
{row.unread && (
<span
className="mr-2 inline-block size-2 rounded-full bg-primary"
aria-label="Unread"
/>
)}
{row.clientName || "Unnamed client"}
</span>
<span className="mt-0.5 block text-xs text-muted-foreground">
{row.reviewNumber} · FFY {row.fiscalYear} · Case {row.caseNumber}
{row.unread ? " · Unread" : ""}
</span>
</Link>
</td>
<td className="min-w-36 px-3 py-2 text-xs">
{staffName(row.routedBy || row.examiner)}
</td>
<td className="min-w-36 px-3 py-2 text-xs">
<span className="inline-flex rounded-md bg-muted px-2 py-1">
{row.state === "awaiting_review" && !queue
? "Awaiting review"
: queue && actor.role === "pec" && row.state === "awaiting_pec_approval"
? "Awaiting my review"
: categoryLabels[row.state]}
</span>
{row.outcome && (
<span className="mt-1 block pl-2 capitalize text-muted-foreground">
{row.outcome === "clean"
? "Valid"
: row.outcome === "error"
? "Invalid"
: "Drop"}
</span>
)}
</td>
<td className="whitespace-nowrap px-3 py-2 text-xs text-muted-foreground">
<time dateTime={row.updatedAt} title={new Date(row.updatedAt).toLocaleString()}>
{new Date(row.updatedAt).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
})}
<span className="mt-1 block">
{new Date(row.updatedAt).toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
})}
</span>
</time>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
}
/** Examiner, fiscal year and month folders with review and unread counts. */
function FolderTree({
branches,
grouped,
selected,
expanded,
onChoose,
}: {
branches: CatalogBranch[];
grouped: boolean;
selected: Branch;
expanded: Set<string>;
onChoose(branch: Branch, toggle: boolean): void;
}) {
function node(branch: Branch, label: string, rows: CatalogBranch[], children?: React.ReactNode) {
const key = branchKey(branch);
const open = expanded.has(key);
const expandable = children != null;
const total = rows.reduce((sum, row) => sum + row.total, 0);
const unread = rows.reduce((sum, row) => sum + row.unread, 0);
return (
<div key={key}>
<button
type="button"
aria-expanded={expandable ? open : undefined}
aria-current={branchKey(selected) === key ? "true" : undefined}
className={`my-0.5 flex min-h-10 w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm hover:bg-muted ${branchKey(selected) === key ? "bg-muted font-semibold" : ""}`}
onClick={() => onChoose(branch, expandable)}
>
{expandable ? (
open ? (
<ChevronDown className="size-[0.9375rem] shrink-0" aria-hidden />
) : (
<ChevronRight className="size-[0.9375rem] shrink-0" aria-hidden />
)
) : null}
<span className="min-w-0 flex-1">{label}</span>
<span className="text-xs tabular-nums text-muted-foreground">{total}</span>
{unread > 0 && (
<span
className="rounded-full bg-primary/10 px-1.5 text-xs tabular-nums text-primary"
aria-label={`${unread} unread`}
>
{unread}
</span>
)}
</button>
{expandable && open ? (
<div className="ml-2.5 flex min-w-0 flex-col border-l pl-2">{children}</div>
) : null}
</div>
);
}
function years(rows: CatalogBranch[], examiner?: string) {
return [...new Set(rows.map((row) => row.year))]
.sort((a, b) => b - a)
.map((year) => {
const yearRows = rows.filter((row) => row.year === year);
return node(
{ examiner, year },
`FFY ${year}`,
yearRows,
[...new Set(yearRows.map((row) => row.month))]
.sort()
.reverse()
.map((month) =>
node(
{ examiner, year, month },
monthName(month),
yearRows.filter((row) => row.month === month),
),
),
);
});
}
return grouped
? [...new Set(branches.map((row) => row.examiner))]
.sort((a, b) => staffName(a).localeCompare(staffName(b)))
.map((examiner) => {
const own = branches.filter((row) => row.examiner === examiner);
return node({ examiner }, staffName(examiner), own, years(own, examiner));
})
: years(branches);
}
function CatalogContent({
queue,
initial,
previewRows,
}: {
queue: boolean;
initial?: CatalogResponse;
previewRows?: number;
}) {
const preview = previewRows !== undefined;
const { actor } = useReview();
const grouped = actor.role !== "examiner";
const [selected, setSelected] = useState<Branch>({});
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const [category, setCategory] = useState("");
const [sort, setSort] = useState("newest");
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(previewRows ?? 10);
const [refresh, setRefresh] = useState(0);
const refreshNow = () => setRefresh((value) => value + 1);
useEffect(() => {
if (preview) return;
let lastRefresh = Date.now();
const check = () => {
if (document.hidden || Date.now() - lastRefresh < 300_000) return;
lastRefresh = Date.now();
setRefresh((value) => value + 1);
};
const timer = setInterval(check, 300_000);
document.addEventListener("visibilitychange", check);
return () => {
clearInterval(timer);
document.removeEventListener("visibilitychange", check);
};
}, [preview]);
const treeKey = `${queue}:${category}`;
const { data, branches, loading, error, refreshedAt } = useCatalog(
queryString(queue, selected, category, sort, page, pageSize),
treeKey,
refresh,
initial,
preview,
);
// A smaller result set moves the page back into range during render.
if (data && page && page * pageSize >= data.total)
setPage(Math.max(0, Math.ceil(data.total / pageSize) - 1));
function choose(branch: Branch, toggle = false) {
setSelected(branch);
setPage(0);
setExpanded((old) => {
const next = new Set(old);
if (branch.examiner) next.add(branchKey({ examiner: branch.examiner }));
if (branch.year) next.add(branchKey({ examiner: branch.examiner, year: branch.year }));
const key = branchKey(branch);
if (toggle && old.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
const availableCategories = queue ? queueStates(actor) : Object.keys(categoryLabels);
const sortLabel = catalogSorts.find((option) => option.value === sort)?.label;
if (!data && !branches.length && loading && !error)
return <CatalogLoading queue={queue} rows={pageSize} />;
return (
<LoadingPreview loading={preview} label="Loading reviews…">
<div className="mx-auto w-full max-w-screen-2xl space-y-4 pb-16">
<h1 className="text-3xl font-semibold tracking-tight">
{queue ? "Approval Queue" : "Reviews"}
</h1>
<div className="flex flex-wrap items-end gap-3">
<label className="grid gap-1 text-xs font-medium">
Category
<select
className={fieldClass}
value={category}
onChange={(event) => {
setCategory(event.target.value);
setPage(0);
}}
>
<option value="">All categories</option>
{availableCategories.map((value) => (
<option key={value} value={value}>
{value === "awaiting_review" && !queue
? "Awaiting review"
: queue && actor.role === "pec"
? "Awaiting my review"
: categoryLabels[value]}
</option>
))}
</select>
</label>
<button
type="button"
className="inline-flex h-10 items-center gap-2 rounded-md px-2 text-sm hover:bg-muted"
aria-label={`Sort reviews: ${sortLabel}`}
onClick={() => {
setSort(nextCatalogSort(sort));
setPage(0);
}}
>
<ArrowDownUp className="size-[0.9375rem]" aria-hidden />
{sortLabel}
</button>
<div className="ml-auto flex items-center gap-2">
<p role="status" className="text-right">
{!data && loading ? (
<span className="text-xs text-muted-foreground">Loading reviews…</span>
) : (
<ElapsedTime at={refreshedAt} />
)}
</p>
<button
type="button"
aria-label="Refresh"
data-tip="Refresh"
className="inline-flex size-10 items-center justify-center rounded-md hover:bg-muted"
onClick={refreshNow}
>
<RefreshCw className="size-[0.9375rem]" aria-hidden />
</button>
</div>
</div>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
<div className="grid items-start gap-4 lg:grid-cols-[17rem_minmax(0,1fr)] lg:items-stretch">
<div className="relative min-h-0 max-lg:flex max-lg:max-h-[min(28rem,55dvh)] max-lg:flex-col">
<section
aria-label="Review folders"
className="flex min-h-0 flex-col overflow-hidden rounded-lg border bg-card p-3 max-lg:h-full lg:absolute lg:inset-0"
>
<div className="mb-2 flex shrink-0 items-center justify-between gap-2 border-b pb-3">
<h2 className="text-sm font-semibold">Folders</h2>
<button
type="button"
className="min-h-8 rounded px-2 text-xs text-muted-foreground hover:bg-muted"
onClick={() => {
setSelected({});
setPage(0);
}}
>
All reviews
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<FolderTree
branches={branches}
grouped={grouped}
selected={selected}
expanded={expanded}
onChoose={choose}
/>
{!branches.length && !loading && (
<p className="p-2 text-sm text-muted-foreground">
No reviews in these categories.
</p>
)}
</div>
</section>
</div>
<section
aria-label="Selected branch"
aria-busy={loading}
className="min-w-0 overflow-hidden rounded-lg border bg-card"
>
<div className="flex flex-wrap items-center justify-between gap-2 border-b px-4 py-3">
<h2 className="text-sm font-semibold">{branchTitle(selected)}</h2>
<span className="text-xs tabular-nums text-muted-foreground">
{loading && !data ? "…" : (data?.total ?? "…")} reviews
</span>
</div>
<div className="overflow-x-auto" style={reviewListStyle(pageSize)}>
{loading ? (
<LoadingPreview label="Loading branch…">
<ReviewRows
rows={placeholderReviews(pageSize)}
queue={queue}
onChange={() => {}}
/>
</LoadingPreview>
) : data ? (
<>
<ReviewRows rows={data.reviews} queue={queue} onChange={refreshNow} />
{!data.reviews.length && (
<p className="p-6 text-sm text-muted-foreground">
No reviews in this branch. Choose another folder or category.
</p>
)}
</>
) : (
<p className="p-6 text-sm text-muted-foreground">Use Refresh to try again.</p>
)}
</div>
{data && (
<PageControls
page={page}
total={data.total}
loading={loading}
onPage={setPage}
pageSize={pageSize}
onPageSize={(size) => {
setPageSize(size);
setPage(0);
}}
/>
)}
</section>
</div>
</div>
</LoadingPreview>
);
}
export function WorkCatalog({
queue = false,
initial,
}: {
queue?: boolean;
initial?: { scope: string; data: CatalogResponse };
}) {
const { actor } = useReview();
if (queue && !can(actor, "approvalQueue"))
return <p role="alert">Your role does not include the Approval Queue.</p>;
return (
<CatalogContent
key={`${actor.role}:${actor.staffId}:${actor.program}:${(actor.subordinateStaffIds ?? []).join(",")}`}
queue={queue}
initial={initial?.scope === JSON.stringify(actor) ? initial.data : undefined}
/>
);
}