/* ============================================================ 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 (
analytics.view permission.
reports.view permission.
jobs.view permission.