/* ============================================================ Candidates — applications on live backend data. Recruiter rows come from GET /candidate/fetch (inbox + manual), one row per application, so score / stage / job / recruiter have a source. Without candidates.manage the server scopes that list to jobs the user owns as recruiter (or created). Tick Requisitions → Configure in Access Control to see candidates on jobs opened from that user's requisitions; recruiter assignment on the job does not hide them. Hiring managers use GET /candidate/manager/fetch (same job chain). Adding a candidate still goes through CV Import or the Add Candidate modal — both run the CV through persisted ATS scoring. ============================================================ */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import Modal from '../ui/Modal' import DataTable, { DataTableHead, DEFAULT_PAGE_SIZE, Pagination, pageWindow, useDataTable } from '../ui/DataTable' import PageHeader from '../ui/PageHeader' import { Avatar, Badge, EmptyState, FieldError, Icon, ScoreChip, SkeletonRows } from '../ui/primitives' import { useToast } from '../ui/Toast' import { useAuth } from '../auth/AuthContext' import { isHiringManager, seesAllCandidates, scopesToOwnRequisitions } from '../auth/permissions' import CandidateProfile from './CandidateProfile' import { useJobTitles } from './ScoredCandidateProfile' import { qk } from '../lib/queryKeys' import { exportStyledXlsx } from '../lib/exportXlsx' import { friendlyAuthError } from '../lib/errors' import * as candidatesApi from '../api/candidates' import * as jobPostsApi from '../api/jobPosts' import * as pipelineApi from '../api/pipeline' import { PreviousApplications, ReappliedBadge } from '../components/ReapplicantHistory' import { useFormState } from '../components/AuthLayout' import { persist, useSeedMutation } from '../data/seedQueries' import { avatarColor, fmtDate, initials as initialsOf, sources, stages } from '../data/seed' const EMPTY_FILTERS = { account: '', stage: '', band: '' } const SEARCH_DEBOUNCE_MS = 300 const STAGE_FILTERS = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired', 'On Hold', 'Rejected'] const BAND_FILTERS = ['Strong Match', 'Potential Match', 'Weak Match', 'Unscored'] const BAND_BADGE = { 'Strong Match': 'b-green', 'Potential Match': 'b-amber', 'Weak Match': 'b-gray', } /** Same ladder Talent Pool uses for Advance Stage on the shared profile modal. */ const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired'] /* Rows are APPLICATIONS (GET /candidate/fetch), not candidate user accounts. The user list (/candidate/fetch/users) is the wider population, but score, stage, job and recruiter all hang off the application — on a users row those columns have no source at all. One row per application is what a recruiter triages on, so the table follows the application. */ async function fetchCandidates({ limit = DEFAULT_PAGE_SIZE, offset = 0, search, assignedJobPostId } = {}) { const res = await candidatesApi.list({ limit, offset, search: search || undefined, assignedJobPostId: assignedJobPostId || undefined, }) const rows = Array.isArray(res?.data) ? res.data : [] return { rows: rows.map(candidatesApi.toApplicationListView), total: Number(res?.total ?? rows.length) || 0, } } async function fetchJobs({ createdBy } = {}) { const res = await candidatesApi.listJobs() const rows = Array.isArray(res?.data) ? res.data : [] return rows .filter((row) => row && row.id != null) .filter((row) => !createdBy || String(row.created_by || '') === String(createdBy)) .map((row) => ({ id: String(row.id), title: typeof row.title === 'string' && row.title.trim() ? row.title : 'Untitled', })) } function recommendationOf(c) { if (c.recommendation) return c.recommendation if (c.aiScore == null) return null return c.aiScore >= 82 ? 'Strong Match' : c.aiScore >= 65 ? 'Potential Match' : 'Weak Match' } function stageOf(status) { const key = String(status || '').toUpperCase() return pipelineApi.STAGE_FROM_STATUS[key] ?? (key ? 'Shortlist' : null) } function AtsCell({ score, recommendation }) { if (score == null) return Not scored const band = recommendation || recommendationOf({ aiScore: score }) return (
{band && (
{band}
)}
) } /* Client-side guard only — the route has no size cap of its own, so this just stops an obviously wrong file from being read into memory and posted. */ const MAX_CV_MB = 10 /** Live job posts offered by the Add Candidate picker. */ const JOB_POST_LIMIT = 100 /* Referral By must name a colleague, so it is constrained to a company address: a referral from outside the company is not a referral, and a bare name ("Sarah") cannot be resolved to a person later. This is the ONLY place the rule lives. `referral_by` is a free-text column and the route does not check it, so anything posted outside this form is stored as-is — the constraint is a data-entry guard, not an invariant. */ const REFERRAL_DOMAIN = 'utopiabrands.com' const REFERRAL_RE = new RegExp( `^[a-z0-9][a-z0-9._%+-]*@${REFERRAL_DOMAIN.replace(/\./g, '\\.')}$`, 'i', ) /** * The one reading of the Referral By box: surrounding whitespace is stripped, so * a field holding only spaces is absent rather than invalid, and the address is * lower-cased so " Ada@UtopiaBrands.com " and "ada@utopiabrands.com" are stored * as one referrer rather than two. */ const referralValue = (raw) => (raw || '').trim().toLowerCase() const STAGE_BADGE = { Shortlist: 'b-indigo', Screening: 'b-teal', Assessment: 'b-purple', Interview: 'b-amber', Offer: 'b-green', Approved: 'b-green', Hired: 'b-green', 'On Hold': 'b-amber', Rejected: 'b-gray', } export default function Candidates() { const { user } = useAuth() if (isHiringManager(user)) return return } function HiringManagerCandidates() { const navigate = useNavigate() const location = useLocation() const [q, setQ] = useState('') const [jobId, setJobId] = useState('') useEffect(() => { const id = location.state?.openCandidate if (id) navigate(`/candidate/${id}`, { replace: true }) }, [location.state, navigate]) const listQuery = useQuery({ queryKey: qk.candidates.managerList(), queryFn: async () => { const res = await candidatesApi.listForManager({ limit: 200, offset: 0 }) return Array.isArray(res?.data) ? res.data : [] }, }) const rowsAll = listQuery.data ?? [] const jobs = useMemo(() => { const seen = new Set() const out = [] for (const r of rowsAll) { const id = r.job_post_id if (id == null || seen.has(String(id))) continue seen.add(String(id)) out.push({ id: String(id), title: r.job_title || 'Untitled' }) } out.sort((a, b) => a.title.localeCompare(b.title)) return out }, [rowsAll]) const rows = useMemo(() => { const needle = q.trim().toLowerCase() return rowsAll.filter((r) => { if (jobId && String(r.job_post_id) !== jobId) return false if (!needle) return true const hay = [r.name, r.email, r.job_title].filter(Boolean).join(' ').toLowerCase() return hay.includes(needle) }) }, [rowsAll, q, jobId]) const columns = [ { key: 'name', label: 'Candidate', sortable: true, sortValue: (r) => r.name || '', render: (r) => ( <>
{r.name || '—'}
{r.email || '—'}
), }, { key: 'job', label: 'Job', sortable: true, sortValue: (r) => r.job_title || '', render: (r) => r.job_title || '—', }, { key: 'score', label: 'ATS', sortable: true, sortValue: (r) => (r.ai_score == null ? -1 : Number(r.ai_score)), render: (r) => , }, { key: 'stage', label: 'Stage', sortable: true, sortValue: (r) => r.application_status || '', render: (r) => { const stage = stageOf(r.application_status) || 'Shortlist' return {stage} }, }, { key: 'applied', label: 'Allocated', sortable: true, sortValue: (r) => r.created_at || '', render: (r) => ( {r.created_at ? fmtDate(r.created_at) : '—'} ), }, ] return (
setQ(e.target.value)} />
{listQuery.isPending && } {listQuery.isError && ( {friendlyAuthError(listQuery.error, 'Please try again.')} )} {listQuery.isSuccess && ( r.user_id && navigate(`/candidate/${r.user_id}`)} /> )}
) } function RecruiterCandidates() { const { toast } = useToast() const qc = useQueryClient() const location = useLocation() const navigate = useNavigate() const { user } = useAuth() const updateCandidates = useSeedMutation('candidates') const unscoped = seesAllCandidates(user) const requisitionScoped = scopesToOwnRequisitions(user) const [q, setQ] = useState('') const [jobId, setJobId] = useState('') const [search, setSearch] = useState('') const [filters, setFilters] = useState(EMPTY_FILTERS) const [showFilters, setShowFilters] = useState(false) const [sortMode, setSortMode] = useState('recent') const [skip, setSkip] = useState(0) const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE) const [profileFor, setProfileFor] = useState(null) const [atsFor, setAtsFor] = useState(null) const [adding, setAdding] = useState(false) useEffect(() => { const t = setTimeout(() => setSearch(q.trim()), SEARCH_DEBOUNCE_MS) return () => clearTimeout(t) }, [q]) useEffect(() => { setSkip(0) }, [search]) const candidatesQuery = useQuery({ queryKey: qk.candidates.list({ limit: pageSize, offset: skip, search, assignedJobPostId: jobId || undefined, }), queryFn: () => fetchCandidates({ limit: pageSize, offset: skip, search, assignedJobPostId: jobId || undefined, }), }) const jobsQuery = useQuery({ queryKey: qk.jobPosts.list({ createdBy: unscoped ? 'all' : requisitionScoped ? 'requisition' : (user?.id ?? null), }), queryFn: () => fetchJobs({ createdBy: unscoped || requisitionScoped ? undefined : user?.id, }), }) const candidates = useMemo(() => candidatesQuery.data?.rows ?? [], [candidatesQuery.data]) const jobsById = useMemo( () => Object.fromEntries((jobsQuery.data ?? []).map((j) => [j.id, j])), [jobsQuery.data], ) const { data: recentlyViewed = [] } = useQuery({ queryKey: qk.seed.recentlyViewed(), queryFn: async () => [], staleTime: Infinity, gcTime: Infinity, }) const jobTitleOf = useCallback( (c) => c.jobTitle || jobsById[c.jobId]?.title || '—', [jobsById], ) /* Same click-time score fetch Talent Pool uses: GET /pipeline/candidate/score/fetch only while the profile modal is open, cached per userId. */ const scoreQuery = useQuery({ queryKey: qk.pipeline.candidateScore({ userId: profileFor?.userId ?? null }), queryFn: () => pipelineApi.fetchCandidateScore({ userId: profileFor.userId }), select: pipelineApi.toAtsScore, enabled: Boolean(profileFor?.userId), }) const atsScore = scoreQuery.data?.overall_score ?? null const openProfile = useCallback( (c) => { qc.setQueryData(qk.seed.recentlyViewed(), (old = []) => { const next = [c.id, ...old.filter((id) => id !== c.id)].slice(0, 12) persist('tf-recent', next) return next }) // Real candidates get the full profile PAGE; the modal stays only as the // fallback for rows without a user account. const uid = c.userId if (uid) navigate(`/candidate/${uid}`) else setProfileFor(c) }, [qc, navigate], ) // Deep links from Talent Pool, global search, dashboard… useEffect(() => { const st = location.state if (!st) return if (st.openAdd) setAdding(true) if (st.openCandidate) navigate(`/candidate/${st.openCandidate}`, { replace: true }) }, [location.state, navigate]) const rows = useMemo(() => { const f = filters let list = candidates.filter((c) => { if (f.account === 'Active' && !c.isActive) return false if (f.account === 'Unconfirmed' && c.isActive) return false if (f.stage && (c.stage || '') !== f.stage) return false if (f.band === 'Unscored' && c.aiScore != null) return false if (f.band && f.band !== 'Unscored' && recommendationOf(c) !== f.band) return false return true }) if (sortMode === 'name') list = [...list].sort((a, b) => a.name.localeCompare(b.name)) else if (sortMode === 'score') { list = [...list].sort((a, b) => (b.aiScore ?? -1) - (a.aiScore ?? -1)) } else { list = [...list].sort((a, b) => (b.applied?.getTime() ?? 0) - (a.applied?.getTime() ?? 0)) } return list }, [candidates, filters, sortMode]) const columns = useMemo( () => [ { key: 'name', label: 'Candidate', sortable: true }, { key: 'jobTitle', label: 'Job', sortable: true }, { key: 'aiScore', label: 'ATS', sortable: true }, { key: 'stage', label: 'Stage', sortable: true }, { key: 'recruiter', label: 'Recruiter', sortable: true }, { key: 'applied', label: 'Added', sortable: true }, ], [], ) const t = useDataTable({ columns, rows, pageSize: Math.max(rows.length, 1) }) const total = candidatesQuery.data?.total ?? 0 const pages = Math.max(1, Math.ceil(total / pageSize)) const from = total ? skip + 1 : 0 const to = total ? skip + rows.length : 0 const currentPage = Math.min(Math.floor(skip / pageSize) + 1, pages) useEffect(() => { if (total <= 0 || skip < total) return setSkip(Math.max(0, Math.floor((total - 1) / pageSize) * pageSize)) }, [total, pageSize, skip]) const recentChips = recentlyViewed .slice(0, 6) .map((id) => candidates.find((c) => c.id === id)) .filter(Boolean) const setFilter = (k, v) => { setFilters((f) => ({ ...f, [k]: v })) setSkip(0) } function openAts(c) { if (!c.userId) { toast('This candidate has no account to look a score up against', 'info') return } setAtsFor(c) } function toggleFav(c) { updateCandidates((cs) => cs.map((x) => (x.id === c.id ? { ...x, favorite: !x.favorite } : x))) setProfileFor((p) => (p && p.id === c.id ? { ...p, favorite: !p.favorite } : p)) toast(c.favorite ? 'Removed from favorites' : `${c.name} added to favorites`, 'success') } function advance(c) { const i = STAGE_ORDER.indexOf(c.stage) if (i === -1 || i >= STAGE_ORDER.length - 1) { toast(`${c.name} cannot be advanced further`, 'warning') return } const stage = STAGE_ORDER[i + 1] updateCandidates((cs) => cs.map((x) => (x.id === c.id ? { ...x, stage, status: stage } : x))) setProfileFor((p) => (p && p.id === c.id ? { ...p, stage, status: stage } : p)) toast(`${c.name} moved to ${stage}`, 'success') } /* After a manual add, the CV goes through the same persisted scoring pipeline CV Import and the profile ATS match use (POST /candidate/score): the score lands in the scored `candidates` table, and re-uploading the same bytes against the same job updates that row rather than duplicating it. The mutation lives here, not in AddCandidate, because the modal closes on save and an unmounted component's mutation callbacks never fire. */ const scoreCv = useMutation({ mutationFn: ({ jobPostId, file }) => candidatesApi.scoreUploads(jobPostId, [file]), onSuccess: (res) => { const row = Array.isArray(res?.data) ? res.data[0] : null if (row?.status === 'completed') { toast(`CV scored ${row.match_score}/100 against the applied job — saved to the pool`, 'success') } else { toast(`CV could not be scored${row?.error_code ? ` — ${row.error_code}` : ''}`, 'warning') } qc.invalidateQueries({ queryKey: qk.candidates.all() }) }, onError: (err) => toast(friendlyAuthError(err, 'Candidate saved, but CV scoring failed'), 'error'), }) return (
{total} application{total === 1 ? '' : 's'}} actions={<> } /> {recentChips.length > 0 && (
Recently viewed: {recentChips.map((c) => ( ))}
)}
setQ(e.target.value)} placeholder="Search name or email…" />
{/* One flex group so the label and select wrap together on phones instead of stranding "Sort:" at the end of the previous row. */}
{showFilters && (
{/* Job stays a toolbar dropdown, sent as assigned_job_post_id on GET /candidate/fetch. */} setFilter('stage', v)} any="Any stage" options={STAGE_FILTERS} /> setFilter('band', v)} any="Any band" options={BAND_FILTERS} /> setFilter('account', v)} any="Any account" options={['Active', 'Unconfirmed']} />
)}
{candidatesQuery.isPending && (
)} {candidatesQuery.isError && (
{friendlyAuthError(candidatesQuery.error, 'Request failed')}
)} {candidatesQuery.isSuccess && (
{t.pageRows.length === 0 ? ( ) : ( t.pageRows.map((c) => ( openProfile(c)} > )) )}
{candidates.length ? 'Try a different search, job, stage, or band filter.' : unscoped ? 'Import a CV or add a candidate to see score, stage, and recruiter on this table.' : requisitionScoped ? 'Candidates appear here when they are allocated to jobs opened from your requisitions.' : 'Candidates appear here when they are allocated to a job you created.'}
{c.name} {c.source === 'Form' && ( Form )}
{c.email || '—'}
{c.jobTitle || '—'} {c.stage ? {c.stage} : } {c.recruiter || 'Unassigned'} {c.applied ? fmtDate(c.applied) : '—'}
setSkip((p - 1) * pageSize)} pageButtons={pageWindow(currentPage, pages)} pageSize={pageSize} onPageSizeChange={(n) => { setPageSize(n); setSkip(0) }} pageSizeMax={500} />
)}
{atsFor && ( setAtsFor(null)} onProfile={(c) => { setAtsFor(null); openProfile(c) }} /> )} {profileFor && ( c.id === profileFor.id) ?? profileFor), initials: initialsOf(profileFor.name), color: avatarColor(profileFor.name), stage: profileFor.stage || 'Shortlist', userId: profileFor.userId || profileFor.id, }} atsScore={atsScore} recommendation={scoreQuery.data?.band ?? null} onClose={() => setProfileFor(null)} onAdvance={advance} onToggleFav={toggleFav} onAtsMatch={(c) => { setProfileFor(null); openAts(c) }} /> )} {adding && ( setAdding(false)} onSave={({ jobPostId, file } = {}) => { setAdding(false) toast('Candidate added to pipeline', 'success') if (jobPostId && file) scoreCv.mutate({ jobPostId, file }) }} onInvalid={() => toast('Please fix the highlighted fields', 'error')} /> )}
) } function Facet({ label, value, onChange, any, options, labels }) { return (
) } const asList = (value) => (Array.isArray(value) ? value : []) /** ISO stamp -> display date; ats_results.computed_at is a string, fmtDate takes a Date. */ function fmtStamp(value) { return fmtDate(value) || null } /** * The whole ATS result for one candidate, assembled from the two places it lives. * * It CANNOT render from the `candidate` prop on this screen: a row here is a * `users` account and toCandidateUserView leaves score, keywords and critique * null by construction, so the modal used to paint an empty shell. It reads the * same two sources ScoredCandidateProfile does, under the same query keys, so * opening it from that profile is a cache hit rather than two more requests: * * - ats_results (GET /pipeline/candidate/score/fetch) — overall_score, band, * the job post the score was computed against, and when. * - the detail payload (GET /candidate/fetch?user_id=) — matched/missing * keywords and the critique, which the route resolves off the scored * `candidates` row: by candidate_id, or by email+job when the CV's address * matched a user account and candidate_id is therefore NULL * (backend/job/candidate/views.py:782-792). * * The prop is the last fallback, for callers whose rows already carry a score * (Talent Pool cards, the scored leaderboard). * * Exported so TalentPool's profile modal can open the same ATS breakdown. */ export function AtsMatch({ candidate: c, jobTitle, onClose, onProfile }) { const userId = c.userId ?? null const detail = useQuery({ queryKey: qk.candidates.detail(userId), queryFn: async () => candidatesApi.toRows(await candidatesApi.getByUserId(userId))[0] ?? null, enabled: Boolean(userId), }) const ats = useQuery({ queryKey: qk.pipeline.candidateScore({ userId }), queryFn: () => pipelineApi.fetchCandidateScore({ userId }), select: pipelineApi.toAtsScore, enabled: Boolean(userId), }) const { data: jobTitles } = useJobTitles() const live = detail.data ?? null const row = ats.data ?? null // ats_results wins over the detail payload's denormalised copy, because it is // the row the copy is made from; the prop is the fallback for rows that came // from the scored leaderboard already carrying one. const score = row?.overall_score ?? live?.ai_score ?? c.aiScore ?? null const matched = asList(live?.matched_keywords).length ? asList(live.matched_keywords) : asList(c.matchedSkills) const missing = asList(live?.missing_keywords).length ? asList(live.missing_keywords) : asList(c.missingSkills) const critique = live?.summary_critique ?? c.critique ?? null const scoredJobId = row?.job_post_id ?? live?.scored_job_post_id ?? null const against = (scoredJobId && jobTitles?.get(String(scoredJobId))) || live?.job_title || (jobTitle && jobTitle !== '—' ? jobTitle : null) const scoredOn = fmtStamp(row?.computed_at ?? live?.scored_at) const recommendation = row?.band || live?.recommendation || recommendationOf({ aiScore: score }) const recCls = recommendation === 'Strong Match' ? 'recc-strong' : recommendation === 'Potential Match' ? 'recc-potential' : 'recc-weak' const ringColor = score >= 82 ? 'var(--success)' : score >= 65 ? 'var(--warning)' : 'var(--danger)' const pending = Boolean(userId) && (ats.isPending || detail.isPending) return ( } > {pending ? ( Fetching the ATS result. ) : score == null ? ( {ats.isError ? friendlyAuthError(ats.error, 'The ATS result could not be loaded.') : 'This candidate has not been scored against a job post.'} ) : (<>
{recommendation}
{c.name}{against ? ` for ${against}` : ''}
{/* overall_score is a float column; the ring and the number want an int. */}
{Math.round(score)}
ATS MATCH

Assessment

{critique ?? '—'}

Scored Against
{against ?? '—'}
Scored On
{scoredOn ?? '—'}

Matched Skills ({matched.length})

{matched.length ? matched.map((s) => ( {s} )) : }

Missing Skills ({missing.length})

{missing.length ? missing.map((s) => ( {s} )) : None — full match}

Scored by the ATS engine against the job post's requirements. Matched skills are verified to appear in the resume text; the one-line assessment is model-generated and evidence-based.

)} ) } /** * One selectable role, drawn the way Job Matching draws its suggested roles * (Matching.jsx::JobCard) minus the AI-rank tag and resume highlighting — the * CV is parsed server-side after submit, so there is no extracted text to * light requirement chips against yet. */ function RoleCard({ post, selected, onSelect, disabled }) { const meta = [ post?.employment_type, post?.location, post?.experience_min != null || post?.experience_max != null ? `${post?.experience_min ?? '?'}–${post?.experience_max ?? '?'} yrs` : null, ].filter(Boolean).join(' · ') return (
!disabled && onSelect(String(post.id))} onKeyDown={(e) => { if (disabled) return if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() onSelect(String(post.id)) } }} style={{ cursor: disabled ? 'default' : 'pointer', borderColor: selected ? 'var(--primary)' : undefined, boxShadow: selected ? 'var(--ring)' : undefined, marginBottom: 8, alignItems: 'flex-start', }} >
{post.title}
{post.status || 'draft'} {selected && }
{meta &&
{meta}
} {(post.requirements || []).length > 0 && (
{(post.requirements || []).slice(0, 8).map((req) => ( {req} ))}
)}
) } /** * Add Candidate — the only writer on this screen that reaches the server. * * POST /candidate/create/candidate persists the row and, for an unseen email, * the `users` record behind it. The CV is not optional there: the route requires * the file and refuses it when no text can be extracted, so the dropzone below * the fields is part of the contract rather than a convenience. * * Applied Job lists LIVE job posts (/job/fetch), not the seed catalogue, because * job_post_id is a job_posts FK and a seed id would be coerced to NULL without * an error — the link would look saved and simply not exist. * * Manual rows do not pass through `inbox`, so /candidate/fetch may not surface * them immediately; the save still invalidates the candidates query so the * live-backed screens refetch and pick the row up once an application links it. * * On success the CV and job go back to the parent (onSave), which scores the * file against that job via POST /candidate/score. The Matching-style role * cards above the dropzone are that "what to score against" choice — which is * why the picker sits with the CV rather than among the identity fields. */ function AddCandidate({ onClose, onSave, onInvalid }) { const { toast } = useToast() const qc = useQueryClient() const fileInput = useRef(null) const [cv, setCv] = useState(null) const [dragging, setDragging] = useState(false) const postsQuery = useQuery({ queryKey: qk.jobPosts.list({ top: JOB_POST_LIMIT }), queryFn: async () => { const res = await jobPostsApi.list({ top: JOB_POST_LIMIT }) return Array.isArray(res?.data) ? res.data : [] }, }) const posts = postsQuery.data ?? [] const form = useFormState({ name: '', email: '', phone: '', job: '', experience: '3', company: '', position: '', source: sources[0], stage: stages[0], referral: '', }) const lookupEmail = (form.values.email || '').trim().toLowerCase() const [debouncedEmail, setDebouncedEmail] = useState('') useEffect(() => { const timer = setTimeout(() => setDebouncedEmail(lookupEmail), 400) return () => clearTimeout(timer) }, [lookupEmail]) const emailLooksValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(debouncedEmail) const priorQuery = useQuery({ queryKey: qk.candidates.applications(debouncedEmail), queryFn: async () => { const res = await candidatesApi.fetchApplicationHistory(debouncedEmail) return res?.data ?? null }, enabled: emailLooksValid, staleTime: 30_000, }) const priorHistory = priorQuery.data const priorRow = priorHistory?.found ? { is_reapplicant: Boolean(priorHistory.is_reapplicant), previous_applications: Array.isArray(priorHistory.applications) ? priorHistory.applications : [], } : null // Defaulting by derivation rather than in an effect: the picker resolves after // first paint, and useFormState's setters are new every render, so seeding the // field from an effect would either loop or need a ref to guard it. const jobPostId = form.values.job || (posts[0] ? String(posts[0].id) : '') const [roleSearch, setRoleSearch] = useState('') // Client-side filter: the posts are already fetched, and a PickRoleModal-style // server search would refetch on every keystroke for the same rows. const visiblePosts = useMemo(() => { const needle = roleSearch.trim().toLowerCase() if (!needle) return posts return posts.filter((p) => ( (p.title || '').toLowerCase().includes(needle) || (p.location || '').toLowerCase().includes(needle) )) }, [posts, roleSearch]) const create = useMutation({ mutationFn: (vars) => candidatesApi.createManual(vars), onError: (err) => toast(friendlyAuthError(err, 'Could not add the candidate.'), 'error'), onSuccess: (_res, vars) => { // The new user_id lands in /candidate/fetch's join the moment an // application exists for them, so let the live-backed screens refetch. qc.invalidateQueries({ queryKey: qk.candidates.all() }) // Hand the file and job back so the parent can score the CV — from the // mutate vars, not local state, so a mid-flight field edit cannot skew it. onSave({ jobPostId: vars.jobPostId, file: vars.file }) }, }) function pickFile(next) { if (!next) return const name = (next.name || '').toLowerCase() const mime = (next.type || '').toLowerCase() // Gate at the picker — never hold a non-PDF in state or post it. if (!name.endsWith('.pdf')) { setCv(null) form.setErrors((prev) => ({ ...prev, cv: 'Only PDF resumes are allowed' })) toast('Only PDF files are allowed', 'error') return } if (mime && mime !== 'application/pdf' && mime !== 'application/x-pdf') { setCv(null) form.setErrors((prev) => ({ ...prev, cv: 'Only PDF MIME types are allowed' })) toast('Only PDF files are allowed', 'error') return } setCv(next) form.setErrors((prev) => { if (!prev.cv) return prev const rest = { ...prev } delete rest.cv return rest }) } function submit() { if (create.isPending) return const v = form.values const errors = {} if (!v.name.trim()) errors.name = 'Required' if (!/^\S+@\S+\.\S+$/.test(v.email)) errors.email = 'Valid email required' // Only enforceable when the picker actually has something to pick — the // column is nullable server-side. if (posts.length && !jobPostId) errors.job = 'Required' if (!cv) errors.cv = 'Attach the candidate’s CV' else if (!/\.pdf$/i.test(cv.name)) errors.cv = 'Only PDF resumes can be parsed' else if (cv.size > MAX_CV_MB * 1024 * 1024) errors.cv = `Keep the file under ${MAX_CV_MB} MB` // Optional: trimmed first, so a field holding only spaces is genuinely empty // and passes rather than failing the pattern. Anything left must be a // company address — the pattern rejects interior spaces on its own. const referral = referralValue(v.referral) if (referral && !REFERRAL_RE.test(referral)) { errors.referral = `Must be a @${REFERRAL_DOMAIN} address` } form.setErrors(errors) if (Object.keys(errors).length) { onInvalid() return } create.mutate({ file: cv, name: v.name, email: v.email, phone: v.phone, jobPostId, company: v.company, currentPosition: v.position, source: v.source, experience: v.experience, stage: v.stage, referralBy: referral, }) } const field = (n) => ({ value: form.values[n], onChange: (e) => form.setField(n, e.target.value) }) return ( } >
{ e.preventDefault(); submit() }}> {priorHistory?.found && priorRow?.previous_applications.length > 0 && ( )} {priorHistory?.found && !(priorRow?.previous_applications.length) && (
This email already has a candidate account {priorHistory.user?.name ? ` (${priorHistory.user.name})` : ''}.
)}
{form.errors.name}
{form.errors.email}
{/* Optional, and deliberately not gated on Source === 'Referral': a referrer is worth recording whenever there is one, and referrals routinely arrive tagged as LinkedIn or Company Site. */}
form.setField('referral', referralValue(e.target.value))} className={form.errors.referral ? 'err' : ''} placeholder={`name@${REFERRAL_DOMAIN}`} /> {form.errors.referral}
{/* Role selection sits directly above the CV because it is what the CV gets scored against — same card UI as Job Matching's role list. (.req is scoped to `.form-field label .req`, so tint it here.) */}

