Phase 2: wire frontend candidate screens to the real scoring API
Backend: GET /job/fetch (active job posts, job-board OR candidate viewers) and /candidate/fetch now works unscoped for the cross-job pool. Frontend: apiClient gains FormData support; new api/candidates.js with a shared snake->camel view mapper; CvImport is a real upload->score flow (job selector, PDF multipart to /candidate/score, per-file results, no more simulation); TalentPool and Candidates render the persisted pool with job/skill/source/ATS filters; the ATS modal shows the real critique and matched/missing skills; CandidateProfile keeps only tabs the backend can back. Screens hide affordances with no backing column instead of rendering placeholders (Inbox precedent). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>Dashboard_Wiring
parent
a68d4bc2f6
commit
9ffa1ef673
|
|
@ -6,6 +6,8 @@ from job.candidate.views import CandidateScoring,FileRead
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from users.permissions import PermissionTag, require_permission
|
||||
from job.job_post.views import JobPost,JobPostCreate
|
||||
from job.job_post.models import JobPosts
|
||||
from job.job_post.serializers import serialize_job_post
|
||||
import logging
|
||||
from job.job_post.plugins import PlatformAlias
|
||||
from fastapi import UploadFile, File, Form
|
||||
|
|
@ -146,11 +148,12 @@ async def score_inbox_candidates(
|
|||
|
||||
@router.get("/candidate/fetch")
|
||||
async def fetch_candidates(
|
||||
job_id: str = Query(...),
|
||||
job_id: str = Query(None),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Persisted leaderboard for a job: completed by score desc, failures last."""
|
||||
"""Persisted leaderboard: completed by score desc, failures last. Without job_id
|
||||
returns the whole pool across jobs."""
|
||||
try:
|
||||
service=CandidateScoring(session=session)
|
||||
data=await service.fetch_candidates(job_id)
|
||||
|
|
@ -161,6 +164,27 @@ async def fetch_candidates(
|
|||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.get("/job/fetch")
|
||||
async def fetch_jobs(
|
||||
current_user: dict = Depends(
|
||||
require_permission(
|
||||
PermissionTag.JOB_BOARD_VIEW, PermissionTag.CANDIDATES_VIEW, require_all=False
|
||||
)
|
||||
),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Active job posts, for pickers and boards. Either job-board or candidate viewers
|
||||
may list them — recruiters scoring CVs need a job to score against."""
|
||||
try:
|
||||
rows=await JobPosts.get_active_job_posts(session)
|
||||
data=[serialize_job_post(row) for row in rows]
|
||||
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.get("/candidate/fetch_by_id")
|
||||
async def fetch_candidate_by_id(
|
||||
candidate_id: str = Query(...),
|
||||
|
|
|
|||
|
|
@ -67,20 +67,24 @@ class Candidates(SQLModel, table=True):
|
|||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def get_candidates_by_job(cls, session: AsyncSession, job_id: str):
|
||||
"""Leaderboard order: completed by score desc, failures last, ties stable."""
|
||||
uid = cls._as_uuid(job_id)
|
||||
if uid is None:
|
||||
return []
|
||||
result = await session.execute(
|
||||
select(cls)
|
||||
.where(cls.job_id == uid)
|
||||
.order_by(
|
||||
cls.status.asc(), # "completed" < "failed"
|
||||
cls.match_score.desc().nulls_last(),
|
||||
cls.created_at.asc(),
|
||||
)
|
||||
async def get_candidates_by_job(cls, session: AsyncSession, job_id: str | None = None):
|
||||
"""Leaderboard order: completed by score desc, failures last, ties stable.
|
||||
|
||||
job_id=None returns the whole pool across jobs (same ordering) for the
|
||||
frontend's unscoped Candidates/Talent Pool views.
|
||||
"""
|
||||
statement = select(cls)
|
||||
if job_id is not None:
|
||||
uid = cls._as_uuid(job_id)
|
||||
if uid is None:
|
||||
return []
|
||||
statement = statement.where(cls.job_id == uid)
|
||||
statement = statement.order_by(
|
||||
cls.status.asc(), # "completed" < "failed"
|
||||
cls.match_score.desc().nulls_last(),
|
||||
cls.created_at.asc(),
|
||||
)
|
||||
result = await session.execute(statement)
|
||||
return result.scalars().all()
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -160,10 +160,12 @@ class CandidateScoring:
|
|||
raise HTTPException(status_code=400,detail="No attachments found for the given message(s)")
|
||||
return await self._score_and_persist(job_id,sources,"inbox",current_user)
|
||||
|
||||
async def fetch_candidates(self,job_id):
|
||||
job=await JobPosts.get_job_post_by_id(self.session,job_id)
|
||||
if job is None or job.is_deleted:
|
||||
raise HTTPException(status_code=404,detail="Job post not found")
|
||||
async def fetch_candidates(self,job_id=None):
|
||||
# job_id omitted -> the whole pool across jobs (frontend Candidates/TalentPool).
|
||||
if job_id is not None:
|
||||
job=await JobPosts.get_job_post_by_id(self.session,job_id)
|
||||
if job is None or job.is_deleted:
|
||||
raise HTTPException(status_code=404,detail="Job post not found")
|
||||
rows=await Candidates.get_candidates_by_job(self.session,job_id)
|
||||
return [serialize_candidate(row) for row in rows]
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
/* ============================================================
|
||||
candidates.js — ATS scoring endpoints (backend/job/app.py).
|
||||
|
||||
Same conventions as inbox.js: one named export per endpoint, no hooks,
|
||||
camelCase params mapped to snake_case at the call boundary, and every
|
||||
function returns the parsed {data, total, status_code} envelope.
|
||||
============================================================ */
|
||||
|
||||
import { request } from '../lib/apiClient'
|
||||
|
||||
/** Active job posts for pickers. Needs job_board.view OR candidates.view. */
|
||||
export function listJobs() {
|
||||
return request('/job/fetch')
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted scoring leaderboard. Needs candidates.view.
|
||||
* Omit jobId for the whole pool across jobs; rows are ordered completed-by-
|
||||
* score-desc, then failed rows.
|
||||
*/
|
||||
export function listCandidates({ jobId } = {}) {
|
||||
return request('/candidate/fetch', { params: { job_id: jobId } })
|
||||
}
|
||||
|
||||
/** One candidate row by id. Needs candidates.view. 404s on unknown ids. */
|
||||
export function getCandidate(candidateId) {
|
||||
return request('/candidate/fetch_by_id', { params: { candidate_id: candidateId } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Score uploaded CV PDFs against a job post. Needs candidates.create.
|
||||
* Multipart: unreadable/oversized/non-PDF files come back as rows with
|
||||
* status "failed" instead of failing the batch. Re-scoring identical bytes
|
||||
* against the same job updates the existing row (no duplicates).
|
||||
*/
|
||||
export function scoreUploads(jobId, files) {
|
||||
const form = new FormData()
|
||||
form.append('job_id', jobId)
|
||||
for (const file of files) form.append('files', file, file.name)
|
||||
return request('/candidate/score', { method: 'POST', body: form })
|
||||
}
|
||||
|
||||
/**
|
||||
* Score the decoded attachments of inbox messages against a job post.
|
||||
* Needs candidates.create. messageIds are inbox_messages PK uuids (the `id`
|
||||
* field the inbox list returns), not Graph message ids.
|
||||
*/
|
||||
export function scoreInbox(jobId, messageIds) {
|
||||
return request('/candidate/score_inbox', {
|
||||
method: 'POST',
|
||||
body: { job_id: jobId, message_ids: messageIds },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared snake_case → camelCase view-model mapper for candidate rows, so the
|
||||
* three candidate screens agree on field names. Fields the backend does not
|
||||
* store (email, phone, stage, education…) are deliberately absent — screens
|
||||
* hide those affordances rather than render placeholders (Inbox precedent).
|
||||
*/
|
||||
export function toCandidateView(row) {
|
||||
const name = row.candidate_name || row.filename || 'Unknown'
|
||||
return {
|
||||
id: row.id,
|
||||
jobId: row.job_id,
|
||||
name,
|
||||
filename: row.filename,
|
||||
source: row.source, // 'upload' | 'inbox'
|
||||
currentTitle: row.job_title ?? null,
|
||||
currentCompany: row.current_company ?? null,
|
||||
experience: row.years_experience ?? null,
|
||||
aiScore: row.match_score ?? null,
|
||||
matchedSkills: Array.isArray(row.matched_keywords) ? row.matched_keywords : [],
|
||||
missingSkills: Array.isArray(row.missing_keywords) ? row.missing_keywords : [],
|
||||
critique: row.summary_critique ?? null,
|
||||
scoringStatus: row.status, // 'completed' | 'failed'
|
||||
errorCode: row.error_code ?? null,
|
||||
errorMessage: row.error_message ?? null,
|
||||
applied: row.created_at ? new Date(row.created_at) : null,
|
||||
inboxMessageId: row.inbox_message_id ?? null,
|
||||
}
|
||||
}
|
||||
|
|
@ -51,14 +51,17 @@ export async function request(
|
|||
|
||||
const send = async () => {
|
||||
const headers = { Accept: 'application/json' }
|
||||
if (body != null) headers['Content-Type'] = 'application/json'
|
||||
// FormData bodies (file uploads) set their own multipart boundary — adding a
|
||||
// Content-Type here would break the request, and they must not be stringified.
|
||||
const isForm = typeof FormData !== 'undefined' && body instanceof FormData
|
||||
if (body != null && !isForm) headers['Content-Type'] = 'application/json'
|
||||
const bearer = token ?? (auth ? getAccessToken() : null)
|
||||
if (bearer) headers.Authorization = `Bearer ${bearer}`
|
||||
return fetch(buildUrl(path, params), {
|
||||
method,
|
||||
headers,
|
||||
signal,
|
||||
body: body != null ? JSON.stringify(body) : undefined,
|
||||
body: body == null ? undefined : isForm ? body : JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,15 @@ export const qk = {
|
|||
applications: (p = {}) => ['mailbox', 'applications', p],
|
||||
message: (id) => ['mailbox', 'message', id],
|
||||
},
|
||||
jobs: {
|
||||
all: () => ['jobs'],
|
||||
list: () => ['jobs', 'list'],
|
||||
},
|
||||
candidates: {
|
||||
all: () => ['candidates'],
|
||||
list: (p = {}) => ['candidates', 'list', p],
|
||||
detail: (id) => ['candidates', 'detail', id],
|
||||
},
|
||||
|
||||
// --- seed-backed buckets ---
|
||||
// These are not "server state" — the cache IS the store for them, so every
|
||||
|
|
|
|||
|
|
@ -1,72 +1,58 @@
|
|||
/* The 8-tab candidate profile modal, split out of Candidates.jsx — it was the
|
||||
single largest block in js/candidates.js and deserves its own file. */
|
||||
/* The candidate profile modal, on live backend data. Tabs that had no backing
|
||||
store (notes, documents, feedback, fabricated timelines) are gone — what
|
||||
remains is exactly what the scoring engine knows about the candidate. */
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
|
||||
import Modal from '../ui/Modal'
|
||||
import { Tabs } from '../ui/Tabs'
|
||||
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { seedQuery } from '../data/seedQueries'
|
||||
import { companies, fmtDate, moneyK, pick } from '../data/seed'
|
||||
import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed'
|
||||
|
||||
const TABS = ['Overview', 'Resume', 'Timeline', 'Interview', 'Notes', 'Activity', 'Documents', 'Feedback']
|
||||
const TABS = ['Overview', 'Scoring', 'File']
|
||||
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
|
||||
const SOURCE_LABEL = { upload: 'Upload', inbox: 'Inbox' }
|
||||
|
||||
export default function CandidateProfile({ candidate: c, onClose, onAdvance, onToggleFav, onAtsMatch }) {
|
||||
const { toast } = useToast()
|
||||
export default function CandidateProfile({ candidate: c, jobTitle, onClose, onAtsMatch }) {
|
||||
const [tab, setTab] = useState('Overview')
|
||||
const { data: interviews = [] } = useQuery(seedQuery('interviews'))
|
||||
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
|
||||
|
||||
// The prototype called DB.pick() inline while rendering, so the "previous
|
||||
// employer" changed every repaint. Fixed per candidate.
|
||||
const priorCompany = useMemo(() => pick(companies), [])
|
||||
|
||||
const candidateInterviews = interviews.filter((i) => i.candidateId === c.id)
|
||||
const scored = c.scoringStatus === 'completed'
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Candidate Profile"
|
||||
subtitle={c.id}
|
||||
subtitle={c.filename}
|
||||
size="modal-lg"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
className={`btn btn-ghost star-btn${c.favorite ? ' on' : ''}`}
|
||||
style={{ marginRight: 'auto' }}
|
||||
onClick={() => onToggleFav(c)}
|
||||
>
|
||||
<Icon name="star" /> {c.favorite ? 'Favorited' : 'Favorite'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => onAtsMatch(c)}>
|
||||
<Icon name="target" /> ATS Match
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => toast('Email drafted', 'info')}>
|
||||
<Icon name="mail" /> Message
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => { onAdvance(c); onClose() }}>
|
||||
<Icon name="check" /> Advance Stage
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={onClose}>Close</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="profile-hero">
|
||||
<Avatar name={c.name} initials={c.initials} color={c.color} className="avatar-lg" />
|
||||
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} className="avatar-lg" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="ph-name">{c.name}</div>
|
||||
<div className="ph-role">{c.currentTitle} at {c.currentCompany}</div>
|
||||
<div className="ph-role">
|
||||
{c.currentTitle ?? '—'}{c.currentCompany ? ` at ${c.currentCompany}` : ''}
|
||||
</div>
|
||||
<div className="ph-tags">
|
||||
<Badge>{c.stage}</Badge> <Badge className="b-gray">{c.source}</Badge>
|
||||
<span className="badge b-plain b-indigo badge-plain">{c.experience} yrs exp</span>
|
||||
{scored ? <Badge className="b-green">Scored</Badge> : <Badge className="b-red">{c.errorCode ?? 'Failed'}</Badge>}
|
||||
<Badge className="b-gray">{SOURCE_LABEL[c.source] ?? c.source}</Badge>
|
||||
{c.experience != null && (
|
||||
<span className="badge b-plain b-indigo badge-plain">{c.experience} yrs exp</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<ScoreChip score={c.aiScore} />
|
||||
<div className="cell-sub" style={{ marginTop: 4 }}>AI Match</div>
|
||||
</div>
|
||||
{c.aiScore != null && (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<ScoreChip score={c.aiScore} />
|
||||
<div className="cell-sub" style={{ marginTop: 4 }}>AI Match</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 22 }}>
|
||||
|
|
@ -77,200 +63,74 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
|
|||
{tab === 'Overview' && (
|
||||
<>
|
||||
<div className="info-grid" style={{ marginBottom: 20 }}>
|
||||
<div className="info-item"><div className="il">Email</div><div className="iv">{c.email}</div></div>
|
||||
<div className="info-item"><div className="il">Phone</div><div className="iv">{c.phone}</div></div>
|
||||
<div className="info-item"><div className="il">Location</div><div className="iv">{c.location}</div></div>
|
||||
<div className="info-item"><div className="il">Applied For</div><div className="iv">{c.jobTitle}</div></div>
|
||||
<div className="info-item"><div className="il">Current Company</div><div className="iv">{c.currentCompany}</div></div>
|
||||
<div className="info-item"><div className="il">Experience</div><div className="iv">{c.experience} years</div></div>
|
||||
<div className="info-item"><div className="il">Education</div><div className="iv">{c.education}</div></div>
|
||||
<div className="info-item"><div className="il">Source</div><div className="iv">{c.source}</div></div>
|
||||
<div className="info-item"><div className="il">Recruiter</div><div className="iv">{c.recruiter}</div></div>
|
||||
<div className="info-item"><div className="il">Applied On</div><div className="iv">{fmtDate(c.applied)}</div></div>
|
||||
<div className="info-item"><div className="il">Expected Salary</div><div className="iv">{moneyK(c.salary)}</div></div>
|
||||
<div className="info-item"><div className="il">Rating</div><div className="iv">⭐ {c.rating} / 5.0</div></div>
|
||||
<div className="info-item"><div className="il">Scored For</div><div className="iv">{jobTitle ?? '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Current Title</div><div className="iv">{c.currentTitle ?? '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Current Company</div><div className="iv">{c.currentCompany ?? '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Experience</div><div className="iv">{c.experience != null ? `${c.experience} years` : '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Source</div><div className="iv">{SOURCE_LABEL[c.source] ?? c.source}</div></div>
|
||||
<div className="info-item"><div className="il">Added On</div><div className="iv">{c.applied ? fmtDate(c.applied) : '—'}</div></div>
|
||||
</div>
|
||||
<div style={LABEL}>Skills</div>
|
||||
<div className="k-tags">{c.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
|
||||
{scored && (
|
||||
<>
|
||||
<div style={LABEL}>Matched Skills</div>
|
||||
<div className="k-tags">
|
||||
{c.matchedSkills.length
|
||||
? c.matchedSkills.map((s) => <span className="tag" key={s}>{s}</span>)
|
||||
: <span className="text-muted">—</span>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'Resume' && (
|
||||
<>
|
||||
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)' }}>
|
||||
<div className="card-body">
|
||||
<h3 style={{ marginBottom: 4 }}>{c.name}</h3>
|
||||
<p className="text-muted">{c.currentTitle} · {c.location}</p>
|
||||
<div className="divider" />
|
||||
<div className="form-section-title" style={{ marginTop: 0 }}>Summary</div>
|
||||
<p className="text-muted">
|
||||
Results-driven {c.currentTitle.toLowerCase()} with {c.experience} years of experience
|
||||
across {c.department.toLowerCase()}. Passionate about building high-quality products
|
||||
and collaborating with cross-functional teams.
|
||||
</p>
|
||||
<div className="form-section-title">Experience</div>
|
||||
<div className="info-item">
|
||||
<div className="iv">{c.currentTitle} — {c.currentCompany}</div>
|
||||
<div className="il" style={{ textTransform: 'none' }}>2021 – Present</div>
|
||||
</div>
|
||||
<div className="info-item" style={{ marginTop: 10 }}>
|
||||
<div className="iv">Associate — {priorCompany}</div>
|
||||
<div className="il" style={{ textTransform: 'none' }}>2018 – 2021</div>
|
||||
</div>
|
||||
<div className="form-section-title">Education</div>
|
||||
<div className="iv">{c.education}</div>
|
||||
{tab === 'Scoring' && (
|
||||
scored ? (
|
||||
<>
|
||||
<div className="form-section-title" style={{ marginTop: 0 }}>AI Assessment</div>
|
||||
<p className="text-muted" style={{ marginBottom: 18 }}>{c.critique ?? '—'}</p>
|
||||
<div className="form-section-title" style={{ marginTop: 0 }}>
|
||||
Matched Skills ({c.matchedSkills.length})
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-secondary" style={{ marginTop: 14 }} onClick={() => toast('Downloading resume.pdf', 'info')}>
|
||||
<Icon name="download" /> Download PDF
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'Timeline' && (
|
||||
<div className="timeline">
|
||||
{[
|
||||
{ icon: 'user-plus', title: 'Application received', meta: fmtDate(c.applied), desc: `Applied via ${c.source}` },
|
||||
{ icon: 'star', title: 'AI screening completed', meta: '1 day later', desc: `Match score: ${c.aiScore}%` },
|
||||
{ icon: 'phone', title: 'Recruiter screen', meta: '3 days later', desc: `Call with ${c.recruiter}` },
|
||||
{ icon: 'calendar', title: 'Technical interview', meta: '1 week later', desc: 'Panel of 3 interviewers' },
|
||||
{ icon: 'check', title: `Moved to ${c.stage}`, meta: 'Recently', desc: 'Current stage in pipeline' },
|
||||
].map((e) => (
|
||||
<div className="tl-item" key={e.title}>
|
||||
<div className="tl-dot"><Icon name={e.icon} /></div>
|
||||
<div className="tl-title">{e.title}</div>
|
||||
<div className="tl-meta">{e.meta}</div>
|
||||
<div className="tl-desc">{e.desc}</div>
|
||||
<div className="k-tags" style={{ marginBottom: 16 }}>
|
||||
{c.matchedSkills.length
|
||||
? c.matchedSkills.map((s) => (
|
||||
<span className="skill-pill skill-matched" key={s}><Icon name="check" /> {s}</span>
|
||||
))
|
||||
: <span className="text-muted">—</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'Interview' && (
|
||||
candidateInterviews.length ? (
|
||||
<div className="list-tight">
|
||||
{candidateInterviews.map((iv) => (
|
||||
<div className="list-row" key={iv.id}>
|
||||
<span className="kpi-icn i-blue" style={{ width: 38, height: 38, borderRadius: 10 }}>
|
||||
<Icon name="calendar" />
|
||||
</span>
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{iv.type}</div>
|
||||
<div className="lr-sub">{fmtDate(iv.when)} · {iv.meeting}</div>
|
||||
</div>
|
||||
<div className="lr-right"><Badge>{iv.status}</Badge></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="form-section-title" style={{ marginTop: 0 }}>
|
||||
Missing Skills ({c.missingSkills.length})
|
||||
</div>
|
||||
<div className="k-tags">
|
||||
{c.missingSkills.length
|
||||
? c.missingSkills.map((s) => (
|
||||
<span className="skill-pill skill-missing" key={s}><Icon name="x" /> {s}</span>
|
||||
))
|
||||
: <span className="text-muted">None — full match</span>}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState icon="calendar" title="No interviews scheduled">
|
||||
Schedule an interview to get started.
|
||||
<EmptyState icon="target" title="Not scored">
|
||||
{c.errorMessage ?? 'This CV could not be processed.'}
|
||||
</EmptyState>
|
||||
)
|
||||
)}
|
||||
|
||||
{tab === 'Notes' && (
|
||||
<>
|
||||
<div className="form-field">
|
||||
<label>Add a note</label>
|
||||
<textarea placeholder="Write a private note about this candidate…" />
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" style={{ margin: '10px 0 18px' }} onClick={() => toast('Note saved', 'success')}>
|
||||
<Icon name="plus" /> Add Note
|
||||
</button>
|
||||
<div className="list-tight">
|
||||
<div className="list-row">
|
||||
<Avatar name={c.recruiter} />
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{c.recruiter}</div>
|
||||
<div className="lr-sub" style={{ color: 'var(--text-2)' }}>
|
||||
Strong communication skills, great culture fit. Recommend advancing.
|
||||
</div>
|
||||
<div className="lr-sub">2 days ago</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="list-row">
|
||||
<Avatar name="Asfand Ahmed" initials="AA" />
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">Asfand Ahmed</div>
|
||||
<div className="lr-sub" style={{ color: 'var(--text-2)' }}>
|
||||
Reviewed portfolio — impressive work. Schedule technical round.
|
||||
</div>
|
||||
<div className="lr-sub">4 days ago</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'Activity' && (
|
||||
<div className="list-tight">
|
||||
{[
|
||||
{ icon: 'eye', tone: 'i-green', text: `Profile viewed by ${c.recruiter}`, when: '1h ago' },
|
||||
{ icon: 'mail', tone: 'i-blue', text: 'Email sent: Interview invitation', when: '1 day ago' },
|
||||
{ icon: 'star', tone: 'i-amber', text: `Assessment score updated to ${c.aiScore}%`, when: '2 days ago' },
|
||||
{ icon: 'user-plus', tone: 'i-purple', text: `Applied for ${c.jobTitle}`, when: fmtDate(c.applied) },
|
||||
].map((a) => (
|
||||
<div className="list-row" key={a.text}>
|
||||
<span className={`kpi-icn ${a.tone}`} style={{ width: 34, height: 34, borderRadius: 9 }}>
|
||||
<Icon name={a.icon} />
|
||||
</span>
|
||||
<div className="lr-main">
|
||||
<div className="lr-sub" style={{ color: 'var(--text-2)', fontSize: 13 }}>{a.text}</div>
|
||||
<div className="lr-sub">{a.when}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{tab === 'File' && (
|
||||
<div className="info-grid">
|
||||
<div className="info-item"><div className="il">File Name</div><div className="iv">{c.filename}</div></div>
|
||||
<div className="info-item"><div className="il">Source</div><div className="iv">{SOURCE_LABEL[c.source] ?? c.source}</div></div>
|
||||
{c.inboxMessageId && (
|
||||
<div className="info-item"><div className="il">Inbox Message</div><div className="iv">{c.inboxMessageId}</div></div>
|
||||
)}
|
||||
{!scored && (
|
||||
<>
|
||||
<div className="info-item"><div className="il">Error</div><div className="iv">{c.errorCode ?? '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Detail</div><div className="iv">{c.errorMessage ?? '—'}</div></div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'Documents' && (
|
||||
<div className="list-tight">
|
||||
{[
|
||||
{ n: 'Resume.pdf', s: '284 KB' }, { n: 'Cover_Letter.pdf', s: '112 KB' },
|
||||
{ n: 'Portfolio.pdf', s: '4.2 MB' }, { n: 'References.docx', s: '48 KB' },
|
||||
].map((d) => (
|
||||
<div className="list-row" key={d.n}>
|
||||
<span className="kpi-icn i-red" style={{ width: 38, height: 38, borderRadius: 10 }}>
|
||||
<Icon name="file" />
|
||||
</span>
|
||||
<div className="lr-main"><div className="lr-title">{d.n}</div><div className="lr-sub">{d.s}</div></div>
|
||||
<button className="act-btn" onClick={() => toast(`Downloading ${d.n}`, 'info')}>
|
||||
<Icon name="download" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'Feedback' && (
|
||||
<>
|
||||
<div className="list-tight">
|
||||
{['Strong Hire', 'Hire', 'Lean Hire'].map((score, i) => {
|
||||
const r = recruiters[i]
|
||||
if (!r) return null
|
||||
const notes = [
|
||||
'Excellent technical depth and clear communication.',
|
||||
'Good problem solving, would benefit from more system design exposure.',
|
||||
'Solid candidate, positive team energy.',
|
||||
]
|
||||
return (
|
||||
<div className="list-row" key={score}>
|
||||
<Avatar name={r.name} initials={r.initials} color={r.color} />
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{r.name}</div>
|
||||
<div className="lr-sub" style={{ color: 'var(--text-2)' }}>{notes[i]}</div>
|
||||
</div>
|
||||
<div className="lr-right"><Badge>{score}</Badge></div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" style={{ marginTop: 14 }} onClick={() => toast('Scorecard form opened', 'info')}>
|
||||
<Icon name="plus" /> Submit Scorecard
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,74 +1,88 @@
|
|||
/* ============================================================
|
||||
Candidates — the largest screen in the app: a 14-facet filter panel, a
|
||||
composite relevance sort, a multi-select bulk bar, favourites, a
|
||||
recently-viewed strip, the ATS-match modal and the 8-tab profile
|
||||
(CandidateProfile.jsx).
|
||||
Candidates — the scored-candidate pool, on live backend data.
|
||||
|
||||
Uses the headless `useDataTable` rather than <DataTable/>, because the
|
||||
selection column needs to render against a Set this component owns.
|
||||
Rows come from GET /candidate/fetch (all jobs) via the shared
|
||||
toCandidateView mapper. Facets, columns and actions that had no backing
|
||||
column (stage, recruiter, notice period, favourites…) are gone rather than
|
||||
rendered as placeholders — the Inbox screen set that precedent. Adding
|
||||
candidates happens through CV Import (real scoring), not a manual form.
|
||||
============================================================ */
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import Modal from '../ui/Modal'
|
||||
import { Pagination, useDataTable } from '../ui/DataTable'
|
||||
import { Avatar, Badge, EmptyState, FieldError, Icon, ProgressBar, ScoreChip } from '../ui/primitives'
|
||||
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { useFormState } from '../components/AuthLayout'
|
||||
import CandidateProfile from './CandidateProfile'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { persist, seedQuery, useSeedMutation } from '../data/seedQueries'
|
||||
import {
|
||||
atsRecommendationClass, avatarColor, departments, educationLevels, getJob,
|
||||
initials as initialsOf, int, locations, skillsPool, sources, stages, TODAY,
|
||||
} from '../data/seed'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as candidatesApi from '../api/candidates'
|
||||
import { persist } from '../data/seedQueries'
|
||||
import { atsRecommendationClass, avatarColor, initials as initialsOf } from '../data/seed'
|
||||
|
||||
const STAGE_ORDER = ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']
|
||||
const EXP_BUCKETS = ['0-2', '3-5', '6-9', '10+']
|
||||
const ATS_BANDS = ['85+', '70-84', '<70']
|
||||
const INTERVIEW_STATES = ['Not Scheduled', 'Scheduled', 'Completed']
|
||||
const NOTICE = ['Immediate', '2 weeks', '1 month', '2 months', '3 months']
|
||||
const AVAILABILITY = ['Immediate', '2 weeks', '1 month', 'Passive']
|
||||
const SOURCE_LABEL = { upload: 'Upload', inbox: 'Inbox' }
|
||||
const EMPTY_FILTERS = { job: '', skill: '', source: '', ats: '', status: '' }
|
||||
|
||||
const EMPTY_FILTERS = {
|
||||
job: '', skill: '', dept: '', location: '', exp: '', edu: '', recruiter: '',
|
||||
manager: '', source: '', ats: '', stage: '', interview: '', notice: '', availability: '',
|
||||
async function fetchCandidates() {
|
||||
const res = await candidatesApi.listCandidates()
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return rows.map(candidatesApi.toCandidateView)
|
||||
}
|
||||
|
||||
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 recommendationOf(c) {
|
||||
if (c.aiScore == null) return 'Weak Match'
|
||||
return c.aiScore >= 82 ? 'Strong Match' : c.aiScore >= 65 ? 'Potential Match' : 'Weak Match'
|
||||
}
|
||||
|
||||
export default function Candidates() {
|
||||
const { toast } = useToast()
|
||||
const qc = useQueryClient()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
|
||||
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
|
||||
const { data: managers = [] } = useQuery(seedQuery('managers'))
|
||||
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
|
||||
const candidatesQuery = useQuery({ queryKey: qk.candidates.list(), queryFn: fetchCandidates })
|
||||
const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs })
|
||||
const candidates = useMemo(() => candidatesQuery.data ?? [], [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 updateCandidates = useSeedMutation('candidates')
|
||||
|
||||
const [q, setQ] = useState('')
|
||||
const [filters, setFilters] = useState(EMPTY_FILTERS)
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [sortMode, setSortMode] = useState('relevance')
|
||||
const [selected, setSelected] = useState(() => new Set())
|
||||
const [profileFor, setProfileFor] = useState(null)
|
||||
const [atsFor, setAtsFor] = useState(null)
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [bulkAssigning, setBulkAssigning] = useState(false)
|
||||
|
||||
/** ATS + matched-skill ratio + recency. Verbatim from js/candidates.js:14-20. */
|
||||
const jobTitleOf = useCallback(
|
||||
(c) => jobsById[c.jobId]?.title ?? '—',
|
||||
[jobsById],
|
||||
)
|
||||
|
||||
/** ATS score + matched-skill ratio + recency — same shape as before, but every
|
||||
input is now real: matched/missing come from the model, applied from the DB. */
|
||||
const relevance = useCallback((c) => {
|
||||
const req = (getJob(c.jobId) || {}).skills || []
|
||||
const skillRatio = req.length ? c.matchedSkills.length / req.length : 0.5
|
||||
const recency = 1 - Math.min(1, (TODAY - c.applied) / (90 * 864e5))
|
||||
if (c.aiScore == null) return 0
|
||||
const total = c.matchedSkills.length + c.missingSkills.length
|
||||
const skillRatio = total ? c.matchedSkills.length / total : 0.5
|
||||
const recency = c.applied ? 1 - Math.min(1, (Date.now() - c.applied) / (90 * 864e5)) : 0.5
|
||||
return Math.round(c.aiScore * 0.7 + skillRatio * 20 + recency * 10)
|
||||
}, [])
|
||||
|
||||
|
|
@ -84,135 +98,70 @@ export default function Candidates() {
|
|||
[qc],
|
||||
)
|
||||
|
||||
// Deep links from global search, dashboard, pipeline, calendar, interviews…
|
||||
// Deep links from Talent Pool, global search, dashboard…
|
||||
useEffect(() => {
|
||||
const st = location.state
|
||||
if (!st) return
|
||||
if (st.openAdd) setAdding(true)
|
||||
if (st.openCandidate) {
|
||||
const c = candidates.find((x) => x.id === st.openCandidate)
|
||||
if (c) openProfile(c)
|
||||
}
|
||||
if (!st?.openCandidate) return
|
||||
const c = candidates.find((x) => x.id === st.openCandidate)
|
||||
if (c) openProfile(c)
|
||||
}, [location.state, candidates, openProfile])
|
||||
|
||||
const jobTitles = useMemo(() => [...new Set(candidates.map((c) => c.jobTitle))], [candidates])
|
||||
const skillOptions = useMemo(() => {
|
||||
const set = new Set()
|
||||
for (const c of candidates) for (const s of c.matchedSkills) set.add(s)
|
||||
return [...set].sort((a, b) => a.localeCompare(b)).slice(0, 40)
|
||||
}, [candidates])
|
||||
|
||||
const jobOptions = useMemo(
|
||||
() => (jobsQuery.data ?? []).map((j) => j.title),
|
||||
[jobsQuery.data],
|
||||
)
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const f = filters
|
||||
let list = candidates.filter((c) => {
|
||||
if (f.job && c.jobTitle !== f.job) return false
|
||||
if (f.skill && !c.skills.includes(f.skill)) return false
|
||||
if (f.dept && c.department !== f.dept) return false
|
||||
if (f.location && c.location !== f.location) return false
|
||||
if (f.exp === '0-2' && c.experience > 2) return false
|
||||
if (f.exp === '3-5' && (c.experience < 3 || c.experience > 5)) return false
|
||||
if (f.exp === '6-9' && (c.experience < 6 || c.experience > 9)) return false
|
||||
if (f.exp === '10+' && c.experience < 10) return false
|
||||
if (f.edu && c.education !== f.edu) return false
|
||||
if (f.recruiter && c.recruiter !== f.recruiter) return false
|
||||
if (f.manager) {
|
||||
const job = getJob(c.jobId)
|
||||
if (!job || job.manager !== f.manager) return false
|
||||
}
|
||||
if (f.job && jobTitleOf(c) !== f.job) return false
|
||||
if (f.skill && !c.matchedSkills.includes(f.skill)) return false
|
||||
if (f.source && c.source !== f.source) return false
|
||||
if (f.ats === '85+' && c.aiScore < 85) return false
|
||||
if (f.ats === '70-84' && (c.aiScore < 70 || c.aiScore > 84)) return false
|
||||
if (f.ats === '<70' && c.aiScore >= 70) return false
|
||||
if (f.stage && c.stage !== f.stage) return false
|
||||
if (f.interview && c.interviewStatus !== f.interview) return false
|
||||
if (f.notice && c.noticePeriod !== f.notice) return false
|
||||
if (f.availability && c.availability !== f.availability) return false
|
||||
if (f.status === 'Scored' && c.scoringStatus !== 'completed') return false
|
||||
if (f.status === 'Failed' && c.scoringStatus !== 'failed') return false
|
||||
if (f.ats === '85+' && (c.aiScore == null || c.aiScore < 85)) return false
|
||||
if (f.ats === '70-84' && (c.aiScore == null || c.aiScore < 70 || c.aiScore > 84)) return false
|
||||
if (f.ats === '<70' && (c.aiScore == null || c.aiScore >= 70)) return false
|
||||
if (q) {
|
||||
const term = q.toLowerCase()
|
||||
const hay = (c.name + c.email + c.jobTitle + c.currentCompany + c.recruiter + c.skills.join(' ')).toLowerCase()
|
||||
const hay = [
|
||||
c.name, c.filename, c.currentTitle ?? '', c.currentCompany ?? '',
|
||||
c.matchedSkills.join(' '),
|
||||
].join(' ').toLowerCase()
|
||||
if (!hay.includes(term)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (sortMode === 'relevance') list = [...list].sort((a, b) => relevance(b) - relevance(a))
|
||||
else if (sortMode === 'ats') list = [...list].sort((a, b) => b.aiScore - a.aiScore)
|
||||
else if (sortMode === 'recent') list = [...list].sort((a, b) => b.applied - a.applied)
|
||||
else if (sortMode === 'ats') list = [...list].sort((a, b) => (b.aiScore ?? -1) - (a.aiScore ?? -1))
|
||||
else if (sortMode === 'recent') list = [...list].sort((a, b) => (b.applied ?? 0) - (a.applied ?? 0))
|
||||
else if (sortMode === 'name') list = [...list].sort((a, b) => a.name.localeCompare(b.name))
|
||||
return list
|
||||
}, [candidates, filters, q, sortMode, relevance])
|
||||
}, [candidates, filters, q, sortMode, relevance, jobTitleOf])
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{ key: '_sel', label: '' },
|
||||
{ key: 'name', label: 'Candidate', sortable: true },
|
||||
{ key: 'jobTitle', label: 'Applied Job', sortable: true },
|
||||
{ key: '_job', label: 'Scored For', sortable: true, sortValue: jobTitleOf },
|
||||
{ key: 'experience', label: 'Exp', sortable: true, align: 'center' },
|
||||
{ key: '_rel', label: 'Relevance', sortable: true, align: 'center', sortValue: relevance },
|
||||
{ key: 'stage', label: 'Stage', sortable: true },
|
||||
{ key: 'scoringStatus', label: 'Status', sortable: true },
|
||||
{ key: 'aiScore', label: 'ATS', sortable: true, align: 'center' },
|
||||
{ key: 'availability', label: 'Availability' },
|
||||
{ key: 'applied', label: 'Added', sortable: true },
|
||||
{ key: '_a', label: 'Actions', align: 'right' },
|
||||
],
|
||||
[relevance],
|
||||
[relevance, jobTitleOf],
|
||||
)
|
||||
|
||||
const t = useDataTable({ columns, rows, pageSize: 10 })
|
||||
|
||||
function toggleSelect(id) {
|
||||
setSelected((s) => {
|
||||
const next = new Set(s)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
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)))
|
||||
toast(`${c.name} moved to ${stage}`, 'success')
|
||||
}
|
||||
|
||||
function bulk(action) {
|
||||
const ids = [...selected]
|
||||
if (!ids.length) return
|
||||
if (action === 'email') {
|
||||
toast(`Bulk email drafted to ${ids.length} candidates`, 'success')
|
||||
setSelected(new Set())
|
||||
return
|
||||
}
|
||||
if (action === 'assign') {
|
||||
setBulkAssigning(true)
|
||||
return
|
||||
}
|
||||
if (action === 'advance') {
|
||||
updateCandidates((cs) =>
|
||||
cs.map((c) => {
|
||||
if (!selected.has(c.id)) return c
|
||||
const i = STAGE_ORDER.indexOf(c.stage)
|
||||
if (i === -1 || i >= STAGE_ORDER.length - 1) return c
|
||||
const stage = STAGE_ORDER[i + 1]
|
||||
return { ...c, stage, status: stage }
|
||||
}),
|
||||
)
|
||||
toast(`${ids.length} candidates advanced`, 'success')
|
||||
}
|
||||
if (action === 'reject') {
|
||||
updateCandidates((cs) =>
|
||||
cs.map((c) => (selected.has(c.id) ? { ...c, stage: 'Rejected', status: 'Rejected' } : c)),
|
||||
)
|
||||
toast(`${ids.length} candidates rejected`, 'warning')
|
||||
}
|
||||
setSelected(new Set())
|
||||
}
|
||||
|
||||
const recentChips = recentlyViewed
|
||||
.slice(0, 6)
|
||||
.map((id) => candidates.find((c) => c.id === id))
|
||||
|
|
@ -220,6 +169,14 @@ export default function Candidates() {
|
|||
|
||||
const setFilter = (k, v) => setFilters((f) => ({ ...f, [k]: v }))
|
||||
|
||||
function openAts(c) {
|
||||
if (c.scoringStatus !== 'completed') {
|
||||
toast('This CV could not be scored — no match analysis available', 'info')
|
||||
return
|
||||
}
|
||||
setAtsFor(c)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-head">
|
||||
|
|
@ -230,14 +187,11 @@ export default function Candidates() {
|
|||
</p>
|
||||
</div>
|
||||
<div className="page-head-actions">
|
||||
<button className="btn btn-secondary" onClick={() => toast('Search saved', 'success')}>
|
||||
<Icon name="bookmark" /> Save Search
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => toast('Candidates exported', 'success')}>
|
||||
<Icon name="download" /> Export
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => setAdding(true)}>
|
||||
<Icon name="plus" /> Add Candidate
|
||||
<button className="btn btn-primary" onClick={() => navigate('/import')}>
|
||||
<Icon name="upload" /> Import CVs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -247,25 +201,12 @@ export default function Candidates() {
|
|||
<span className="text-muted text-sm fw-600">Recently viewed:</span>
|
||||
{recentChips.map((c) => (
|
||||
<button key={c.id} className="prompt-chip" style={{ padding: '5px 10px' }} onClick={() => openProfile(c)}>
|
||||
<Avatar name={c.name} initials={c.initials} color={c.color} /> {c.name.split(' ')[0]}
|
||||
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} /> {c.name.split(' ')[0]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.size > 0 && (
|
||||
<div className="bulk-bar" style={{ display: 'flex' }}>
|
||||
<span className="checkbox on"><Icon name="check" /></span>
|
||||
<span className="fw-600">{selected.size} selected</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-sm" onClick={() => bulk('email')}><Icon name="mail" /> Bulk Email</button>
|
||||
<button className="btn btn-sm" onClick={() => bulk('assign')}><Icon name="users" /> Assign</button>
|
||||
<button className="btn btn-sm" onClick={() => bulk('advance')}><Icon name="check" /> Advance</button>
|
||||
<button className="btn btn-sm" onClick={() => bulk('reject')}><Icon name="x" /> Reject</button>
|
||||
<button className="btn btn-sm" onClick={() => setSelected(new Set())}><Icon name="x" /> Clear</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-body" style={{ paddingBottom: 0 }}>
|
||||
<div className="toolbar">
|
||||
|
|
@ -291,196 +232,171 @@ export default function Candidates() {
|
|||
className="filter-panel"
|
||||
style={{ display: 'grid', padding: '16px 0', borderTop: '1px solid var(--border)', marginTop: 12 }}
|
||||
>
|
||||
<Facet label="Job" value={filters.job} onChange={(v) => setFilter('job', v)} any="Any Job" options={jobTitles} />
|
||||
<Facet label="Skill" value={filters.skill} onChange={(v) => setFilter('skill', v)} any="Any Skill" options={skillsPool} />
|
||||
<Facet label="Department" value={filters.dept} onChange={(v) => setFilter('dept', v)} any="Any Dept" options={departments} />
|
||||
<Facet label="Location" value={filters.location} onChange={(v) => setFilter('location', v)} any="Any Location" options={locations} />
|
||||
<Facet label="Experience" value={filters.exp} onChange={(v) => setFilter('exp', v)} any="Any Exp" options={EXP_BUCKETS} />
|
||||
<Facet label="Education" value={filters.edu} onChange={(v) => setFilter('edu', v)} any="Any" options={educationLevels} />
|
||||
<Facet label="Recruiter" value={filters.recruiter} onChange={(v) => setFilter('recruiter', v)} any="Any Recruiter" options={recruiters.map((r) => r.name)} />
|
||||
<Facet label="Hiring Manager" value={filters.manager} onChange={(v) => setFilter('manager', v)} any="Any Manager" options={managers.map((m) => m.name)} />
|
||||
<Facet label="Source" value={filters.source} onChange={(v) => setFilter('source', v)} any="Any Source" options={sources} />
|
||||
<Facet label="Job" value={filters.job} onChange={(v) => setFilter('job', v)} any="Any Job" options={jobOptions} />
|
||||
<Facet label="Matched Skill" value={filters.skill} onChange={(v) => setFilter('skill', v)} any="Any Skill" options={skillOptions} />
|
||||
<Facet label="Source" value={filters.source} onChange={(v) => setFilter('source', v)} any="Any Source" options={['upload', 'inbox']} labels={SOURCE_LABEL} />
|
||||
<Facet label="ATS Score" value={filters.ats} onChange={(v) => setFilter('ats', v)} any="Any Score" options={ATS_BANDS} />
|
||||
<Facet label="Pipeline Stage" value={filters.stage} onChange={(v) => setFilter('stage', v)} any="Any Stage" options={stages} />
|
||||
<Facet label="Interview Status" value={filters.interview} onChange={(v) => setFilter('interview', v)} any="Any" options={INTERVIEW_STATES} />
|
||||
<Facet label="Notice Period" value={filters.notice} onChange={(v) => setFilter('notice', v)} any="Any" options={NOTICE} />
|
||||
<Facet label="Availability" value={filters.availability} onChange={(v) => setFilter('availability', v)} any="Any" options={AVAILABILITY} />
|
||||
<Facet label="Status" value={filters.status} onChange={(v) => setFilter('status', v)} any="Any Status" options={['Scored', 'Failed']} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="dt">
|
||||
<div className="table-wrap">
|
||||
<table className="data">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((c) => {
|
||||
const isSorted = t.sort.key === c.key
|
||||
const cls = [
|
||||
c.sortable ? 'sortable' : '',
|
||||
isSorted ? (t.sort.dir === 1 ? 'sorted-asc' : 'sorted-desc') : '',
|
||||
].filter(Boolean).join(' ')
|
||||
return (
|
||||
<th
|
||||
key={c.key}
|
||||
className={cls}
|
||||
style={{ textAlign: c.align || 'left' }}
|
||||
onClick={c.sortable ? () => t.toggleSort(c.key) : undefined}
|
||||
>
|
||||
{c.label}
|
||||
{c.sortable && (
|
||||
<span className="sort-ind">{isSorted ? (t.sort.dir === 1 ? '▲' : '▼') : '⇅'}</span>
|
||||
)}
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{t.pageRows.length === 0 ? (
|
||||
<tr><td colSpan={columns.length}><EmptyState /></td></tr>
|
||||
) : (
|
||||
t.pageRows.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td>
|
||||
<span
|
||||
className={`checkbox ${selected.has(c.id) ? 'on' : ''}`}
|
||||
onClick={() => toggleSelect(c.id)}
|
||||
role="checkbox"
|
||||
aria-checked={selected.has(c.id)}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSelect(c.id) } }}
|
||||
{candidatesQuery.isPending && (
|
||||
<div className="card-body">
|
||||
<EmptyState icon="users" title="Loading…">Fetching candidates from the server.</EmptyState>
|
||||
</div>
|
||||
)}
|
||||
{candidatesQuery.isError && (
|
||||
<div className="card-body">
|
||||
<EmptyState icon="users" title="Couldn’t load candidates">
|
||||
{friendlyAuthError(candidatesQuery.error, 'Request failed')}
|
||||
</EmptyState>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{candidatesQuery.isSuccess && (
|
||||
<div className="dt">
|
||||
<div className="table-wrap">
|
||||
<table className="data">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((c) => {
|
||||
const isSorted = t.sort.key === c.key
|
||||
const cls = [
|
||||
c.sortable ? 'sortable' : '',
|
||||
isSorted ? (t.sort.dir === 1 ? 'sorted-asc' : 'sorted-desc') : '',
|
||||
].filter(Boolean).join(' ')
|
||||
return (
|
||||
<th
|
||||
key={c.key}
|
||||
className={cls}
|
||||
style={{ textAlign: c.align || 'left' }}
|
||||
onClick={c.sortable ? () => t.toggleSort(c.key) : undefined}
|
||||
>
|
||||
<Icon name="check" />
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="user-cell">
|
||||
<Avatar name={c.name} initials={c.initials} color={c.color} />
|
||||
<div>
|
||||
<div className="cell-primary">
|
||||
{c.name}{' '}
|
||||
{c.favorite && (
|
||||
<span className="star-btn on" style={{ display: 'inline' }}><Icon name="star" /></span>
|
||||
)}
|
||||
</div>
|
||||
<div className="cell-sub">{c.currentTitle} · {c.location}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-sm">{c.jobTitle}</div>
|
||||
<div className="cell-sub">{c.department}</div>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}><b>{c.experience}</b>y</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<span className={`badge ${atsRecommendationClass(c.recommendation)} badge-plain`}>
|
||||
{relevance(c)}%
|
||||
</span>
|
||||
</td>
|
||||
<td><Badge>{c.stage}</Badge></td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<span style={{ cursor: 'pointer' }} onClick={() => setAtsFor(c)}>
|
||||
<ScoreChip score={c.aiScore} />
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="text-sm">{c.availability}</span>
|
||||
<div className="cell-sub">{c.noticePeriod} notice</div>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<div className="row-actions">
|
||||
<button className={`act-btn star-btn ${c.favorite ? 'on' : ''}`} data-tip="Favorite" onClick={() => toggleFav(c)}>
|
||||
<Icon name="star" />
|
||||
</button>
|
||||
<button className="act-btn" data-tip="ATS Match" onClick={() => setAtsFor(c)}><Icon name="target" /></button>
|
||||
<button className="act-btn" data-tip="Profile" onClick={() => openProfile(c)}><Icon name="eye" /></button>
|
||||
<button className="act-btn" data-tip="Advance" onClick={() => advance(c)}><Icon name="check" /></button>
|
||||
</div>
|
||||
{c.label}
|
||||
{c.sortable && (
|
||||
<span className="sort-ind">{isSorted ? (t.sort.dir === 1 ? '▲' : '▼') : '⇅'}</span>
|
||||
)}
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{t.pageRows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length}>
|
||||
<EmptyState title="No candidates yet">
|
||||
Score resumes in CV Import to fill this table.
|
||||
</EmptyState>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
t.pageRows.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td>
|
||||
<div className="user-cell">
|
||||
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} />
|
||||
<div>
|
||||
<div className="cell-primary">{c.name}</div>
|
||||
<div className="cell-sub">
|
||||
{c.currentTitle ?? c.filename}
|
||||
{c.currentCompany ? ` · ${c.currentCompany}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-sm">{jobTitleOf(c)}</div>
|
||||
<div className="cell-sub">{SOURCE_LABEL[c.source] ?? c.source}</div>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
{c.experience != null ? <><b>{c.experience}</b>y</> : '—'}
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
{c.scoringStatus === 'completed' ? (
|
||||
<span className={`badge ${atsRecommendationClass(recommendationOf(c))} badge-plain`}>
|
||||
{relevance(c)}%
|
||||
</span>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td>
|
||||
{c.scoringStatus === 'completed'
|
||||
? <Badge className="b-green">Scored</Badge>
|
||||
: <Badge className="b-red">{c.errorCode ?? 'Failed'}</Badge>}
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
{c.aiScore != null ? (
|
||||
<span style={{ cursor: 'pointer' }} onClick={() => openAts(c)}>
|
||||
<ScoreChip score={c.aiScore} />
|
||||
</span>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td>
|
||||
<span className="text-sm">
|
||||
{c.applied ? c.applied.toLocaleDateString() : '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<div className="row-actions">
|
||||
<button className="act-btn" data-tip="ATS Match" onClick={() => openAts(c)}><Icon name="target" /></button>
|
||||
<button className="act-btn" data-tip="Profile" onClick={() => openProfile(c)}><Icon name="eye" /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination {...t} />
|
||||
</div>
|
||||
<Pagination {...t} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{atsFor && <AtsMatch candidate={atsFor} onClose={() => setAtsFor(null)} onProfile={(c) => { setAtsFor(null); openProfile(c) }} />}
|
||||
{atsFor && (
|
||||
<AtsMatch
|
||||
candidate={atsFor}
|
||||
jobTitle={jobTitleOf(atsFor)}
|
||||
onClose={() => setAtsFor(null)}
|
||||
onProfile={(c) => { setAtsFor(null); openProfile(c) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{profileFor && (
|
||||
<CandidateProfile
|
||||
candidate={candidates.find((c) => c.id === profileFor.id) ?? profileFor}
|
||||
jobTitle={jobTitleOf(profileFor)}
|
||||
onClose={() => setProfileFor(null)}
|
||||
onAdvance={advance}
|
||||
onToggleFav={toggleFav}
|
||||
onAtsMatch={(c) => { setProfileFor(null); setAtsFor(c) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{bulkAssigning && (
|
||||
<BulkAssign
|
||||
count={selected.size}
|
||||
recruiters={recruiters}
|
||||
onClose={() => setBulkAssigning(false)}
|
||||
onSave={(name) => {
|
||||
updateCandidates((cs) => cs.map((c) => (selected.has(c.id) ? { ...c, recruiter: name } : c)))
|
||||
setBulkAssigning(false)
|
||||
setSelected(new Set())
|
||||
toast('Recruiter assigned to selected candidates', 'success')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{adding && (
|
||||
<AddCandidate
|
||||
jobs={jobs}
|
||||
count={candidates.length}
|
||||
onClose={() => setAdding(false)}
|
||||
onSave={(c) => {
|
||||
updateCandidates((cs) => [c, ...cs])
|
||||
setAdding(false)
|
||||
toast('Candidate added to pipeline', 'success')
|
||||
}}
|
||||
onInvalid={() => toast('Please fix the highlighted fields', 'error')}
|
||||
onAtsMatch={(c) => { setProfileFor(null); openAts(c) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Facet({ label, value, onChange, any, options }) {
|
||||
function Facet({ label, value, onChange, any, options, labels }) {
|
||||
return (
|
||||
<div className="form-field">
|
||||
<label>{label}</label>
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="">{any}</option>
|
||||
{options.map((o) => <option key={o}>{o}</option>)}
|
||||
{options.map((o) => <option key={o} value={o}>{labels?.[o] ?? o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AtsMatch({ candidate: c, onClose, onProfile }) {
|
||||
const sub = c.subScores
|
||||
const recCls = c.recommendation === 'Strong Match' ? 'recc-strong'
|
||||
: c.recommendation === 'Potential Match' ? 'recc-potential' : 'recc-weak'
|
||||
function AtsMatch({ candidate: c, jobTitle, onClose, onProfile }) {
|
||||
const recommendation = recommendationOf(c)
|
||||
const recCls = recommendation === 'Strong Match' ? 'recc-strong'
|
||||
: recommendation === 'Potential Match' ? 'recc-potential' : 'recc-weak'
|
||||
const ringColor = c.aiScore >= 82 ? 'var(--success)' : c.aiScore >= 65 ? 'var(--warning)' : 'var(--danger)'
|
||||
|
||||
const Row = ({ label, val }) => (
|
||||
<div className="flex items-center gap-12" style={{ marginBottom: 12 }}>
|
||||
<span style={{ width: 110, fontSize: 13 }}>{label}</span>
|
||||
<div style={{ flex: 1 }}><ProgressBar pct={val} /></div>
|
||||
<b style={{ width: 42, textAlign: 'right' }}>{val}%</b>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="ATS Match Analysis"
|
||||
subtitle={`${c.id} · ${c.jobTitle}`}
|
||||
subtitle={jobTitle}
|
||||
size="modal-lg"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
|
|
@ -492,11 +408,11 @@ function AtsMatch({ candidate: c, onClose, onProfile }) {
|
|||
>
|
||||
<div className={`recc-banner ${recCls}`}>
|
||||
<span className="recc-icn">
|
||||
<Icon name={c.recommendation === 'Weak Match' ? 'x-circle' : 'check-circle'} />
|
||||
<Icon name={recommendation === 'Weak Match' ? 'x-circle' : 'check-circle'} />
|
||||
</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="fw-600" style={{ fontSize: 15 }}>{c.recommendation}</div>
|
||||
<div style={{ opacity: 0.85, fontSize: 13 }}>{c.name} for {c.jobTitle}</div>
|
||||
<div className="fw-600" style={{ fontSize: 15 }}>{recommendation}</div>
|
||||
<div style={{ opacity: 0.85, fontSize: 13 }}>{c.name} for {jobTitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -510,12 +426,8 @@ function AtsMatch({ candidate: c, onClose, onProfile }) {
|
|||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Row label="Skills" val={sub.skills} />
|
||||
<Row label="Experience" val={sub.experience} />
|
||||
<Row label="Education" val={sub.education} />
|
||||
<Row label="Keywords" val={sub.keywords} />
|
||||
<Row label="Location" val={sub.location} />
|
||||
<Row label="Salary" val={sub.salary} />
|
||||
<div className="form-section-title" style={{ marginTop: 0 }}>Assessment</div>
|
||||
<p className="text-muted" style={{ fontSize: 13 }}>{c.critique ?? '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -543,118 +455,10 @@ function AtsMatch({ candidate: c, onClose, onProfile }) {
|
|||
|
||||
<div className="divider" />
|
||||
<p className="text-muted text-sm">
|
||||
<Icon name="sparkles" /> Score computed from JD keywords, resume parsing, experience,
|
||||
education, location and salary alignment. Connect an AI model to refine with semantic matching.
|
||||
<Icon name="sparkles" /> 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.
|
||||
</p>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function BulkAssign({ count, recruiters, onClose, onSave }) {
|
||||
const [name, setName] = useState(recruiters[0]?.name ?? '')
|
||||
return (
|
||||
<Modal
|
||||
title="Bulk Assign Recruiter"
|
||||
subtitle={`${count} candidates`}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={() => onSave(name)}>Assign</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="form-field">
|
||||
<label>Assign to</label>
|
||||
<select value={name} onChange={(e) => setName(e.target.value)}>
|
||||
{recruiters.map((r) => <option key={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function AddCandidate({ jobs, count, onClose, onSave, onInvalid }) {
|
||||
const open = jobs.filter((j) => j.status === 'Open')
|
||||
const form = useFormState({
|
||||
name: '', email: '', phone: '', job: open[0]?.title ?? '',
|
||||
experience: '3', company: '', source: sources[0], stage: stages[0],
|
||||
})
|
||||
|
||||
function submit() {
|
||||
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'
|
||||
form.setErrors(errors)
|
||||
if (Object.keys(errors).length) {
|
||||
onInvalid()
|
||||
return
|
||||
}
|
||||
const job = jobs.find((j) => j.title === v.job) || jobs[0]
|
||||
const score = int(55, 95)
|
||||
onSave({
|
||||
id: `CAN-${5001 + count}`,
|
||||
name: v.name, initials: initialsOf(v.name), color: avatarColor(v.name),
|
||||
email: v.email, phone: v.phone || '+1 (555) 000-0000',
|
||||
jobId: job.id, jobTitle: job.title, department: job.department,
|
||||
experience: Number(v.experience) || 1, currentCompany: v.company || '—',
|
||||
currentTitle: job.title, location: job.location,
|
||||
stage: v.stage, status: v.stage, aiScore: score, source: v.source,
|
||||
recruiter: job.recruiter, recruiterId: job.recruiterId,
|
||||
applied: new Date(TODAY), education: "Bachelor's Degree",
|
||||
skills: job.skills.slice(0, 4), rating: '4.0', salary: 120000,
|
||||
matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3),
|
||||
recommendation: score >= 82 ? 'Strong Match' : score >= 65 ? 'Potential Match' : 'Weak Match',
|
||||
subScores: { skills: score, experience: 80, education: 80, keywords: score, location: 100, salary: 90 },
|
||||
noticePeriod: '1 month', availability: '2 weeks', certifications: [],
|
||||
favorite: false, interviewStatus: 'Not Scheduled',
|
||||
})
|
||||
}
|
||||
|
||||
const field = (n) => ({ value: form.values[n], onChange: (e) => form.setField(n, e.target.value) })
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Add Candidate"
|
||||
subtitle="Manually add a candidate to the pipeline"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={submit}><Icon name="check" /> Add Candidate</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
|
||||
<div className="form-grid">
|
||||
<div className="form-field">
|
||||
<label>Full Name <span className="req">*</span></label>
|
||||
<input {...field('name')} className={form.errors.name ? 'err' : ''} placeholder="Jane Doe" />
|
||||
<FieldError>{form.errors.name}</FieldError>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Email <span className="req">*</span></label>
|
||||
<input type="email" {...field('email')} className={form.errors.email ? 'err' : ''} placeholder="jane@email.com" />
|
||||
<FieldError>{form.errors.email}</FieldError>
|
||||
</div>
|
||||
<div className="form-field"><label>Phone</label><input {...field('phone')} placeholder="+1 (555) 000-0000" /></div>
|
||||
<div className="form-field">
|
||||
<label>Applied Job <span className="req">*</span></label>
|
||||
<select {...field('job')}>{open.map((j) => <option key={j.id}>{j.title}</option>)}</select>
|
||||
</div>
|
||||
<div className="form-field"><label>Experience (years)</label><input type="number" {...field('experience')} /></div>
|
||||
<div className="form-field"><label>Current Company</label><input {...field('company')} placeholder="Acme Inc." /></div>
|
||||
<div className="form-field">
|
||||
<label>Source</label>
|
||||
<select {...field('source')}>{sources.map((s) => <option key={s}>{s}</option>)}</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Stage</label>
|
||||
<select {...field('stage')}>{stages.map((s) => <option key={s}>{s}</option>)}</select>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,169 +1,135 @@
|
|||
/* ============================================================
|
||||
CV Import — the UX shape is right; the mechanics are still simulated.
|
||||
CV Import — real upload → score → persist flow.
|
||||
|
||||
The prototype's dropzone read only `e.dataTransfer.files.length` and threw
|
||||
the files away, then invented a queue with setInterval-driven progress. That
|
||||
is preserved deliberately: there is no upload endpoint, no object storage and
|
||||
no parser behind this yet, so pretending otherwise would be worse than the
|
||||
honest "processed locally in this demo" label the screen already carries.
|
||||
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 { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import Modal from '../ui/Modal'
|
||||
import { Badge, Icon, ScoreChip } from '../ui/primitives'
|
||||
import { Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { seedQuery, useSeedMutation } from '../data/seedQueries'
|
||||
import {
|
||||
avatarColor, companies, initials as initialsOf, int, locations, pick, TODAY,
|
||||
} from '../data/seed'
|
||||
|
||||
const FIRST = ['Olivia', 'Liam', 'Emma', 'Noah', 'Ava', 'Ethan', 'Sophia', 'Mason', 'Priya', 'Diego', 'Yuki', 'Omar']
|
||||
const LAST = ['Chen', 'Patel', 'Kim', 'Garcia', 'Silva', 'Ahmed', 'Novak', 'Reyes', 'Khan', 'Costa']
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as candidatesApi from '../api/candidates'
|
||||
|
||||
const STEPS = [
|
||||
{ i: 'file', t: 'Resume parsing', d: 'Extract name, contact, experience, skills & education' },
|
||||
{ i: 'target', t: 'ATS scoring', d: 'Generate a match score against the requisition' },
|
||||
{ i: 'briefcase', t: 'Job matching', d: 'Suggest the best-matching open roles' },
|
||||
{ i: 'users', t: 'Duplicate detection', d: 'Flag candidates already in the system' },
|
||||
{ i: 'user-plus', t: 'Profile creation', d: 'Create a candidate profile in Applied stage' },
|
||||
{ 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 { data: jobs = [] } = useQuery(seedQuery('jobs'))
|
||||
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
|
||||
const updateCandidates = useSeedMutation('candidates')
|
||||
const qc = useQueryClient()
|
||||
const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs })
|
||||
const jobs = jobsQuery.data ?? []
|
||||
|
||||
const [jobId, setJobId] = useState('')
|
||||
const [queue, setQueue] = useState([])
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [duplicateFor, setDuplicateFor] = useState(null)
|
||||
const timers = useRef(new Set())
|
||||
const fileInput = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const set = timers.current
|
||||
return () => {
|
||||
set.forEach((t) => { clearInterval(t); clearTimeout(t) })
|
||||
set.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const advance = useCallback((id) => {
|
||||
const tick = setInterval(() => {
|
||||
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 (item.id !== id || item.status !== 'Uploading') return item
|
||||
const progress = Math.min(100, item.progress + int(12, 30))
|
||||
if (progress >= 100) {
|
||||
clearInterval(tick)
|
||||
timers.current.delete(tick)
|
||||
const done = setTimeout(() => {
|
||||
setQueue((q2) =>
|
||||
q2.map((x) => (x.id === id ? { ...x, status: 'Ready', atsScore: int(52, 96) } : x)),
|
||||
)
|
||||
timers.current.delete(done)
|
||||
}, 700 + int(0, 500))
|
||||
timers.current.add(done)
|
||||
return { ...item, progress: 100, status: 'Parsing' }
|
||||
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,
|
||||
}
|
||||
return { ...item, progress }
|
||||
}),
|
||||
)
|
||||
}, 220)
|
||||
timers.current.add(tick)
|
||||
}, [])
|
||||
|
||||
const simulate = useCallback(
|
||||
(count, isZip) => {
|
||||
const n = isZip ? 8 : count
|
||||
const open = jobs.filter((j) => j.status === 'Open')
|
||||
const items = []
|
||||
for (let k = 0; k < n; k++) {
|
||||
const name = `${pick(FIRST)} ${pick(LAST)}`
|
||||
items.push({
|
||||
id: `UP-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name,
|
||||
file: `${name.split(' ')[0]}_Resume.${pick(['pdf', 'docx', 'doc'])}`,
|
||||
size: `${int(120, 620)} KB`,
|
||||
progress: 0,
|
||||
status: 'Uploading',
|
||||
atsScore: null,
|
||||
job: pick(open.length ? open : jobs),
|
||||
duplicate: Math.random() < 0.18,
|
||||
imported: false,
|
||||
})
|
||||
}
|
||||
setQueue((q) => [...q, ...items])
|
||||
items.forEach((i) => advance(i.id))
|
||||
toast(isZip ? 'ZIP extracted — 8 resumes queued' : `${n} file(s) uploaded`, 'info')
|
||||
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',
|
||||
)
|
||||
},
|
||||
[jobs, advance, toast],
|
||||
)
|
||||
|
||||
const doImport = useCallback(
|
||||
(id) => {
|
||||
const item = queue.find((x) => x.id === id)
|
||||
if (!item || item.imported) return
|
||||
const job = item.job
|
||||
updateCandidates((cs) => [
|
||||
{
|
||||
id: `CAN-${5001 + cs.length}`,
|
||||
name: item.name,
|
||||
initials: initialsOf(item.name),
|
||||
color: avatarColor(item.name),
|
||||
email: `${item.name.toLowerCase().replace(/ /g, '.')}@email.com`,
|
||||
phone: '+1 (555) 000-0000',
|
||||
jobId: job.id, jobTitle: job.title, department: job.department,
|
||||
experience: int(2, 12), currentCompany: pick(companies), currentTitle: job.title,
|
||||
location: pick(locations), stage: 'Applied', status: 'Applied',
|
||||
aiScore: item.atsScore, source: 'Manual CV Upload',
|
||||
recruiter: job.recruiter, recruiterId: '',
|
||||
applied: new Date(TODAY), education: "Bachelor's Degree",
|
||||
skills: job.skills.slice(0, 4), rating: '4.0', salary: int(90, 180) * 1000,
|
||||
matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3),
|
||||
recommendation: item.atsScore >= 82 ? 'Strong Match' : 'Potential Match',
|
||||
// Kept verbatim from the prototype, including the literal constants —
|
||||
// this breakdown is fabricated and is flagged as the most misleading
|
||||
// artefact in the repo (01-repository-assessment.md §2.2).
|
||||
subScores: { skills: item.atsScore, experience: 80, education: 80, keywords: item.atsScore, location: 100, salary: 90 },
|
||||
noticePeriod: '1 month', availability: '2 weeks', certifications: [],
|
||||
favorite: false, interviewStatus: 'Not Scheduled',
|
||||
},
|
||||
...cs,
|
||||
])
|
||||
setQueue((q) => q.map((x) => (x.id === id ? { ...x, imported: true } : x)))
|
||||
toast(`${item.name} imported → ${job.title}`, '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')
|
||||
},
|
||||
[queue, updateCandidates, toast],
|
||||
)
|
||||
})
|
||||
|
||||
function importOne(item) {
|
||||
if (item.duplicate) setDuplicateFor(item)
|
||||
else doImport(item.id)
|
||||
}
|
||||
|
||||
function importAll() {
|
||||
const ready = queue.filter((i) => i.status === 'Ready' && !i.imported && !i.duplicate)
|
||||
if (!ready.length) {
|
||||
toast('No files ready to import', 'warning')
|
||||
function handleFiles(fileList) {
|
||||
const all = Array.from(fileList || [])
|
||||
if (!all.length) return
|
||||
if (!jobId) {
|
||||
toast('Select a job to score against first', 'warning')
|
||||
return
|
||||
}
|
||||
ready.forEach((i) => doImport(i.id))
|
||||
toast(`${ready.length} candidates imported`, 'success')
|
||||
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 importedCount = queue.filter((i) => i.imported).length
|
||||
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 resumes — we parse, score, match, and dedupe automatically</p>
|
||||
<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 Parser · Ready</span>
|
||||
<span className="integration-status pending"><span className="pulse" />AI Resume Scoring · Live</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -171,45 +137,59 @@ export default function CvImport() {
|
|||
<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={() => simulate(int(2, 4))}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
simulate(e.dataTransfer.files.length || int(2, 4))
|
||||
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 & drop resumes here</h3>
|
||||
<p className="text-muted" style={{ marginBottom: 16 }}>
|
||||
or click to browse — PDF, DOC, DOCX and ZIP supported · up to 20 files
|
||||
or click to browse — PDF only · up to 50 files per batch
|
||||
</p>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={(e) => { e.stopPropagation(); simulate(int(2, 4)) }}
|
||||
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 }}>
|
||||
{['PDF', 'DOC', 'DOCX', 'ZIP'].map((t) => (
|
||||
<span className="badge b-gray badge-plain" key={t}>{t}</span>
|
||||
))}
|
||||
<span className="badge b-gray badge-plain">PDF</span>
|
||||
<span className="text-muted text-sm">DOC / DOCX support coming later</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-8" style={{ marginTop: 16, flexWrap: 'wrap' }}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => simulate(3)}>
|
||||
<Icon name="sparkles" /> Simulate 3 files
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => simulate(1, true)}>
|
||||
<Icon name="layers" /> Simulate ZIP (8 CVs)
|
||||
</button>
|
||||
<span className="text-muted text-sm" style={{ marginLeft: 'auto' }}>
|
||||
Files are processed locally in this demo
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -219,12 +199,11 @@ export default function CvImport() {
|
|||
<div>
|
||||
<h3>Processing Queue</h3>
|
||||
<span className="ch-sub">
|
||||
{queue.length} file{queue.length === 1 ? '' : 's'} · {importedCount} imported
|
||||
{queue.length} file{queue.length === 1 ? '' : 's'} · {scored} scored
|
||||
{failed ? ` · ${failed} failed` : ''}
|
||||
{selectedJob ? ` · vs ${selectedJob.title}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={importAll}>
|
||||
<Icon name="check" /> Import All
|
||||
</button>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{queue.map((i) => (
|
||||
|
|
@ -233,46 +212,39 @@ export default function CvImport() {
|
|||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="flex items-center gap-8">
|
||||
<span className="fw-600 text-sm">{i.name}</span>
|
||||
{i.duplicate && (
|
||||
<span className="badge b-red badge-plain" style={{ padding: '1px 7px', fontSize: 10 }}>
|
||||
DUPLICATE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="cell-sub">{i.file} · {i.size}</div>
|
||||
{i.status === 'Uploading' || i.status === 'Parsing' ? (
|
||||
{i.status === 'Scoring' && (
|
||||
<div className="upload-progress" style={{ marginTop: 6 }}>
|
||||
<div className="upload-progress-fill" style={{ width: `${i.progress}%` }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="cell-sub" style={{ marginTop: 4 }}>
|
||||
Best match: <b>{i.job?.title}</b>
|
||||
<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} />
|
||||
) : (
|
||||
<Badge className={i.status === 'Parsing' ? 'b-amber' : 'b-blue'}>
|
||||
{i.status}{i.status === 'Uploading' ? ` ${i.progress}%` : ''}
|
||||
</Badge>
|
||||
)}
|
||||
{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.imported ? (
|
||||
<Badge className="b-green">Imported</Badge>
|
||||
) : i.status === 'Ready' ? (
|
||||
<button className="btn btn-primary btn-sm" onClick={() => importOne(i)}>Import</button>
|
||||
) : (
|
||||
<button className="act-btn" disabled><Icon name="clock" /></button>
|
||||
)}
|
||||
{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' }}>
|
||||
|
|
@ -290,44 +262,6 @@ export default function CvImport() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{duplicateFor && (
|
||||
<Modal
|
||||
title="Duplicate Detected"
|
||||
subtitle={duplicateFor.name}
|
||||
onClose={() => setDuplicateFor(null)}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={() => setDuplicateFor(null)}>Cancel</button>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => { setDuplicateFor(null); toast('Merged into existing profile', 'success') }}
|
||||
>
|
||||
Merge
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => { const id = duplicateFor.id; setDuplicateFor(null); doImport(id) }}
|
||||
>
|
||||
Import Anyway
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex gap-16 items-center">
|
||||
<span className="kpi-icn i-amber" style={{ width: 48, height: 48, borderRadius: 12, flexShrink: 0 }}>
|
||||
<Icon name="users" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="fw-600" style={{ fontSize: 15 }}>A similar candidate already exists</p>
|
||||
<p className="text-muted" style={{ marginTop: 4 }}>
|
||||
{duplicateFor.name} matches an existing profile (95% similarity on name + email).
|
||||
Importing will create a duplicate.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,30 +4,50 @@ import { useQuery } from '@tanstack/react-query'
|
|||
|
||||
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { seedQuery } from '../data/seedQueries'
|
||||
import { departments } from '../data/seed'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as candidatesApi from '../api/candidates'
|
||||
import { avatarColor, initials as initialsOf } from '../data/seed'
|
||||
|
||||
async function fetchPool() {
|
||||
const res = await candidatesApi.listCandidates()
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return rows.map(candidatesApi.toCandidateView)
|
||||
}
|
||||
|
||||
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 }))
|
||||
}
|
||||
|
||||
const SOURCE_LABEL = { upload: 'Upload', inbox: 'Inbox' }
|
||||
|
||||
export default function TalentPool() {
|
||||
const { toast } = useToast()
|
||||
const navigate = useNavigate()
|
||||
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
|
||||
const poolQuery = useQuery({ queryKey: qk.candidates.list(), queryFn: fetchPool })
|
||||
const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs })
|
||||
const [q, setQ] = useState('')
|
||||
const [dept, setDept] = useState('')
|
||||
const [jobId, setJobId] = useState('')
|
||||
|
||||
// Silver medalists / passive talent — candidates outside the active loop.
|
||||
// Scored candidates only; failed extraction rows are noise in a talent pool.
|
||||
const pool = useMemo(
|
||||
() => candidates.filter((c) => ['Rejected', 'Applied', 'Hired'].includes(c.stage)),
|
||||
[candidates],
|
||||
() => (poolQuery.data ?? []).filter((c) => c.scoringStatus === 'completed'),
|
||||
[poolQuery.data],
|
||||
)
|
||||
|
||||
const list = useMemo(
|
||||
() =>
|
||||
pool.filter((c) => {
|
||||
if (dept && c.department !== dept) return false
|
||||
if (q && !(c.name + c.currentCompany + c.skills.join(' ')).toLowerCase().includes(q.toLowerCase())) return false
|
||||
if (jobId && c.jobId !== jobId) return false
|
||||
if (q) {
|
||||
const hay = `${c.name} ${c.currentCompany ?? ''} ${c.matchedSkills.join(' ')}`.toLowerCase()
|
||||
if (!hay.includes(q.toLowerCase())) return false
|
||||
}
|
||||
return true
|
||||
}),
|
||||
[pool, q, dept],
|
||||
[pool, q, jobId],
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
@ -35,7 +55,7 @@ export default function TalentPool() {
|
|||
<div className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">Talent Pool</h1>
|
||||
<p className="page-sub">{pool.length} silver-medalists & passive candidates to re-engage</p>
|
||||
<p className="page-sub">{pool.length} scored candidate{pool.length === 1 ? '' : 's'} across all jobs</p>
|
||||
</div>
|
||||
<div className="page-head-actions">
|
||||
<button className="btn btn-primary" onClick={() => toast('Talent campaign created', 'success')}>
|
||||
|
|
@ -51,50 +71,67 @@ export default function TalentPool() {
|
|||
<Icon name="search" />
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by name, skill, company…" />
|
||||
</div>
|
||||
<select className="select" value={dept} onChange={(e) => setDept(e.target.value)}>
|
||||
<option value="">All Departments</option>
|
||||
{departments.map((d) => <option key={d}>{d}</option>)}
|
||||
<select className="select" value={jobId} onChange={(e) => setJobId(e.target.value)}>
|
||||
<option value="">All Jobs</option>
|
||||
{(jobsQuery.data ?? []).map((j) => <option key={j.id} value={j.id}>{j.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid g-3">
|
||||
{list.length === 0 ? (
|
||||
<div style={{ gridColumn: '1/-1' }}>
|
||||
<EmptyState title="No talent found">Try a different search or department.</EmptyState>
|
||||
</div>
|
||||
) : (
|
||||
list.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="card"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/candidates', { state: { openCandidate: c.id } })}
|
||||
>
|
||||
<div className="card-body">
|
||||
<div className="flex items-center gap-12" style={{ marginBottom: 12 }}>
|
||||
<Avatar name={c.name} initials={c.initials} color={c.color} className="avatar-lg" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="lr-title">{c.name}</div>
|
||||
<div className="lr-sub">{c.currentTitle}</div>
|
||||
{poolQuery.isPending && (
|
||||
<EmptyState icon="talent" title="Loading…">Fetching scored candidates from the server.</EmptyState>
|
||||
)}
|
||||
{poolQuery.isError && (
|
||||
<EmptyState icon="talent" title="Couldn’t load the talent pool">
|
||||
{friendlyAuthError(poolQuery.error, 'Request failed')}
|
||||
</EmptyState>
|
||||
)}
|
||||
|
||||
{poolQuery.isSuccess && (
|
||||
<div className="grid g-3">
|
||||
{list.length === 0 ? (
|
||||
<div style={{ gridColumn: '1/-1' }}>
|
||||
<EmptyState title="No candidates found">
|
||||
{pool.length === 0
|
||||
? 'Score some resumes in CV Import to build the pool.'
|
||||
: 'Try a different search or job filter.'}
|
||||
</EmptyState>
|
||||
</div>
|
||||
) : (
|
||||
list.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="card"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/candidates', { state: { openCandidate: c.id } })}
|
||||
>
|
||||
<div className="card-body">
|
||||
<div className="flex items-center gap-12" style={{ marginBottom: 12 }}>
|
||||
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} className="avatar-lg" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="lr-title">{c.name}</div>
|
||||
<div className="lr-sub">{c.currentTitle ?? c.filename}</div>
|
||||
</div>
|
||||
<ScoreChip score={c.aiScore} />
|
||||
</div>
|
||||
<div className="k-tags" style={{ marginBottom: 12 }}>
|
||||
{c.matchedSkills.slice(0, 4).map((s) => <span className="tag" key={s}>{s}</span>)}
|
||||
</div>
|
||||
<div className="divider" style={{ margin: '12px 0' }} />
|
||||
<div className="flex items-center" style={{ justifyContent: 'space-between' }}>
|
||||
<span className="cell-sub">
|
||||
<Icon name="briefcase" /> {c.experience != null ? `${c.experience} yrs` : '—'}
|
||||
</span>
|
||||
<span className="cell-sub">{c.currentCompany ?? '—'}</span>
|
||||
<Badge className="b-gray">{SOURCE_LABEL[c.source] ?? c.source}</Badge>
|
||||
</div>
|
||||
<ScoreChip score={c.aiScore} />
|
||||
</div>
|
||||
<div className="k-tags" style={{ marginBottom: 12 }}>
|
||||
{c.skills.slice(0, 4).map((s) => <span className="tag" key={s}>{s}</span>)}
|
||||
</div>
|
||||
<div className="divider" style={{ margin: '12px 0' }} />
|
||||
<div className="flex items-center" style={{ justifyContent: 'space-between' }}>
|
||||
<span className="cell-sub"><Icon name="briefcase" /> {c.experience} yrs</span>
|
||||
<span className="cell-sub">{c.currentCompany}</span>
|
||||
<Badge className="b-gray">{c.source}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue