419 lines
15 KiB
JavaScript
419 lines
15 KiB
JavaScript
/* ============================================================
|
||
Progress — single master/detail view of GET /job/stats/fetch.
|
||
|
||
Left: searchable/filterable job index (paged). Right: selected job
|
||
pipeline breakdown. Days-open is computed client-side from created_at.
|
||
============================================================ */
|
||
|
||
import { useEffect, useMemo, useState } from 'react'
|
||
import { useSearchParams } from 'react-router-dom'
|
||
import { useQuery } from '@tanstack/react-query'
|
||
|
||
import PageHeader from '../ui/PageHeader'
|
||
import { Badge, EmptyState, Icon, SkeletonRows } from '../ui/primitives'
|
||
import { qk } from '../lib/queryKeys'
|
||
import { friendlyAuthError } from '../lib/errors'
|
||
import * as jobStatsApi from '../api/jobStats'
|
||
|
||
const PAGE_SIZE = 10
|
||
|
||
const STAGES = [
|
||
{ key: 'shortlist', label: 'Shortlisted', tone: 'blue' },
|
||
{ key: 'screened', label: 'Screened', tone: 'purple' },
|
||
{ key: 'assessment', label: 'Assessment', tone: 'amber' },
|
||
{ key: 'interviewed', label: 'Interviewed', tone: 'indigo' },
|
||
{ key: 'offered', label: 'Offered', tone: 'teal' },
|
||
{ key: 'onHold', label: 'On hold', tone: 'amber' },
|
||
{ key: 'rejected', label: 'Rejected', tone: 'red' },
|
||
{ key: 'hired', label: 'Hired', tone: 'teal' },
|
||
]
|
||
|
||
function sumField(jobs, key) {
|
||
return jobs.reduce((total, job) => total + (Number(job[key]) || 0), 0)
|
||
}
|
||
|
||
function statusTone(status) {
|
||
const value = String(status || '').toLowerCase()
|
||
if (value === 'open') return 'success'
|
||
if (value === 'on_hold' || value === 'on hold') return 'warning'
|
||
return 'muted'
|
||
}
|
||
|
||
function Metric({ value, label, accent = false }) {
|
||
return (
|
||
<div className="progress-metric">
|
||
<strong className={accent ? 'accent' : undefined}>{value}</strong>
|
||
<span>{label}</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function JobRow({ job, active, onSelect }) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={`progress-job-row${active ? ' active' : ''}`}
|
||
onClick={onSelect}
|
||
aria-current={active ? 'true' : undefined}
|
||
>
|
||
<div className="progress-job-row-top">
|
||
<span className="progress-job-title">{job.title}</span>
|
||
<span className="progress-job-count">{job.total.toLocaleString()}</span>
|
||
</div>
|
||
<div className="progress-job-row-sub">
|
||
<span>
|
||
{[job.department, job.recruiterName || 'Unassigned'].filter(Boolean).join(' · ')}
|
||
</span>
|
||
<i className={`progress-status-dot tone-${statusTone(job.requisitionStatus)}`} aria-hidden="true" />
|
||
</div>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function StageRow({ job, stage }) {
|
||
const count = job[stage.key] || 0
|
||
const pct = job.total ? Math.round((count / job.total) * 100) : 0
|
||
return (
|
||
<div className="progress-stage-row">
|
||
<div className="progress-stage-row-label">
|
||
<i className={`progress-stage-dot stage-${stage.tone}`} aria-hidden="true" />
|
||
<span>{stage.label}</span>
|
||
</div>
|
||
<div className="progress-stage-row-meter" aria-hidden="true">
|
||
<i className={`stage-${stage.tone}`} style={{ width: `${pct}%` }} />
|
||
</div>
|
||
<strong>{count.toLocaleString()}</strong>
|
||
<span className="progress-stage-row-pct">{pct}%</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function JobDetail({ job }) {
|
||
const interviewRate = job.total ? Math.round((job.interviewed / job.total) * 100) : null
|
||
const offerRate = job.offered ? Math.round((job.hired / job.offered) * 100) : null
|
||
const active = job.shortlist + job.screened + job.assessment + job.interviewed
|
||
const used = STAGES.reduce((n, stage) => n + (job[stage.key] || 0), 0)
|
||
const barBase = Math.max(used, job.total, 1)
|
||
|
||
return (
|
||
<div className="progress-detail">
|
||
<div className="progress-detail-head">
|
||
<div>
|
||
<div className="progress-detail-tags">
|
||
{job.department && <span className="progress-eyebrow">{job.department}</span>}
|
||
<Badge className={`b-${statusTone(job.requisitionStatus) === 'success' ? 'green' : statusTone(job.requisitionStatus) === 'warning' ? 'amber' : 'gray'}`}>
|
||
{job.status}
|
||
</Badge>
|
||
</div>
|
||
<h2>{job.title}</h2>
|
||
<p className="progress-meta">
|
||
{job.location && <span><Icon name="map" /> {job.location}</span>}
|
||
<span>
|
||
Recruiter ·{' '}
|
||
{job.recruiterName
|
||
? <b>{job.recruiterName}</b>
|
||
: <span className="text-muted">Unassigned</span>}
|
||
</span>
|
||
{job.daysOpen != null && (
|
||
<span>Open {job.daysOpen} day{job.daysOpen === 1 ? '' : 's'}</span>
|
||
)}
|
||
</p>
|
||
</div>
|
||
<div className="progress-selected-total">
|
||
<strong>{job.total.toLocaleString()}</strong>
|
||
<span>unique applicants</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="progress-bar-wrap">
|
||
<div className="progress-bar-labels">
|
||
<span>Candidate distribution by current stage</span>
|
||
<span>{job.total.toLocaleString()} applicants</span>
|
||
</div>
|
||
<div className="progress-bar-track" role="img" aria-label="Stage distribution">
|
||
{STAGES.map((stage) => {
|
||
const n = job[stage.key] || 0
|
||
if (!n) return null
|
||
return (
|
||
<div
|
||
key={stage.key}
|
||
className={`progress-bar-seg stage-${stage.tone}`}
|
||
title={`${stage.label}: ${n}`}
|
||
style={{ width: `${(n / barBase) * 100}%` }}
|
||
/>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="progress-breakdown">
|
||
<div className="progress-section-head">
|
||
<h3>Pipeline breakdown</h3>
|
||
<span>Count · share of applicants</span>
|
||
</div>
|
||
<div className="progress-stage-rows">
|
||
{STAGES.map((stage) => (
|
||
<StageRow key={stage.key} job={job} stage={stage} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="progress-metric-grid">
|
||
<Metric value={active.toLocaleString()} label="Active pipeline" />
|
||
<Metric value={interviewRate == null ? '—' : `${interviewRate}%`} label="Interview rate" />
|
||
<Metric value={job.offered.toLocaleString()} label="Offers made" accent />
|
||
<Metric value={offerRate == null ? '—' : `${offerRate}%`} label="Offer-to-hire" />
|
||
</div>
|
||
|
||
<div className="progress-bottom-grid">
|
||
<div>
|
||
<h3>Attention needed</h3>
|
||
<div className="progress-attention-list">
|
||
<div className="progress-attention-item">
|
||
<span>Candidates on hold</span>
|
||
<strong className="text-warning">{job.onHold}</strong>
|
||
</div>
|
||
<div className="progress-attention-item">
|
||
<span>Job aging</span>
|
||
<strong>
|
||
{job.daysOpen == null ? '—' : `${job.daysOpen} day${job.daysOpen === 1 ? '' : 's'}`}
|
||
</strong>
|
||
</div>
|
||
<div className="progress-attention-item">
|
||
<span>Rejected</span>
|
||
<strong className="text-danger">{job.rejected}</strong>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="card progress-outcome-card">
|
||
<div className="card-body">
|
||
<h3>Hiring outcome</h3>
|
||
<div className="progress-outcome-grid">
|
||
<Metric value={job.offered} label="Offers" />
|
||
<Metric value={job.hired} label="Hired" accent />
|
||
<Metric value={interviewRate == null ? '—' : `${interviewRate}%`} label="Interview rate" />
|
||
<Metric value={offerRate == null ? '—' : `${offerRate}%`} label="Offer-to-hire" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function Progress() {
|
||
const [searchParams, setSearchParams] = useSearchParams()
|
||
const deepLinkJobId = searchParams.get('job') || ''
|
||
|
||
const [selectedId, setSelectedId] = useState(deepLinkJobId)
|
||
const [query, setQuery] = useState('')
|
||
const [status, setStatus] = useState('all')
|
||
const [page, setPage] = useState(0)
|
||
|
||
const statsQuery = useQuery({
|
||
queryKey: qk.jobs.stats({ top: 500, skip: 0 }),
|
||
queryFn: async () => {
|
||
const res = await jobStatsApi.list({ top: 500, skip: 0 })
|
||
const rows = Array.isArray(res?.data) ? res.data : res?.data ? [res.data] : []
|
||
return rows.map(jobStatsApi.toJobStatsView)
|
||
},
|
||
})
|
||
|
||
const jobs = statsQuery.data ?? []
|
||
|
||
const filtered = useMemo(() => {
|
||
const term = query.trim().toLowerCase()
|
||
return jobs
|
||
.filter((job) => {
|
||
if (status !== 'all' && String(job.requisitionStatus || '').toLowerCase() !== status) {
|
||
return false
|
||
}
|
||
if (!term) return true
|
||
return (
|
||
job.title.toLowerCase().includes(term)
|
||
|| (job.department || '').toLowerCase().includes(term)
|
||
|| (job.location || '').toLowerCase().includes(term)
|
||
|| (job.recruiterName || '').toLowerCase().includes(term)
|
||
)
|
||
})
|
||
.sort((a, b) => (b.total - a.total) || a.title.localeCompare(b.title))
|
||
}, [jobs, query, status])
|
||
|
||
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
|
||
const safePage = Math.min(page, pageCount - 1)
|
||
const pageRows = filtered.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE)
|
||
|
||
useEffect(() => {
|
||
setPage(0)
|
||
}, [query, status])
|
||
|
||
// Deep-link: once jobs load, jump the sidebar page to that role.
|
||
useEffect(() => {
|
||
if (!deepLinkJobId || !filtered.length) return
|
||
const idx = filtered.findIndex((j) => String(j.id) === String(deepLinkJobId))
|
||
if (idx >= 0) setPage(Math.floor(idx / PAGE_SIZE))
|
||
}, [deepLinkJobId, filtered])
|
||
|
||
useEffect(() => {
|
||
if (!jobs.length) {
|
||
setSelectedId('')
|
||
return
|
||
}
|
||
const matchId = (id) => jobs.some((j) => String(j.id) === String(id))
|
||
if (deepLinkJobId && matchId(deepLinkJobId)) {
|
||
setSelectedId(String(deepLinkJobId))
|
||
return
|
||
}
|
||
if (!selectedId || !matchId(selectedId)) {
|
||
setSelectedId(String(jobs[0].id))
|
||
}
|
||
}, [jobs, selectedId, deepLinkJobId])
|
||
|
||
const selectJob = (id) => {
|
||
const next = String(id || '')
|
||
setSelectedId(next)
|
||
const idx = filtered.findIndex((j) => String(j.id) === next)
|
||
if (idx >= 0) setPage(Math.floor(idx / PAGE_SIZE))
|
||
setSearchParams((prev) => {
|
||
const nextParams = new URLSearchParams(prev)
|
||
if (next) nextParams.set('job', next)
|
||
else nextParams.delete('job')
|
||
return nextParams
|
||
}, { replace: true })
|
||
}
|
||
|
||
const selected = jobs.find((j) => String(j.id) === String(selectedId))
|
||
|| filtered[0]
|
||
|| jobs[0]
|
||
|| null
|
||
|
||
const totalApplicants = sumField(jobs, 'total')
|
||
const activeRoles = jobs.filter((j) => String(j.requisitionStatus || '').toLowerCase() === 'open').length
|
||
const rangeStart = filtered.length ? safePage * PAGE_SIZE + 1 : 0
|
||
const rangeEnd = Math.min(filtered.length, safePage * PAGE_SIZE + PAGE_SIZE)
|
||
|
||
return (
|
||
<div className="page progress-page">
|
||
<PageHeader
|
||
title="Progress"
|
||
sub="One operational view for high-volume recruiting"
|
||
actions={(
|
||
<div className="progress-header-stats">
|
||
<Metric value={jobs.length.toLocaleString()} label="job posts" />
|
||
<Metric value={totalApplicants.toLocaleString()} label="unique applicants" />
|
||
<Metric value={activeRoles.toLocaleString()} label="active roles" accent />
|
||
</div>
|
||
)}
|
||
/>
|
||
|
||
{statsQuery.isPending && (
|
||
<div className="card"><div className="card-body"><SkeletonRows rows={8} /></div></div>
|
||
)}
|
||
|
||
{statsQuery.isError && (
|
||
<div className="card"><div className="card-body">
|
||
<EmptyState icon="alert" title="Couldn’t load job progress">
|
||
{friendlyAuthError(statsQuery.error, 'The server did not answer.')}
|
||
{' '}This screen needs <code>jobs.view</code> or <code>pipeline.view</code>.
|
||
</EmptyState>
|
||
</div></div>
|
||
)}
|
||
|
||
{!statsQuery.isPending && !statsQuery.isError && jobs.length === 0 && (
|
||
<div className="card"><div className="card-body">
|
||
<EmptyState icon="briefcase" title="No job posts yet">
|
||
Open a requisition to start tracking candidate progress.
|
||
</EmptyState>
|
||
</div></div>
|
||
)}
|
||
|
||
{!statsQuery.isPending && !statsQuery.isError && jobs.length > 0 && (
|
||
<div className="progress-shell">
|
||
<aside className="progress-sidebar">
|
||
<div className="progress-sidebar-filters">
|
||
<div className="toolbar-search">
|
||
<Icon name="search" />
|
||
<input
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
placeholder="Search jobs, teams, recruiters…"
|
||
aria-label="Search job posts"
|
||
/>
|
||
</div>
|
||
<div className="progress-sidebar-controls">
|
||
<select
|
||
className="select"
|
||
value={status}
|
||
onChange={(e) => setStatus(e.target.value)}
|
||
aria-label="Filter by status"
|
||
>
|
||
<option value="all">All statuses</option>
|
||
<option value="open">Open</option>
|
||
<option value="on_hold">On hold</option>
|
||
<option value="closed">Closed</option>
|
||
</select>
|
||
<span className="progress-sort-chip">Sort: applicants</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="progress-sidebar-meta">
|
||
<span>{filtered.length} shown · {jobs.length} total roles</span>
|
||
<span>Applicants</span>
|
||
</div>
|
||
|
||
<div className="progress-job-list">
|
||
{pageRows.length === 0 ? (
|
||
<div className="progress-sidebar-empty">No jobs match this filter.</div>
|
||
) : (
|
||
pageRows.map((job) => (
|
||
<JobRow
|
||
key={job.id}
|
||
job={job}
|
||
active={String(job.id) === String(selected?.id)}
|
||
onSelect={() => selectJob(job.id)}
|
||
/>
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
<div className="progress-sidebar-pager">
|
||
<span>
|
||
{rangeStart}–{rangeEnd} of {filtered.length}
|
||
</span>
|
||
<div className="progress-pager-actions">
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary btn-sm"
|
||
disabled={safePage <= 0}
|
||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||
>
|
||
Previous
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary btn-sm"
|
||
disabled={safePage >= pageCount - 1}
|
||
onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
|
||
>
|
||
Next
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<main className="progress-main">
|
||
{selected
|
||
? <JobDetail job={selected} />
|
||
: (
|
||
<EmptyState icon="briefcase" title="Select a job post">
|
||
Choose a role from the list to inspect its pipeline.
|
||
</EmptyState>
|
||
)}
|
||
</main>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|