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

495 lines
20 KiB
JavaScript

/* ============================================================
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 (
<div className="page">
<PageHeader
title="CV Import"
sub="Upload resume PDFs — score them against a job, or store them in the CV bank"
actions={<span className="integration-status pending"><span className="pulse" />AI Resume Scoring · Live</span>}
/>
<div className="grid g-2-1">
<div>
<div className="card mb-18">
<div className="card-body">
<div className="flex items-center gap-8 mb-16">
<span className="fw-600 text-sm" style={{ flexShrink: 0 }}>Score against</span>
<select
className="select flex-1"
value={jobId}
onChange={(e) => setJobId(e.target.value)}
>
<option value="">Select a job post</option>
<option value={NO_JOB}>No job store in CV bank</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} scored` : ''}
{stored ? ` · ${stored} stored` : ''}
{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' || i.status === 'Storing') && (
<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 === 'Stored' && (
<div className="cell-sub" style={{ marginTop: 4 }}>
{i.email ? `Candidate email: ${i.email}` : 'No email in the CV — stored anyway'}
</div>
)}
{i.status === 'Failed' && (
<div className="cell-sub" style={{ marginTop: 4 }}>{i.error || 'Could not be processed'}</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 === 'Storing' && <Badge className="b-blue">Storing</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>}
{i.status === 'Stored' && <Badge className="b-green">Stored</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 to score against or pick No job store in CV bank above to just save CVs.
</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">
{(noJobMode ? STORE_STEPS : SCORE_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>
{/* No-Job mode swaps the scored grid for the bank itself. */}
{noJobMode ? (
<CvBank />
) : (
/* 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. */
<JobCandidates jobId={jobId} jobTitle={selectedJob?.title} />
)}
</div>
)
}
/* The stored-CV bank — CVs imported with No job. Unassigned rows live here;
assigning a job in Job Matching sets job_post_id and they leave this list. */
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 (
<div className="card mt-18">
<div className="card-head">
<div>
<h3>CV Bank</h3>
<span className="ch-sub">
{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'}
</span>
</div>
</div>
<div className="card-body">
{bankQuery.isLoading && <p className="text-muted text-sm">Loading stored CVs</p>}
{bankQuery.isError && (
<p className="text-muted text-sm">{friendlyAuthError(bankQuery.error, 'Could not load the CV bank')}</p>
)}
{bankQuery.isSuccess && rows.length === 0 && (
<EmptyState icon="file" title="The CV bank is empty">
Drop CVs above with No job store in CV bank selected and they will be kept here.
</EmptyState>
)}
{rows.map((r) => (
<div className="upload-row" key={r.id}>
<span className="attach-icn" style={{ width: 38, height: 38 }}><Icon name="file" /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="fw-600 text-sm">{r.file_name || 'CV'}</div>
<div className="cell-sub">
{r.candidate_email || 'No email detected'}
{r.created_at ? ` · added ${new Date(r.created_at).toLocaleDateString()}` : ''}
</div>
{r.linkedin_url ? (
<div className="cell-sub" style={{ marginTop: 2 }}>
<a href={r.linkedin_url} target="_blank" rel="noopener noreferrer">{r.linkedin_url}</a>
</div>
) : null}
{r.file_path ? (
<div className="cell-sub truncate" title={r.file_path} style={{ marginTop: 2 }}>
{r.file_path}
</div>
) : (
<div className="cell-sub" style={{ marginTop: 2 }}>No S3 path yet</div>
)}
</div>
<div className="flex items-center gap-8" style={{ flexShrink: 0 }}>
<OpenResumeButton filePath={r.file_path} className="btn btn-secondary btn-sm" />
<button className="act-btn" data-tip="View" aria-label="View CV" onClick={() => view(r)}>
<Icon name="eye" />
</button>
<button className="act-btn" data-tip="Download" aria-label="Download CV" onClick={() => download(r)}>
<Icon name="download" />
</button>
<button
className="act-btn"
data-tip="Remove"
aria-label="Remove CV from bank"
disabled={removing.isPending}
onClick={() => {
if (window.confirm(`Remove “${r.file_name}” from the CV bank? The file is deleted permanently.`)) {
removing.mutate(r.id)
}
}}
>
<Icon name="trash" />
</button>
</div>
</div>
))}
</div>
{preview && (
<Modal
title={preview.name}
subtitle="CV preview"
size="modal-lg"
onClose={closePreview}
footer={
<button className="btn btn-secondary" onClick={closePreview}>Close</button>
}
>
{/* Blob URL re-typed to application/pdf, so the browser's built-in
viewer renders inline instead of triggering a download. */}
<iframe
src={preview.url}
title={`Preview of ${preview.name}`}
style={{ width: '100%', height: '70vh', border: 'none', borderRadius: 10, background: 'var(--bg-sunken)' }}
/>
</Modal>
)}
</div>
)
}