662 lines
29 KiB
JavaScript
662 lines
29 KiB
JavaScript
/* ============================================================
|
||
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).
|
||
|
||
Uses the headless `useDataTable` rather than <DataTable/>, because the
|
||
selection column needs to render against a Set this component owns.
|
||
============================================================ */
|
||
|
||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||
import { useLocation } 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 { 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'
|
||
|
||
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 EMPTY_FILTERS = {
|
||
job: '', skill: '', dept: '', location: '', exp: '', edu: '', recruiter: '',
|
||
manager: '', source: '', ats: '', stage: '', interview: '', notice: '', availability: '',
|
||
}
|
||
|
||
export default function Candidates() {
|
||
const { toast } = useToast()
|
||
const qc = useQueryClient()
|
||
const location = useLocation()
|
||
|
||
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 { 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 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))
|
||
return Math.round(c.aiScore * 0.7 + skillRatio * 20 + recency * 10)
|
||
}, [])
|
||
|
||
const openProfile = useCallback(
|
||
(c) => {
|
||
setProfileFor(c)
|
||
qc.setQueryData(qk.seed.recentlyViewed(), (old = []) => {
|
||
const next = [c.id, ...old.filter((id) => id !== c.id)].slice(0, 12)
|
||
persist('tf-recent', next)
|
||
return next
|
||
})
|
||
},
|
||
[qc],
|
||
)
|
||
|
||
// Deep links from global search, dashboard, pipeline, calendar, interviews…
|
||
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)
|
||
}
|
||
}, [location.state, candidates, openProfile])
|
||
|
||
const jobTitles = useMemo(() => [...new Set(candidates.map((c) => c.jobTitle))], [candidates])
|
||
|
||
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.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 (q) {
|
||
const term = q.toLowerCase()
|
||
const hay = (c.name + c.email + c.jobTitle + c.currentCompany + c.recruiter + c.skills.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 === 'name') list = [...list].sort((a, b) => a.name.localeCompare(b.name))
|
||
return list
|
||
}, [candidates, filters, q, sortMode, relevance])
|
||
|
||
const columns = useMemo(
|
||
() => [
|
||
{ key: '_sel', label: '' },
|
||
{ key: 'name', label: 'Candidate', sortable: true },
|
||
{ key: 'jobTitle', label: 'Applied Job', sortable: true },
|
||
{ 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: 'aiScore', label: 'ATS', sortable: true, align: 'center' },
|
||
{ key: 'availability', label: 'Availability' },
|
||
{ key: '_a', label: 'Actions', align: 'right' },
|
||
],
|
||
[relevance],
|
||
)
|
||
|
||
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))
|
||
.filter(Boolean)
|
||
|
||
const setFilter = (k, v) => setFilters((f) => ({ ...f, [k]: v }))
|
||
|
||
return (
|
||
<div className="page">
|
||
<div className="page-head">
|
||
<div>
|
||
<h1 className="page-title">Candidates</h1>
|
||
<p className="page-sub">
|
||
{rows.length} candidate{rows.length === 1 ? '' : 's'} · ranked by AI relevance
|
||
</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>
|
||
</div>
|
||
</div>
|
||
|
||
{recentChips.length > 0 && (
|
||
<div className="flex items-center gap-8" style={{ marginBottom: 14, flexWrap: 'wrap' }}>
|
||
<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]}
|
||
</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">
|
||
<div className="toolbar-search">
|
||
<Icon name="search" />
|
||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search name, skill, company…" />
|
||
</div>
|
||
<button className="btn btn-secondary" onClick={() => setShowFilters((s) => !s)}>
|
||
<Icon name="filter" /> Filters
|
||
</button>
|
||
<div className="spacer" />
|
||
<label className="text-muted text-sm">Sort:</label>
|
||
<select className="select" value={sortMode} onChange={(e) => setSortMode(e.target.value)}>
|
||
<option value="relevance">AI Relevance</option>
|
||
<option value="ats">ATS Score</option>
|
||
<option value="recent">Most Recent</option>
|
||
<option value="name">Name A–Z</option>
|
||
</select>
|
||
</div>
|
||
|
||
{showFilters && (
|
||
<div
|
||
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="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} />
|
||
</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) } }}
|
||
>
|
||
<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>
|
||
</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<Pagination {...t} />
|
||
</div>
|
||
</div>
|
||
|
||
{atsFor && <AtsMatch candidate={atsFor} onClose={() => setAtsFor(null)} onProfile={(c) => { setAtsFor(null); openProfile(c) }} />}
|
||
|
||
{profileFor && (
|
||
<CandidateProfile
|
||
candidate={candidates.find((c) => c.id === profileFor.id) ?? 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')}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Facet({ label, value, onChange, any, options }) {
|
||
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>)}
|
||
</select>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** Exported so TalentPool's profile modal can open the same ATS breakdown. */
|
||
export 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'
|
||
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}`}
|
||
size="modal-lg"
|
||
onClose={onClose}
|
||
footer={
|
||
<>
|
||
<button className="btn btn-secondary" onClick={onClose}>Close</button>
|
||
<button className="btn btn-primary" onClick={() => onProfile(c)}>View Full Profile</button>
|
||
</>
|
||
}
|
||
>
|
||
<div className={`recc-banner ${recCls}`}>
|
||
<span className="recc-icn">
|
||
<Icon name={c.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>
|
||
</div>
|
||
|
||
<div className="grid g-2" style={{ alignItems: 'center', marginBottom: 20 }}>
|
||
<div style={{ textAlign: 'center' }}>
|
||
<div className="ats-ring" style={{ '--pct': c.aiScore, '--c': ringColor }}>
|
||
<div className="ats-val">
|
||
<div className="ats-num">{c.aiScore}</div>
|
||
<div className="ats-lbl">ATS MATCH</div>
|
||
</div>
|
||
</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>
|
||
</div>
|
||
|
||
<div className="form-section-title" style={{ marginTop: 0 }}>
|
||
Matched Skills ({c.matchedSkills.length})
|
||
</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 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>
|
||
|
||
<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.
|
||
</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>
|
||
)
|
||
}
|