Applied Job *

{posts.length > 0 && (
setRoleSearch(e.target.value)} placeholder="Search title or location…" />
)} {postsQuery.isPending && ( Fetching open job posts. )} {postsQuery.isError && ( {friendlyAuthError(postsQuery.error, 'Request failed')} )} {postsQuery.isSuccess && posts.length === 0 && ( The candidate is saved without a role link — create a job post first to score CVs. )} {visiblePosts.length > 0 && (
{visiblePosts.map((p) => ( form.setField('job', id)} disabled={create.isPending} /> ))}
)} {postsQuery.isSuccess && posts.length > 0 && visiblePosts.length === 0 && ( Try a different search. )} {form.errors.job}

CV / Resume *

{ pickFile(e.target.files?.[0]); e.target.value = '' }} />
{ if (!create.isPending) fileInput.current?.click() }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.current?.click() } }} onDragOver={(e) => { e.preventDefault(); setDragging(true) }} onDragLeave={() => setDragging(false)} onDrop={(e) => { e.preventDefault() setDragging(false) pickFile(e.dataTransfer.files?.[0]) }} >

Drop the CV here or click to browse

PDF only · text-based resumes · up to {MAX_CV_MB} MB

{cv && (
{cv.name}
{Math.max(1, Math.round(cv.size / 1024))} KB
)} {form.errors.cv}
) }