612 lines
25 KiB
JavaScript
612 lines
25 KiB
JavaScript
/* ============================================================
|
||
Analytics — live on the /analytics/* endpoints, including POST /analytics/ask
|
||
(natural-language questions routed onto the same governed queries).
|
||
|
||
The Week / Month / Quarter pills are real now: every endpoint takes
|
||
from_date / to_date / department / recruiter_id, and all four filters are
|
||
sent on every request. The department and recruiter option lists are
|
||
themselves live (from /jobs/fetch and /analytics/recruiter-performance), so
|
||
the filters can only offer values the data actually contains.
|
||
|
||
THREE OF THE PROTOTYPE'S EIGHT CHARTS CHANGED SOURCE OR SHAPE:
|
||
|
||
- Offer Acceptance now counts real offers (/offers/fetch) instead of a seed
|
||
ratio. It renders a permission notice rather than a chart when the viewer
|
||
lacks offers.view, because a doughnut of zeros reads as "nobody accepted".
|
||
|
||
- Applications by Department fans one funnel request out per department.
|
||
There is no group-by-department endpoint, but `department` is a filter on
|
||
every route, so N small parallel reads is the honest way to get it. The
|
||
list is capped at DEPT_CAP and the cap is stated on the card.
|
||
|
||
- Time to Hire / Time to Fill were monthly line charts over seed arrays.
|
||
/analytics/kpis/fetch returns those two as SCALARS plus a prior-window
|
||
comparison — there is no monthly series anywhere in the API — so they are
|
||
now a current-vs-prior grouped bar, which is what the data supports.
|
||
============================================================ */
|
||
|
||
import { useMemo, useState } from 'react'
|
||
import { useMutation, useQueries, useQuery } from '@tanstack/react-query'
|
||
|
||
import Chart, { ChartLegend } from '../ui/Chart'
|
||
import Charts from '../lib/charts'
|
||
import DataTable from '../ui/DataTable'
|
||
import { EmptyState, Icon } from '../ui/primitives'
|
||
import { qk } from '../lib/queryKeys'
|
||
import { friendlyAuthError } from '../lib/errors'
|
||
import * as analyticsApi from '../api/analytics'
|
||
import * as offersApi from '../api/offers'
|
||
import * as jobsApi from '../api/jobs'
|
||
|
||
/** Fanning out per department is cheap but not free — a hard ceiling, stated on the card. */
|
||
const DEPT_CAP = 12
|
||
const TREND_MONTHS = 7
|
||
|
||
const RANGES = [
|
||
{ key: 'week', label: 'Week', days: 7 },
|
||
{ key: 'month', label: 'Month', days: 30 },
|
||
{ key: 'quarter', label: 'Quarter', days: 90 },
|
||
{ key: 'year', label: 'Year', days: 365 },
|
||
]
|
||
|
||
function rangeWindow(key) {
|
||
const range = RANGES.find((r) => r.key === key) ?? RANGES[1]
|
||
const to = new Date()
|
||
const from = new Date(to.getTime() - range.days * 86400000)
|
||
return { fromDate: from.toISOString(), toDate: to.toISOString() }
|
||
}
|
||
|
||
const INTENT_LABELS = {
|
||
kpis: 'KPI summary',
|
||
funnel: 'pipeline funnel',
|
||
hiring_trend: 'hiring trend',
|
||
source_performance: 'source performance',
|
||
recruiter_performance: 'recruiter performance',
|
||
}
|
||
|
||
/* Ask Analytics (REQ-ANL-05). The backend maps the question onto ONE
|
||
whitelisted analytics intent and runs the same governed query the charts
|
||
use — the model never writes SQL — then narrates the result. Every number
|
||
in the answer is therefore also on this screen somewhere. */
|
||
function AskAnalyticsCard() {
|
||
const [question, setQuestion] = useState('')
|
||
const ask = useMutation({
|
||
mutationFn: (q) => analyticsApi.ask(q),
|
||
})
|
||
|
||
const submit = () => {
|
||
const q = question.trim()
|
||
if (q) ask.mutate(q)
|
||
}
|
||
|
||
const result = ask.data?.data
|
||
const rows = Array.isArray(result?.data) ? result.data : null
|
||
const tableColumns = rows?.length
|
||
? Object.keys(rows[0]).filter((key) => key !== 'id').slice(0, 6).map((key) => ({
|
||
key,
|
||
label: key.replaceAll('_', ' '),
|
||
sortable: true,
|
||
render: (r) => (r[key] == null || r[key] === '' ? <span className="text-muted">—</span> : String(r[key])),
|
||
}))
|
||
: null
|
||
|
||
return (
|
||
<div className="card mb-18">
|
||
<div className="card-head">
|
||
<div>
|
||
<h3>Ask Analytics</h3>
|
||
<span className="ch-sub">Plain-language questions, answered from the same governed queries as the charts</span>
|
||
</div>
|
||
</div>
|
||
<div className="card-body">
|
||
<form
|
||
noValidate
|
||
className="flex items-center gap-8"
|
||
onSubmit={(e) => { e.preventDefault(); submit() }}
|
||
>
|
||
<input
|
||
style={{ flex: 1 }}
|
||
value={question}
|
||
maxLength={500}
|
||
onChange={(e) => setQuestion(e.target.value)}
|
||
placeholder="e.g. How many hires did Engineering make last quarter?"
|
||
/>
|
||
<button type="submit" className="btn btn-primary" disabled={ask.isPending || !question.trim()}>
|
||
<Icon name="sparkles" /> {ask.isPending ? 'Asking…' : 'Ask'}
|
||
</button>
|
||
</form>
|
||
|
||
{ask.isError && (
|
||
<p className="text-muted" style={{ marginTop: 12 }}>
|
||
<Icon name="alert" /> {friendlyAuthError(ask.error, 'The AI assistant did not answer. The charts below are unaffected.')}
|
||
</p>
|
||
)}
|
||
|
||
{result && (
|
||
<div style={{ marginTop: 14 }}>
|
||
<p style={{ whiteSpace: 'pre-wrap' }}>{result.answer}</p>
|
||
{result.intent && (
|
||
<p className="text-muted" style={{ marginTop: 8, fontSize: 12 }}>
|
||
<Icon name="info" /> Answered from the {INTENT_LABELS[result.intent] ?? result.intent} query
|
||
{result.params?.department ? ` · ${result.params.department}` : ''}
|
||
{result.params?.from_date ? ` · from ${new Date(result.params.from_date).toLocaleDateString()}` : ''}
|
||
{result.params?.to_date ? ` · to ${new Date(result.params.to_date).toLocaleDateString()}` : ''}
|
||
</p>
|
||
)}
|
||
{tableColumns && (
|
||
<div style={{ marginTop: 10 }}>
|
||
<DataTable
|
||
columns={tableColumns}
|
||
rows={rows.slice(0, 20).map((r, i) => ({ ...r, id: r.id ?? i }))}
|
||
pageSize={5}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** Every chart that can render is wrapped in this, so one failing read never blanks the page. */
|
||
function ChartCard({ title, sub, query, height = 260, permission, children, footer }) {
|
||
return (
|
||
<div className="card">
|
||
<div className="card-head">
|
||
<div><h3>{title}</h3>{sub && <span className="ch-sub">{sub}</span>}</div>
|
||
</div>
|
||
<div className="card-body">
|
||
{query.isPending && <EmptyState icon="clock" title="Loading…">Fetching from the server.</EmptyState>}
|
||
{query.isError && (
|
||
<EmptyState icon="alert" title={`Couldn’t load ${title.toLowerCase()}`}>
|
||
{friendlyAuthError(query.error, 'The server did not answer.')}
|
||
{permission && <> This card needs the <code>{permission}</code> permission.</>}
|
||
</EmptyState>
|
||
)}
|
||
{!query.isPending && !query.isError && children(height)}
|
||
{!query.isPending && !query.isError && footer}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function Analytics() {
|
||
const [rangeKey, setRangeKey] = useState('month')
|
||
const [department, setDepartment] = useState('')
|
||
const [recruiterId, setRecruiterId] = useState('')
|
||
|
||
const span = useMemo(() => rangeWindow(rangeKey), [rangeKey])
|
||
const filters = useMemo(
|
||
() => ({ ...span, department: department || undefined, recruiterId: recruiterId || undefined }),
|
||
[span, department, recruiterId],
|
||
)
|
||
/* One stable object identity for every query key, so changing a filter
|
||
invalidates all six reads together instead of six times over. */
|
||
const keyParams = useMemo(
|
||
() => ({ range: rangeKey, department: department || null, recruiterId: recruiterId || null }),
|
||
[rangeKey, department, recruiterId],
|
||
)
|
||
|
||
const kpisQuery = useQuery({
|
||
queryKey: qk.analytics.kpis(keyParams),
|
||
queryFn: async () => (await analyticsApi.kpis(filters))?.data ?? null,
|
||
})
|
||
const trendQuery = useQuery({
|
||
queryKey: qk.analytics.trend({ ...keyParams, months: TREND_MONTHS }),
|
||
queryFn: async () => (await analyticsApi.hiringTrend({ months: TREND_MONTHS, ...filters }))?.data
|
||
?? { labels: [], applications: [], hires: [] },
|
||
})
|
||
const funnelQuery = useQuery({
|
||
queryKey: qk.analytics.funnel(keyParams),
|
||
queryFn: async () => {
|
||
const res = await analyticsApi.funnel(filters)
|
||
return Array.isArray(res?.data) ? res.data : []
|
||
},
|
||
})
|
||
const sourcesQuery = useQuery({
|
||
queryKey: qk.analytics.sources(keyParams),
|
||
queryFn: async () => {
|
||
const res = await analyticsApi.sourcePerformance(filters)
|
||
return Array.isArray(res?.data) ? res.data : []
|
||
},
|
||
})
|
||
const recruitersQuery = useQuery({
|
||
queryKey: qk.analytics.recruiters({ ...keyParams, top: 8 }),
|
||
queryFn: async () => {
|
||
const res = await analyticsApi.recruiterPerformance({ top: 8, ...filters })
|
||
return Array.isArray(res?.data) ? res.data : []
|
||
},
|
||
})
|
||
|
||
/* Offer acceptance is not an analytics endpoint — it is counted off the real
|
||
offers table, which is the only place offer outcomes exist. */
|
||
const offersQuery = useQuery({
|
||
queryKey: qk.offers.list({ top: 500, scope: 'analytics' }),
|
||
queryFn: async () => {
|
||
const res = await offersApi.list({ top: 500 })
|
||
return Array.isArray(res?.data) ? res.data : []
|
||
},
|
||
retry: false,
|
||
})
|
||
|
||
/* Department options come from the requisition list, so the filter can only
|
||
offer departments that exist. active_only is false: a closed requisition's
|
||
department is still a legitimate lens on a past window. */
|
||
const deptsQuery = useQuery({
|
||
queryKey: qk.jobs.list({ scope: 'departments' }),
|
||
queryFn: async () => {
|
||
const res = await jobsApi.list({ top: 500, activeOnly: false })
|
||
const rows = Array.isArray(res?.data) ? res.data : []
|
||
return [...new Set(rows.map((r) => r.department).filter(Boolean))].sort()
|
||
},
|
||
})
|
||
const departments = deptsQuery.data ?? []
|
||
|
||
/* The recruiter filter reuses the unfiltered recruiter list so selecting one
|
||
never empties its own option list. */
|
||
const allRecruitersQuery = useQuery({
|
||
queryKey: qk.analytics.recruiters({ scope: 'options' }),
|
||
queryFn: async () => {
|
||
const res = await analyticsApi.recruiterPerformance({ top: 100 })
|
||
return Array.isArray(res?.data) ? res.data : []
|
||
},
|
||
})
|
||
|
||
/* Applications per department: one funnel read each, in parallel. Capped, and
|
||
skipped entirely while a department filter is already applied — the answer
|
||
would be a single bar. */
|
||
const deptTargets = useMemo(
|
||
() => (department ? [] : departments.slice(0, DEPT_CAP)),
|
||
[departments, department],
|
||
)
|
||
const deptQueries = useQueries({
|
||
queries: deptTargets.map((dept) => ({
|
||
queryKey: qk.analytics.funnel({ ...keyParams, department: dept }),
|
||
queryFn: async () => {
|
||
const res = await analyticsApi.funnel({ ...filters, department: dept })
|
||
const rows = Array.isArray(res?.data) ? res.data : []
|
||
return { dept, count: rows.reduce((sum, r) => sum + (r.count || 0), 0) }
|
||
},
|
||
})),
|
||
})
|
||
const deptPending = deptQueries.some((qr) => qr.isPending)
|
||
/* useQueries returns a FRESH ARRAY every render, so memoising on it directly
|
||
is a no-op — and the chart payload derived from it would change identity on
|
||
every parent render, which re-runs Chart's effect and re-animates the
|
||
canvas each time. Memoise on a value-based signature instead. */
|
||
const deptSignature = deptQueries
|
||
.map((qr) => (qr.data ? `${qr.data.dept}:${qr.data.count}` : '-'))
|
||
.join('|')
|
||
const deptRows = useMemo(
|
||
() => deptQueries
|
||
.map((qr) => qr.data)
|
||
.filter(Boolean)
|
||
.filter((r) => r.count > 0)
|
||
.sort((a, b) => b.count - a.count),
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
[deptSignature],
|
||
)
|
||
|
||
/* ---------- chart payloads (memoised: Chart requires stable identity) ---------- */
|
||
|
||
const trend = useMemo(() => {
|
||
const t = trendQuery.data ?? { labels: [], applications: [], hires: [] }
|
||
return {
|
||
labels: t.labels ?? [],
|
||
area: true,
|
||
datasets: [
|
||
{ label: 'Applications', data: t.applications ?? [], color: Charts.PALETTE[4] },
|
||
{ label: 'Hires', data: t.hires ?? [], color: Charts.PALETTE[0] },
|
||
],
|
||
}
|
||
}, [trendQuery.data])
|
||
|
||
const apps = useMemo(() => {
|
||
const t = trendQuery.data ?? { labels: [], applications: [] }
|
||
return { labels: t.labels ?? [], data: t.applications ?? [] }
|
||
}, [trendQuery.data])
|
||
|
||
const source = useMemo(() => {
|
||
const rows = sourcesQuery.data ?? []
|
||
return {
|
||
labels: rows.map((s) => s.source),
|
||
data: rows.map((s) => s.count),
|
||
centerValue: rows.reduce((sum, s) => sum + (s.count || 0), 0),
|
||
centerLabel: 'Applications',
|
||
}
|
||
}, [sourcesQuery.data])
|
||
|
||
const sourceLegend = useMemo(
|
||
() => (sourcesQuery.data ?? []).map((s, i) => ({
|
||
label: s.source,
|
||
color: Charts.PALETTE[i % Charts.PALETTE.length],
|
||
})),
|
||
[sourcesQuery.data],
|
||
)
|
||
|
||
const offerSplit = useMemo(() => {
|
||
const rows = offersQuery.data ?? []
|
||
const accepted = rows.filter((o) => o.status === 'accepted').length
|
||
const declined = rows.filter((o) => o.status === 'declined').length
|
||
const pending = rows.filter((o) => ['sent', 'negotiating'].includes(o.status)).length
|
||
const decided = accepted + declined
|
||
return {
|
||
labels: ['Accepted', 'Pending', 'Declined'],
|
||
data: [accepted, pending, declined],
|
||
colors: [Charts.token('--success'), Charts.token('--warning'), Charts.token('--danger')],
|
||
centerValue: decided ? `${Math.round((accepted / decided) * 100)}%` : '—',
|
||
centerLabel: 'Accept rate',
|
||
empty: rows.length === 0,
|
||
}
|
||
}, [offersQuery.data])
|
||
|
||
/* The funnel has 11 statuses; REJECTED is dropped because it is an outcome,
|
||
not a stage, and its volume flattens every other bar. */
|
||
const pipeline = useMemo(() => {
|
||
const rows = (funnelQuery.data ?? []).filter((p) => p.stage !== 'REJECTED')
|
||
return {
|
||
labels: rows.map((p) => p.stage),
|
||
data: rows.map((p) => p.count),
|
||
colors: Charts.PALETTE,
|
||
}
|
||
}, [funnelQuery.data])
|
||
|
||
const dept = useMemo(
|
||
() => ({ labels: deptRows.map((d) => d.dept), data: deptRows.map((d) => d.count) }),
|
||
[deptRows],
|
||
)
|
||
|
||
const rec = useMemo(() => {
|
||
const rows = [...(recruitersQuery.data ?? [])].sort((a, b) => (b.hires ?? 0) - (a.hires ?? 0))
|
||
return { labels: rows.map((r) => r.name || 'Recruiter'), data: rows.map((r) => r.hires ?? 0) }
|
||
}, [recruitersQuery.data])
|
||
|
||
/* Current vs prior window. The KPI payload has no monthly series for these,
|
||
only the two scalars and their prior-window counterparts. */
|
||
const cycle = useMemo(() => {
|
||
const k = kpisQuery.data ?? {}
|
||
const round = (v) => (v == null ? 0 : Math.round(Number(v)))
|
||
return {
|
||
labels: ['Time to Hire', 'Time to Fill'],
|
||
datasets: [
|
||
{ label: 'Current', data: [round(k.time_to_hire), round(k.time_to_fill)], color: Charts.PALETTE[0] },
|
||
{ label: 'Prior', data: [round(k.time_to_hire_prior), round(k.time_to_fill_prior)], color: Charts.PALETTE[2] },
|
||
],
|
||
yFmt: (v) => `${v}d`,
|
||
}
|
||
}, [kpisQuery.data])
|
||
|
||
const trendLegend = useMemo(
|
||
() => [
|
||
{ label: 'Applications', color: Charts.PALETTE[4] },
|
||
{ label: 'Hires', color: Charts.PALETTE[0] },
|
||
],
|
||
[],
|
||
)
|
||
const cycleLegend = useMemo(
|
||
() => [
|
||
{ label: 'Current window', color: Charts.PALETTE[0] },
|
||
{ label: 'Prior window', color: Charts.PALETTE[2] },
|
||
],
|
||
[],
|
||
)
|
||
|
||
const k = kpisQuery.data
|
||
|
||
return (
|
||
<div className="page">
|
||
<div className="page-head">
|
||
<div>
|
||
<h1 className="page-title">Analytics</h1>
|
||
<p className="page-sub">Deep-dive metrics across your recruitment funnel</p>
|
||
</div>
|
||
<div className="page-head-actions">
|
||
<div className="pill-tabs">
|
||
{RANGES.map((r) => (
|
||
<span
|
||
key={r.key}
|
||
className={`pill-tab${rangeKey === r.key ? ' active' : ''}`}
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={() => setRangeKey(r.key)}
|
||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setRangeKey(r.key) } }}
|
||
>
|
||
{r.label}
|
||
</span>
|
||
))}
|
||
</div>
|
||
<select className="select" value={department} onChange={(e) => setDepartment(e.target.value)}>
|
||
<option value="">All Departments</option>
|
||
{departments.map((d) => <option key={d}>{d}</option>)}
|
||
</select>
|
||
<select className="select" value={recruiterId} onChange={(e) => setRecruiterId(e.target.value)}>
|
||
<option value="">All Recruiters</option>
|
||
{(allRecruitersQuery.data ?? []).map((r) => (
|
||
<option key={r.id} value={r.id}>{r.name}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{kpisQuery.isError && (
|
||
<div className="card mb-18">
|
||
<div className="card-body">
|
||
<EmptyState icon="alert" title="Couldn’t load analytics">
|
||
{friendlyAuthError(kpisQuery.error, 'The server did not answer.')}
|
||
{' '}This screen needs the <code>analytics.view</code> permission.
|
||
</EmptyState>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<AskAnalyticsCard />
|
||
|
||
<div className="grid g-2 mb-18">
|
||
<ChartCard
|
||
title="Hiring Trend"
|
||
sub={`Hires vs applications, last ${TREND_MONTHS} months`}
|
||
query={trendQuery}
|
||
permission="analytics.view"
|
||
footer={<ChartLegend items={trendLegend} />}
|
||
>
|
||
{(h) => <div className="chart-wrap"><Chart type="line" data={trend} height={h} /></div>}
|
||
</ChartCard>
|
||
|
||
<ChartCard title="Applications Received" sub="Monthly volume" query={trendQuery} permission="analytics.view">
|
||
{(h) => <div className="chart-wrap"><Chart type="bar" data={apps} height={h} /></div>}
|
||
</ChartCard>
|
||
</div>
|
||
|
||
<div className="grid g-3 mb-18">
|
||
<ChartCard
|
||
title="Source Breakdown"
|
||
sub="Where applications arrive from"
|
||
query={sourcesQuery}
|
||
height={220}
|
||
permission="analytics.view"
|
||
footer={<ChartLegend items={sourceLegend} />}
|
||
>
|
||
{(h) => (
|
||
(sourcesQuery.data ?? []).length === 0
|
||
? <EmptyState icon="inbox" title="No source data">Applications are tagged once a source channel is matched.</EmptyState>
|
||
: <div className="chart-wrap"><Chart type="doughnut" data={source} height={h} /></div>
|
||
)}
|
||
</ChartCard>
|
||
|
||
<div className="card">
|
||
<div className="card-head"><div><h3>Offer Acceptance</h3><span className="ch-sub">From the offers table</span></div></div>
|
||
<div className="card-body">
|
||
{offersQuery.isPending && <EmptyState icon="clock" title="Loading…">Counting offers.</EmptyState>}
|
||
{offersQuery.isError && (
|
||
<EmptyState icon="lock" title="Offers not visible">
|
||
{friendlyAuthError(offersQuery.error, 'The offers table did not answer.')}
|
||
{' '}This card needs the <code>offers.view</code> permission.
|
||
</EmptyState>
|
||
)}
|
||
{!offersQuery.isPending && !offersQuery.isError && (
|
||
offerSplit.empty ? (
|
||
<EmptyState icon="file" title="No offers yet">This fills in once the first offer is issued.</EmptyState>
|
||
) : (
|
||
<>
|
||
<div className="chart-wrap"><Chart type="doughnut" data={offerSplit} height={220} /></div>
|
||
<div className="chart-legend">
|
||
<span className="legend-item"><span className="legend-dot" style={{ background: 'var(--success)' }} />Accepted</span>
|
||
<span className="legend-item"><span className="legend-dot" style={{ background: 'var(--warning)' }} />Pending</span>
|
||
<span className="legend-item"><span className="legend-dot" style={{ background: 'var(--danger)' }} />Declined</span>
|
||
</div>
|
||
</>
|
||
)
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<ChartCard
|
||
title="Pipeline Distribution"
|
||
sub="Active by stage, rejections excluded"
|
||
query={funnelQuery}
|
||
permission="analytics.view"
|
||
>
|
||
{(h) => (
|
||
pipeline.data.every((n) => !n)
|
||
? <EmptyState icon="inbox" title="No pipeline data">Stage counts appear once applications land.</EmptyState>
|
||
: <div className="chart-wrap"><Chart type="horizontalBar" data={pipeline} height={h} /></div>
|
||
)}
|
||
</ChartCard>
|
||
</div>
|
||
|
||
<div className="grid g-2 mb-18">
|
||
<div className="card">
|
||
<div className="card-head">
|
||
<div>
|
||
<h3>Applications by Department</h3>
|
||
<span className="ch-sub">
|
||
{department
|
||
? 'Filtered to one department'
|
||
: `Top ${Math.min(departments.length, DEPT_CAP)} of ${departments.length}`}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="card-body">
|
||
{department ? (
|
||
<EmptyState icon="filter" title={`Filtered to ${department}`}>
|
||
Clear the department filter to compare teams.
|
||
</EmptyState>
|
||
) : deptPending ? (
|
||
<EmptyState icon="clock" title="Loading…">One read per department.</EmptyState>
|
||
) : deptRows.length === 0 ? (
|
||
<EmptyState icon="inbox" title="No applications in this window">
|
||
Departments appear once their requisitions receive applications.
|
||
</EmptyState>
|
||
) : (
|
||
<div className="chart-wrap"><Chart type="bar" data={dept} height={300} /></div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<ChartCard
|
||
title="Recruiter Performance"
|
||
sub="Hires by recruiter (top 8)"
|
||
query={recruitersQuery}
|
||
height={300}
|
||
permission="analytics.view"
|
||
>
|
||
{(h) => (
|
||
rec.labels.length === 0
|
||
? <EmptyState icon="users" title="No recruiter stats">Assign recruiters to requisitions to populate this.</EmptyState>
|
||
: <div className="chart-wrap"><Chart type="horizontalBar" data={rec} height={h} /></div>
|
||
)}
|
||
</ChartCard>
|
||
</div>
|
||
|
||
<div className="grid g-2">
|
||
<ChartCard
|
||
title="Cycle Time"
|
||
sub="Days, current window vs prior"
|
||
query={kpisQuery}
|
||
height={240}
|
||
permission="analytics.view"
|
||
footer={<ChartLegend items={cycleLegend} />}
|
||
>
|
||
{(h) => (
|
||
k?.time_to_hire == null && k?.time_to_fill == null
|
||
? <EmptyState icon="clock" title="No completed cycles">Time to hire needs at least one hire in the window.</EmptyState>
|
||
: <div className="chart-wrap"><Chart type="groupedBar" data={cycle} height={h} /></div>
|
||
)}
|
||
</ChartCard>
|
||
|
||
<div className="card">
|
||
<div className="card-head"><div><h3>Window Summary</h3><span className="ch-sub">Totals behind the charts</span></div></div>
|
||
<div className="card-body">
|
||
{kpisQuery.isPending ? (
|
||
<EmptyState icon="clock" title="Loading…">Fetching totals.</EmptyState>
|
||
) : (
|
||
<div className="info-grid">
|
||
<div className="info-item"><div className="il">Open Jobs</div><div className="iv">{k?.open_jobs ?? '—'}</div></div>
|
||
<div className="info-item"><div className="il">Candidates</div><div className="iv">{k?.total_candidates ?? '—'}</div></div>
|
||
<div className="info-item"><div className="il">Hires</div><div className="iv">{k?.hires ?? '—'}</div></div>
|
||
<div className="info-item"><div className="il">Offers Sent</div><div className="iv">{k?.offers_sent ?? '—'}</div></div>
|
||
<div className="info-item"><div className="il">Offers Accepted</div><div className="iv">{k?.offers_accepted ?? '—'}</div></div>
|
||
<div className="info-item">
|
||
<div className="il">Cost per Hire</div>
|
||
<div className="iv">
|
||
{k?.cost_per_hire != null ? `$${Math.round(k.cost_per_hire).toLocaleString()}` : '—'}
|
||
</div>
|
||
</div>
|
||
<div className="info-item"><div className="il">Closed Jobs</div><div className="iv">{k?.closed_jobs ?? '—'}</div></div>
|
||
<div className="info-item">
|
||
<div className="il">Interviews Today</div>
|
||
<div className="iv">{k?.interviews_today ?? '—'}</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<p className="text-muted" style={{ marginTop: 14, fontSize: 13 }}>
|
||
<Icon name="info" /> Every figure here respects the range, department and recruiter filters above.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|