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 url = new URL(request.url);
const source = url.searchParams.get('source') ?? undefined; const source = url.searchParams.get('source') ?? undefined;
const matches = await serverListPendingReviewMatches(db, { source }); const statusParam = url.searchParams.get('status');
return { matches, source: source ?? null }; 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) { 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) { export default function ReviewQueue({ loaderData }: Route.ComponentProps) {
const matches = loaderData?.matches ?? []; const matches = loaderData?.matches ?? [];
const activeSource = loaderData?.source ?? null; 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 ( return (
<main className="container mx-auto p-6"> <main className="container mx-auto p-6">
<h1 className="mb-2 text-2xl font-semibold">Grant Match Review Queue</h1> <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'], [null, 'All'],
['irs_990pf', 'Foundations'], ['irs_990pf', 'Foundations'],
@@ -51,7 +66,7 @@ export default function ReviewQueue({ loaderData }: Route.ComponentProps) {
].map(([value, label]) => ( ].map(([value, label]) => (
<a <a
key={label as string} key={label as string}
href={value == null ? '/' : `/?source=${value}`} href={withParams({ source: value })}
className={ className={
activeSource === value activeSource === value
? 'font-semibold underline' ? 'font-semibold underline'
@@ -62,6 +77,21 @@ export default function ReviewQueue({ loaderData }: Route.ComponentProps) {
</a> </a>
))} ))}
</nav> </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 ? ( {matches.length === 0 ? (
<p role="status" className="text-gray-600"> <p role="status" className="text-gray-600">

View File

@@ -23,7 +23,10 @@ export interface PendingReviewMatch {
export async function serverListPendingReviewMatches( export async function serverListPendingReviewMatches(
db: NpOutreachDatabase | NpOutreachTransaction, db: NpOutreachDatabase | NpOutreachTransaction,
{ source }: { source?: string } = {}, {
source,
status = 'pending',
}: { source?: string; status?: 'pending' | 'approved' | 'rejected' } = {},
): Promise<PendingReviewMatch[]> { ): Promise<PendingReviewMatch[]> {
return db return db
.select({ .select({
@@ -42,9 +45,9 @@ export async function serverListPendingReviewMatches(
.innerJoin(schema.grants, eq(schema.matches.grantId, schema.grants.id)) .innerJoin(schema.grants, eq(schema.matches.grantId, schema.grants.id))
.where( .where(
source == null source == null
? eq(schema.matches.reviewStatus, 'pending') ? eq(schema.matches.reviewStatus, status)
: and( : and(
eq(schema.matches.reviewStatus, 'pending'), eq(schema.matches.reviewStatus, status),
eq(schema.grants.source, source as never), eq(schema.grants.source, source as never),
), ),
) )