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:
@@ -1,3 +1,6 @@
|
|||||||
import { index, type RouteConfig } from '@react-router/dev/routes';
|
import { index, route, type RouteConfig } from '@react-router/dev/routes';
|
||||||
|
|
||||||
export default [index('routes/_index.tsx')] satisfies RouteConfig;
|
export default [
|
||||||
|
index('routes/_index.tsx'),
|
||||||
|
route('matches/:matchId', 'routes/matches.$matchId.tsx'),
|
||||||
|
] satisfies RouteConfig;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {
|
|||||||
serverListPendingReviewMatches,
|
serverListPendingReviewMatches,
|
||||||
serverSetMatchReview,
|
serverSetMatchReview,
|
||||||
} from '@novelpad/outreach-core/server';
|
} from '@novelpad/outreach-core/server';
|
||||||
import { Form } from 'react-router';
|
import { Form, Link } from 'react-router';
|
||||||
|
|
||||||
import { db } from '#~/db.server.js';
|
import { db } from '#~/db.server.js';
|
||||||
|
|
||||||
@@ -69,7 +69,14 @@ export default function ReviewQueue({ loaderData }: Route.ComponentProps) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{matches.map((match) => (
|
{matches.map((match) => (
|
||||||
<tr key={match.id} className="border-b">
|
<tr key={match.id} className="border-b">
|
||||||
<td className="p-2">{match.orgName}</td>
|
<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.grantTitle}</td>
|
||||||
<td className="p-2">{match.totalScore}</td>
|
<td className="p-2">{match.totalScore}</td>
|
||||||
<td className="p-2">{match.easyWin ? 'Yes' : 'No'}</td>
|
<td className="p-2">{match.easyWin ? 'Yes' : 'No'}</td>
|
||||||
|
|||||||
278
apps/outreach-review/app/routes/matches.$matchId.tsx
Normal file
278
apps/outreach-review/app/routes/matches.$matchId.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { desc, eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
|
||||||
|
import { schema } from '#~/db/db.js';
|
||||||
|
|
||||||
|
export interface MatchDetail {
|
||||||
|
match: {
|
||||||
|
id: string;
|
||||||
|
totalScore: number;
|
||||||
|
subscores: unknown;
|
||||||
|
rationale: unknown;
|
||||||
|
easyWin: boolean;
|
||||||
|
isHero: boolean;
|
||||||
|
reviewStatus: 'pending' | 'approved' | 'rejected' | 'edited';
|
||||||
|
};
|
||||||
|
org: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
city: string | null;
|
||||||
|
state: string;
|
||||||
|
ein: string | null;
|
||||||
|
nteeCode: string | null;
|
||||||
|
totalRevenue: number | null;
|
||||||
|
registrationNumber: string | null;
|
||||||
|
missionStatement: string | null;
|
||||||
|
profileConfidence: number | null;
|
||||||
|
};
|
||||||
|
grant: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
funder: string;
|
||||||
|
funderEin: string | null;
|
||||||
|
synopsis: string | null;
|
||||||
|
awardFloor: number | null;
|
||||||
|
awardCeiling: number | null;
|
||||||
|
closeDate: Date | null;
|
||||||
|
sourceUrl: string;
|
||||||
|
source: string;
|
||||||
|
eligibilityEntityTypes: string[] | null;
|
||||||
|
geographicScope: string | null;
|
||||||
|
applicationEffortEstimate: string;
|
||||||
|
};
|
||||||
|
/** For 990-PF matches: the funder's actual giving history into the
|
||||||
|
* org's state — the concrete evidence behind the precedent subscore. */
|
||||||
|
funderGivingHistory: Array<{
|
||||||
|
recipientName: string;
|
||||||
|
recipientCity: string | null;
|
||||||
|
amount: number | null;
|
||||||
|
purpose: string | null;
|
||||||
|
taxYear: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serverGetMatchDetail(
|
||||||
|
db: NpOutreachDatabase | NpOutreachTransaction,
|
||||||
|
matchId: string,
|
||||||
|
): Promise<MatchDetail | null> {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
matchId: schema.matches.id,
|
||||||
|
totalScore: schema.matches.totalScore,
|
||||||
|
subscores: schema.matches.subscores,
|
||||||
|
rationale: schema.matches.rationale,
|
||||||
|
easyWin: schema.matches.easyWin,
|
||||||
|
isHero: schema.matches.isHero,
|
||||||
|
reviewStatus: schema.matches.reviewStatus,
|
||||||
|
orgId: schema.orgs.id,
|
||||||
|
orgName: schema.orgs.name,
|
||||||
|
orgCity: schema.orgs.city,
|
||||||
|
orgState: schema.orgs.state,
|
||||||
|
orgEin: schema.orgs.ein,
|
||||||
|
orgNtee: schema.orgs.nteeCode,
|
||||||
|
orgRevenue: schema.orgs.totalRevenue,
|
||||||
|
orgRegNo: schema.orgs.registrationNumber,
|
||||||
|
grantId: schema.grants.id,
|
||||||
|
grantTitle: schema.grants.title,
|
||||||
|
grantFunder: schema.grants.funder,
|
||||||
|
grantFunderEin: schema.grants.funderEin,
|
||||||
|
grantSynopsis: schema.grants.synopsis,
|
||||||
|
grantFloor: schema.grants.awardFloor,
|
||||||
|
grantCeiling: schema.grants.awardCeiling,
|
||||||
|
grantClose: schema.grants.closeDate,
|
||||||
|
grantSourceUrl: schema.grants.sourceUrl,
|
||||||
|
grantSource: schema.grants.source,
|
||||||
|
grantEligibility: schema.grants.eligibilityEntityTypes,
|
||||||
|
grantGeo: schema.grants.geographicScope,
|
||||||
|
grantEffort: schema.grants.applicationEffortEstimate,
|
||||||
|
})
|
||||||
|
.from(schema.matches)
|
||||||
|
.innerJoin(schema.orgs, eq(schema.matches.orgId, schema.orgs.id))
|
||||||
|
.innerJoin(schema.grants, eq(schema.matches.grantId, schema.grants.id))
|
||||||
|
.where(eq(schema.matches.id, matchId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const row = rows[0];
|
||||||
|
if (row == null) return null;
|
||||||
|
|
||||||
|
const profiles = await db
|
||||||
|
.select({
|
||||||
|
missionStatement: schema.orgProfiles.missionStatement,
|
||||||
|
confidence: schema.orgProfiles.confidence,
|
||||||
|
})
|
||||||
|
.from(schema.orgProfiles)
|
||||||
|
.where(eq(schema.orgProfiles.orgId, row.orgId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let funderGivingHistory: MatchDetail['funderGivingHistory'] = [];
|
||||||
|
if (row.grantFunderEin != null) {
|
||||||
|
const funderRows = await db
|
||||||
|
.select({
|
||||||
|
recipientName: schema.funderGrants.recipientName,
|
||||||
|
recipientCity: schema.funderGrants.recipientCity,
|
||||||
|
recipientState: schema.funderGrants.recipientState,
|
||||||
|
amount: schema.funderGrants.amount,
|
||||||
|
purpose: schema.funderGrants.purpose,
|
||||||
|
taxYear: schema.funderGrants.taxYear,
|
||||||
|
})
|
||||||
|
.from(schema.funderGrants)
|
||||||
|
.innerJoin(
|
||||||
|
schema.funders,
|
||||||
|
eq(schema.funderGrants.funderId, schema.funders.id),
|
||||||
|
)
|
||||||
|
.where(eq(schema.funders.ein, row.grantFunderEin))
|
||||||
|
.orderBy(desc(schema.funderGrants.taxYear), desc(schema.funderGrants.amount))
|
||||||
|
.limit(200);
|
||||||
|
|
||||||
|
// In-state grants first (the precedent evidence), then the rest.
|
||||||
|
funderGivingHistory = [...funderRows]
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aIn = a.recipientState === row.orgState ? 0 : 1;
|
||||||
|
const bIn = b.recipientState === row.orgState ? 0 : 1;
|
||||||
|
return aIn - bIn;
|
||||||
|
})
|
||||||
|
.slice(0, 40)
|
||||||
|
.map(({ recipientState: _s, ...rest }) => rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
match: {
|
||||||
|
id: row.matchId,
|
||||||
|
totalScore: row.totalScore,
|
||||||
|
subscores: row.subscores,
|
||||||
|
rationale: row.rationale,
|
||||||
|
easyWin: row.easyWin,
|
||||||
|
isHero: row.isHero,
|
||||||
|
reviewStatus: row.reviewStatus,
|
||||||
|
},
|
||||||
|
org: {
|
||||||
|
id: row.orgId,
|
||||||
|
name: row.orgName,
|
||||||
|
city: row.orgCity,
|
||||||
|
state: row.orgState,
|
||||||
|
ein: row.orgEin,
|
||||||
|
nteeCode: row.orgNtee,
|
||||||
|
totalRevenue: row.orgRevenue,
|
||||||
|
registrationNumber: row.orgRegNo,
|
||||||
|
missionStatement: profiles[0]?.missionStatement ?? null,
|
||||||
|
profileConfidence: profiles[0]?.confidence ?? null,
|
||||||
|
},
|
||||||
|
grant: {
|
||||||
|
id: row.grantId,
|
||||||
|
title: row.grantTitle,
|
||||||
|
funder: row.grantFunder,
|
||||||
|
funderEin: row.grantFunderEin,
|
||||||
|
synopsis: row.grantSynopsis,
|
||||||
|
awardFloor: row.grantFloor,
|
||||||
|
awardCeiling: row.grantCeiling,
|
||||||
|
closeDate: row.grantClose,
|
||||||
|
sourceUrl: row.grantSourceUrl,
|
||||||
|
source: row.grantSource,
|
||||||
|
eligibilityEntityTypes: row.grantEligibility,
|
||||||
|
geographicScope: row.grantGeo,
|
||||||
|
applicationEffortEstimate: row.grantEffort,
|
||||||
|
},
|
||||||
|
funderGivingHistory,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
export * from './list-pending-review-matches.server.js';
|
export * from './list-pending-review-matches.server.js';
|
||||||
|
export * from './get-match-detail.server.js';
|
||||||
|
|||||||
Reference in New Issue
Block a user