ingest-grants spent its 200-detail budget on hits.slice(0, 200) — the same head of the search results every night; the backlog never drained. Now new opportunities fill the budget first (serverListGrantSourceUrls partition), remaining budget refreshes known ones; search cap raised to 2,000. Full eligible pool turns out to be 565 federal opportunities — drained in two passes, 366 newly embedded. Review queue gains a source badge column and All/Foundations/Federal RFPs filter (?source=) — foundation easy-wins otherwise bury posted-RFP matches, which score lower by design (no precedent, national pools) but are the deadline-driven sends. Immediate proof: DOJ OVW FY2026 DV program (closes 9/8) matched five NH domestic-violence orgs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
147 lines
4.9 KiB
TypeScript
147 lines
4.9 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 matches = await serverListPendingReviewMatches(db, { source });
|
|
return { matches, source: source ?? null };
|
|
}
|
|
|
|
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;
|
|
|
|
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">
|
|
{[
|
|
[null, 'All'],
|
|
['irs_990pf', 'Foundations'],
|
|
['grants_gov', 'Federal RFPs'],
|
|
].map(([value, label]) => (
|
|
<a
|
|
key={label as string}
|
|
href={value == null ? '/' : `/?source=${value}`}
|
|
className={
|
|
activeSource === value
|
|
? 'font-semibold underline'
|
|
: 'text-blue-700 underline'
|
|
}
|
|
>
|
|
{label}
|
|
</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>
|
|
);
|
|
}
|