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>
This commit is contained in:
Croissant Le Doux
2026-07-16 21:32:28 -04:00
parent 8f9c19a32c
commit d8ca133e95
5 changed files with 470 additions and 4 deletions

View File

@@ -0,0 +1,278 @@
import { nteeDescription } from '@novelpad/outreach-core';
import {
serverGetMatchDetail,
serverSetMatchReview,
} from '@novelpad/outreach-core/server';
import { Form, Link, redirect } from 'react-router';
import { db } from '#~/db.server.js';
import type { Route } from './+types/matches.$matchId.js';
export async function loader({ request, params }: Route.LoaderArgs) {
if (request.method === 'HEAD') return;
const detail = await serverGetMatchDetail(db, params.matchId);
if (detail == null) {
throw new Response('Match not found', { status: 404 });
}
return { detail };
}
export async function action({ request, params }: Route.ActionArgs) {
if (request.method === 'HEAD') return;
const formData = await request.formData();
const decision = formData.get('decision');
if (decision !== 'approved' && decision !== 'rejected') {
throw new Response('Invalid review submission', { status: 400 });
}
await serverSetMatchReview(db, {
matchId: params.matchId,
reviewStatus: decision,
rejectReason: decision === 'rejected' ? 'other' : undefined,
});
return redirect('/');
}
function dollars(n: number | null): string {
if (n == null) return '—';
return `$${n.toLocaleString('en-US')}`;
}
const SUBSCORE_MAX: Record<string, number> = {
missionFit: 30,
funderPrecedent: 25,
capacityFit: 15,
competition: 15,
effort: 10,
runway: 5,
};
export default function MatchDetail({ loaderData }: Route.ComponentProps) {
const { detail } = loaderData ?? {};
if (detail == null) return null;
const { match, org, grant, funderGivingHistory } = detail;
const subscores = (match.subscores ?? {}) as Record<string, number>;
const rationale = (match.rationale ?? {}) as Record<string, unknown>;
const nhHistory = funderGivingHistory;
return (
<main className="container mx-auto max-w-4xl space-y-6 p-6">
<nav>
<Link to="/" className="text-blue-700 underline">
Back to queue
</Link>
</nav>
<header className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold">
{org.name} {grant.funder}
</h1>
<p className="text-gray-600">
Score {match.totalScore}/100
{match.easyWin ? ' · Easy win' : ''}
{match.isHero ? ' · Hero' : ''} · Status: {match.reviewStatus}
</p>
</div>
<div className="flex shrink-0 gap-2">
<Form method="post">
<input type="hidden" name="decision" value="approved" />
<button
type="submit"
className="rounded bg-green-600 px-4 py-2 text-white hover:bg-green-700"
>
Approve
</button>
</Form>
<Form method="post">
<input type="hidden" name="decision" value="rejected" />
<button
type="submit"
className="rounded bg-red-600 px-4 py-2 text-white hover:bg-red-700"
>
Reject
</button>
</Form>
</div>
</header>
<section className="grid gap-6 md:grid-cols-2">
<div className="rounded border p-4">
<h2 className="mb-2 text-lg font-semibold">Organization</h2>
<dl className="space-y-1 text-sm">
<div>
<dt className="inline font-medium">Location: </dt>
<dd className="inline">
{[org.city, org.state].filter(Boolean).join(', ')}
</dd>
</div>
<div>
<dt className="inline font-medium">Focus (NTEE): </dt>
<dd className="inline">
{org.nteeCode ?? '—'}
{nteeDescription(org.nteeCode) != null &&
`${nteeDescription(org.nteeCode)}`}
</dd>
</div>
<div>
<dt className="inline font-medium">Annual revenue: </dt>
<dd className="inline">{dollars(org.totalRevenue)}</dd>
</div>
<div>
<dt className="inline font-medium">Mission (profile): </dt>
<dd className="inline">
{org.missionStatement ?? '—'}
{org.profileConfidence != null &&
org.profileConfidence <= 0.2 && (
<span className="text-amber-700">
{' '}
(auto-generated stub verify on their website)
</span>
)}
</dd>
</div>
</dl>
<div className="mt-3 space-x-3 text-sm">
{org.ein != null && (
<a
className="text-blue-700 underline"
href={`https://projects.propublica.org/nonprofits/organizations/${org.ein.replace(/^0+/, '')}`}
target="_blank"
rel="noreferrer"
>
ProPublica filings
</a>
)}
<a
className="text-blue-700 underline"
href={`https://www.google.com/search?q=${encodeURIComponent(`"${org.name}" ${org.city ?? ''} NH nonprofit`)}`}
target="_blank"
rel="noreferrer"
>
Search their site
</a>
</div>
</div>
<div className="rounded border p-4">
<h2 className="mb-2 text-lg font-semibold">Grant</h2>
<dl className="space-y-1 text-sm">
<div>
<dt className="inline font-medium">Title: </dt>
<dd className="inline">{grant.title}</dd>
</div>
<div>
<dt className="inline font-medium">Award: </dt>
<dd className="inline">
{dollars(grant.awardFloor)} {dollars(grant.awardCeiling)}
</dd>
</div>
<div>
<dt className="inline font-medium">Closes: </dt>
<dd className="inline">
{grant.closeDate == null
? 'Rolling / no stated deadline'
: new Date(grant.closeDate).toLocaleDateString('en-US')}
</dd>
</div>
<div>
<dt className="inline font-medium">Source: </dt>
<dd className="inline">{grant.source}</dd>
</div>
{grant.eligibilityEntityTypes != null &&
grant.eligibilityEntityTypes.length > 0 && (
<div>
<dt className="inline font-medium">Eligibility: </dt>
<dd className="inline">
{grant.eligibilityEntityTypes.join('; ')}
</dd>
</div>
)}
</dl>
<div className="mt-3 text-sm">
<a
className="text-blue-700 underline"
href={grant.sourceUrl}
target="_blank"
rel="noreferrer"
>
{grant.source === 'irs_990pf'
? 'Funder on ProPublica ↗'
: 'Grant listing ↗'}
</a>
</div>
</div>
</section>
<section className="rounded border p-4">
<h2 className="mb-2 text-lg font-semibold">Score breakdown</h2>
<table className="w-full max-w-md text-sm">
<tbody>
{Object.entries(SUBSCORE_MAX).map(([key, max]) => (
<tr key={key} className="border-b">
<td className="py-1 capitalize">
{key.replace(/([A-Z])/g, ' $1').toLowerCase()}
</td>
<td className="py-1 text-right tabular-nums">
{subscores[key] ?? 0} / {max}
</td>
</tr>
))}
</tbody>
</table>
{typeof rationale.similarity === 'number' && (
<p className="mt-2 text-sm text-gray-600">
Embedding similarity: {(rationale.similarity as number).toFixed(3)}
{Array.isArray(rationale.gateFailures) &&
(rationale.gateFailures as string[]).length > 0 &&
` · Ignored gate flags: ${(rationale.gateFailures as string[]).join(', ')}`}
</p>
)}
</section>
<section className="rounded border p-4">
<h2 className="mb-2 text-lg font-semibold">Grant synopsis</h2>
<p className="whitespace-pre-wrap text-sm">{grant.synopsis ?? '—'}</p>
</section>
{nhHistory.length > 0 && (
<section className="rounded border p-4">
<h2 className="mb-1 text-lg font-semibold">
Funder giving history (from 990-PF filings)
</h2>
<p className="mb-3 text-sm text-gray-600">
The concrete evidence behind the precedent score who this
funder actually paid, sorted in-state first.
</p>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="py-1 pr-2">Recipient</th>
<th className="py-1 pr-2">City</th>
<th className="py-1 pr-2 text-right">Amount</th>
<th className="py-1 pr-2">Year</th>
<th className="py-1">Purpose</th>
</tr>
</thead>
<tbody>
{nhHistory.map((g, i) => (
<tr key={i} className="border-b align-top">
<td className="py-1 pr-2">{g.recipientName}</td>
<td className="py-1 pr-2">{g.recipientCity ?? '—'}</td>
<td className="py-1 pr-2 text-right tabular-nums">
{dollars(g.amount)}
</td>
<td className="py-1 pr-2">{g.taxYear}</td>
<td className="py-1">{g.purpose ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</section>
)}
</main>
);
}