/* ============================================================ 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} ))}
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) => (
{r.stage}
{r.count}
))}
) )}

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.title}
{row.hint}
{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}
)}
))}
)}
) }