619 lines
23 KiB
JavaScript
619 lines
23 KiB
JavaScript
/* ============================================================
|
||
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 { fmtWeekdayDate, fmtShort } from '../lib/format'
|
||
import { 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 fmtWeekdayDate(d)
|
||
}
|
||
|
||
function fmtWhen(iso) {
|
||
return fmtShort(iso) || '—'
|
||
}
|
||
|
||
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={`Couldn’t 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="Couldn’t 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="Couldn’t 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>
|
||
)
|
||
}
|