/* ============================================================
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 (
{value}
{label}
)
}
function JobRow({ job, active, onSelect }) {
return (
)
}
function StageRow({ job, stage }) {
const count = job[stage.key] || 0
const pct = job.total ? Math.round((count / job.total) * 100) : 0
return (
{stage.label}
{count.toLocaleString()}
{pct}%
)
}
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 (
{job.department && {job.department}}
{job.status}
{job.title}
{job.location && {job.location}}
Recruiter ·{' '}
{job.recruiterName
? {job.recruiterName}
: Unassigned}
{job.daysOpen != null && (
Open {job.daysOpen} day{job.daysOpen === 1 ? '' : 's'}
)}
{job.total.toLocaleString()}
unique applicants
Candidate distribution by current stage
{job.total.toLocaleString()} applicants
{STAGES.map((stage) => {
const n = job[stage.key] || 0
if (!n) return null
return (
)
})}
Pipeline breakdown
Count · share of applicants
{STAGES.map((stage) => (
))}
Attention needed
Candidates on hold
{job.onHold}
Job aging
{job.daysOpen == null ? '—' : `${job.daysOpen} day${job.daysOpen === 1 ? '' : 's'}`}
Rejected
{job.rejected}
)
}
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 (
)}
/>
{statsQuery.isPending && (
)}
{statsQuery.isError && (
{friendlyAuthError(statsQuery.error, 'The server did not answer.')}
{' '}This screen needs jobs.view or pipeline.view.
)}
{!statsQuery.isPending && !statsQuery.isError && jobs.length === 0 && (
Open a requisition to start tracking candidate progress.
)}
{!statsQuery.isPending && !statsQuery.isError && jobs.length > 0 && (
{selected
?
: (
Choose a role from the list to inspect its pipeline.
)}
)}
)
}