/* ============================================================ CV Import — two modes behind one dropzone. Score mode: files go to POST /candidate/score as one multipart batch — each PDF is extracted, scored against the selected job, persisted a row per file. Unreadable/oversized files come back as "failed" rows, and re-uploading the same bytes updates the existing record. No-Job mode ("store in CV bank"): each file goes to POST /candidate/cv-bank/upload individually — parsed, stored as a bank row, and uploaded to S3 under Temp/{id}/. file_path is the permanent object URL. ============================================================ */ import { useRef, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import Modal from '../ui/Modal' import OpenResumeButton from '../ui/OpenResumeButton' import PageHeader from '../ui/PageHeader' import { Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives' import { useToast } from '../ui/Toast' import JobCandidates from './JobCandidates' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' import * as candidatesApi from '../api/candidates' import * as s3Api from '../api/s3' /* Sentinel for the job picker: store CVs without scoring or assignment. */ const NO_JOB = '__none__' const SCORE_STEPS = [ { i: 'file', t: 'Resume parsing', d: 'PDF text extraction with layout cleanup' }, { i: 'target', t: 'ATS scoring', d: 'LLM match score with matched & missing skills vs the selected job' }, { i: 'users', t: 'Duplicate detection', d: 'Re-uploading the same file updates its existing record' }, { i: 'user-plus', t: 'Saved to pool', d: 'Results persist — see Candidates and Talent Pool' }, ] const STORE_STEPS = [ { i: 'file', t: 'Resume parsing', d: 'PDF text extraction with layout cleanup' }, { i: 'users', t: 'Details captured', d: 'Candidate email is picked up when the CV contains one' }, { i: 'target', t: 'Nothing else happens', d: 'No scoring, no candidate account, no inbox entry — just stored' }, { i: 'user-plus', t: 'Saved to CV bank', d: 'Browse, download or remove stored CVs in the bank below' }, ] async function fetchJobs() { const res = await candidatesApi.listJobs() const rows = Array.isArray(res?.data) ? res.data : [] return rows.map((row) => ({ id: row.id, title: row.title })) } function fmtSize(bytes) { if (!Number.isFinite(bytes)) return '' if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB` return `${(bytes / (1024 * 1024)).toFixed(1)} MB` } let rowSeq = 0 export default function CvImport() { const { toast } = useToast() const qc = useQueryClient() const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs }) const jobs = jobsQuery.data ?? [] const [jobId, setJobId] = useState('') const [queue, setQueue] = useState([]) const [dragging, setDragging] = useState(false) const fileInput = useRef(null) const scoring = useMutation({ mutationFn: ({ job, files }) => candidatesApi.scoreUploads(job, files), onSuccess: (res, vars) => { const rows = Array.isArray(res?.data) ? res.data : [] setQueue((q) => q.map((item) => { if (!vars.rowIds.includes(item.id)) return item const match = rows.find((r) => r.filename === item.file) if (!match) return { ...item, status: 'Failed', error: 'NO_RESULT' } if (match.status !== 'completed') { return { ...item, status: 'Failed', error: match.error_code || 'FAILED' } } return { ...item, status: 'Ready', name: match.candidate_name || item.file, atsScore: match.match_score, critique: match.summary_critique, } }), ) qc.invalidateQueries({ queryKey: qk.candidates.all() }) const ok = rows.filter((r) => r.status === 'completed').length const failed = rows.length - ok toast( failed ? `${ok} scored, ${failed} failed — results saved to the candidate pool` : `${ok} resume${ok === 1 ? '' : 's'} scored and saved`, failed ? 'warning' : 'success', ) }, onError: (err, vars) => { setQueue((q) => q.map((item) => vars.rowIds.includes(item.id) ? { ...item, status: 'Failed', error: 'REQUEST_FAILED' } : item, ), ) toast(friendlyAuthError(err, 'Scoring failed'), 'error') }, }) /* No-Job mode: one request per file, so one unreadable CV fails alone and the rest of the batch still lands in the bank. */ const storing = useMutation({ mutationFn: async ({ files, rowIds }) => { const results = [] for (let k = 0; k < files.length; k++) { try { const res = await candidatesApi.uploadToCvBank(files[k]) results.push({ rowId: rowIds[k], ok: true, email: res?.data?.candidate_email ?? null }) } catch (err) { results.push({ rowId: rowIds[k], ok: false, error: friendlyAuthError(err, 'FAILED') }) } } return results }, onSuccess: (results) => { setQueue((q) => q.map((item) => { const r = results.find((x) => x.rowId === item.id) if (!r) return item return r.ok ? { ...item, status: 'Stored', email: r.email } : { ...item, status: 'Failed', error: r.error } }), ) qc.invalidateQueries({ queryKey: qk.cvBank.all() }) const ok = results.filter((r) => r.ok).length const bad = results.length - ok toast( bad ? `${ok} stored, ${bad} failed — see the queue for details` : `${ok} CV${ok === 1 ? '' : 's'} stored in the CV bank`, bad ? 'warning' : 'success', ) }, }) function handleFiles(fileList) { const all = Array.from(fileList || []) if (!all.length) return if (!jobId) { toast('Select a job — or "No job" to just store the CVs', 'warning') return } const bad = all.filter((f) => { const name = (f.name || '').toLowerCase() const mime = (f.type || '').toLowerCase() if (!name.endsWith('.pdf')) return true if (mime && mime !== 'application/pdf' && mime !== 'application/x-pdf') return true return false }) if (bad.length) { toast('Only PDF files are allowed — remove non-PDF uploads and try again', 'error') return } const files = all const noJob = jobId === NO_JOB const items = files.map((f) => ({ id: `UP-${++rowSeq}-${Date.now()}`, name: f.name, file: f.name, size: fmtSize(f.size), status: noJob ? 'Storing' : 'Scoring', atsScore: null, critique: null, email: null, error: null, })) setQueue((q) => [...q, ...items]) const rowIds = items.map((i) => i.id) if (noJob) storing.mutate({ files, rowIds }) else scoring.mutate({ job: jobId, files, rowIds }) } const scored = queue.filter((i) => i.status === 'Ready').length const stored = queue.filter((i) => i.status === 'Stored').length const failed = queue.filter((i) => i.status === 'Failed').length const selectedJob = jobs.find((j) => j.id === jobId) const noJobMode = jobId === NO_JOB return (
AI Resume Scoring · Live} />
Score against
{jobsQuery.isError && (

{friendlyAuthError(jobsQuery.error, 'Could not load job posts')}

)}
fileInput.current?.click()} onDragOver={(e) => { e.preventDefault(); setDragging(true) }} onDragLeave={() => setDragging(false)} onDrop={(e) => { e.preventDefault() setDragging(false) handleFiles(e.dataTransfer.files) }} > { handleFiles(e.target.files); e.target.value = '' }} />

Drag & drop resumes here

or click to browse — PDF only · up to 50 files per batch

PDF DOC / DOCX support coming later
{queue.length > 0 && (

Processing Queue

{queue.length} file{queue.length === 1 ? '' : 's'} {scored ? ` · ${scored} scored` : ''} {stored ? ` · ${stored} stored` : ''} {failed ? ` · ${failed} failed` : ''} {selectedJob ? ` · vs ${selectedJob.title}` : ''}
{queue.map((i) => (
{i.name}
{i.file} · {i.size}
{(i.status === 'Scoring' || i.status === 'Storing') && (
)} {i.status === 'Ready' && i.critique && (
{i.critique}
)} {i.status === 'Stored' && (
{i.email ? `Candidate email: ${i.email}` : 'No email in the CV — stored anyway'}
)} {i.status === 'Failed' && (
{i.error || 'Could not be processed'}
)}
{i.status === 'Ready' && } {i.status === 'Scoring' && Scoring…} {i.status === 'Storing' && Storing…} {i.status === 'Failed' && {i.error}}
{i.status === 'Ready' && Saved} {i.status === 'Stored' && Stored}
))}
)} {queue.length === 0 && jobsQuery.isSuccess && jobs.length === 0 && ( Create a job post to score against — or pick “No job — store in CV bank” above to just save CVs. )}

Auto-Processing

What happens on upload
{(noJobMode ? STORE_STEPS : SCORE_STEPS).map((s) => (
{s.t}
{s.d}
))}
{/* No-Job mode swaps the scored grid for the bank itself. */} {noJobMode ? ( ) : ( /* Everything ever scored against the selected job — this batch, earlier uploads and synced inbox CVs alike. The scoring mutation invalidates qk.candidates.all(), so the grid refreshes as each batch lands. */ )}
) } /* The stored-CV bank — a private store with no job, account or inbox entry. This list is the bank's home: browse, download, or remove; picking a CV up for a job later is a future action. */ function CvBank() { const { toast } = useToast() const qc = useQueryClient() const [preview, setPreview] = useState(null) // { name, url } — url is an object URL we own const bankQuery = useQuery({ queryKey: qk.cvBank.list(), queryFn: () => candidatesApi.listCvBank({ top: 200 }), }) const rows = Array.isArray(bankQuery.data?.data) ? bankQuery.data.data : [] async function view(row) { const tab = s3Api.canOpen(row.file_path) ? window.open('about:blank', '_blank') : null try { if (s3Api.canOpen(row.file_path)) { await s3Api.openPdf(row.file_path, { tab }) return } const url = await candidatesApi.viewCvBankCv(row.id) if (!url) { toast('The CV file could not be found', 'error') return } setPreview({ name: row.file_name || 'CV', url }) } catch (err) { if (tab && !tab.closed) tab.close() toast(friendlyAuthError(err, 'Could not open the CV'), 'error') } } function closePreview() { if (preview) URL.revokeObjectURL(preview.url) setPreview(null) } const removing = useMutation({ mutationFn: (id) => candidatesApi.deleteCvBankCv(id), onSuccess: () => { qc.invalidateQueries({ queryKey: qk.cvBank.all() }) toast('CV removed from the bank', 'success') }, onError: (err) => toast(friendlyAuthError(err, 'Could not remove the CV'), 'error'), }) async function download(row) { try { await candidatesApi.downloadCvBankCv(row.id) } catch (err) { toast(friendlyAuthError(err, 'Could not download the CV'), 'error') } } return (

CV Bank

{bankQuery.isSuccess ? `${bankQuery.data?.total ?? rows.length} stored CV${(bankQuery.data?.total ?? rows.length) === 1 ? '' : 's'} · no job attached` : 'Stored CVs with no job attached'}
{bankQuery.isLoading &&

Loading stored CVs…

} {bankQuery.isError && (

{friendlyAuthError(bankQuery.error, 'Could not load the CV bank')}

)} {bankQuery.isSuccess && rows.length === 0 && ( Drop CVs above with “No job — store in CV bank” selected and they will be kept here. )} {rows.map((r) => (
{r.file_name || 'CV'}
{r.candidate_email || 'No email detected'} {r.created_at ? ` · added ${new Date(r.created_at).toLocaleDateString()}` : ''}
{r.file_path ? (
{r.file_path}
) : (
No S3 path yet
)}
))}
{preview && ( Close } > {/* Blob URL re-typed to application/pdf, so the browser's built-in viewer renders inline instead of triggering a download. */}