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

419 lines
15 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.

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