feat(review): approved/rejected queue views — approve now produces a working list

serverListPendingReviewMatches gains a status param; queue gains
Pending/Approved/Rejected tabs composed with the source filter. Approve
was already durable (review fields survive nightly re-scoring) but
approved matches vanished from the UI — now they're the working list
for manual contact pulls and sends.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Croissant Le Doux
2026-07-17 00:56:58 -04:00
parent 4f35dcdb8d
commit 7cefbbbfa1
2 changed files with 40 additions and 7 deletions

View File

@@ -13,8 +13,13 @@ export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const source = url.searchParams.get('source') ?? undefined;
const matches = await serverListPendingReviewMatches(db, { source });
return { matches, source: source ?? null };
const statusParam = url.searchParams.get('status');
const status =
statusParam === 'approved' || statusParam === 'rejected'
? statusParam
: 'pending';
const matches = await serverListPendingReviewMatches(db, { source, status });
return { matches, source: source ?? null, status };
}
export async function action({ request }: Route.ActionArgs) {
@@ -39,11 +44,21 @@ export async function action({ request }: Route.ActionArgs) {
export default function ReviewQueue({ loaderData }: Route.ComponentProps) {
const matches = loaderData?.matches ?? [];
const activeSource = loaderData?.source ?? null;
const activeStatus = loaderData?.status ?? 'pending';
const withParams = (overrides: Record<string, string | null>) => {
const params = new URLSearchParams();
const merged = { source: activeSource, status: activeStatus, ...overrides };
if (merged.source != null) params.set('source', merged.source);
if (merged.status != null && merged.status !== 'pending')
params.set('status', merged.status);
const qs = params.toString();
return qs === '' ? '/' : `/?${qs}`;
};
return (
<main className="container mx-auto p-6">
<h1 className="mb-2 text-2xl font-semibold">Grant Match Review Queue</h1>
<nav className="mb-4 flex gap-3 text-sm" aria-label="Filter by source">
<nav className="mb-1 flex gap-3 text-sm" aria-label="Filter by source">
{[
[null, 'All'],
['irs_990pf', 'Foundations'],
@@ -51,7 +66,7 @@ export default function ReviewQueue({ loaderData }: Route.ComponentProps) {
].map(([value, label]) => (
<a
key={label as string}
href={value == null ? '/' : `/?source=${value}`}
href={withParams({ source: value })}
className={
activeSource === value
? 'font-semibold underline'
@@ -62,6 +77,21 @@ export default function ReviewQueue({ loaderData }: Route.ComponentProps) {
</a>
))}
</nav>
<nav className="mb-4 flex gap-3 text-sm" aria-label="Filter by review status">
{(['pending', 'approved', 'rejected'] as const).map((value) => (
<a
key={value}
href={withParams({ status: value })}
className={
activeStatus === value
? 'font-semibold underline'
: 'text-blue-700 underline'
}
>
{value[0].toUpperCase() + value.slice(1)}
</a>
))}
</nav>
{matches.length === 0 ? (
<p role="status" className="text-gray-600">