/* ============================================================ 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 { fmtDate, fmtDateTime, toDate } from '../lib/format' 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 CLOSED) 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 ? fmtDate(v) || null : 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) => {r.dept} }, { key: 'open', label: 'Open Roles', sortable: true, align: 'center' }, { key: 'apps', label: 'Applications', sortable: true, align: 'center', render: (r) => {r.apps} }, { 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` : ), }, { 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 (
{rate}%
) }, }, ] const reportColumns = [ { key: 'name', label: 'Report', sortable: true, render: (r) => (
{r.name} {r.description ? (
{r.description}
) : null}
), }, { 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) => toDate(r.last_run_at)?.getTime() ?? 0, render: (r) => (r.last_run_at ? fmtDateTime(r.last_run_at) : never), }, { key: '_actions', label: '', render: (r) => (
{can('reports.export') && ( )} {can('reports.delete') && ( )}
), }, ] const sourceColumns = [ { key: 'source', label: 'Source', sortable: true, render: (r) => {r.source} }, { key: 'count', label: 'Applications', sortable: true, align: 'center', render: (r) => {r.count} }, { key: 'spend', label: 'Tagged Spend', sortable: true, align: 'right', render: (r) => (r.spend ? money(Math.round(r.spend)) : ), }, { 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) : ), }, ] const costColumns = [ { key: 'type', label: 'Cost Type', sortable: true, render: (r) => {r.type} }, { key: 'amount', label: 'Total', sortable: true, align: 'right', render: (r) => {money(Math.round(r.amount))}, }, { key: '_share', label: 'Share', sortable: true, sortValue: (r) => r.amount, render: (r) => { const share = costSum ? Math.round((r.amount / costSum) * 100) : 0 return (
{share}%
) }, }, ] return (
setRangeKey(e.target.value)}> {RANGES.map((r) => )} } /> {kpisQuery.isError && (
{friendlyAuthError(kpisQuery.error, 'The server did not answer.')} {' '}This screen needs the analytics.view permission.
)}
{cards.map((c) => )}

Report Library

Saved reports rerun the same governed queries as the charts, on a rolling window
{can('reports.create') && ( )}
{reportsQuery.isPending ? (
Fetching saved reports.
) : reportsQuery.isError ? (
{friendlyAuthError(reportsQuery.error, 'The report library did not answer.')} {' '}This card needs the reports.view permission.
) : (reportsQuery.data ?? []).length === 0 ? (
Save a report once and rerun or export it with one click.
) : ( )}

Hiring Funnel

Share reaching each stage{funnel.base ? ` · base ${funnel.base}` : ''}
{funnelQuery.isPending ? ( Fetching stage counts. ) : funnelQuery.isError ? ( {friendlyAuthError(funnelQuery.error, 'The server did not answer.')} ) : !funnel.base ? ( The funnel fills in once applications arrive. ) : ( <>

Derived from current stage counts, so rejected applications are not counted at the stage they reached.

)}

Cycle Time

Days, current window vs prior
{kpisQuery.isPending ? ( Fetching cycle times. ) : k?.time_to_hire == null && k?.time_to_fill == null ? ( Needs at least one hire and one closed requisition in the window. ) : ( <>
)}

Department Performance

{deptsQuery.data && deptsQuery.data.length > DEPT_CAP ? `Top ${DEPT_CAP} of ${deptsQuery.data.length} departments` : 'Hiring breakdown by team'}
{deptPending ? (
One read per department.
) : deptRows.length === 0 ? (
Set a department on a requisition for it to appear here.
) : ( )}

Hiring Spend

{costSum ? `${money(Math.round(costSum))} recorded in this window` : 'From the hiring-cost ledger'}
{can('jobs.edit') && ( )}
{costsQuery.isPending ? (
Fetching the cost ledger.
) : costsQuery.isError ? (
{friendlyAuthError(costsQuery.error, 'The cost ledger did not answer.')} {' '}This card needs the jobs.view permission.
) : costTotals.length === 0 ? (
Cost-per-hire stays blank until spend is logged against a requisition.
) : ( ({ id: r.type, ...r }))} pageSize={50} /> )}

Source Performance

Applications and tagged spend per channel — cost per application counts only source-tagged spend
{sourcesQuery.isPending ? (
Fetching source counts.
) : sourcesQuery.isError ? (
{friendlyAuthError(sourcesQuery.error, 'The server did not answer.')}
) : (sourcesQuery.data ?? []).length === 0 ? (
Applications carry a source once inbound channels are mapped.
) : ( ({ ...r, id: r.id ?? r.source }))} pageSize={50} /> )}
{creatingReport && ( setCreatingReport(false)} onCreated={() => { setCreatingReport(false) qc.invalidateQueries({ queryKey: qk.reports.all() }) }} /> )} {loggingCost && ( setLoggingCost(false)} onLogged={() => { setLoggingCost(false) qc.invalidateQueries({ queryKey: qk.costs.all() }) qc.invalidateQueries({ queryKey: qk.analytics.all() }) }} /> )} {runResult && ( setRunResult(null)} footer={ <> {can('reports.export') && runResult.saved_report_id && ( )} } > {runResult.rows?.length ? ( ({ key: c.key, label: c.label, sortable: true, render: (row) => (row[c.key] == null || row[c.key] === '' ? : String(row[c.key])), }))} rows={runResult.rows.map((row, i) => ({ id: i, ...row }))} pageSize={50} /> ) : ( The window resolved to {runSubtitle(runResult)}. )} )}
) } 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 ( } >
{ e.preventDefault(); submit() }}>
setName(e.target.value)} placeholder="e.g. Quarterly hiring funnel" />
setDescription(e.target.value)} placeholder="Optional" />
) } 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 ( } >
{ e.preventDefault(); submit() }}>
setAmount(e.target.value)} />
setIncurredAt(e.target.value)} />
setDescription(e.target.value)} placeholder="Optional" />
) }