900 lines
34 KiB
JavaScript
900 lines
34 KiB
JavaScript
/* ============================================================
|
||
Reports — live on /analytics/* plus the hiring-cost ledger (/job/costs/fetch).
|
||
|
||
THE FUNNEL IS AN APPROXIMATION AND THE CARD SAYS SO. /analytics/funnel/fetch
|
||
returns a POINT-IN-TIME count per stage — where everyone stands right now —
|
||
not how many ever passed through a stage. "Reached this stage" is therefore
|
||
derived as the sum of every stage at or beyond it. Rejected applications are
|
||
excluded because a point-in-time count does not record how far they got; the
|
||
true history lives in application_stage_transitions, which has no global read
|
||
(/pipeline/transitions/fetch needs one application id).
|
||
|
||
DEPARTMENT PERFORMANCE is one /analytics/kpis read per department, in
|
||
parallel. There is no group-by endpoint, but `department` is a filter on
|
||
every analytics route, and one KPI payload carries all four columns at once.
|
||
|
||
THE REPORT LIBRARY IS REAL NOW (/reports/*). A saved report is a stored
|
||
parameterisation of a governed analytics query — a report type plus a
|
||
rolling window — run and exported (CSV) server-side, with every run
|
||
recorded. An earlier grid of six cards fired a toast and generated nothing;
|
||
the library only returned once the endpoints existed.
|
||
|
||
SPEND ATTRIBUTION: a hiring cost can be tagged with a source channel, which
|
||
feeds the cost-per-application column of Source Performance. Untagged spend
|
||
deliberately counts toward cost-per-hire only — folding it into a source
|
||
would fabricate a ROI figure.
|
||
|
||
TTH BASELINE (REQ-ANL-08): the KPI payload carries tth_baseline_days only
|
||
when the org setting `analytics.tth_baseline` is set, with provenance. No
|
||
setting → no baseline shown; the 27-day BRD figure is not hardcoded here.
|
||
============================================================ */
|
||
|
||
import { useMemo, useState } from 'react'
|
||
import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'
|
||
|
||
import Chart, { ChartLegend } from '../ui/Chart'
|
||
import Charts from '../lib/charts'
|
||
import DataTable from '../ui/DataTable'
|
||
import Modal from '../ui/Modal'
|
||
import PageHeader from '../ui/PageHeader'
|
||
import { EmptyState, Icon, KpiCard, ProgressBar } from '../ui/primitives'
|
||
import { useToast } from '../ui/Toast'
|
||
import { qk } from '../lib/queryKeys'
|
||
import { friendlyAuthError } from '../lib/errors'
|
||
import { useAuth } from '../auth/AuthContext'
|
||
import * as analyticsApi from '../api/analytics'
|
||
import * as costsApi from '../api/costs'
|
||
import * as jobsApi from '../api/jobs'
|
||
import * as reportsApi from '../api/reports'
|
||
import { money } from '../data/seed'
|
||
|
||
const DEPT_CAP = 12
|
||
const TREND_MONTHS = 7
|
||
|
||
const RANGES = [
|
||
{ key: 'quarter', label: 'This quarter', days: 90 },
|
||
{ key: 'half', label: 'Last 6 months', days: 182 },
|
||
{ key: 'year', label: 'This year', days: 365 },
|
||
]
|
||
|
||
/* Order matters: "reached" is a running sum from the end of this list back to
|
||
the start. REJECTED, CLOSED (shown as Rejected on the board) and ONHOLD are
|
||
absent — parking and outcomes are not a step in the happy-path suffix sum. */
|
||
const FUNNEL_ORDER = [
|
||
{ key: 'PENDING', label: 'Shortlist' },
|
||
{ key: 'SCREENING', label: 'Screened' },
|
||
{ key: 'PROCESS', label: 'Screened' },
|
||
{ key: 'ASSESSMENT', label: 'Assessed' },
|
||
{ key: 'INTERVIEW', label: 'Interviewed' },
|
||
{ key: 'OFFER', label: 'Offered' },
|
||
{ key: 'APPROVED', label: 'Approved' },
|
||
{ key: 'HIRED', label: 'Hired' },
|
||
]
|
||
|
||
function rangeWindow(key) {
|
||
const range = RANGES.find((r) => r.key === key) ?? RANGES[0]
|
||
const to = new Date()
|
||
const from = new Date(to.getTime() - range.days * 86400000)
|
||
return { fromDate: from.toISOString(), toDate: to.toISOString() }
|
||
}
|
||
|
||
const COST_TYPES = [
|
||
{ value: 'job_board', label: 'Job board' },
|
||
{ value: 'agency_fee', label: 'Agency fee' },
|
||
{ value: 'referral_bonus', label: 'Referral bonus' },
|
||
{ value: 'tooling', label: 'Recruiting tools' },
|
||
{ value: 'travel', label: 'Travel' },
|
||
{ value: 'other', label: 'Other' },
|
||
]
|
||
|
||
/* Saved reports store a ROLLING window (window_days): "last 90 days" means the
|
||
last 90 days on every run, not the quarter current at save time. */
|
||
const REPORT_WINDOWS = [
|
||
{ days: 30, label: 'Last 30 days' },
|
||
{ days: 90, label: 'Last 90 days' },
|
||
{ days: 182, label: 'Last 6 months' },
|
||
{ days: 365, label: 'Last 12 months' },
|
||
{ days: null, label: 'All time' },
|
||
]
|
||
|
||
function reportWindowLabel(filters) {
|
||
if (filters?.window_days) {
|
||
const match = REPORT_WINDOWS.find((w) => w.days === Number(filters.window_days))
|
||
return match ? match.label : `Last ${filters.window_days} days`
|
||
}
|
||
if (filters?.from_date || filters?.to_date) return 'Fixed dates'
|
||
return 'All time'
|
||
}
|
||
|
||
function runSubtitle(result) {
|
||
const win = result?.window || {}
|
||
const fmt = (v) => (v ? new Date(v).toLocaleDateString() : null)
|
||
const from = fmt(win.from_date)
|
||
const to = fmt(win.to_date)
|
||
const range = from || to ? `${from ?? '…'} – ${to ?? 'now'}` : 'All time'
|
||
return `${result.row_count} rows · ${range}`
|
||
}
|
||
|
||
export default function Reports() {
|
||
const [rangeKey, setRangeKey] = useState('quarter')
|
||
const { can } = useAuth()
|
||
const { toast } = useToast()
|
||
const qc = useQueryClient()
|
||
|
||
const [runResult, setRunResult] = useState(null)
|
||
const [creatingReport, setCreatingReport] = useState(false)
|
||
const [loggingCost, setLoggingCost] = useState(false)
|
||
const [exportBusyId, setExportBusyId] = useState(null)
|
||
|
||
const span = useMemo(() => rangeWindow(rangeKey), [rangeKey])
|
||
const keyParams = useMemo(() => ({ range: rangeKey, scope: 'reports' }), [rangeKey])
|
||
|
||
const kpisQuery = useQuery({
|
||
queryKey: qk.analytics.kpis(keyParams),
|
||
queryFn: async () => (await analyticsApi.kpis(span))?.data ?? null,
|
||
})
|
||
const trendQuery = useQuery({
|
||
queryKey: qk.analytics.trend({ ...keyParams, months: TREND_MONTHS }),
|
||
queryFn: async () => (await analyticsApi.hiringTrend({ months: TREND_MONTHS, ...span }))?.data
|
||
?? { labels: [], applications: [], hires: [] },
|
||
})
|
||
const funnelQuery = useQuery({
|
||
queryKey: qk.analytics.funnel(keyParams),
|
||
queryFn: async () => {
|
||
const res = await analyticsApi.funnel(span)
|
||
return Array.isArray(res?.data) ? res.data : []
|
||
},
|
||
})
|
||
|
||
const costsQuery = useQuery({
|
||
queryKey: qk.costs.list(keyParams),
|
||
queryFn: async () => {
|
||
const res = await costsApi.list({ ...span, top: 500 })
|
||
const rows = Array.isArray(res?.data) ? res.data : []
|
||
return rows.map(costsApi.toCostView)
|
||
},
|
||
retry: false,
|
||
})
|
||
|
||
const sourcesQuery = useQuery({
|
||
queryKey: qk.analytics.sources(keyParams),
|
||
queryFn: async () => {
|
||
const res = await analyticsApi.sourcePerformance(span)
|
||
return Array.isArray(res?.data) ? res.data : []
|
||
},
|
||
})
|
||
|
||
const reportsQuery = useQuery({
|
||
queryKey: qk.reports.list(),
|
||
queryFn: async () => (await reportsApi.list())?.data ?? [],
|
||
retry: false,
|
||
})
|
||
|
||
const runReport = useMutation({
|
||
mutationFn: (recordId) => reportsApi.run({ recordId }),
|
||
onSuccess: (res) => {
|
||
setRunResult(res?.data ?? null)
|
||
qc.invalidateQueries({ queryKey: qk.reports.all() })
|
||
},
|
||
onError: (err) => toast(friendlyAuthError(err, 'The report did not run.'), 'error'),
|
||
})
|
||
|
||
const deleteReport = useMutation({
|
||
mutationFn: (recordId) => reportsApi.remove(recordId),
|
||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.reports.all() }),
|
||
onError: (err) => toast(friendlyAuthError(err, 'Could not delete the report.'), 'error'),
|
||
})
|
||
|
||
async function exportReport(row) {
|
||
setExportBusyId(row.id)
|
||
try {
|
||
await reportsApi.exportCsv({ recordId: row.id })
|
||
qc.invalidateQueries({ queryKey: qk.reports.all() })
|
||
} catch (err) {
|
||
toast(friendlyAuthError(err, 'The export failed.'), 'error')
|
||
} finally {
|
||
setExportBusyId(null)
|
||
}
|
||
}
|
||
|
||
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 = useMemo(() => (deptsQuery.data ?? []).slice(0, DEPT_CAP), [deptsQuery.data])
|
||
|
||
/* One KPI read per department: open_jobs, total_candidates, hires and
|
||
time_to_fill all arrive together, which is the whole table in one payload. */
|
||
const deptQueries = useQueries({
|
||
queries: departments.map((dept) => ({
|
||
queryKey: qk.analytics.kpis({ ...keyParams, department: dept }),
|
||
queryFn: async () => {
|
||
const data = (await analyticsApi.kpis({ ...span, department: dept }))?.data ?? {}
|
||
return {
|
||
id: dept,
|
||
dept,
|
||
open: data.open_jobs ?? 0,
|
||
apps: data.total_candidates ?? 0,
|
||
hires: data.hires ?? 0,
|
||
ttf: data.time_to_fill != null ? Math.round(Number(data.time_to_fill)) : null,
|
||
}
|
||
},
|
||
})),
|
||
})
|
||
const deptPending = deptQueries.some((qr) => qr.isPending)
|
||
/* useQueries hands back a new array every render, so memoise on a value
|
||
signature — otherwise the table re-sorts and the row identity churns on
|
||
every unrelated re-render. */
|
||
const deptSignature = deptQueries
|
||
.map((qr) => (qr.data ? `${qr.data.dept}:${qr.data.open}:${qr.data.apps}:${qr.data.hires}:${qr.data.ttf}` : '-'))
|
||
.join('|')
|
||
const deptRows = useMemo(
|
||
() => deptQueries.map((qr) => qr.data).filter(Boolean).filter((r) => r.open || r.apps || r.hires),
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
[deptSignature],
|
||
)
|
||
|
||
/* ---------- derived payloads ---------- */
|
||
|
||
/* Fold the 11 raw statuses onto the six funnel labels, then run a suffix sum
|
||
so each label carries "reached at least here". */
|
||
const funnel = useMemo(() => {
|
||
const raw = new Map((funnelQuery.data ?? []).map((r) => [r.stage, r.count || 0]))
|
||
const labels = []
|
||
const perLabel = []
|
||
for (const { key, label } of FUNNEL_ORDER) {
|
||
const idx = labels.indexOf(label)
|
||
if (idx === -1) {
|
||
labels.push(label)
|
||
perLabel.push(raw.get(key) ?? 0)
|
||
} else {
|
||
perLabel[idx] += raw.get(key) ?? 0
|
||
}
|
||
}
|
||
const reached = perLabel.map((_, i) => perLabel.slice(i).reduce((s, n) => s + n, 0))
|
||
const base = reached[0] || 0
|
||
return {
|
||
labels,
|
||
counts: reached,
|
||
data: reached.map((n) => (base ? Math.round((n / base) * 100) : 0)),
|
||
colors: Charts.PALETTE,
|
||
yFmt: (v) => `${v}%`,
|
||
base,
|
||
}
|
||
}, [funnelQuery.data])
|
||
|
||
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 cycleLegend = useMemo(
|
||
() => [
|
||
{ label: 'Current window', color: Charts.PALETTE[0] },
|
||
{ label: 'Prior window', color: Charts.PALETTE[2] },
|
||
],
|
||
[],
|
||
)
|
||
|
||
const costTotals = useMemo(
|
||
() => costsApi.totalsByType(costsQuery.data ?? []),
|
||
[costsQuery.data],
|
||
)
|
||
const costSum = useMemo(
|
||
() => costTotals.reduce((s, r) => s + r.amount, 0),
|
||
[costTotals],
|
||
)
|
||
|
||
const k = kpisQuery.data
|
||
const totalApplications = useMemo(() => {
|
||
const t = trendQuery.data
|
||
if (!t?.applications?.length) return null
|
||
return t.applications.reduce((s, v) => s + (v || 0), 0)
|
||
}, [trendQuery.data])
|
||
|
||
/* REQ-ANL-08: shown only when the baseline org setting exists — see header. */
|
||
let tthFoot = k?.time_to_hire == null ? 'no hires in window' : 'offer → start'
|
||
if (k?.time_to_hire != null && k?.tth_baseline_days != null) {
|
||
const delta = Math.round(k.time_to_hire) - k.tth_baseline_days
|
||
tthFoot = `vs ${k.tth_baseline_days}d baseline (${delta > 0 ? '+' : ''}${delta}d)`
|
||
}
|
||
|
||
const cards = [
|
||
{
|
||
label: 'Total Hires',
|
||
value: kpisQuery.isPending ? '—' : (k?.hires ?? 0),
|
||
icon: 'award',
|
||
tone: 'i-green',
|
||
foot: 'in the selected window',
|
||
},
|
||
{
|
||
label: 'Total Applications',
|
||
value: trendQuery.isPending ? '—' : (totalApplications?.toLocaleString() ?? '—'),
|
||
icon: 'users',
|
||
tone: 'i-blue',
|
||
foot: `last ${TREND_MONTHS} months`,
|
||
},
|
||
{
|
||
label: 'Avg. Time to Hire',
|
||
value: kpisQuery.isPending ? '—' : (k?.time_to_hire != null ? `${Math.round(k.time_to_hire)} days` : '—'),
|
||
icon: 'clock',
|
||
tone: 'i-teal',
|
||
foot: tthFoot,
|
||
},
|
||
{
|
||
label: 'Avg. Cost per Hire',
|
||
value: kpisQuery.isPending ? '—' : (k?.cost_per_hire != null ? money(Math.round(k.cost_per_hire)) : '—'),
|
||
icon: 'dollar',
|
||
tone: 'i-amber',
|
||
foot: k?.cost_per_hire == null ? 'no cost data recorded' : 'from the cost ledger',
|
||
},
|
||
]
|
||
|
||
const deptColumns = [
|
||
{ key: 'dept', label: 'Department', sortable: true, render: (r) => <span className="cell-primary">{r.dept}</span> },
|
||
{ key: 'open', label: 'Open Roles', sortable: true, align: 'center' },
|
||
{ key: 'apps', label: 'Applications', sortable: true, align: 'center', render: (r) => <b>{r.apps}</b> },
|
||
{ key: 'hires', label: 'Hires', sortable: true, align: 'center' },
|
||
{
|
||
key: 'ttf', label: 'Time to Fill', sortable: true, align: 'center',
|
||
sortValue: (r) => r.ttf ?? Number.MAX_SAFE_INTEGER,
|
||
render: (r) => (r.ttf != null ? `${r.ttf} days` : <span className="text-muted">—</span>),
|
||
},
|
||
{
|
||
key: '_conv', label: 'Applicant → hire', sortable: true,
|
||
sortValue: (r) => (r.apps ? r.hires / r.apps : 0),
|
||
render: (r) => {
|
||
const rate = r.apps ? Math.round((r.hires / r.apps) * 100) : 0
|
||
return (
|
||
<div className="flex items-center gap-8">
|
||
<div style={{ flex: 1 }}><ProgressBar pct={rate} /></div>
|
||
<b style={{ width: 38, textAlign: 'right' }}>{rate}%</b>
|
||
</div>
|
||
)
|
||
},
|
||
},
|
||
]
|
||
|
||
const reportColumns = [
|
||
{
|
||
key: 'name', label: 'Report', sortable: true,
|
||
render: (r) => (
|
||
<div>
|
||
<span className="cell-primary">{r.name}</span>
|
||
{r.description ? (
|
||
<div className="text-muted" style={{ fontSize: 12 }}>{r.description}</div>
|
||
) : null}
|
||
</div>
|
||
),
|
||
},
|
||
{ key: 'report_label', label: 'Type', sortable: true },
|
||
{ key: '_window', label: 'Window', render: (r) => reportWindowLabel(r.filters) },
|
||
{
|
||
key: 'last_run_at', label: 'Last Run', sortable: true,
|
||
sortValue: (r) => (r.last_run_at ? new Date(r.last_run_at).getTime() : 0),
|
||
render: (r) => (r.last_run_at
|
||
? new Date(r.last_run_at).toLocaleString()
|
||
: <span className="text-muted">never</span>),
|
||
},
|
||
{
|
||
key: '_actions', label: '',
|
||
render: (r) => (
|
||
<div className="flex items-center gap-8">
|
||
<button
|
||
className="btn btn-secondary btn-sm"
|
||
disabled={runReport.isPending}
|
||
onClick={() => runReport.mutate(r.id)}
|
||
>
|
||
Run
|
||
</button>
|
||
{can('reports.export') && (
|
||
<button
|
||
className="btn btn-secondary btn-sm"
|
||
disabled={exportBusyId === r.id}
|
||
onClick={() => exportReport(r)}
|
||
>
|
||
{exportBusyId === r.id ? 'Exporting…' : 'CSV'}
|
||
</button>
|
||
)}
|
||
{can('reports.delete') && (
|
||
<button
|
||
className="btn btn-danger btn-sm"
|
||
disabled={deleteReport.isPending}
|
||
onClick={() => { if (window.confirm(`Delete "${r.name}"?`)) deleteReport.mutate(r.id) }}
|
||
>
|
||
Delete
|
||
</button>
|
||
)}
|
||
</div>
|
||
),
|
||
},
|
||
]
|
||
|
||
const sourceColumns = [
|
||
{ key: 'source', label: 'Source', sortable: true, render: (r) => <span className="cell-primary">{r.source}</span> },
|
||
{ key: 'count', label: 'Applications', sortable: true, align: 'center', render: (r) => <b>{r.count}</b> },
|
||
{
|
||
key: 'spend', label: 'Tagged Spend', sortable: true, align: 'right',
|
||
render: (r) => (r.spend ? money(Math.round(r.spend)) : <span className="text-muted">—</span>),
|
||
},
|
||
{
|
||
key: 'cost_per_application', label: 'Cost / Application', sortable: true, align: 'right',
|
||
sortValue: (r) => r.cost_per_application ?? 0,
|
||
render: (r) => (r.cost_per_application != null
|
||
? money(r.cost_per_application)
|
||
: <span className="text-muted">—</span>),
|
||
},
|
||
]
|
||
|
||
const costColumns = [
|
||
{ key: 'type', label: 'Cost Type', sortable: true, render: (r) => <span className="cell-primary">{r.type}</span> },
|
||
{
|
||
key: 'amount', label: 'Total', sortable: true, align: 'right',
|
||
render: (r) => <b>{money(Math.round(r.amount))}</b>,
|
||
},
|
||
{
|
||
key: '_share', label: 'Share', sortable: true,
|
||
sortValue: (r) => r.amount,
|
||
render: (r) => {
|
||
const share = costSum ? Math.round((r.amount / costSum) * 100) : 0
|
||
return (
|
||
<div className="flex items-center gap-8">
|
||
<div style={{ flex: 1 }}><ProgressBar pct={share} /></div>
|
||
<b style={{ width: 38, textAlign: 'right' }}>{share}%</b>
|
||
</div>
|
||
)
|
||
},
|
||
},
|
||
]
|
||
|
||
return (
|
||
<div className="page">
|
||
<PageHeader
|
||
title="Reports"
|
||
sub="Recruitment metrics across the selected window"
|
||
actions={
|
||
<select className="select" value={rangeKey} onChange={(e) => setRangeKey(e.target.value)}>
|
||
{RANGES.map((r) => <option key={r.key} value={r.key}>{r.label}</option>)}
|
||
</select>
|
||
}
|
||
/>
|
||
|
||
{kpisQuery.isError && (
|
||
<div className="card mb-18"><div className="card-body">
|
||
<EmptyState icon="alert" title="Couldn’t load reporting metrics">
|
||
{friendlyAuthError(kpisQuery.error, 'The server did not answer.')}
|
||
{' '}This screen needs the <code>analytics.view</code> permission.
|
||
</EmptyState>
|
||
</div></div>
|
||
)}
|
||
|
||
<div className="grid g-kpi mb-18">
|
||
{cards.map((c) => <KpiCard key={c.label} {...c} />)}
|
||
</div>
|
||
|
||
<div className="card mb-18">
|
||
<div className="card-head">
|
||
<div>
|
||
<h3>Report Library</h3>
|
||
<span className="ch-sub">Saved reports rerun the same governed queries as the charts, on a rolling window</span>
|
||
</div>
|
||
{can('reports.create') && (
|
||
<button className="btn btn-primary btn-sm" onClick={() => setCreatingReport(true)}>
|
||
<Icon name="plus" /> New report
|
||
</button>
|
||
)}
|
||
</div>
|
||
{reportsQuery.isPending ? (
|
||
<div className="card-body">
|
||
<EmptyState icon="clock" title="Loading…">Fetching saved reports.</EmptyState>
|
||
</div>
|
||
) : reportsQuery.isError ? (
|
||
<div className="card-body">
|
||
<EmptyState icon="lock" title="Reports not visible">
|
||
{friendlyAuthError(reportsQuery.error, 'The report library did not answer.')}
|
||
{' '}This card needs the <code>reports.view</code> permission.
|
||
</EmptyState>
|
||
</div>
|
||
) : (reportsQuery.data ?? []).length === 0 ? (
|
||
<div className="card-body">
|
||
<EmptyState icon="reports" title="No saved reports yet">
|
||
Save a report once and rerun or export it with one click.
|
||
</EmptyState>
|
||
</div>
|
||
) : (
|
||
<DataTable columns={reportColumns} rows={reportsQuery.data} pageSize={8} />
|
||
)}
|
||
</div>
|
||
|
||
<div className="grid g-2 mb-18">
|
||
<div className="card">
|
||
<div className="card-head">
|
||
<div>
|
||
<h3>Hiring Funnel</h3>
|
||
<span className="ch-sub">
|
||
Share reaching each stage{funnel.base ? ` · base ${funnel.base}` : ''}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="card-body">
|
||
{funnelQuery.isPending ? (
|
||
<EmptyState icon="clock" title="Loading…">Fetching stage counts.</EmptyState>
|
||
) : funnelQuery.isError ? (
|
||
<EmptyState icon="alert" title="Couldn’t load the funnel">
|
||
{friendlyAuthError(funnelQuery.error, 'The server did not answer.')}
|
||
</EmptyState>
|
||
) : !funnel.base ? (
|
||
<EmptyState icon="inbox" title="No applications in this window">
|
||
The funnel fills in once applications arrive.
|
||
</EmptyState>
|
||
) : (
|
||
<>
|
||
<div className="chart-wrap"><Chart type="bar" data={funnel} height={280} /></div>
|
||
<p className="text-muted" style={{ marginTop: 10, fontSize: 12 }}>
|
||
<Icon name="info" /> Derived from current stage counts, so rejected applications are
|
||
not counted at the stage they reached.
|
||
</p>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="card">
|
||
<div className="card-head">
|
||
<div><h3>Cycle Time</h3><span className="ch-sub">Days, current window vs prior</span></div>
|
||
</div>
|
||
<div className="card-body">
|
||
{kpisQuery.isPending ? (
|
||
<EmptyState icon="clock" title="Loading…">Fetching cycle times.</EmptyState>
|
||
) : k?.time_to_hire == null && k?.time_to_fill == null ? (
|
||
<EmptyState icon="clock" title="No completed cycles">
|
||
Needs at least one hire and one closed requisition in the window.
|
||
</EmptyState>
|
||
) : (
|
||
<>
|
||
<div className="chart-wrap"><Chart type="groupedBar" data={cycle} height={280} /></div>
|
||
<ChartLegend items={cycleLegend} />
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="card mb-18">
|
||
<div className="card-head">
|
||
<div>
|
||
<h3>Department Performance</h3>
|
||
<span className="ch-sub">
|
||
{deptsQuery.data && deptsQuery.data.length > DEPT_CAP
|
||
? `Top ${DEPT_CAP} of ${deptsQuery.data.length} departments`
|
||
: 'Hiring breakdown by team'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
{deptPending ? (
|
||
<div className="card-body">
|
||
<EmptyState icon="clock" title="Loading…">One read per department.</EmptyState>
|
||
</div>
|
||
) : deptRows.length === 0 ? (
|
||
<div className="card-body">
|
||
<EmptyState icon="inbox" title="No department activity">
|
||
Set a department on a requisition for it to appear here.
|
||
</EmptyState>
|
||
</div>
|
||
) : (
|
||
<DataTable columns={deptColumns} rows={deptRows} pageSize={10} />
|
||
)}
|
||
</div>
|
||
|
||
<div className="card mb-18">
|
||
<div className="card-head">
|
||
<div>
|
||
<h3>Hiring Spend</h3>
|
||
<span className="ch-sub">
|
||
{costSum ? `${money(Math.round(costSum))} recorded in this window` : 'From the hiring-cost ledger'}
|
||
</span>
|
||
</div>
|
||
{can('jobs.edit') && (
|
||
<button className="btn btn-secondary btn-sm" onClick={() => setLoggingCost(true)}>
|
||
<Icon name="plus" /> Log cost
|
||
</button>
|
||
)}
|
||
</div>
|
||
{costsQuery.isPending ? (
|
||
<div className="card-body">
|
||
<EmptyState icon="clock" title="Loading…">Fetching the cost ledger.</EmptyState>
|
||
</div>
|
||
) : costsQuery.isError ? (
|
||
<div className="card-body">
|
||
<EmptyState icon="lock" title="Costs not visible">
|
||
{friendlyAuthError(costsQuery.error, 'The cost ledger did not answer.')}
|
||
{' '}This card needs the <code>jobs.view</code> permission.
|
||
</EmptyState>
|
||
</div>
|
||
) : costTotals.length === 0 ? (
|
||
<div className="card-body">
|
||
<EmptyState icon="dollar" title="No costs recorded">
|
||
Cost-per-hire stays blank until spend is logged against a requisition.
|
||
</EmptyState>
|
||
</div>
|
||
) : (
|
||
<DataTable columns={costColumns} rows={costTotals.map((r) => ({ id: r.type, ...r }))} pageSize={10} />
|
||
)}
|
||
</div>
|
||
|
||
<div className="card">
|
||
<div className="card-head">
|
||
<div>
|
||
<h3>Source Performance</h3>
|
||
<span className="ch-sub">
|
||
Applications and tagged spend per channel — cost per application counts only source-tagged spend
|
||
</span>
|
||
</div>
|
||
</div>
|
||
{sourcesQuery.isPending ? (
|
||
<div className="card-body">
|
||
<EmptyState icon="clock" title="Loading…">Fetching source counts.</EmptyState>
|
||
</div>
|
||
) : sourcesQuery.isError ? (
|
||
<div className="card-body">
|
||
<EmptyState icon="alert" title="Couldn’t load sources">
|
||
{friendlyAuthError(sourcesQuery.error, 'The server did not answer.')}
|
||
</EmptyState>
|
||
</div>
|
||
) : (sourcesQuery.data ?? []).length === 0 ? (
|
||
<div className="card-body">
|
||
<EmptyState icon="inbox" title="No source data in this window">
|
||
Applications carry a source once inbound channels are mapped.
|
||
</EmptyState>
|
||
</div>
|
||
) : (
|
||
<DataTable
|
||
columns={sourceColumns}
|
||
rows={(sourcesQuery.data ?? []).map((r) => ({ ...r, id: r.id ?? r.source }))}
|
||
pageSize={10}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{creatingReport && (
|
||
<NewReportModal
|
||
onClose={() => setCreatingReport(false)}
|
||
onCreated={() => {
|
||
setCreatingReport(false)
|
||
qc.invalidateQueries({ queryKey: qk.reports.all() })
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{loggingCost && (
|
||
<LogCostModal
|
||
onClose={() => setLoggingCost(false)}
|
||
onLogged={() => {
|
||
setLoggingCost(false)
|
||
qc.invalidateQueries({ queryKey: qk.costs.all() })
|
||
qc.invalidateQueries({ queryKey: qk.analytics.all() })
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{runResult && (
|
||
<Modal
|
||
title={runResult.name || runResult.report_label}
|
||
subtitle={runSubtitle(runResult)}
|
||
size="modal-lg"
|
||
onClose={() => setRunResult(null)}
|
||
footer={
|
||
<>
|
||
{can('reports.export') && runResult.saved_report_id && (
|
||
<button
|
||
className="btn btn-secondary"
|
||
onClick={() => reportsApi.exportCsv({ recordId: runResult.saved_report_id })
|
||
.catch((err) => toast(friendlyAuthError(err, 'The export failed.'), 'error'))}
|
||
>
|
||
<Icon name="download" /> Export CSV
|
||
</button>
|
||
)}
|
||
<button className="btn btn-primary" onClick={() => setRunResult(null)}>Close</button>
|
||
</>
|
||
}
|
||
>
|
||
{runResult.rows?.length ? (
|
||
<DataTable
|
||
columns={(runResult.columns ?? []).map((c) => ({
|
||
key: c.key,
|
||
label: c.label,
|
||
sortable: true,
|
||
render: (row) => (row[c.key] == null || row[c.key] === ''
|
||
? <span className="text-muted">—</span>
|
||
: String(row[c.key])),
|
||
}))}
|
||
rows={runResult.rows.map((row, i) => ({ id: i, ...row }))}
|
||
pageSize={10}
|
||
/>
|
||
) : (
|
||
<EmptyState icon="inbox" title="No rows in this window">
|
||
The window resolved to {runSubtitle(runResult)}.
|
||
</EmptyState>
|
||
)}
|
||
</Modal>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function NewReportModal({ onClose, onCreated }) {
|
||
const { toast } = useToast()
|
||
const [name, setName] = useState('')
|
||
const [reportType, setReportType] = useState(reportsApi.REPORT_TYPES[0].key)
|
||
const [windowDays, setWindowDays] = useState(90)
|
||
const [description, setDescription] = useState('')
|
||
|
||
const save = useMutation({
|
||
mutationFn: () => reportsApi.create({
|
||
name: name.trim(),
|
||
reportType,
|
||
description: description.trim() || undefined,
|
||
filters: windowDays ? { window_days: Number(windowDays) } : {},
|
||
}),
|
||
onSuccess: () => { toast('Report saved', 'success'); onCreated() },
|
||
onError: (err) => toast(friendlyAuthError(err, 'Could not save the report.'), 'error'),
|
||
})
|
||
|
||
const submit = () => {
|
||
if (!name.trim()) { toast('Give the report a name', 'error'); return }
|
||
save.mutate()
|
||
}
|
||
|
||
return (
|
||
<Modal
|
||
title="New Report"
|
||
subtitle="Saved reports rerun with a rolling window"
|
||
onClose={onClose}
|
||
footer={
|
||
<>
|
||
<button className="btn btn-secondary" onClick={onClose} disabled={save.isPending}>Cancel</button>
|
||
<button className="btn btn-primary" onClick={submit} disabled={save.isPending}>
|
||
<Icon name="check" /> {save.isPending ? 'Saving…' : 'Save Report'}
|
||
</button>
|
||
</>
|
||
}
|
||
>
|
||
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
|
||
<div className="form-grid">
|
||
<div className="form-field col-span-2">
|
||
<label>Name <span className="req">*</span></label>
|
||
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Quarterly hiring funnel" />
|
||
</div>
|
||
<div className="form-field">
|
||
<label>Report type</label>
|
||
<select value={reportType} onChange={(e) => setReportType(e.target.value)}>
|
||
{reportsApi.REPORT_TYPES.map((t) => <option key={t.key} value={t.key}>{t.label}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="form-field">
|
||
<label>Window</label>
|
||
<select
|
||
value={windowDays ?? ''}
|
||
onChange={(e) => setWindowDays(e.target.value ? Number(e.target.value) : null)}
|
||
>
|
||
{REPORT_WINDOWS.map((w) => <option key={w.label} value={w.days ?? ''}>{w.label}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="form-field col-span-2">
|
||
<label>Description</label>
|
||
<input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Optional" />
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</Modal>
|
||
)
|
||
}
|
||
|
||
function LogCostModal({ onClose, onLogged }) {
|
||
const { toast } = useToast()
|
||
const [costType, setCostType] = useState('job_board')
|
||
const [amount, setAmount] = useState('')
|
||
const [jobPostId, setJobPostId] = useState('')
|
||
const [sourceChannelId, setSourceChannelId] = useState('')
|
||
const [incurredAt, setIncurredAt] = useState(() => new Date().toISOString().slice(0, 10))
|
||
const [description, setDescription] = useState('')
|
||
|
||
const jobsQuery = useQuery({
|
||
queryKey: qk.jobs.list({ scope: 'cost-form' }),
|
||
queryFn: async () => {
|
||
const res = await jobsApi.list({ top: 500, activeOnly: false })
|
||
return Array.isArray(res?.data) ? res.data : []
|
||
},
|
||
})
|
||
const channelsQuery = useQuery({
|
||
queryKey: qk.costs.sources(),
|
||
queryFn: async () => (await costsApi.sourceChannels())?.data ?? [],
|
||
})
|
||
|
||
const save = useMutation({
|
||
mutationFn: () => costsApi.create({
|
||
cost_type: costType,
|
||
amount: Number(amount),
|
||
job_post_id: jobPostId || undefined,
|
||
source_channel_id: sourceChannelId ? Number(sourceChannelId) : undefined,
|
||
incurred_at: incurredAt ? new Date(`${incurredAt}T00:00:00Z`).toISOString() : undefined,
|
||
description: description.trim() || undefined,
|
||
}),
|
||
onSuccess: () => { toast('Cost logged', 'success'); onLogged() },
|
||
onError: (err) => toast(friendlyAuthError(err, 'Could not log the cost.'), 'error'),
|
||
})
|
||
|
||
const submit = () => {
|
||
if (!amount || Number.isNaN(Number(amount)) || Number(amount) <= 0) {
|
||
toast('Enter a positive amount', 'error')
|
||
return
|
||
}
|
||
save.mutate()
|
||
}
|
||
|
||
return (
|
||
<Modal
|
||
title="Log Hiring Cost"
|
||
subtitle="Feeds cost-per-hire; tag a source to feed cost-per-application"
|
||
onClose={onClose}
|
||
footer={
|
||
<>
|
||
<button className="btn btn-secondary" onClick={onClose} disabled={save.isPending}>Cancel</button>
|
||
<button className="btn btn-primary" onClick={submit} disabled={save.isPending}>
|
||
<Icon name="check" /> {save.isPending ? 'Saving…' : 'Log Cost'}
|
||
</button>
|
||
</>
|
||
}
|
||
>
|
||
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
|
||
<div className="form-grid">
|
||
<div className="form-field">
|
||
<label>Cost type</label>
|
||
<select value={costType} onChange={(e) => setCostType(e.target.value)}>
|
||
{COST_TYPES.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="form-field">
|
||
<label>Amount (USD) <span className="req">*</span></label>
|
||
<input type="number" min="0" step="0.01" value={amount} onChange={(e) => setAmount(e.target.value)} />
|
||
</div>
|
||
<div className="form-field">
|
||
<label>Requisition</label>
|
||
<select value={jobPostId} onChange={(e) => setJobPostId(e.target.value)}>
|
||
<option value="">Not tied to a job</option>
|
||
{(jobsQuery.data ?? []).map((j) => <option key={j.id} value={j.id}>{j.title}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="form-field">
|
||
<label>Source channel</label>
|
||
<select value={sourceChannelId} onChange={(e) => setSourceChannelId(e.target.value)}>
|
||
<option value="">Untagged (cost-per-hire only)</option>
|
||
{(channelsQuery.data ?? []).map((c) => <option key={c.id} value={c.id}>{c.label}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="form-field">
|
||
<label>Incurred on</label>
|
||
<input type="date" value={incurredAt} onChange={(e) => setIncurredAt(e.target.value)} />
|
||
</div>
|
||
<div className="form-field">
|
||
<label>Description</label>
|
||
<input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Optional" />
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</Modal>
|
||
)
|
||
}
|