Files
Croissant Le Doux 7cefbbbfa1 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>
2026-07-17 00:56:58 -04:00

177 lines
6.1 KiB
TypeScript

import {
serverListPendingReviewMatches,
serverSetMatchReview,
} from '@novelpad/outreach-core/server';
import { Form, Link } from 'react-router';
import { db } from '#~/db.server.js';
import type { Route } from './+types/_index.js';
export async function loader({ request }: Route.LoaderArgs) {
if (request.method === 'HEAD') return;
const url = new URL(request.url);
const source = url.searchParams.get('source') ?? undefined;
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) {
if (request.method === 'HEAD') return;
const formData = await request.formData();
const matchId = String(formData.get('matchId') ?? '');
const decision = formData.get('decision');
if (!matchId || (decision !== 'approved' && decision !== 'rejected')) {
throw new Response('Invalid review submission', { status: 400 });
}
await serverSetMatchReview(db, {
matchId,
reviewStatus: decision,
rejectReason: decision === 'rejected' ? 'other' : undefined,
});
return { ok: true as const };
}
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-1 flex gap-3 text-sm" aria-label="Filter by source">
{[
[null, 'All'],
['irs_990pf', 'Foundations'],
['grants_gov', 'Federal RFPs'],
].map(([value, label]) => (
<a
key={label as string}
href={withParams({ source: value })}
className={
activeSource === value
? 'font-semibold underline'
: 'text-blue-700 underline'
}
>
{label}
</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">
No pending matches to review.
</p>
) : (
<table className="w-full border-collapse text-left">
<thead>
<tr className="border-b">
<th scope="col" className="p-2">
Organization
</th>
<th scope="col" className="p-2">
Grant
</th>
<th scope="col" className="p-2">
Source
</th>
<th scope="col" className="p-2">
Score
</th>
<th scope="col" className="p-2">
Easy Win
</th>
<th scope="col" className="p-2">
Actions
</th>
</tr>
</thead>
<tbody>
{matches.map((match) => (
<tr key={match.id} className="border-b">
<td className="p-2">
<Link
to={`/matches/${match.id}`}
className="text-blue-700 underline"
>
{match.orgName}
</Link>
</td>
<td className="p-2">{match.grantTitle}</td>
<td className="p-2">
<span className="rounded bg-gray-200 px-2 py-0.5 text-xs">
{match.source === 'irs_990pf' ? 'foundation' : match.source}
</span>
</td>
<td className="p-2">{match.totalScore}</td>
<td className="p-2">{match.easyWin ? 'Yes' : 'No'}</td>
<td className="p-2">
<div className="flex gap-2">
<Form method="post">
<input type="hidden" name="matchId" value={match.id} />
<input type="hidden" name="decision" value="approved" />
<button
type="submit"
aria-label={`Approve match for ${match.orgName}`}
className="rounded bg-green-600 px-3 py-1 text-white hover:bg-green-700"
>
Approve
</button>
</Form>
<Form method="post">
<input type="hidden" name="matchId" value={match.id} />
<input type="hidden" name="decision" value="rejected" />
<button
type="submit"
aria-label={`Reject match for ${match.orgName}`}
className="rounded bg-red-600 px-3 py-1 text-white hover:bg-red-700"
>
Reject
</button>
</Form>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</main>
);
}