Files
grant-outreach-engine/apps/outreach-review/app/routes/matches.$matchId.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

279 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}