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

662 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/* ============================================================
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 AZ</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>
)
}