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

901 lines
34 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.

/* ============================================================
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) => <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) => toDate(r.last_run_at)?.getTime() ?? 0,
render: (r) => (r.last_run_at
? fmtDateTime(r.last_run_at)
: <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="Couldnt 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={50} />
)}
</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="Couldnt 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={50} />
)}
</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={50} />
)}
</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="Couldnt 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={50}
/>
)}
</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={50}
/>
) : (
<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>
)
}