/* ============================================================
Dashboard — the org-wide recruiting activity summary.
Redesigned from the personal-workspace version: My Tasks, Today's Schedule
and Quick Actions moved out (they have their own tabs), and every widget now
obeys a shared Week/Month/Quarter/Year + department filter, like Analytics.
The two screens stay complementary on purpose — this one is the operational
snapshot (what is happening, what needs attention), Analytics keeps the deep
lenses (sources, recruiters, cycle time, Ask).
The hero is Applications per Job — /analytics/applications-per-job/fetch,
which counts the same two sources the funnel counts (inbox + manual upload),
so the hero rows and the pipeline card always agree. Open reqs with ZERO
applications are rendered on purpose: an empty bar under a job title is the
strongest signal on the page. Rendered as DOM rows, not a canvas
horizontalBar — the chart engine truncates labels at 13 characters and job
titles do not survive that.
The pipeline card reads /analytics/funnel/fetch, not the board endpoint the
old dashboard used, so it obeys the window/department filter and shares the
hero's counting basis. Cost: a drag on the pipeline board no longer shows
here instantly — staleTime 0 plus the 60s poll bounds the staleness.
============================================================ */
import { useMemo, useState } from 'react'
import { Link, Navigate, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import Chart from '../ui/Chart'
import Charts from '../lib/charts'
import ChartCard, { widgetError } from '../ui/ChartCard'
import PageHeader from '../ui/PageHeader'
import { Badge, EmptyState, Icon, KpiTile } from '../ui/primitives'
import { useAuth } from '../auth/AuthContext'
import { isHiringManager } from '../auth/permissions'
import { qk } from '../lib/queryKeys'
import { RANGES, rangeLabel, rangeWindow } from '../lib/timeRanges'
import { fmtShort, money } from '../data/seed'
import * as activityApi from '../api/activity'
import * as analyticsApi from '../api/analytics'
import * as inboxApi from '../api/inbox'
import * as jobsApi from '../api/jobs'
import * as offersApi from '../api/offers'
const POLL_MS = 60_000
const TREND_MONTHS = 7
/** Fetch more rows than the hero shows: the surplus is what makes the derived
"open jobs with no applications" count accurate past the visible ten. */
const JOBS_FETCHED = 100
const JOBS_SHOWN = 10
function asObject(data) {
return data && typeof data === 'object' && !Array.isArray(data) ? data : null
}
function asList(data) {
return Array.isArray(data) ? data : []
}
function pctDelta(cur, prior) {
if (cur == null || prior == null || prior === 0) return null
const d = ((Number(cur) - Number(prior)) / Math.abs(Number(prior))) * 100
if (!Number.isFinite(d)) return null
return `${d >= 0 ? '+' : ''}${Math.round(d)}%`
}
function dayDelta(cur, prior) {
if (cur == null || prior == null) return null
const d = Math.round(Number(prior) - Number(cur))
if (!Number.isFinite(d) || d === 0) return null
return `${d > 0 ? '-' : '+'}${Math.abs(d)} days`
}
/**
* Trend chip props for one KPI. Arrow only when a delta is computable — a
* green up-arrow beside "—" reads as an improvement that never happened. For
* lower-is-better metrics (time to hire, cost per hire) the colour tracks
* goodness while the arrow tracks the data direction, so "-3 days" never
* ships with an up arrow.
*/
function trendProps(cur, prior, { lowerIsBetter = false, fmt = pctDelta } = {}) {
const text = fmt(cur, prior)
if (!text) return { trend: '—', dir: 'flat' }
const went = Number(cur) >= Number(prior) ? 'up' : 'down'
const good = lowerIsBetter ? went === 'down' : went === 'up'
return { trend: text, dir: good ? 'up' : 'down', arrow: went }
}
function greetingFor(now = new Date()) {
const h = now.getHours()
if (h < 12) return 'Good morning'
if (h < 17) return 'Good afternoon'
return 'Good evening'
}
function formatDashDate(d = new Date()) {
return d.toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
})
}
function fmtWhen(iso) {
if (!iso) return '—'
const d = new Date(iso)
return Number.isNaN(d.getTime()) ? '—' : fmtShort(d)
}
function ListGate({ query, title, permission, children, emptyTitle, emptyHint }) {
if (query.isPending) {
return (
Fetching from the server…
)
}
if (query.isError) {
return (
{widgetError(query.error, permission, `The server did not return ${title}.`)}
)
}
const rows = asList(query.data)
if (rows.length === 0) {
return (
{emptyHint || 'Nothing to show for this window.'}
)
}
return children(rows)
}
export default function Dashboard() {
const { user } = useAuth()
if (isHiringManager(user)) return
return
}
function DashboardHome() {
const navigate = useNavigate()
const { user } = useAuth()
const firstName = (user?.name || 'there').split(' ')[0]
const todayLabel = formatDashDate()
const [rangeKey, setRangeKey] = useState('month')
const [department, setDepartment] = useState('')
// rangeWindow returns fresh ISO strings on every call — recompute only when
// the key changes, or every render would churn the query keys below.
const span = useMemo(() => rangeWindow(rangeKey), [rangeKey])
const filters = useMemo(
() => ({ ...span, department: department || undefined }),
[span, department],
)
/* One stable identity for every query key. recruiterId is pinned to null so
the keys line up with Analytics' and the two screens share cache entries
for the same window. */
const keyParams = useMemo(
() => ({ range: rangeKey, department: department || null, recruiterId: null }),
[rangeKey, department],
)
const kpisQuery = useQuery({
queryKey: qk.analytics.kpis(keyParams),
queryFn: async () => asObject((await analyticsApi.kpis(filters))?.data),
refetchInterval: POLL_MS,
})
// Sparkline only — the full trend chart lives on Analytics.
const trendQuery = useQuery({
queryKey: qk.analytics.trend({ months: TREND_MONTHS }),
queryFn: async () => asObject((await analyticsApi.hiringTrend({ months: TREND_MONTHS }))?.data)
|| { labels: [], applications: [], hires: [] },
refetchInterval: POLL_MS,
})
const jobAppsQuery = useQuery({
queryKey: qk.analytics.jobApps({ ...keyParams, top: JOBS_FETCHED }),
queryFn: async () => {
const res = await analyticsApi.applicationsPerJob({ top: JOBS_FETCHED, ...filters })
return asList(res?.data)
},
refetchInterval: POLL_MS,
})
/* staleTime 0 so navigating back here never serves a pre-drag snapshot as
fresh — the board invalidates qk.pipeline.*, not this key. */
const funnelQuery = useQuery({
queryKey: qk.analytics.funnel(keyParams),
queryFn: async () => asList((await analyticsApi.funnel(filters))?.data),
staleTime: 0,
refetchInterval: POLL_MS,
})
/* Shared with Analytics under one key — the fetcher lives in api/jobs.js so
the two screens can never cache different shapes on it. */
const deptsQuery = useQuery({
queryKey: qk.jobs.list({ scope: 'departments' }),
queryFn: jobsApi.fetchDepartmentOptions,
})
const departments = deptsQuery.data ?? []
/* Point-in-time by design: /offers/fetch has no date filter, and the deep
acceptance-rate doughnut stays on Analytics. */
const offersQuery = useQuery({
queryKey: qk.offers.list({ top: 500, scope: 'dashboard' }),
queryFn: async () => asList((await offersApi.list({ top: 500 }))?.data),
retry: false,
refetchInterval: POLL_MS,
})
// Whole counts object under the SHARED sidebar-badge key — see api/inbox.js.
const inboxCountsQuery = useQuery({
queryKey: qk.mailbox.counts(),
queryFn: inboxApi.fetchCounts,
refetchInterval: POLL_MS,
})
const activityQuery = useQuery({
queryKey: qk.activity.feed({ top: 8 }),
queryFn: async () => asList((await activityApi.feed({ top: 8 }))?.data),
refetchInterval: POLL_MS,
})
const k = kpisQuery.data
/* ---------- derived (chart payloads memoised: Chart requires stable identity) ---------- */
const candidateSpark = useMemo(
() => asList(trendQuery.data?.applications),
[trendQuery.data],
)
/* Occupied stages only. Zero-count stages in the doughnut are a canvas
footgun (arc(a,a) can paint a full circle and hide Screening). */
const pipeRows = useMemo(() => {
const rows = analyticsApi
.toBoardStageRows(funnelQuery.data ?? [], { includeRejected: true })
.filter((r) => r.count > 0)
const total = rows.reduce((sum, r) => sum + r.count, 0)
const pal = Charts.PALETTE
return rows.map((r, i) => ({
...r,
pct: total ? Math.round((r.count / total) * 100) : 0,
color: pal[i % pal.length],
}))
}, [funnelQuery.data])
const pipelineDoughnut = useMemo(() => ({
labels: pipeRows.map((p) => p.stage),
data: pipeRows.map((p) => p.count),
colors: pipeRows.map((p) => p.color),
centerValue: pipeRows.reduce((sum, s) => sum + s.count, 0),
centerLabel: 'In pipeline',
}), [pipeRows])
// DOM bars, no canvas — no identity hazard, but the % math is shared.
const jobRows = useMemo(() => {
const shown = asList(jobAppsQuery.data).slice(0, JOBS_SHOWN)
const max = Math.max(1, ...shown.map((r) => r.count || 0))
return shown.map((r) => ({ ...r, pct: Math.round(((r.count || 0) / max) * 100) }))
}, [jobAppsQuery.data])
const starvingCount = useMemo(
() => asList(jobAppsQuery.data)
.filter((r) => r.requisition_status === 'open' && !r.count).length,
[jobAppsQuery.data],
)
const offerCounts = useMemo(() => {
const counts = Object.fromEntries(offersApi.OFFER_STATUSES.map((s) => [s, 0]))
for (const o of asList(offersQuery.data)) {
if (o.status in counts) counts[o.status] += 1
}
return counts
}, [offersQuery.data])
const pending = kpisQuery.isPending
const dash = (v) => (pending || v == null || v === '' ? '—' : v)
const tiles = [
{
label: 'Open Jobs',
value: dash(k?.open_jobs),
...trendProps(k?.open_jobs, k?.open_jobs_prior),
spark: null,
},
{
label: 'Applications',
value: dash(k?.total_candidates),
...trendProps(k?.total_candidates, k?.total_candidates_prior),
spark: candidateSpark,
sparkColor: Charts.PALETTE[4],
},
{
label: 'Hires',
value: dash(k?.hires),
...trendProps(k?.hires, k?.hires_prior),
spark: null,
},
{
label: 'Offers Sent',
value: dash(k?.offers_sent),
...trendProps(k?.offers_sent, k?.offers_sent_prior),
spark: null,
},
{
label: 'Offers Accepted',
value: dash(k?.offers_accepted),
...trendProps(k?.offers_accepted, k?.offers_accepted_prior),
spark: null,
},
{
label: 'Interviews Today',
value: dash(k?.interviews_today),
trend: k?.interviews_upcoming != null ? `${k.interviews_upcoming} upcoming` : '—',
dir: 'flat',
spark: null,
},
{
label: 'Time to Hire',
value: k?.time_to_hire != null && !pending ? `${Math.round(k.time_to_hire)} days` : '—',
...trendProps(k?.time_to_hire, k?.time_to_hire_prior, { lowerIsBetter: true, fmt: dayDelta }),
spark: null,
},
{
label: 'Cost per Hire',
value: k?.cost_per_hire != null && !pending ? money(Math.round(k.cost_per_hire)) : '—',
...trendProps(k?.cost_per_hire, k?.cost_per_hire_prior, { lowerIsBetter: true }),
spark: null,
},
]
const counts = inboxCountsQuery.data ?? {}
const attentionRows = [
{
key: 'unread',
title: 'Unread applications',
hint: 'Waiting for a first look',
count: counts.unread,
to: '/inbox',
},
{
key: 'unassigned',
title: 'Not assigned to a job',
hint: 'Applications without a requisition',
count: counts.unassigned,
to: '/inbox',
},
{
key: 'duplicates',
title: 'Flagged duplicates',
hint: 'Same candidate, more than once',
count: counts.duplicates,
to: '/inbox',
},
]
return (
{greetingFor()}, {firstName} 👋>}
sub={<>Org-wide recruiting activity — {todayLabel}>}
actions={<>
{RANGES.map((r) => (
setRangeKey(r.key)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setRangeKey(r.key) } }}
>
{r.label}
))}
setDepartment(e.target.value)}>
All Departments
{departments.map((d) => {d} )}
Export
Create Job
>}
/>
{kpisQuery.isError && (
{widgetError(kpisQuery.error, 'analytics.view', 'The server did not return the KPIs.')}
)}
{tiles.map((c) => )}
{() => (
jobRows.length === 0 ? (
Create a requisition or widen the range to see applications per job.
) : (
{jobRows.map((j) => (
navigate(`/progress?job=${encodeURIComponent(j.job_post_id)}`)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
navigate(`/progress?job=${encodeURIComponent(j.job_post_id)}`)
}
}}
>
{j.title}
{j.requisition_status !== 'open' && (
{jobsApi.REQ_STATUS_LABEL[j.requisition_status] || j.requisition_status}
)}
{j.department || 'No department'}
{j.vacancies ? ` · ${j.vacancies} vacanc${j.vacancies === 1 ? 'y' : 'ies'}` : ''}
{j.count}
))}
)
)}
{(h) => (
pipeRows.length === 0 ? (
Stage counts appear once applications land in this window.
) : (
{pipeRows.map((r) => (
))}
)
)}
Offer Book
All offers by status · point-in-time
View all
{offersQuery.isPending &&
Counting offers… }
{offersQuery.isError && (
{widgetError(offersQuery.error, 'offers.view', 'The offers table did not answer.')}
)}
{!offersQuery.isPending && !offersQuery.isError && (
asList(offersQuery.data).length === 0 ? (
This fills in once the first offer is issued.
) : (
{offersApi.OFFER_STATUSES.map((s) => (
{offersApi.OFFER_STATUS_LABEL[s]}
{offerCounts[s]}
))}
)
)}
Needs Attention
Work queued right now
{inboxCountsQuery.isPending &&
Checking the inbox… }
{inboxCountsQuery.isError && (
{widgetError(inboxCountsQuery.error, 'inbox.view', 'The inbox did not answer.')}
)}
{!inboxCountsQuery.isPending && !inboxCountsQuery.isError && (
{attentionRows.map((row) => (
navigate(row.to)}
>
{row.count ?? 0}
))}
{/* Derived from the per-job rows already on this page — no extra read. */}
navigate('/jobs')}
>
Open jobs with no applications
In the selected window
{jobAppsQuery.isSuccess ? starvingCount : '—'}
)}
Recent Activity
Latest across the org
{(rows) => (
{rows.map((a) => (
{a.activity_type || 'Activity'}
{fmtWhen(a.activity_date)}
{a.actor_name ? ` · ${a.actor_name}` : ''}
{(a.description || a.activity_status) && (
{a.description || a.activity_status}
)}
))}
)}
)
}