Skip to content

Commit 38c7eb0

Browse files
Merge pull request #698 from ResearchHub/rfp_pending_system
Pending System for Grants
2 parents baa3c20 + af5cc6b commit 38c7eb0

File tree

18 files changed

+582
-31
lines changed

18 files changed

+582
-31
lines changed

app/grant/[id]/[slug]/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export default async function GrantSlugLayout({ params, children }: Props) {
3232
const grant = work.note?.post?.grant;
3333
const grantId = grant?.id ?? undefined;
3434
const grantTitle = grant?.shortTitle || work.title;
35+
const isPending = grant?.status === 'PENDING';
3536
const isActive =
3637
grant?.status === 'OPEN' && (grant?.endDate ? isDeadlineInFuture(grant.endDate) : true);
3738
const hasProposals = (grant?.applicants?.length ?? 0) > 0;
@@ -44,6 +45,7 @@ export default async function GrantSlugLayout({ params, children }: Props) {
4445
amountUsd={grant?.amount?.usd}
4546
grantId={grantId?.toString()}
4647
isActive={isActive}
48+
isPending={isPending}
4749
work={work}
4850
organization={grant?.organization}
4951
/>
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
'use client';
2+
3+
import { useState, useEffect, useCallback } from 'react';
4+
import { RefreshCw, CheckCircle, XCircle } from 'lucide-react';
5+
import { Button } from '@/components/ui/Button';
6+
import { GrantModerationService } from '@/services/grant-moderation.service';
7+
import { DeclineGrantModal } from '@/components/Moderators/DeclineGrantModal';
8+
import { FeedItemGrant } from '@/components/Feed/items/FeedItemGrant';
9+
import { FeedEntry, FeedGrantContent } from '@/types/feed';
10+
import toast from 'react-hot-toast';
11+
12+
export default function PendingWorksPage() {
13+
const [entries, setEntries] = useState<FeedEntry[]>([]);
14+
const [isLoading, setIsLoading] = useState(true);
15+
const [isRefreshing, setIsRefreshing] = useState(false);
16+
const [actioningId, setActioningId] = useState<number | null>(null);
17+
const [declineTarget, setDeclineTarget] = useState<{ grantId: number; entryId: string } | null>(
18+
null
19+
);
20+
21+
const fetchEntries = useCallback(async () => {
22+
setIsLoading(true);
23+
try {
24+
const response = await GrantModerationService.fetchPendingGrants();
25+
setEntries(response.entries);
26+
} catch {
27+
toast.error('Failed to load pending submissions');
28+
} finally {
29+
setIsLoading(false);
30+
setIsRefreshing(false);
31+
}
32+
}, []);
33+
34+
useEffect(() => {
35+
fetchEntries();
36+
}, [fetchEntries]);
37+
38+
const handleRefresh = async () => {
39+
setIsRefreshing(true);
40+
await fetchEntries();
41+
};
42+
43+
const handleApprove = async (grantId: number, entryId: string) => {
44+
setActioningId(grantId);
45+
try {
46+
await GrantModerationService.approveGrant(grantId);
47+
toast.success('RFP approved successfully');
48+
setEntries((prev) => prev.filter((e) => e.id !== entryId));
49+
} catch (err) {
50+
toast.error(err instanceof Error ? err.message : 'Failed to approve RFP');
51+
} finally {
52+
setActioningId(null);
53+
}
54+
};
55+
56+
const handleDecline = async (data: { reasonChoice: string; reason: string }) => {
57+
if (!declineTarget) return;
58+
setActioningId(declineTarget.grantId);
59+
try {
60+
await GrantModerationService.declineGrant(declineTarget.grantId, {
61+
reason_choice: data.reasonChoice,
62+
...(data.reason && { reason: data.reason }),
63+
});
64+
toast.success('RFP declined');
65+
setEntries((prev) => prev.filter((e) => e.id !== declineTarget.entryId));
66+
setDeclineTarget(null);
67+
} catch (err) {
68+
toast.error(err instanceof Error ? err.message : 'Failed to decline RFP');
69+
} finally {
70+
setActioningId(null);
71+
}
72+
};
73+
74+
return (
75+
<div className="h-full flex flex-col p-4">
76+
<div className="bg-white">
77+
<div className="flex items-center justify-between mb-4">
78+
<div>
79+
<h1 className="text-2xl font-semibold text-gray-900">Pending</h1>
80+
<p className="text-sm text-gray-600 mt-1">
81+
Review and approve or decline pending submissions
82+
</p>
83+
</div>
84+
85+
<Button
86+
variant="outlined"
87+
size="sm"
88+
onClick={handleRefresh}
89+
disabled={isRefreshing}
90+
className="flex items-center space-x-2"
91+
>
92+
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
93+
<span className="hidden tablet:!block">Refresh</span>
94+
</Button>
95+
</div>
96+
</div>
97+
98+
<div className="flex-1 overflow-auto">
99+
<div className="max-w-4xl mx-auto space-y-4">
100+
{isLoading && (
101+
<div className="space-y-4">
102+
{[1, 2, 3].map((i) => (
103+
<div
104+
key={i}
105+
className="bg-white border border-gray-200 rounded-lg p-6 animate-pulse"
106+
>
107+
<div className="h-5 bg-gray-200 rounded w-2/3 mb-3" />
108+
<div className="h-4 bg-gray-100 rounded w-1/3 mb-4" />
109+
<div className="h-4 bg-gray-100 rounded w-full mb-2" />
110+
<div className="h-4 bg-gray-100 rounded w-4/5" />
111+
</div>
112+
))}
113+
</div>
114+
)}
115+
116+
{!isLoading && entries.length === 0 && (
117+
<div className="text-center py-12">
118+
<div className="flex flex-col items-center justify-center">
119+
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-4">
120+
<CheckCircle className="h-8 w-8 text-green-600" />
121+
</div>
122+
<h3 className="text-lg font-medium text-gray-900 mb-2">All caught up!</h3>
123+
<p className="text-gray-600 text-center max-w-md">
124+
No pending submissions require your review at the moment.
125+
</p>
126+
</div>
127+
</div>
128+
)}
129+
130+
{entries.map((entry) => {
131+
const grant = entry.content as FeedGrantContent;
132+
const grantId = grant.grant?.id;
133+
const isActioning = actioningId === grantId;
134+
135+
return (
136+
<FeedItemGrant
137+
key={entry.id}
138+
entry={entry}
139+
showActions={false}
140+
showHeader={false}
141+
footer={
142+
grantId ? (
143+
<div className="flex items-center gap-2 px-4 py-3">
144+
<Button
145+
variant="ghost"
146+
size="sm"
147+
onClick={() => handleApprove(grantId, entry.id)}
148+
disabled={isActioning}
149+
className="text-green-600 hover:text-green-700 hover:bg-green-50"
150+
>
151+
<CheckCircle className="h-4 w-4 mr-1" />
152+
{isActioning ? 'Approving...' : 'Approve'}
153+
</Button>
154+
155+
<Button
156+
variant="ghost"
157+
size="sm"
158+
onClick={() => setDeclineTarget({ grantId, entryId: entry.id })}
159+
disabled={isActioning}
160+
className="text-red-600 hover:text-red-700 hover:bg-red-50"
161+
>
162+
<XCircle className="h-4 w-4 mr-1" />
163+
Decline
164+
</Button>
165+
</div>
166+
) : undefined
167+
}
168+
/>
169+
);
170+
})}
171+
</div>
172+
</div>
173+
174+
{declineTarget && (
175+
<DeclineGrantModal
176+
isOpen={!!declineTarget}
177+
onClose={() => setDeclineTarget(null)}
178+
onConfirm={handleDecline}
179+
isSubmitting={actioningId === declineTarget.grantId}
180+
/>
181+
)}
182+
</div>
183+
);
184+
}

components/Feed/BaseFeedItem.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,17 @@ export interface BaseFeedItemProps {
4141
cardImage?: ReactNode;
4242
/** Image rendered on the left side; content + actions span full width below */
4343
cardImageLeft?: ReactNode;
44+
/** Optional footer rendered at the bottom of the card, below the actions row */
45+
footer?: ReactNode;
46+
/** Extra items to add to the "..." dropdown menu */
47+
menuItems?: Array<{
48+
icon: any;
49+
label: string;
50+
tooltip?: string;
51+
disabled?: boolean;
52+
onClick: (e?: React.MouseEvent) => void;
53+
className?: string;
54+
}>;
4455
}
4556

4657
// Badge component interface
@@ -291,6 +302,8 @@ export const BaseFeedItem: FC<BaseFeedItemProps> = ({
291302
badges,
292303
cardImage,
293304
cardImageLeft,
305+
footer,
306+
menuItems,
294307
}) => {
295308
const content = entry.content;
296309
const author = content.createdBy;
@@ -463,11 +476,14 @@ export const BaseFeedItem: FC<BaseFeedItemProps> = ({
463476
onFeedItemClick={onFeedItemClick}
464477
bounties={showBountyInfo ? undefined : content.bounties}
465478
hideReportButton={hideReportButton}
479+
menuItems={menuItems}
466480
hideCommentButton={(entry.metrics?.comments ?? 0) === 0}
467481
className="gap-1"
468482
/>
469483
</div>
470484
)}
485+
486+
{footer}
471487
</CardWrapper>
472488
</div>
473489
);

components/Feed/items/FeedItemGrant.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { FC } from 'react';
3+
import { FC, ReactNode } from 'react';
44
import { FeedEntry, FeedGrantContent } from '@/types/feed';
55
import {
66
BaseFeedItem,
@@ -32,6 +32,7 @@ interface FeedItemGrantProps {
3232
onFeedItemClick?: () => void;
3333
onAbstractExpanded?: () => void;
3434
highlights?: Highlight[];
35+
footer?: ReactNode;
3536
}
3637

3738
export const FeedItemGrant: FC<FeedItemGrantProps> = ({
@@ -45,6 +46,7 @@ export const FeedItemGrant: FC<FeedItemGrantProps> = ({
4546
showHeader = true,
4647
onFeedItemClick,
4748
highlights,
49+
footer,
4850
}) => {
4951
const router = useRouter();
5052
const { showUSD } = useCurrencyPreference();
@@ -62,6 +64,7 @@ export const FeedItemGrant: FC<FeedItemGrantProps> = ({
6264

6365
const imageUrl = grant.previewImage ?? undefined;
6466

67+
const isPending = grant.grant?.status === 'PENDING';
6568
const isActive =
6669
grant.grant?.status === 'OPEN' &&
6770
(grant.grant?.endDate ? isDeadlineInFuture(grant.grant.endDate) : true);
@@ -90,13 +93,13 @@ export const FeedItemGrant: FC<FeedItemGrantProps> = ({
9093
showHeader={showHeader}
9194
onFeedItemClick={onFeedItemClick}
9295
hideReportButton={false}
96+
footer={footer}
9397
cardImageLeft={
9498
imageUrl ? (
9599
<ImageSection imageUrl={imageUrl} alt={grant.title || 'Grant image'} naturalDimensions />
96100
) : undefined
97101
}
98102
>
99-
{/* Mobile image */}
100103
{imageUrl && (
101104
<div className="md:!hidden w-[calc(100%+2rem)] mb-5 -mx-4 -mt-4 overflow-hidden">
102105
<ImageSection imageUrl={imageUrl} alt={grant.title || 'Grant image'} aspectRatio="16/9" />
@@ -162,7 +165,11 @@ export const FeedItemGrant: FC<FeedItemGrantProps> = ({
162165
<ArrowRight size={14} className="ml-1" />
163166
</Button>
164167
) : (
165-
<span className="flex-shrink-0 text-sm text-gray-400">Ended</span>
168+
<span
169+
className={`flex-shrink-0 text-sm ${isPending ? 'text-yellow-600' : 'text-gray-400'}`}
170+
>
171+
{isPending ? 'Pending' : 'Ended'}
172+
</span>
166173
)}
167174
</div>
168175
</PrimaryActionSection>

components/Funding/GrantActionBar.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ export function GrantActionBar({ work, className }: GrantActionBarProps) {
128128
className={cn(
129129
'px-3 py-2 rounded-l-lg transition-colors',
130130
isUpvoted ? 'text-green-600 hover:bg-green-100' : 'text-gray-600 hover:bg-gray-100',
131-
(isVoting || isLoadingVotes) ? 'cursor-not-allowed' : 'cursor-pointer'
131+
isVoting || isLoadingVotes ? 'cursor-not-allowed' : 'cursor-pointer'
132132
)}
133133
aria-label="Upvote"
134134
>
@@ -143,7 +143,7 @@ export function GrantActionBar({ work, className }: GrantActionBarProps) {
143143
className={cn(
144144
'px-3 py-2 rounded-r-lg transition-colors',
145145
isDownvoted ? 'text-red-600 hover:bg-red-100' : 'text-gray-600 hover:bg-gray-100',
146-
(isVoting || isLoadingVotes) ? 'cursor-not-allowed' : 'cursor-pointer'
146+
isVoting || isLoadingVotes ? 'cursor-not-allowed' : 'cursor-pointer'
147147
)}
148148
aria-label="Downvote"
149149
>
@@ -197,9 +197,7 @@ export function GrantActionBar({ work, className }: GrantActionBarProps) {
197197
</button>
198198
}
199199
>
200-
<BaseMenuItem
201-
onSelect={() => executeAuthenticatedAction(() => setIsFlagModalOpen(true))}
202-
>
200+
<BaseMenuItem onSelect={() => executeAuthenticatedAction(() => setIsFlagModalOpen(true))}>
203201
<Flag className="h-4 w-4 mr-2" />
204202
<span>Flag Content</span>
205203
</BaseMenuItem>

components/Funding/GrantBannerWithTabs.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ interface GrantBannerWithTabsProps {
88
amountUsd?: number;
99
grantId?: string;
1010
isActive?: boolean;
11+
isPending?: boolean;
1112
work?: Work;
1213
organization?: string;
1314
}

components/Funding/GrantInfoBanner.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ interface GrantInfoBannerProps {
4242
amountUsd?: number;
4343
grantId?: string;
4444
isActive?: boolean;
45+
isPending?: boolean;
4546
work?: Work;
4647
organization?: string;
4748
activeTab?: GrantBannerTab;
@@ -51,14 +52,21 @@ interface GrantInfoBannerProps {
5152
function GrantSubtitle({
5253
amountUsd,
5354
isActive,
55+
isPending,
5456
organization,
5557
}: {
5658
amountUsd?: number;
5759
isActive: boolean;
60+
isPending: boolean;
5861
organization?: string;
5962
}) {
6063
return (
6164
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
65+
{isPending && (
66+
<span className="font-medium text-sm px-2 py-0.5 rounded-md text-yellow-700 bg-yellow-100">
67+
Pending Review
68+
</span>
69+
)}
6270
{amountUsd != null && amountUsd > 0 && (
6371
<span
6472
className={cn(
@@ -79,6 +87,7 @@ export const GrantInfoBanner = ({
7987
amountUsd,
8088
grantId,
8189
isActive = true,
90+
isPending = false,
8291
work,
8392
organization,
8493
activeTab = 'proposals',
@@ -243,7 +252,12 @@ export const GrantInfoBanner = ({
243252
<HeroHeader
244253
title={work?.title || ''}
245254
subtitle={
246-
<GrantSubtitle amountUsd={amountUsd} isActive={isActive} organization={organization} />
255+
<GrantSubtitle
256+
amountUsd={amountUsd}
257+
isActive={isActive}
258+
isPending={isPending}
259+
organization={organization}
260+
/>
247261
}
248262
cta={ctaButtons}
249263
className={className}

0 commit comments

Comments
 (0)