Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
'use client';

import { useEffect, useState } from 'react';
import { ChevronDown } from 'lucide-react';
import { BaseModal } from '@/components/ui/BaseModal';
import { Alert } from '@/components/ui/Alert';
import { Button } from '@/components/ui/Button';
import { Dropdown, DropdownItem } from '@/components/ui/form/Dropdown';
import { Textarea } from '@/components/ui/form/Textarea';
import { LoadingButton } from '@/components/ui/LoadingButton';
import { cn } from '@/utils/styles';
import {
EXPERT_COUNT_OPTIONS,
EXPERT_SEARCH_ADDITIONAL_CONTEXT_MAX_LENGTH,
type ExpertCountOption,
type FindMoreExpertsPayload,
} from '@/services/expertFinder.service';

export interface FindMoreExpertsModalProps {
isOpen: boolean;
onClose: () => void;
initialAdditionalContext: string;
isSubmitting: boolean;
error: string | null;
onSubmit: (payload: FindMoreExpertsPayload) => Promise<void>;
}

const INITIAL_EXPERT_COUNT: ExpertCountOption = 10;

export function FindMoreExpertsModal({
isOpen,
onClose,
initialAdditionalContext,
isSubmitting,
error,
onSubmit,
}: Readonly<FindMoreExpertsModalProps>) {
const [expertCount, setExpertCount] = useState<ExpertCountOption>(INITIAL_EXPERT_COUNT);
const [additionalContext, setAdditionalContext] = useState(initialAdditionalContext);
const [countOpen, setCountOpen] = useState(false);

useEffect(() => {
if (!isOpen) return;
setExpertCount(INITIAL_EXPERT_COUNT);
setAdditionalContext(initialAdditionalContext);
setCountOpen(false);
}, [isOpen, initialAdditionalContext]);

const handleSubmit = async () => {
const payload: FindMoreExpertsPayload = { expert_count: expertCount };
if (additionalContext !== initialAdditionalContext) {
payload.additional_context = additionalContext;
}
await onSubmit(payload);
};

return (
<BaseModal isOpen={isOpen} onClose={onClose} title="Find more experts" size="md">
<div className="space-y-4">
<p className="text-sm text-gray-600">
Request additional experts for this search. New results are appended; existing experts
stay.
</p>

<Dropdown
label="Number of researchers"
helperText={`Up to ${expertCount} more researchers will be found`}
trigger={
<Button
type="button"
variant="outlined"
size="md"
className="w-full justify-between text-left text-gray-900 font-normal"
disabled={isSubmitting}
>
{expertCount}
<ChevronDown
className={cn(
'ml-2 h-4 w-4 shrink-0 transition-transform',
countOpen && 'rotate-180'
)}
/>
</Button>
}
className="max-h-60 overflow-y-auto py-1"
onOpenChange={setCountOpen}
>
<div className="py-1 max-h-60 overflow-y-auto">
{EXPERT_COUNT_OPTIONS.map((option) => (
<DropdownItem
key={option}
onClick={() => setExpertCount(option)}
className={expertCount === option ? 'bg-primary-50 text-primary-900' : ''}
>
{option}
</DropdownItem>
))}
</div>
</Dropdown>

<Textarea
label="Additional guidance (optional)"
value={additionalContext}
onChange={(e) => setAdditionalContext(e.target.value)}
maxLength={EXPERT_SEARCH_ADDITIONAL_CONTEXT_MAX_LENGTH}
rows={4}
disabled={isSubmitting}
helperText="Leave unchanged to keep the current search guidance. Clearing the field replaces it with empty guidance."
/>

{error ? <Alert variant="error">{error}</Alert> : null}

<div className="flex justify-end gap-2 pt-1">
<Button type="button" variant="outlined" onClick={onClose} disabled={isSubmitting}>
Cancel
</Button>
<LoadingButton
type="button"
variant="default"
onClick={() => void handleSubmit()}
isLoading={isSubmitting}
>
Find more
</LoadingButton>
</div>
</div>
</BaseModal>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,23 @@ import { useState, useCallback } from 'react';
import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';
import { TAB_EXPERT_RESULTS, TAB_OUTREACH } from '@/app/expert-finder/lib/searchDetailTabs';
import { Loader2, RefreshCw, Download, Mail, UserPlus, MoreVertical } from 'lucide-react';
import { Loader2, RefreshCw, Download, Mail, UserPlus, MoreVertical, Search } from 'lucide-react';
import toast from 'react-hot-toast';
import { Alert } from '@/components/ui/Alert';
import { Breadcrumbs } from '@/components/ui/Breadcrumbs';
import { Button } from '@/components/ui/Button';
import { BaseMenu, BaseMenuItem } from '@/components/ui/form/BaseMenu';
import { Tabs } from '@/components/ui/Tabs';
import { cn } from '@/utils/styles';
import { useExpertSearchDetail } from '@/hooks/useExpertFinder';
import { useExpertSearchDetail, useFindMoreExperts } from '@/hooks/useExpertFinder';
import { ApiError } from '@/services/types/api';
import { type FindMoreExpertsPayload } from '@/services/expertFinder.service';
import { SearchDetailHeader } from './SearchDetailHeader';
import { ExpertResultCard } from './ExpertResultCard';
import { GenerateEmailModal, type GenerateEmailConfirmPayload } from './GenerateEmailModal';
import { GenerateEmailProgressModal } from './GenerateEmailProgressModal';
import { ExpertFormModal } from './ExpertFormModal';
import { FindMoreExpertsModal } from './FindMoreExpertsModal';
import { GeneratedEmailsList } from '@/app/expert-finder/library/[searchId]/outreach/components/GeneratedEmailsList';
import { SearchDetailSkeleton } from '@/components/ExpertFinder/SearchDetailSkeleton';
import type { ExpertResult } from '@/types/expertFinder';
Expand Down Expand Up @@ -76,14 +80,22 @@ export function SearchDetailContent({ searchId }: SearchDetailContentProps) {
const tab = searchParams?.get('tab') === TAB_OUTREACH ? TAB_OUTREACH : TAB_EXPERT_RESULTS;

const [{ searchDetail, isLoading, error }, refetch] = useExpertSearchDetail(searchId);
const [{ isLoading: isFindMoreSubmitting, error: findMoreError }, findMore] =
useFindMoreExperts();

const [showAddExpertModal, setShowAddExpertModal] = useState(false);
const [showFindMoreModal, setShowFindMoreModal] = useState(false);

const [selectedIndices, setSelectedIndices] = useState<Set<number>>(new Set());
const [showGenerateModal, setShowGenerateModal] = useState(false);
const [showProgressModal, setShowProgressModal] = useState(false);
const [generateExperts, setGenerateExperts] = useState<ExpertResult[]>([]);
const [generatePayload, setGeneratePayload] = useState<GenerateEmailConfirmPayload | null>(null);

const isInProgress =
searchDetail != null &&
(searchDetail.status === 'pending' || searchDetail.status === 'processing');

const toggleSelection = useCallback((index: number) => {
setSelectedIndices((prev) => {
const next = new Set(prev);
Expand Down Expand Up @@ -113,13 +125,28 @@ export function SearchDetailContent({ searchId }: SearchDetailContentProps) {
setSelectedIndices(new Set());
}, []);

const handleFindMoreSubmit = useCallback(
async (payload: FindMoreExpertsPayload) => {
try {
await findMore(searchId, payload);
setShowFindMoreModal(false);
await refetch();
} catch (err: unknown) {
if (err instanceof ApiError && err.status === 409) {
toast.error('Search is already running');
setShowFindMoreModal(false);
await refetch();
return;
}
// Modal surfaces findMoreError from the hook
}
},
[findMore, searchId, refetch]
);

const expertResultsTabHref = pathname ? `${pathname}?tab=${TAB_EXPERT_RESULTS}` : undefined;
const outreachTabHref = pathname ? `${pathname}?tab=${TAB_OUTREACH}` : undefined;

const isInProgress =
searchDetail != null &&
(searchDetail.status === 'pending' || searchDetail.status === 'processing');

if (isLoading && !searchDetail) {
return <SearchDetailSkeleton activeTab={tab} />;
}
Expand All @@ -144,15 +171,36 @@ export function SearchDetailContent({ searchId }: SearchDetailContentProps) {
return null;
}

const displayedExpertTotal =
searchDetail.status === 'completed'
? Math.max(searchDetail.expertCount, searchDetail.expertResults.length)
: searchDetail.expertResults.length;
const displayedExpertTotal = Math.max(
searchDetail.expertCount,
searchDetail.expertResults.length
);

const statusAllowsFindMore =
searchDetail.status === 'completed' || searchDetail.status === 'failed';
const canFindMore = statusAllowsFindMore && !isInProgress;

const showCompletedResults =
searchDetail.status === 'completed' ||
(searchDetail.status === 'failed' && searchDetail.expertResults.length > 0) ||
(isInProgress && searchDetail.expertResults.length > 0);

// Proposal drafts / proposal invitations only apply to searches linked to a
// grant (funding round) document.
const isGrantLinked = searchDetail.work?.contentType === 'funding_request';

const findMoreButton = canFindMore ? (
<Button
variant="outlined"
size="sm"
className="gap-2"
onClick={() => setShowFindMoreModal(true)}
>
<Search className="h-4 w-4" aria-hidden />
Find more experts
</Button>
) : null;

return (
<div className="w-full max-w-5xl mx-auto px-4 py-8 space-y-6">
<Breadcrumbs
Expand Down Expand Up @@ -188,10 +236,49 @@ export function SearchDetailContent({ searchId }: SearchDetailContentProps) {
</p>
</div>
</Alert>
{canFindMore && searchDetail.expertResults.length === 0 ? (
<div className="flex flex-wrap items-center gap-2">{findMoreButton}</div>
) : null}
</div>
)}

{searchDetail.status === 'completed' && (
{searchDetail.status === 'completed' && searchDetail.errorMessage.trim() !== '' && (
<Alert variant="warning">
<div>
<p className="font-semibold mb-1">Find more did not finish cleanly</p>
<p className="font-normal text-sm whitespace-pre-wrap">{searchDetail.errorMessage}</p>
<p className="font-normal text-sm mt-2 text-amber-900/80">
Existing experts from this search are still available below.
</p>
</div>
</Alert>
)}

{isInProgress && (
<div className="flex flex-wrap items-center gap-3" aria-live="polite">
<Loader2 className="h-5 w-5 animate-spin text-primary-600 shrink-0" aria-hidden />
<p className="text-sm text-gray-600 min-w-0 flex-1">
Finding experts…
{' This can take a bit of time.'}
</p>
<Button
variant="secondary"
size="sm"
className="gap-2"
onClick={() => void refetch()}
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin shrink-0" aria-hidden />
) : (
<RefreshCw className="h-4 w-4 shrink-0" aria-hidden />
)}
<span>{isLoading ? 'Refreshing…' : 'Refresh'}</span>
</Button>
</div>
)}

{showCompletedResults && (
<>
<Tabs
tabs={[
Expand Down Expand Up @@ -257,6 +344,7 @@ export function SearchDetailContent({ searchId }: SearchDetailContentProps) {
<span className="text-sm text-gray-600">{selectedIndices.size} selected</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{findMoreButton}
<Button
variant="default"
size="sm"
Expand Down Expand Up @@ -299,7 +387,8 @@ export function SearchDetailContent({ searchId }: SearchDetailContentProps) {
) : tab === TAB_EXPERT_RESULTS ? (
<div className="rounded-lg border border-gray-200 bg-white p-6 text-center text-gray-600 space-y-3">
<p>No experts found for this search.</p>
<div className="flex justify-center">
<div className="flex flex-wrap justify-center gap-2">
{findMoreButton}
<SearchActionsMenu
reportPdfUrl={searchDetail.reportPdfUrl}
reportCsvUrl={searchDetail.reportCsvUrl}
Expand All @@ -317,6 +406,14 @@ export function SearchDetailContent({ searchId }: SearchDetailContentProps) {
searchId={searchId}
onSuccess={refetch}
/>
<FindMoreExpertsModal
isOpen={showFindMoreModal}
onClose={() => setShowFindMoreModal(false)}
initialAdditionalContext={searchDetail.additionalContext}
isSubmitting={isFindMoreSubmitting}
error={findMoreError}
onSubmit={handleFindMoreSubmit}
/>
<GenerateEmailModal
isOpen={showGenerateModal}
onClose={() => setShowGenerateModal(false)}
Expand All @@ -332,28 +429,6 @@ export function SearchDetailContent({ searchId }: SearchDetailContentProps) {
generation={generatePayload}
onDone={handleProgressDone}
/>

{isInProgress && (
<div className="flex flex-wrap items-center gap-3">
<p className="text-sm text-gray-500">
Re-check status when the search has had time to complete.
</p>
<Button
variant="secondary"
size="sm"
className="gap-2"
onClick={refetch}
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin shrink-0" aria-hidden />
) : (
<RefreshCw className="h-4 w-4 shrink-0" aria-hidden />
)}
<span>{isLoading ? 'Refreshing…' : 'Refresh'}</span>
</Button>
</div>
)}
</div>
);
}
Loading
Loading