583 lines
24 KiB
JavaScript
583 lines
24 KiB
JavaScript
/* ============================================================
|
||
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 Mon–Sun 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="Couldn’t 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="Couldn’t load this recruiter’s 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="Couldn’t 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="Couldn’t 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 recruiter’s 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 recruiter’s 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="Couldn’t 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 recruiter’s tasks.</EmptyState>
|
||
) : query.isError ? (
|
||
<EmptyState icon="alert" title="Couldn’t 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>
|
||
)
|
||
}
|