feat(review): approved/rejected queue views — approve now produces a working list
serverListPendingReviewMatches gains a status param; queue gains Pending/Approved/Rejected tabs composed with the source filter. Approve was already durable (review fields survive nightly re-scoring) but approved matches vanished from the UI — now they're the working list for manual contact pulls and sends. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,8 +13,13 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const source = url.searchParams.get('source') ?? undefined;
|
const source = url.searchParams.get('source') ?? undefined;
|
||||||
const matches = await serverListPendingReviewMatches(db, { source });
|
const statusParam = url.searchParams.get('status');
|
||||||
return { matches, source: source ?? null };
|
const status =
|
||||||
|
statusParam === 'approved' || statusParam === 'rejected'
|
||||||
|
? statusParam
|
||||||
|
: 'pending';
|
||||||
|
const matches = await serverListPendingReviewMatches(db, { source, status });
|
||||||
|
return { matches, source: source ?? null, status };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action({ request }: Route.ActionArgs) {
|
export async function action({ request }: Route.ActionArgs) {
|
||||||
@@ -39,11 +44,21 @@ export async function action({ request }: Route.ActionArgs) {
|
|||||||
export default function ReviewQueue({ loaderData }: Route.ComponentProps) {
|
export default function ReviewQueue({ loaderData }: Route.ComponentProps) {
|
||||||
const matches = loaderData?.matches ?? [];
|
const matches = loaderData?.matches ?? [];
|
||||||
const activeSource = loaderData?.source ?? null;
|
const activeSource = loaderData?.source ?? null;
|
||||||
|
const activeStatus = loaderData?.status ?? 'pending';
|
||||||
|
const withParams = (overrides: Record<string, string | null>) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const merged = { source: activeSource, status: activeStatus, ...overrides };
|
||||||
|
if (merged.source != null) params.set('source', merged.source);
|
||||||
|
if (merged.status != null && merged.status !== 'pending')
|
||||||
|
params.set('status', merged.status);
|
||||||
|
const qs = params.toString();
|
||||||
|
return qs === '' ? '/' : `/?${qs}`;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto p-6">
|
<main className="container mx-auto p-6">
|
||||||
<h1 className="mb-2 text-2xl font-semibold">Grant Match Review Queue</h1>
|
<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">
|
<nav className="mb-1 flex gap-3 text-sm" aria-label="Filter by source">
|
||||||
{[
|
{[
|
||||||
[null, 'All'],
|
[null, 'All'],
|
||||||
['irs_990pf', 'Foundations'],
|
['irs_990pf', 'Foundations'],
|
||||||
@@ -51,7 +66,7 @@ export default function ReviewQueue({ loaderData }: Route.ComponentProps) {
|
|||||||
].map(([value, label]) => (
|
].map(([value, label]) => (
|
||||||
<a
|
<a
|
||||||
key={label as string}
|
key={label as string}
|
||||||
href={value == null ? '/' : `/?source=${value}`}
|
href={withParams({ source: value })}
|
||||||
className={
|
className={
|
||||||
activeSource === value
|
activeSource === value
|
||||||
? 'font-semibold underline'
|
? 'font-semibold underline'
|
||||||
@@ -62,6 +77,21 @@ export default function ReviewQueue({ loaderData }: Route.ComponentProps) {
|
|||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
<nav className="mb-4 flex gap-3 text-sm" aria-label="Filter by review status">
|
||||||
|
{(['pending', 'approved', 'rejected'] as const).map((value) => (
|
||||||
|
<a
|
||||||
|
key={value}
|
||||||
|
href={withParams({ status: value })}
|
||||||
|
className={
|
||||||
|
activeStatus === value
|
||||||
|
? 'font-semibold underline'
|
||||||
|
: 'text-blue-700 underline'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{value[0].toUpperCase() + value.slice(1)}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
{matches.length === 0 ? (
|
{matches.length === 0 ? (
|
||||||
<p role="status" className="text-gray-600">
|
<p role="status" className="text-gray-600">
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ export interface PendingReviewMatch {
|
|||||||
|
|
||||||
export async function serverListPendingReviewMatches(
|
export async function serverListPendingReviewMatches(
|
||||||
db: NpOutreachDatabase | NpOutreachTransaction,
|
db: NpOutreachDatabase | NpOutreachTransaction,
|
||||||
{ source }: { source?: string } = {},
|
{
|
||||||
|
source,
|
||||||
|
status = 'pending',
|
||||||
|
}: { source?: string; status?: 'pending' | 'approved' | 'rejected' } = {},
|
||||||
): Promise<PendingReviewMatch[]> {
|
): Promise<PendingReviewMatch[]> {
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
@@ -42,9 +45,9 @@ export async function serverListPendingReviewMatches(
|
|||||||
.innerJoin(schema.grants, eq(schema.matches.grantId, schema.grants.id))
|
.innerJoin(schema.grants, eq(schema.matches.grantId, schema.grants.id))
|
||||||
.where(
|
.where(
|
||||||
source == null
|
source == null
|
||||||
? eq(schema.matches.reviewStatus, 'pending')
|
? eq(schema.matches.reviewStatus, status)
|
||||||
: and(
|
: and(
|
||||||
eq(schema.matches.reviewStatus, 'pending'),
|
eq(schema.matches.reviewStatus, status),
|
||||||
eq(schema.grants.source, source as never),
|
eq(schema.grants.source, source as never),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user