/* ============================================================ 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 (
{friendlyAuthError(jobsQuery.error, 'Could not load job posts')}
)}or click to browse — PDF only · up to 50 files per batch
Loading stored CVs…
} {bankQuery.isError && ({friendlyAuthError(bankQuery.error, 'Could not load the CV bank')}
)} {bankQuery.isSuccess && rows.length === 0 && (