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

625 lines
23 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.

/* ============================================================
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 (
<EmptyState icon="clock" title={`Loading ${title}`}>
Fetching from the server
</EmptyState>
)
}
if (query.isError) {
return (
<EmptyState icon="alert" title={`Couldnt load ${title}`}>
{widgetError(query.error, permission, `The server did not return ${title}.`)}
</EmptyState>
)
}
const rows = asList(query.data)
if (rows.length === 0) {
return (
<EmptyState icon="inbox" title={emptyTitle || `No ${title} yet`}>
{emptyHint || 'Nothing to show for this window.'}
</EmptyState>
)
}
return children(rows)
}
export default function Dashboard() {
const { user } = useAuth()
if (isHiringManager(user)) return <Navigate to="/candidates" replace />
return <DashboardHome />
}
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 (
<div className="page">
<PageHeader
title={<>{greetingFor()}, {firstName} 👋</>}
sub={<>Org-wide recruiting activity {todayLabel}</>}
actions={<>
<div className="pill-tabs">
{RANGES.map((r) => (
<span
key={r.key}
className={`pill-tab${rangeKey === r.key ? ' active' : ''}`}
role="button"
tabIndex={0}
onClick={() => setRangeKey(r.key)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setRangeKey(r.key) } }}
>
{r.label}
</span>
))}
</div>
<select className="select" value={department} onChange={(e) => setDepartment(e.target.value)}>
<option value="">All Departments</option>
{departments.map((d) => <option key={d}>{d}</option>)}
</select>
<Link className="btn btn-secondary" to="/reports">
<Icon name="download" /> Export
</Link>
<Link className="btn btn-primary" to="/jobs" state={{ openCreate: true }}>
<Icon name="plus" /> Create Job
</Link>
</>}
/>
{kpisQuery.isError && (
<div className="card mb-18">
<div className="card-body">
<EmptyState icon="alert" title="Couldnt load the summary">
{widgetError(kpisQuery.error, 'analytics.view', 'The server did not return the KPIs.')}
</EmptyState>
</div>
</div>
)}
<div className="grid g-kpi-8">
{tiles.map((c) => <KpiTile key={c.label} {...c} />)}
</div>
<div className="grid g-2-1 mt-18">
<ChartCard
title="Applications per Job"
sub={`Top ${JOBS_SHOWN} · ${rangeLabel(rangeKey)}${department ? ` · ${department}` : ''} · empty bars are open jobs nobody applied to`}
query={jobAppsQuery}
permission="analytics.view"
>
{() => (
jobRows.length === 0 ? (
<EmptyState icon="briefcase" title="No jobs in this window">
Create a requisition or widen the range to see applications per job.
</EmptyState>
) : (
<div className="list-tight">
{jobRows.map((j) => (
<div
key={j.job_post_id}
className="jobapp-row"
role="button"
tabIndex={0}
onClick={() => 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)}`)
}
}}
>
<div className="jobapp-main">
<div className="lr-title">
{j.title}
{j.requisition_status !== 'open' && (
<Badge className="b-gray" style={{ marginLeft: 8 }}>
{jobsApi.REQ_STATUS_LABEL[j.requisition_status] || j.requisition_status}
</Badge>
)}
</div>
<div className="lr-sub">
{j.department || 'No department'}
{j.vacancies ? ` · ${j.vacancies} vacanc${j.vacancies === 1 ? 'y' : 'ies'}` : ''}
</div>
</div>
<div className="pipe-track">
<div
className="pipe-fill"
style={{ width: `${j.count ? j.pct : 0}%`, background: Charts.PALETTE[0] }}
/>
</div>
<span className="jobapp-count">{j.count}</span>
</div>
))}
</div>
)
)}
</ChartCard>
<ChartCard
title="Candidate Pipeline"
sub="Active by stage · this window"
query={funnelQuery}
height={160}
permission="analytics.view"
>
{(h) => (
pipeRows.length === 0 ? (
<EmptyState icon="inbox" title="No pipeline data yet">
Stage counts appear once applications land in this window.
</EmptyState>
) : (
<div className="pipe-split">
<div>
{pipeRows.map((r) => (
<div className="pipe-row" key={r.stage}>
<span className="pipe-label" title={r.stage}>{r.stage}</span>
<div className="pipe-track">
<div
className="pipe-fill"
style={{ width: `${r.pct}%`, background: r.color }}
/>
</div>
<span className="pipe-pct" title={`${r.pct}%`}>{r.count}</span>
</div>
))}
</div>
<div className="chart-wrap">
<Chart type="doughnut" data={pipelineDoughnut} height={h} />
</div>
</div>
)
)}
</ChartCard>
</div>
<div className="grid g-3 mt-18">
<div className="card">
<div className="card-head">
<div>
<h3>Offer Book</h3>
<span className="ch-sub">All offers by status · point-in-time</span>
</div>
<Link className="btn btn-ghost btn-sm" to="/offers">View all</Link>
</div>
<div className="card-body">
{offersQuery.isPending && <EmptyState icon="clock" title="Loading offers">Counting offers</EmptyState>}
{offersQuery.isError && (
<EmptyState icon="lock" title="Offers not visible">
{widgetError(offersQuery.error, 'offers.view', 'The offers table did not answer.')}
</EmptyState>
)}
{!offersQuery.isPending && !offersQuery.isError && (
asList(offersQuery.data).length === 0 ? (
<EmptyState icon="file" title="No offers yet">
This fills in once the first offer is issued.
</EmptyState>
) : (
<div className="info-grid">
{offersApi.OFFER_STATUSES.map((s) => (
<div className="info-item" key={s}>
<div className="il">{offersApi.OFFER_STATUS_LABEL[s]}</div>
<div className="iv">{offerCounts[s]}</div>
</div>
))}
</div>
)
)}
</div>
</div>
<div className="card">
<div className="card-head">
<div>
<h3>Needs Attention</h3>
<span className="ch-sub">Work queued right now</span>
</div>
</div>
<div className="card-body">
{inboxCountsQuery.isPending && <EmptyState icon="clock" title="Loading counts">Checking the inbox</EmptyState>}
{inboxCountsQuery.isError && (
<EmptyState icon="alert" title="Couldnt load inbox counts">
{widgetError(inboxCountsQuery.error, 'inbox.view', 'The inbox did not answer.')}
</EmptyState>
)}
{!inboxCountsQuery.isPending && !inboxCountsQuery.isError && (
<div className="list-tight">
{attentionRows.map((row) => (
<div
key={row.key}
className="list-row"
style={{ cursor: 'pointer' }}
onClick={() => navigate(row.to)}
>
<div className="lr-main">
<div className="lr-title">{row.title}</div>
<div className="lr-sub">{row.hint}</div>
</div>
<Badge className={row.count ? 'b-amber' : 'b-gray'}>{row.count ?? 0}</Badge>
</div>
))}
{/* Derived from the per-job rows already on this page — no extra read. */}
<div
className="list-row"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/jobs')}
>
<div className="lr-main">
<div className="lr-title">Open jobs with no applications</div>
<div className="lr-sub">In the selected window</div>
</div>
<Badge className={starvingCount ? 'b-amber' : 'b-gray'}>
{jobAppsQuery.isSuccess ? starvingCount : '—'}
</Badge>
</div>
</div>
)}
</div>
</div>
<div className="card">
<div className="card-head">
<div>
<h3>Recent Activity</h3>
<span className="ch-sub">Latest across the org</span>
</div>
</div>
<div className="card-body">
<ListGate
query={activityQuery}
title="activity"
permission="candidates.view"
emptyTitle="No activity yet"
emptyHint="Actions on candidates appear here as they happen."
>
{(rows) => (
<div className="timeline">
{rows.map((a) => (
<div className="tl-item" key={a.id}>
<div className="tl-dot"><Icon name="zap" /></div>
<div className="tl-title">{a.activity_type || 'Activity'}</div>
<div className="tl-meta">
{fmtWhen(a.activity_date)}
{a.actor_name ? ` · ${a.actor_name}` : ''}
</div>
{(a.description || a.activity_status) && (
<div className="tl-desc">{a.description || a.activity_status}</div>
)}
</div>
))}
</div>
)}
</ListGate>
</div>
</div>
</div>
</div>
)
}