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

583 lines
24 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.

/* ============================================================
Recruiter Hub — live, by pointing every analytics endpoint at one recruiter.
The trick that makes this screen real: /analytics/kpis, /hiring-trend and
/funnel all take a `recruiter_id`, so selecting a recruiter re-scopes the
whole page server-side rather than filtering a client-side array. The
recruiter list itself is /analytics/recruiter-performance, which is also the
leaderboard.
TEN OF THE PROTOTYPE'S EIGHTEEN TILES ARE GONE. workload %, efficiency %, SLA
state, interview completion %, avg response time, TAT %, star rating, jobs
awaiting approval and jobs overdue have no column, no table and in most cases
no concept behind them — there is no approval workflow and no requisition
deadline in the schema. They were random numbers re-rolled on every render.
What replaced them is derived from real counts and labelled as such:
conversion rate is hires ÷ candidates, offer acceptance is accepted ÷ sent.
The workload heatmap survived because interviews are real: it buckets
/interview/fetch over the last five weeks by weekday. It is TEAM-WIDE, not
per recruiter — the interviews table has no recruiter column — and the card
says so rather than implying the selected person owns all of it.
Tasks belong here too: GET /tasks/fetch?assignee_id= the selected recruiter
is the worklist the prototype filed under Recruiter Hub. Completing a row
writes the same /tasks/update the Tasks screen uses, so the two stay in
sync. Hidden without tasks.view; the rest of the hub still loads.
Interviews Today / upcoming / the heatmap join interviews → job_posts via
COALESCE(interviews.job_post_id, inbox_messages.assigned_job_post_id) and
filter on current_recruiter_id. The leaderboard ranks by completed
requisitions (requisition_status=completed), not inbox hires.
============================================================ */
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Chart from '../ui/Chart'
import Charts from '../lib/charts'
import PageHeader from '../ui/PageHeader'
import { Avatar, Badge, EmptyState, Icon, KpiCard, PRIORITY_CLASS, ProgressBar } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as analyticsApi from '../api/analytics'
import * as interviewsApi from '../api/interviews'
import * as tasksApi from '../api/tasks'
import { avatarColor, fmtShort, initials as initialsOf } from '../data/seed'
const TASK_PREVIEW = 8
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const WEEKS = 5
const TREND_MONTHS = 7
const HEAT_MAX = 5
const heatColor = (v) => (v === 0 ? 'var(--bg-sunken)' : `rgba(79,70,229,${0.2 + (Math.min(v, HEAT_MAX) / HEAT_MAX) * 0.8})`)
const pct = (num, den) => (den ? `${Math.round((num / den) * 100)}%` : '—')
const days = (v) => (v == null ? '—' : `${Math.round(Number(v))}d`)
/** Monday-indexed weekday, so the grid reads MonSun like the rest of the app. */
function weekdayIndex(date) {
return (date.getDay() + 6) % 7
}
export default function RecruiterHub() {
const { can } = useAuth()
const { toast } = useToast()
const qc = useQueryClient()
const [recruiterId, setRecruiterId] = useState('')
const canViewTasks = can('tasks.view')
const canEditTasks = can('tasks.edit')
const boardQuery = useQuery({
queryKey: qk.analytics.recruiters({ top: 50, scope: 'hub' }),
queryFn: async () => {
const res = await analyticsApi.recruiterPerformance({ top: 50 })
return Array.isArray(res?.data) ? res.data : []
},
})
const recruiters = boardQuery.data ?? []
const selected = recruiters.find((r) => r.id === recruiterId) ?? recruiters[0] ?? null
const activeId = selected?.id ?? null
const kpisQuery = useQuery({
queryKey: qk.analytics.kpis({ recruiterId: activeId, scope: 'hub' }),
queryFn: async () => (await analyticsApi.kpis({ recruiterId: activeId }))?.data ?? null,
enabled: Boolean(activeId),
})
const trendQuery = useQuery({
queryKey: qk.analytics.trend({ recruiterId: activeId, months: TREND_MONTHS, scope: 'hub' }),
queryFn: async () => (await analyticsApi.hiringTrend({ months: TREND_MONTHS, recruiterId: activeId }))?.data
?? { labels: [], hires: [] },
enabled: Boolean(activeId),
})
const funnelQuery = useQuery({
queryKey: qk.analytics.funnel({ recruiterId: activeId, scope: 'hub' }),
queryFn: async () => {
const res = await analyticsApi.funnel({ recruiterId: activeId })
return Array.isArray(res?.data) ? res.data : []
},
enabled: Boolean(activeId),
})
const tasksKey = qk.tasks.list({ assigneeId: activeId, scope: 'hub' })
const tasksQuery = useQuery({
queryKey: tasksKey,
queryFn: async () => {
const res = await tasksApi.list({ assigneeId: activeId })
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map(tasksApi.toTaskView)
},
enabled: Boolean(activeId) && canViewTasks,
})
const flip = useMutation({
mutationFn: ({ id, done }) => tasksApi.update(id, { status: done ? 'done' : 'open' }),
onMutate: async ({ id, done }) => {
await qc.cancelQueries({ queryKey: qk.tasks.all() })
const previous = qc.getQueryData(tasksKey)
qc.setQueryData(tasksKey, (old = []) =>
old.map((t) => (t.id === id ? { ...t, done } : t)),
)
return { previous }
},
onError: (err, _vars, ctx) => {
if (ctx?.previous) qc.setQueryData(tasksKey, ctx.previous)
toast(friendlyAuthError(err, 'Could not update the task.'), 'error')
},
onSuccess: (_res, { done }) => {
toast(done ? 'Task completed' : 'Task reopened', done ? 'success' : 'info')
},
onSettled: () => qc.invalidateQueries({ queryKey: qk.tasks.all() }),
})
/* The heatmap window: the last five whole weeks ending today. Sent as a real
range so the request stays small however long the table gets. */
const heatFrom = useMemo(() => {
const d = new Date()
d.setHours(0, 0, 0, 0)
d.setDate(d.getDate() - (WEEKS * 7 - 1))
return d
}, [])
const heatQuery = useQuery({
queryKey: qk.interviews.range({ scope: 'heatmap', weeks: WEEKS, recruiterId: activeId }),
queryFn: async () => {
const res = await interviewsApi.listRange({
fromDate: heatFrom.toISOString(),
toDate: new Date().toISOString(),
recruiterId: activeId,
top: 500,
})
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map(interviewsApi.toInterviewView)
},
enabled: Boolean(activeId),
})
const heatmap = useMemo(() => {
const grid = Array.from({ length: 7 }, () => Array.from({ length: WEEKS }, () => 0))
for (const iv of heatQuery.data ?? []) {
if (!iv.when) continue
const dayOffset = Math.floor((iv.when - heatFrom) / 86400000)
if (dayOffset < 0 || dayOffset >= WEEKS * 7) continue
const week = Math.floor(dayOffset / 7)
grid[weekdayIndex(iv.when)][week] += 1
}
return grid
}, [heatQuery.data, heatFrom])
const weekLabels = useMemo(
() => Array.from({ length: WEEKS }, (_, i) => (i === WEEKS - 1 ? 'This' : `W${i + 1}`)),
[],
)
const trendData = useMemo(() => {
const t = trendQuery.data ?? { labels: [], hires: [] }
return {
labels: t.labels ?? [],
area: true,
datasets: [{ label: 'Hires', data: t.hires ?? [], color: Charts.PALETTE[0] }],
}
}, [trendQuery.data])
const pipelineData = useMemo(() => {
const rows = analyticsApi.toBoardStageRows(funnelQuery.data ?? [])
return {
labels: rows.map((p) => p.stage),
data: rows.map((p) => p.count),
colors: Charts.PALETTE,
}
}, [funnelQuery.data])
const board = useMemo(
() => [...recruiters].sort((a, b) => (b.completed ?? 0) - (a.completed ?? 0) || (b.hires ?? 0) - (a.hires ?? 0)).slice(0, 8),
[recruiters],
)
if (boardQuery.isPending) {
return (
<div className="page">
<div className="card"><div className="card-body">
<EmptyState icon="clock" title="Loading recruiters…">Fetching the recruiter roster.</EmptyState>
</div></div>
</div>
)
}
if (boardQuery.isError) {
return (
<div className="page">
<div className="card"><div className="card-body">
<EmptyState icon="alert" title="Couldnt load recruiter performance">
{friendlyAuthError(boardQuery.error, 'The server did not answer.')}
{' '}This screen needs the <code>analytics.view</code> permission.
</EmptyState>
</div></div>
</div>
)
}
if (!selected) {
return (
<div className="page">
<div className="card"><div className="card-body">
<EmptyState icon="users" title="No recruiters yet">
Users with the <code>recruiter</code> role appear here once they exist.
</EmptyState>
</div></div>
</div>
)
}
const k = kpisQuery.data
const name = selected.name || 'Recruiter'
const loading = kpisQuery.isPending
const now = new Date()
const tasks = tasksQuery.data ?? []
const openTasks = tasks.filter((t) => !t.done)
const overdueCount = openTasks.filter((t) => t.due && t.due < now).length
const doneCount = tasks.filter((t) => t.done).length
const taskPct = tasks.length ? Math.round((doneCount / tasks.length) * 100) : 0
const tasksLoading = canViewTasks && tasksQuery.isPending
function toggleTask(task) {
if (!canEditTasks) {
toast('Requires tasks.edit', 'info')
return
}
flip.mutate({ id: task.id, done: !task.done })
}
return (
<div className="page">
<PageHeader
title="Recruiter Hub"
sub="Per-recruiter hiring progress and assigned tasks"
actions={
<select className="select" value={selected.id} onChange={(e) => setRecruiterId(e.target.value)}>
{recruiters.map((x) => <option key={x.id} value={x.id}>{x.name}</option>)}
</select>
}
/>
<div className="card brand-hero mb-18">
<div className="card-body flex items-center flex-wrap" style={{ gap: 18 }}>
<Avatar name={name} initials={initialsOf(name)} color="rgba(255,255,255,.18)" className="avatar-lg" />
<div style={{ flex: 1, minWidth: 200 }}>
<div style={{ fontSize: 20, fontWeight: 700 }}>{name}</div>
<div style={{ opacity: 0.85 }}>
{selected.open_reqs ?? 0} open req{(selected.open_reqs ?? 0) === 1 ? '' : 's'}
{' · '}
{selected.completed ?? 0} completed
{' · '}
{selected.hires ?? 0} hire{(selected.hires ?? 0) === 1 ? '' : 's'}
{canViewTasks && !tasksLoading ? (
<>
{' · '}
{openTasks.length} open task{openTasks.length === 1 ? '' : 's'}
</>
) : null}
</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 26, fontWeight: 600, letterSpacing: '-0.015em' }}>
{loading ? '—' : pct(k?.hires ?? 0, k?.total_candidates ?? 0)}
</div>
<div style={{ opacity: 0.85, fontSize: 12 }}>Applicant hire</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 26, fontWeight: 600, letterSpacing: '-0.015em' }}>
{loading ? '—' : pct(k?.offers_accepted ?? 0, k?.offers_sent ?? 0)}
</div>
<div style={{ opacity: 0.85, fontSize: 12 }}>Offer acceptance</div>
</div>
{canViewTasks && (
<div style={{ textAlign: 'center' }}>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 26, fontWeight: 600, letterSpacing: '-0.015em' }}>
{tasksLoading ? '—' : (tasks.length ? `${taskPct}%` : '—')}
</div>
<div style={{ opacity: 0.85, fontSize: 12 }}>Tasks done</div>
</div>
)}
</div>
</div>
{kpisQuery.isError && (
<div className="card mb-18"><div className="card-body">
<EmptyState icon="alert" title="Couldnt load this recruiters metrics">
{friendlyAuthError(kpisQuery.error, 'The server did not answer.')}
</EmptyState>
</div></div>
)}
<div className="grid g-kpi mb-18">
<KpiCard label="Open Positions" value={loading ? '—' : (k?.open_jobs ?? selected.open_reqs ?? 0)} icon="briefcase" tone="i-indigo" foot="active reqs" />
<KpiCard label="Closed Positions" value={loading ? '—' : (k?.closed_jobs ?? 0)} icon="check-circle" tone="i-green" foot="in window" />
<KpiCard label="Avg Time to Hire" value={loading ? '—' : days(k?.time_to_hire ?? selected.avg_time_to_hire)} icon="clock" tone="i-teal" foot="offer accepted → hire" />
<KpiCard label="Avg Time to Fill" value={loading ? '—' : days(k?.time_to_fill)} icon="target" tone="i-amber" foot="req opened → closed" />
</div>
<div className="grid g-kpi mb-18">
<KpiCard label="Interviews Today" value={loading ? '—' : (k?.interviews_today ?? 0)} icon="calendar" tone="i-purple" foot={k?.interviews_upcoming != null ? `${k.interviews_upcoming} upcoming` : undefined} />
<KpiCard label="Offers Sent" value={loading ? '—' : (k?.offers_sent ?? 0)} icon="send" tone="i-blue" />
<KpiCard label="Offers Accepted" value={loading ? '—' : (k?.offers_accepted ?? 0)} icon="file" tone="i-green" />
<KpiCard label="Candidates" value={loading ? '—' : (k?.total_candidates ?? 0)} icon="users" tone="i-indigo" foot="in their pipeline" />
</div>
{canViewTasks && (
<div className="grid g-kpi mb-18">
<KpiCard label="Open Tasks" value={tasksLoading ? '—' : openTasks.length} icon="check-square" tone="i-indigo" foot="assigned to them" />
<KpiCard label="Overdue" value={tasksLoading ? '—' : overdueCount} icon="alert" tone="i-red" foot="past due date" />
<KpiCard label="Completed" value={tasksLoading ? '—' : doneCount} icon="check-circle" tone="i-green" foot="of their worklist" />
<KpiCard label="Task Progress" value={tasksLoading ? '—' : (tasks.length ? `${taskPct}%` : '—')} icon="target" tone="i-teal" foot={tasks.length ? `${doneCount} of ${tasks.length}` : 'no tasks yet'} />
</div>
)}
{canViewTasks && (
<RecruiterTasks
name={name}
assigneeId={activeId}
query={tasksQuery}
now={now}
canEdit={canEditTasks}
toggling={flip.isPending}
onToggle={toggleTask}
/>
)}
<div className="grid g-2-1 mb-18">
<div className="card">
<div className="card-head">
<div><h3>Monthly Hiring Trend</h3><span className="ch-sub">Hires per month, this recruiter</span></div>
</div>
<div className="card-body">
{trendQuery.isPending && <EmptyState icon="clock" title="Loading…">Fetching the trend.</EmptyState>}
{trendQuery.isError && (
<EmptyState icon="alert" title="Couldnt load the trend">
{friendlyAuthError(trendQuery.error, 'The server did not answer.')}
</EmptyState>
)}
{trendQuery.isSuccess && (
<div className="chart-wrap"><Chart type="line" data={trendData} height={260} /></div>
)}
</div>
</div>
<div className="card">
<div className="card-head">
<div>
<h3>Interview Load</h3>
<span className="ch-sub">This recruiter, last {WEEKS} weeks</span>
</div>
</div>
<div className="card-body">
{heatQuery.isPending ? (
<EmptyState icon="clock" title="Loading…">Bucketing interviews.</EmptyState>
) : heatQuery.isError ? (
<EmptyState icon="alert" title="Couldnt load interviews">
{friendlyAuthError(heatQuery.error, 'The server did not answer.')}
</EmptyState>
) : (
<>
<div className="heatmap">
<div className="hm-label" />
{weekLabels.map((w) => (
<div className="hm-label" style={{ justifyContent: 'center' }} key={w}>{w}</div>
))}
{DAYS.map((d, di) => (
<div style={{ display: 'contents' }} key={d}>
<div className="hm-label">{d}</div>
{heatmap[di].map((v, wi) => (
<div
className="hm-cell"
key={`${d}-${wi}`}
style={{ background: heatColor(v) }}
data-tip={`${v} interview${v === 1 ? '' : 's'}`}
/>
))}
</div>
))}
</div>
<div className="hm-legend">
Less
{[0, 1, 2, 3, 5].map((v) => (
<span className="hm-box" key={v} style={{ background: heatColor(v) }} />
))}
More
</div>
<p className="text-muted" style={{ marginTop: 10, fontSize: 12 }}>
<Icon name="info" /> Counted from interviews on this recruiters jobs.
</p>
</>
)}
</div>
</div>
</div>
<div className="grid g-2">
<div className="card">
<div className="card-head">
<div><h3>Recruiter Leaderboard</h3><span className="ch-sub">Ranked by completed requisitions</span></div>
</div>
<div className="card-body">
{board.length === 0 ? (
<EmptyState icon="users" title="No completed requisitions yet">
Mark a job Completed when hiring finishes to rank recruiters here.
</EmptyState>
) : (
board.map((rec, i) => {
const rn = rec.name || 'Recruiter'
return (
<div
className="leader-row"
key={rec.id}
style={
rec.id === selected.id
? { background: 'var(--primary-soft)', borderRadius: 10, paddingLeft: 8, paddingRight: 8 }
: undefined
}
>
<span className={`leader-rank ${i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : ''}`}>
{i + 1}
</span>
<Avatar name={rn} initials={initialsOf(rn)} color={avatarColor(rn)} />
<div className="lr-main">
<div className="lr-title">{rn}</div>
<div className="lr-sub">
{rec.open_reqs ?? 0} open · {days(rec.avg_time_to_hire)} avg
</div>
</div>
<div className="lr-right">
<div className="fw-600">{rec.completed ?? 0}</div>
<div className="lr-sub">completed</div>
</div>
</div>
)
})
)}
</div>
</div>
<div className="card">
<div className="card-head">
<div><h3>Candidate Pipeline</h3><span className="ch-sub">This recruiters active candidates</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 pipeline">
{friendlyAuthError(funnelQuery.error, 'The server did not answer.')}
</EmptyState>
) : pipelineData.data.every((n) => !n) ? (
<EmptyState icon="inbox" title="No active candidates">
Applications assigned to this recruiter appear here.
</EmptyState>
) : (
<div className="chart-wrap"><Chart type="horizontalBar" data={pipelineData} height={260} /></div>
)}
</div>
</div>
</div>
</div>
)
}
function RecruiterTasks({ name, assigneeId, query, now, canEdit, toggling, onToggle }) {
const tasks = query.data ?? []
const ranked = [...tasks].sort((a, b) => {
const aOver = !a.done && a.due && a.due < now
const bOver = !b.done && b.due && b.due < now
if (aOver !== bOver) return aOver ? -1 : 1
if (a.done !== b.done) return a.done ? 1 : -1
const aDue = a.due ? a.due.getTime() : Infinity
const bDue = b.due ? b.due.getTime() : Infinity
return aDue - bDue
})
const preview = ranked.slice(0, TASK_PREVIEW)
const done = tasks.filter((t) => t.done).length
const pctDone = tasks.length ? Math.round((done / tasks.length) * 100) : 0
return (
<div className="card mb-18">
<div className="card-head">
<div>
<h3>Tasks</h3>
<span className="ch-sub">Assigned to {name}</span>
</div>
<Link className="btn btn-ghost btn-sm" to={assigneeId ? `/tasks?assignee=${encodeURIComponent(assigneeId)}` : '/tasks'}>View all</Link>
</div>
<div className="card-body">
<div className="list-tight">
{query.isPending ? (
<EmptyState icon="clock" title="Loading…">Fetching this recruiters tasks.</EmptyState>
) : query.isError ? (
<EmptyState icon="alert" title="Couldnt load tasks">
{friendlyAuthError(query.error, 'The server did not answer.')}
{' '}This list needs the <code>tasks.view</code> permission.
</EmptyState>
) : preview.length === 0 ? (
<EmptyState icon="check-square" title="No tasks assigned">
Create a task on the Tasks screen and assign it to {name}.
</EmptyState>
) : (
<>
{preview.map((t) => {
const overdue = !t.done && t.due && t.due < now
return (
<div className="list-row" style={{ alignItems: 'center' }} key={t.id}>
<span
className={`checkbox ${t.done ? 'on' : ''}`}
onClick={() => onToggle(t)}
role="checkbox"
aria-checked={t.done}
tabIndex={canEdit ? 0 : -1}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle(t)
}
}}
>
<Icon name="check" />
</span>
<div className="lr-main">
<div
className="lr-title"
style={t.done ? { textDecoration: 'line-through', color: 'var(--text-3)' } : undefined}
>
{t.title}
</div>
<div
className="lr-sub"
style={overdue ? { color: 'var(--danger)', fontWeight: 600 } : undefined}
>
{t.due ? `${overdue ? 'Overdue · ' : 'Due '}${fmtShort(t.due)}` : 'No due date'}
</div>
</div>
<Badge className={PRIORITY_CLASS[t.priority]}>{t.priority}</Badge>
</div>
)
})}
<div className="task-foot">
<ProgressBar pct={pctDone} />
<span>
{done} of {tasks.length}
{ranked.length > TASK_PREVIEW ? ` · showing ${TASK_PREVIEW}` : ''}
{toggling ? ' · saving…' : ''}
</span>
</div>
</>
)}
</div>
</div>
</div>
)
}