diff --git a/apps/admin_dashboard/src/main.tsx b/apps/admin_dashboard/src/main.tsx index b0a3e10a..4e008187 100644 --- a/apps/admin_dashboard/src/main.tsx +++ b/apps/admin_dashboard/src/main.tsx @@ -1822,7 +1822,7 @@ function App() { async function addGigApplication(gigId: string, crmProfile: string) { const normalizedProfile = crmProfile.trim() if (!normalizedProfile) { - showToast("Paste a CRM Contact profile first", "warning") + showToast("Choose a candidate first", "warning") return false } setBusy(`gig:${gigId}:addCandidate`, true) @@ -2798,6 +2798,7 @@ function App() { limit={gigLimit} staleDays={staleRecruitingDays} canWrite={can("gigs:write")} + canSearchCandidates={can("people:read")} canManageLeads={can("people:read")} canIncludeHistorical={can("people:read")} crmContactUrl={crmContactUrl} @@ -5172,6 +5173,7 @@ function GigsView(props: { limit: number staleDays: number canWrite: boolean + canSearchCandidates: boolean canManageLeads: boolean canIncludeHistorical: boolean crmContactUrl: (contactId?: string) => string @@ -5449,9 +5451,11 @@ function GigsView(props: { <> {filterBar} canWrite: boolean + canSearchCandidates: boolean crmContactUrl: (contactId?: string) => string crmAttachmentUrl: (attachmentId?: string) => string staleDays: number @@ -6034,9 +6044,19 @@ function GigDetailPage({ onAddApplication: (gigId: string, crmProfile: string) => Promise onUpdateApplicationStatus: (gigId: string, applicationId: string, status: string) => void }) { + const [candidateQuery, setCandidateQuery] = useState("") + const [candidateMatches, setCandidateMatches] = useState([]) + const [selectedCandidate, setSelectedCandidate] = useState(null) + const [candidateSearchError, setCandidateSearchError] = useState("") const [crmProfile, setCrmProfile] = useState("") const applications = Array.isArray(gig.applications) ? gig.applications : [] const isRecruiting = gig.status === "recruiting" + const candidateQueryReady = candidateQuery.trim().length >= 2 + const selectedCandidateId = selectedCandidate?.crm_contact_id || "" + const candidateProfile = selectedCandidateId + ? crmContactUrl(selectedCandidateId) || selectedCandidateId + : "" + const profileForAdd = canSearchCandidates ? candidateProfile : crmProfile.trim() const threadUrl = gig.discord_guild_id && gig.discord_thread_id ? `https://discord.com/channels/${encodeURIComponent( @@ -6044,6 +6064,56 @@ function GigDetailPage({ )}/${encodeURIComponent(gig.discord_thread_id)}` : "" const staleAge = staleRecruitingAge(gig, staleDays) + + useEffect(() => { + if (!canWrite || !canSearchCandidates) return + const query = candidateQuery.trim() + if (selectedCandidate && query === personSearchLabel(selectedCandidate)) { + setCandidateMatches([]) + setCandidateSearchError("") + return + } + if (query.length < 2) { + setCandidateMatches([]) + setCandidateSearchError("") + return + } + + const controller = new AbortController() + const timer = window.setTimeout(() => { + const params = new URLSearchParams({ limit: "8", query }) + void requestJson(`/dashboard/api/people?${params.toString()}`, { + signal: controller.signal, + }) + .then((people) => { + setCandidateMatches(people.filter((person) => Boolean(person.crm_contact_id))) + setCandidateSearchError("") + }) + .catch((error) => { + if ( + controller.signal.aborted || + (error instanceof DOMException && error.name === "AbortError") + ) { + return + } + setCandidateMatches([]) + setCandidateSearchError(messageFromUnknown(error, "Unable to search candidates")) + }) + }, 300) + + return () => { + controller.abort() + window.clearTimeout(timer) + } + }, [candidateQuery, canSearchCandidates, canWrite, selectedCandidate]) + + function chooseCandidate(person: Person) { + setSelectedCandidate(person) + setCandidateQuery(personSearchLabel(person)) + setCandidateMatches([]) + setCandidateSearchError("") + } + return (
{ event.preventDefault() - void onAddApplication(gig.id, crmProfile).then((added) => { - if (added) setCrmProfile("") + void onAddApplication(gig.id, profileForAdd).then((added) => { + if (added) { + setCandidateQuery("") + setCandidateMatches([]) + setSelectedCandidate(null) + setCrmProfile("") + } }) }} > - + {canSearchCandidates ? ( +
+ + {candidateQueryReady && !selectedCandidate ? ( +
+ {candidateSearchError ? ( +
+ {candidateSearchError} +
+ ) : candidateMatches.length ? ( + candidateMatches.map((person) => { + const label = personSearchLabel(person) + const detail = [person.email_508 || person.email, person.contact_type] + .filter(Boolean) + .join(" | ") + return ( + + ) + }) + ) : ( +
+ No candidates match this search +
+ )} +
+ ) : null} +
+ ) : ( + + )}