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

612 lines
25 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.

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