Files
grant-outreach-engine/apps/outreach-review/app/routes/_index.tsx
Croissant Le Doux d8ca133e95 feat(review): match detail view — evidence for fit at review time
/matches/:matchId shows the org (location, NTEE + description, revenue,
profile mission w/ stub warning, ProPublica + web-search links), the
grant (award band, deadline, eligibility, source link), the full
subscore breakdown with embedding similarity + ignored-gate flags, the
grant synopsis, and — for 990-PF matches — the funder's actual giving
history table (recipient, city, amount, year, purpose; in-state first),
which is the concrete evidence behind the precedent score. Approve/
reject on the detail page redirects back to the queue; queue org names
link through.

Core: serverGetMatchDetail (match+org+grant join, profile, up to 40
funder-grant rows in-state-first).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 21:32:28 -04:00

117 lines
3.8 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 matches = await serverListPendingReviewMatches(db);
return { matches };
}
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 ?? [];
return (
<main className="container mx-auto p-6">
<h1 className="mb-4 text-2xl font-semibold">Grant Match Review Queue</h1>
{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">
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">{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>
);
}