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

535 lines
21 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.

/* ============================================================
JobProfile — full-page job profile at /job/:jobId (replaces the Jobs
board's detail modal, per the "Job Profile" design).
One request, GET /jobs/profile/fetch, feeds the whole page: the requisition
row, the suggested candidates and the Suggested / Top Match header stats.
Suggested = the newest ats_results score per person for this job; Top Match
= how many of them sit in the Strong Match band. Search and the "Show" size
go to the API as `search` / `top`; band filter and sort then run client-side
over the rows that come back.
Tabs: Details · Suggested Candidates · History (?tab= deep-links a tab).
============================================================ */
import { useMemo, useState } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Tabs } from '../ui/Tabs'
import { DEFAULT_PAGE_SIZE, Pagination, pageAfterSizeChange, pageWindow } from '../ui/DataTable'
import { Avatar, Badge, EmptyState, Icon, SkeletonRows } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import { platformLabel } from '../lib/platforms'
import { fmtShort } from '../lib/format'
import { avatarColor, initials as initialsOf } from '../data/seed'
import * as jobsApi from '../api/jobs'
import * as assignmentsApi from '../api/assignments'
import * as offersApi from '../api/offers'
import { EditJobForm, JobCover, JobHistory, JobOwnership, SECTION_LABEL } from './Jobs'
import { MiniRing, ScoredCandidateDetail, displayName } from './JobCandidates'
/* The department name as the hero line and the Details tab show it. Kept local
rather than imported from Jobs.jsx so this page does not break when that
screen's helpers are reshuffled. */
function deptValue(j) {
return String(j.requisitionDepartment || j.department || '').trim()
}
function deptLabel(j) {
return deptValue(j) || '—'
}
const TAB_KEYS = ['details', 'suggested', 'history']
const SORTS = [
{ value: 'score', label: 'Best Match → Worst Match' },
{ value: 'recent', label: 'Most Recent' },
{ value: 'name', label: 'A → Z' },
]
export default function JobProfile() {
const { jobId } = useParams()
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const { toast } = useToast()
const { can } = useAuth()
const qc = useQueryClient()
const canEdit = can('jobs.edit')
const canDelete = can('jobs.delete')
const [editing, setEditing] = useState(false)
const requestedTab = String(searchParams.get('tab') || '').toLowerCase()
const tab = TAB_KEYS.includes(requestedTab) ? requestedTab : 'details'
const setTab = (next) => {
const params = new URLSearchParams(searchParams)
if (next === 'details') params.delete('tab')
else params.set('tab', next)
setSearchParams(params, { replace: true })
}
// Suggested-list request params. They live on the page, not in the tab,
// because the one profile call carries them.
const [search, setSearch] = useState('')
const [top, setTop] = useState(DEFAULT_PAGE_SIZE)
const [skip, setSkip] = useState(0)
const params = { search: search.trim() || undefined, top, skip }
const profileQuery = useQuery({
queryKey: qk.jobs.profile(jobId, params),
queryFn: async () => (await jobsApi.fetchProfile(jobId, params))?.data ?? null,
enabled: Boolean(jobId),
retry: false,
// A new search or size must not blank the page already on screen.
placeholderData: keepPreviousData,
})
const profile = profileQuery.data
const job = useMemo(() => (profile?.job ? jobsApi.toJobView(profile.job) : null), [profile])
const suggested = useMemo(
() => (Array.isArray(profile?.candidates) ? profile.candidates.map(jobsApi.toSuggestedView) : []),
[profile],
)
const statusesQuery = useQuery({
queryKey: qk.jobs.requisitionStatuses(),
queryFn: async () => {
const res = await jobsApi.listRequisitionStatuses()
const rows = Array.isArray(res?.data) ? res.data : []
return rows.length ? rows : jobsApi.REQUISITION_STATUSES
},
})
const statusLabels = (statusesQuery.data ?? jobsApi.REQUISITION_STATUSES).map((s) => s.label)
// Shared key documented on jobsApi.fetchDepartmentOptions — same fetcher everywhere.
const departmentsQuery = useQuery({
queryKey: qk.jobs.list({ scope: 'departments' }),
queryFn: jobsApi.fetchDepartmentOptions,
enabled: editing,
})
const historyQuery = useQuery({
queryKey: qk.assignments.job(jobId),
queryFn: async () => {
const res = await assignmentsApi.listJob(jobId, { currentOnly: false })
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((r) => assignmentsApi.toAssignmentView(r))
},
enabled: Boolean(jobId),
retry: false,
})
const statusQuery = useQuery({
queryKey: qk.jobs.statusHistory(jobId),
queryFn: async () => {
const res = await jobsApi.listStatusHistory(jobId)
return Array.isArray(res?.data) ? res.data : []
},
enabled: Boolean(jobId),
retry: false,
})
const offersQuery = useQuery({
queryKey: qk.offers.list({ jobPostId: jobId, top: 200 }),
queryFn: async () => {
const res = await offersApi.list({ jobPostId: jobId, top: 200 })
return Array.isArray(res?.data) ? res.data : []
},
enabled: Boolean(jobId),
retry: false,
})
const historyCount = (historyQuery.data?.length ?? 0) + (statusQuery.data?.length ?? 0) + (offersQuery.data?.length ?? 0)
const updateJob = useMutation({
mutationFn: (body) => jobsApi.update(jobId, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.jobs.all() })
qc.invalidateQueries({ queryKey: qk.requisitions.all() })
setEditing(false)
toast('Job updated', 'success')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not update the job'), 'error'),
})
const setJobStatus = useMutation({
mutationFn: (status) => jobsApi.setStatus(jobId, status),
onSuccess: (_d, status) => {
qc.invalidateQueries({ queryKey: qk.jobs.all() })
qc.invalidateQueries({ queryKey: qk.notifications.all() })
toast(`Status set to ${status}`, 'success')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not update status'), 'error'),
})
const deleteJob = useMutation({
mutationFn: () => jobsApi.remove(jobId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.jobs.all() })
qc.invalidateQueries({ queryKey: qk.requisitions.all() })
toast('Job deleted', 'success')
navigate('/jobs', { replace: true })
},
onError: (err) => toast(friendlyAuthError(err, 'Could not delete the job'), 'error'),
})
const goBack = () => (window.history.length > 1 ? navigate(-1) : navigate('/jobs'))
if (profileQuery.isPending || profileQuery.isError || !job) {
return (
<div className="cand-page">
<div className="cand-page-bar">
<button className="btn btn-secondary btn-sm" onClick={goBack}><Icon name="chevron-left" /> Back</button>
<div className="cand-page-crumb"><Link to="/jobs">Jobs</Link></div>
</div>
<div className="card">
<div className="card-body">
{profileQuery.isPending ? (
<SkeletonRows rows={6} />
) : (
<EmptyState icon="briefcase" title="Couldnt load this job">
{friendlyAuthError(profileQuery.error, 'The job may have been deleted or is outside your scope.')}
</EmptyState>
)}
</div>
</div>
</div>
)
}
const roleLine = [deptValue(job), job.location, job.type].filter(Boolean).join(' · ')
return (
<div className="cand-page">
<div className="cand-page-bar">
<button className="btn btn-secondary btn-sm" onClick={goBack}><Icon name="chevron-left" /> Back</button>
<div className="cand-page-crumb">
<Link to="/jobs">Jobs</Link> <span></span> <strong>{job.title}</strong>
</div>
</div>
<div className="card">
<div className="card-body">
<div className="profile-hero job-hero">
<span className="job-hero-icn"><Icon name="briefcase" /></span>
<div className="job-hero-id">
<div className="ph-name">{job.title}</div>
<div className="ph-role">{roleLine || '—'}</div>
<div className="ph-tags">
<Badge>{job.status}</Badge>
<Badge className="b-gray">{job.vacancies ?? 0} {job.vacancies === 1 ? 'Vacancy' : 'Vacancies'}</Badge>
<Badge className="b-gray">{job.applicantCount} {job.applicantCount === 1 ? 'Applicant' : 'Applicants'}</Badge>
</div>
</div>
<div className="job-hero-actions">
<div className="hero-stat">
<div className="v">{profile.total ?? profile.suggested ?? 0}</div>
<div className="l">Suggested</div>
</div>
<div
className="hero-stat"
title={profile.top_score != null ? `Candidates in the Strong Match band · best score ${profile.top_score}` : 'Candidates in the Strong Match band'}
>
<div className="v" style={{ color: profile.top_match ? 'var(--success)' : undefined }}>{profile.top_match ?? 0}</div>
<div className="l">Top Match</div>
</div>
{canEdit ? (
<select
className="select"
aria-label="Requisition status"
value={job.status}
disabled={setJobStatus.isPending}
onChange={(e) => setJobStatus.mutate(e.target.value)}
>
{statusLabels.map((s) => <option key={s}>{s}</option>)}
</select>
) : null}
{canDelete && (
<button
className="btn btn-ghost"
style={{ color: 'var(--danger)' }}
disabled={deleteJob.isPending}
onClick={() => { if (window.confirm(`Delete “${job.title}”?`)) deleteJob.mutate() }}
>
<Icon name="trash" /> {deleteJob.isPending ? 'Deleting…' : 'Delete'}
</button>
)}
{canEdit && (
<button className="btn btn-secondary" onClick={() => setEditing(true)}><Icon name="edit" /> Edit</button>
)}
<button className="btn btn-primary" onClick={() => navigate('/jobboard', { state: { publishJob: job.id } })}>
<Icon name="send" /> Publish
</button>
</div>
</div>
<Tabs
value={tab}
onChange={setTab}
tabs={[
{ key: 'details', label: 'Details' },
{ key: 'suggested', label: 'Suggested Candidates', count: (profile.total ?? profile.suggested) || undefined },
{ key: 'history', label: 'History', count: historyCount || undefined },
]}
/>
<div className="tab-pane active">
{tab === 'details' && <JobDetailsTab job={job} canEdit={canEdit} />}
{tab === 'suggested' && (
<SuggestedCandidatesTab
job={job}
profile={profile}
candidates={suggested}
search={search}
setSearch={(v) => { setSearch(v); setSkip(0) }}
top={top}
skip={skip}
setSkip={setSkip}
setTop={(n) => {
const page = pageAfterSizeChange(Math.floor(skip / top) + 1, profile.total ?? 0, n)
setTop(n)
setSkip((page - 1) * n)
}}
/>
)}
{tab === 'history' && <JobHistory historyQuery={historyQuery} statusQuery={statusQuery} offersQuery={offersQuery} />}
</div>
</div>
</div>
{editing && (
<EditJobForm
job={job}
departmentOptions={departmentsQuery.data ?? []}
busy={updateJob.isPending}
onClose={() => setEditing(false)}
onSubmit={(body) => updateJob.mutate(body)}
/>
)}
</div>
)
}
function JobDetailsTab({ job: j, canEdit }) {
const created = [j.created ? fmtShort(j.created) : null, j.createdByName ? `by ${j.createdByName}` : null]
.filter(Boolean)
.join(' · ')
return (
<>
<JobCover jobId={j.id} />
<div className="info-grid mb-18">
<div className="info-item"><div className="il">Department</div><div className="iv">{deptLabel(j)}</div></div>
<div className="info-item"><div className="il">Location</div><div className="iv">{j.location || ''}</div></div>
<div className="info-item"><div className="il">Employment Type</div><div className="iv">{j.type || ''}</div></div>
<div className="info-item"><div className="il">Platform</div><div className="iv">{platformLabel(j.platform) || ''}</div></div>
<div className="info-item"><div className="il">Vacancies</div><div className="iv">{j.vacancies ?? ''}</div></div>
<div className="info-item"><div className="il">Experience</div><div className="iv">{j.experience || ''}</div></div>
<div className="info-item"><div className="il">Created</div><div className="iv">{created || ''}</div></div>
<div className="info-item"><div className="il">Requisition</div><div className="iv">{j.requisitionLabel || ''}</div></div>
<div className="info-item"><div className="il">Hiring Manager</div><div className="iv">{j.hiringManager || ''}</div></div>
<div className="info-item"><div className="il">Assigned Recruiters</div><div className="iv">{j.recruiter || ''}</div></div>
{j.closedAt && (
<div className="info-item"><div className="il">Closed at</div><div className="iv">{fmtShort(j.closedAt)}</div></div>
)}
</div>
<JobOwnership job={j} canEdit={canEdit} />
{j.description && (
<>
<div className="divider" />
<div className="mb-16">
<div style={SECTION_LABEL}>Description</div>
<p style={{ color: 'var(--text-2)' }}>{j.description}</p>
</div>
</>
)}
{j.skills.length > 0 && (
<div className="mb-16">
<div style={SECTION_LABEL}>Required Skills</div>
<div className="k-tags">{j.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
</div>
)}
{j.optionalSkills.length > 0 && (
<div>
<div style={SECTION_LABEL}>Optional Skills</div>
<div className="k-tags">
{j.optionalSkills.map((s) => (
<span className="tag tag-optional" key={s}><Icon name="star" /> {s}</span>
))}
</div>
</div>
)}
</>
)
}
function SuggestedCandidatesTab({ job, profile, candidates, search, setSearch, top, skip, setSkip, setTop }) {
const navigate = useNavigate()
const [band, setBand] = useState('')
const [sort, setSort] = useState('score')
const [viewing, setViewing] = useState(null)
// Search already ran on the server; band and sort apply to what came back.
const list = useMemo(() => {
let rows = candidates.filter((c) => !band || c.band === band)
if (sort === 'name') rows = [...rows].sort((a, b) => a.name.localeCompare(b.name))
else if (sort === 'recent') rows = [...rows].sort((a, b) => (b.scoredAt?.getTime() ?? 0) - (a.scoredAt?.getTime() ?? 0))
else rows = [...rows].sort((a, b) => (b.score ?? -1) - (a.score ?? -1))
return rows
}, [candidates, band, sort])
// Paging comes from the API: `total` counts every match, the page holds `top`.
const total = profile.total ?? candidates.length
const pages = Math.max(1, Math.ceil(total / top))
const page = Math.floor(skip / top) + 1
const from = total === 0 ? 0 : skip + 1
const to = Math.min(skip + candidates.length, total)
const bands = profile.bands || {}
function open(c) {
if (c.userId) {
navigate(`/candidate/${c.userId}`)
return
}
setViewing(c)
}
return (
<>
<div className="cand-sub">
{total} candidate{total === 1 ? '' : 's'} suggested · vs {job.title}
{sort === 'score' && <span className="auto-tag">Sorted: Best Worst</span>}
</div>
<div className="toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search name, email, title, company…" />
</div>
<select className="select" aria-label="Match band" value={band} onChange={(e) => setBand(e.target.value)}>
<option value="">All results</option>
{jobsApi.MATCH_BANDS.map((b) => (
<option key={b} value={b}>{b} ({bands[b] ?? 0})</option>
))}
</select>
<div className="spacer" />
<div className="flex items-center gap-8">
<label className="text-muted text-sm" htmlFor="suggested-sort">Sort:</label>
<select
id="suggested-sort"
className={`select${sort === 'score' ? ' active-filter' : ''}`}
value={sort}
onChange={(e) => setSort(e.target.value)}
>
{SORTS.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</div>
</div>
{list.length === 0 ? (
candidates.length === 0 && !search.trim() ? (
<EmptyState icon="users" title="No suggested candidates yet">
Candidates appear here once their CVs are ATS-scored against this job.
</EmptyState>
) : (
<EmptyState title="No matches">Try a different search or band.</EmptyState>
)
) : (
<div className="grid g-3">
{list.map((c, i) => (
<SuggestedCard key={c.id} c={c} rank={sort === 'score' ? skip + i + 1 : null} onOpen={() => open(c)} />
))}
</div>
)}
{total > 0 && (
<Pagination
from={from}
to={to}
total={total}
page={page}
pages={pages}
setPage={(p) => setSkip((p - 1) * top)}
pageButtons={pageWindow(page, pages)}
pageSize={top}
pageSizeMax={100}
onPageSizeChange={setTop}
/>
)}
{viewing && (
<ScoredCandidateDetail
candidate={{
id: viewing.id,
name: viewing.name,
filename: viewing.email,
source: viewing.sourceLabel,
currentTitle: viewing.currentTitle,
currentCompany: viewing.currentCompany,
experience: viewing.experience,
aiScore: viewing.score,
matchedSkills: viewing.matchedSkills,
missingSkills: viewing.missingSkills,
critique: viewing.summary,
scoringStatus: 'completed',
applied: viewing.scoredAt,
}}
jobTitle={job.title}
onClose={() => setViewing(null)}
/>
)}
</>
)
}
function SuggestedCard({ c, rank, onOpen }) {
const matched = c.matchedSkills.slice(0, 3)
const missing = c.missingSkills.slice(0, 2)
const optional = c.optionalMatched.slice(0, 3)
const more = (c.matchedSkills.length - matched.length)
+ (c.missingSkills.length - missing.length)
+ (c.optionalMatched.length - optional.length)
const roleLine = [c.currentTitle, c.currentCompany].filter(Boolean).join(' at ')
const foot = [c.experience != null ? `${c.experience} yrs` : null, c.currentCompany].filter(Boolean).join(' · ')
return (
<div
className={`card cand-card${rank ? ' ranked' : ''}`}
role="button"
tabIndex={0}
onClick={onOpen}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen() } }}
>
<div className="card-body">
{rank && <span className="cand-rank">{rank}</span>}
<div className="cand-head">
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} className="avatar-lg" />
<div className="cand-id">
<div className="cand-name">{displayName(c.name)}</div>
<div className="cand-role">{roleLine || c.email || '—'}</div>
</div>
{c.score != null && <MiniRing score={c.score} />}
</div>
<div className="cand-skills">
{matched.map((s) => <span className="cand-chip ok" key={`m-${s}`}><Icon name="check" /> {s}</span>)}
{missing.map((s) => <span className="cand-chip miss" key={`x-${s}`}><Icon name="x" /> {s}</span>)}
{optional.map((s) => (
<span className="cand-chip opt" key={`o-${s}`} title="Optional skill from the job post"><Icon name="star" /> {s}</span>
))}
{more > 0 && <span className="cand-chip more">+{more} more</span>}
</div>
<p className="cand-crit">{c.summary || <span className="text-muted">No summary</span>}</p>
<div className="cand-foot">
<span className="cand-company">{foot || '—'}</span>
<Badge className="b-gray">{c.sourceLabel}</Badge>
</div>
</div>
</div>
)
}