HR-ATS-Portal/frontend/src/screens/CvImport.jsx

268 lines
11 KiB
JavaScript

/* ============================================================
CV Import — real upload → score → persist flow.
Files go to POST /candidate/score as one multipart batch: the backend
extracts each PDF, scores it against the selected job with the ATS engine,
and persists a row per file. Unreadable/oversized/non-PDF files come back
as status "failed" rows instead of failing the batch, and re-uploading the
same bytes updates the existing record (content-hash dedupe) — so there is
no separate "import" step and no duplicate modal anymore.
============================================================ */
import { useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
const 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' },
]
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')
},
})
function handleFiles(fileList) {
const all = Array.from(fileList || [])
if (!all.length) return
if (!jobId) {
toast('Select a job to score against first', 'warning')
return
}
const files = all.filter((f) => f.name.toLowerCase().endsWith('.pdf'))
const skipped = all.length - files.length
if (skipped) toast(`Only PDF resumes are supported — ${skipped} file(s) skipped`, 'warning')
if (!files.length) return
const items = files.map((f) => ({
id: `UP-${++rowSeq}-${Date.now()}`,
name: f.name,
file: f.name,
size: fmtSize(f.size),
status: 'Scoring',
atsScore: null,
critique: null,
error: null,
}))
setQueue((q) => [...q, ...items])
scoring.mutate({ job: jobId, files, rowIds: items.map((i) => i.id) })
}
const scored = queue.filter((i) => i.status === 'Ready').length
const failed = queue.filter((i) => i.status === 'Failed').length
const selectedJob = jobs.find((j) => j.id === jobId)
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">CV Import</h1>
<p className="page-sub">Upload resume PDFs parsed, scored against a job, and saved automatically</p>
</div>
<div className="page-head-actions">
<span className="integration-status pending"><span className="pulse" />AI Resume Scoring · Live</span>
</div>
</div>
<div className="grid g-2-1">
<div>
<div className="card mb-18">
<div className="card-body">
<div className="flex items-center gap-8" style={{ marginBottom: 16 }}>
<span className="fw-600 text-sm" style={{ flexShrink: 0 }}>Score against</span>
<select
className="select"
style={{ flex: 1 }}
value={jobId}
onChange={(e) => setJobId(e.target.value)}
>
<option value="">Select a job post</option>
{jobs.map((j) => <option key={j.id} value={j.id}>{j.title}</option>)}
</select>
</div>
{jobsQuery.isError && (
<p className="text-muted text-sm" style={{ marginBottom: 12 }}>
{friendlyAuthError(jobsQuery.error, 'Could not load job posts')}
</p>
)}
<div
className={`dropzone${dragging ? ' drag' : ''}`}
onClick={() => fileInput.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault()
setDragging(false)
handleFiles(e.dataTransfer.files)
}}
>
<input
ref={fileInput}
type="file"
accept=".pdf,application/pdf"
multiple
hidden
onChange={(e) => { handleFiles(e.target.files); e.target.value = '' }}
/>
<div className="dz-icn"><Icon name="upload" /></div>
<h3>Drag &amp; drop resumes here</h3>
<p className="text-muted" style={{ marginBottom: 16 }}>
or click to browse PDF only · up to 50 files per batch
</p>
<button
className="btn btn-primary"
onClick={(e) => { e.stopPropagation(); fileInput.current?.click() }}
>
<Icon name="upload" /> Browse Files
</button>
<div className="flex items-center gap-8" style={{ justifyContent: 'center', marginTop: 16 }}>
<span className="badge b-gray badge-plain">PDF</span>
<span className="text-muted text-sm">DOC / DOCX support coming later</span>
</div>
</div>
</div>
</div>
{queue.length > 0 && (
<div className="card">
<div className="card-head">
<div>
<h3>Processing Queue</h3>
<span className="ch-sub">
{queue.length} file{queue.length === 1 ? '' : 's'} · {scored} scored
{failed ? ` · ${failed} failed` : ''}
{selectedJob ? ` · vs ${selectedJob.title}` : ''}
</span>
</div>
</div>
<div className="card-body">
{queue.map((i) => (
<div className="upload-row" key={i.id}>
<span className="attach-icn" style={{ width: 38, height: 38 }}><Icon name="file" /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="flex items-center gap-8">
<span className="fw-600 text-sm">{i.name}</span>
</div>
<div className="cell-sub">{i.file} · {i.size}</div>
{i.status === 'Scoring' && (
<div className="upload-progress" style={{ marginTop: 6 }}>
<div className="upload-progress-fill" style={{ width: '66%' }} />
</div>
)}
{i.status === 'Ready' && i.critique && (
<div className="cell-sub" style={{ marginTop: 4 }}>{i.critique}</div>
)}
{i.status === 'Failed' && (
<div className="cell-sub" style={{ marginTop: 4 }}>Could not be scored</div>
)}
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
{i.status === 'Ready' && <ScoreChip score={i.atsScore} />}
{i.status === 'Scoring' && <Badge className="b-blue">Scoring</Badge>}
{i.status === 'Failed' && <Badge className="b-red">{i.error}</Badge>}
</div>
<div style={{ flexShrink: 0 }}>
{i.status === 'Ready' && <Badge className="b-green">Saved</Badge>}
</div>
</div>
))}
</div>
</div>
)}
{queue.length === 0 && jobsQuery.isSuccess && jobs.length === 0 && (
<EmptyState icon="briefcase" title="No job posts yet">
Create a job post first resumes are always scored against a job.
</EmptyState>
)}
</div>
<div className="card" style={{ alignSelf: 'start' }}>
<div className="card-head"><div><h3>Auto-Processing</h3><span className="ch-sub">What happens on upload</span></div></div>
<div className="card-body">
<div className="timeline">
{STEPS.map((s) => (
<div className="tl-item" key={s.t}>
<div className="tl-dot"><Icon name={s.i} /></div>
<div className="tl-title">{s.t}</div>
<div className="tl-desc">{s.d}</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
)
}