update ui design for all pages
This commit is contained in:
@@ -1,384 +1,6 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Bar, BarChart, CartesianGrid, Cell, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
|
||||
} from 'recharts';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
AXIS_PROPS, CHART_TONES, ChartTooltip, InsightList, InsightRow, MetricStrip,
|
||||
ProgressBar, SegmentedToggle, Surface,
|
||||
} from '@/components/ds';
|
||||
import { useApplications, useInterviews, useJobPostings, useStaff, useWorkerProfiles } from '@/lib/krowHooks';
|
||||
import { buildFacts } from '@/components/ai-assistant/insights';
|
||||
import { HiringFlow } from '@/components/charts/HiringFlow';
|
||||
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
|
||||
|
||||
/**
|
||||
* Admin Analytics — an analytical workspace.
|
||||
*
|
||||
* Sections are open rather than boxed: a chart inside a card inside a page is
|
||||
* two frames too many, and the borders end up competing with the data. Surfaces
|
||||
* appear only where something genuinely needs separating from what surrounds it.
|
||||
*/
|
||||
|
||||
/** Bar colour follows the score band, so the chart needs no legend. */
|
||||
const bandColor = (label) => ({
|
||||
'0–39': CHART_TONES.navy,
|
||||
'40–59': CHART_TONES.mint,
|
||||
'60–79': CHART_TONES.accentPale,
|
||||
'80–100': CHART_TONES.brand,
|
||||
}[label]);
|
||||
import React from 'react';
|
||||
import AdminHiredHistory from './HiredHistory';
|
||||
|
||||
export default function AdminAnalytics() {
|
||||
const [range, setRange] = useState('30d');
|
||||
|
||||
const { data: applications = [] } = useApplications();
|
||||
const { data: postings = [] } = useJobPostings();
|
||||
const { data: interviews = [] } = useInterviews();
|
||||
const { data: staff = [] } = useStaff();
|
||||
const { data: profiles = [] } = useWorkerProfiles();
|
||||
|
||||
const f = useMemo(
|
||||
() => buildFacts({ applications, postings, interviews, staff, profiles }),
|
||||
[applications, postings, interviews, staff, profiles]
|
||||
);
|
||||
|
||||
const trend = useMemo(() => {
|
||||
const days = range === '7d' ? 7 : range === '30d' ? 30 : 90;
|
||||
const buckets = new Map();
|
||||
for (let i = days - 1; i >= 0; i -= 1) {
|
||||
const d = new Date(f.today);
|
||||
d.setDate(d.getDate() - i);
|
||||
buckets.set(d.toISOString().slice(0, 10), {
|
||||
label: `${d.getMonth() + 1}/${d.getDate()}`, applied: 0, screened: 0,
|
||||
});
|
||||
}
|
||||
applications.forEach((a) => {
|
||||
const created = String(a.created_date).slice(0, 10);
|
||||
if (buckets.has(created)) buckets.get(created).applied += 1;
|
||||
if (a.ai_score > 0) {
|
||||
const scored = String(a.updated_date).slice(0, 10);
|
||||
if (buckets.has(scored)) buckets.get(scored).screened += 1;
|
||||
}
|
||||
});
|
||||
const rows = [...buckets.values()];
|
||||
const step = days > 30 ? 6 : days > 7 ? 3 : 1;
|
||||
return rows.filter((_, i) => i % step === 0 || i === rows.length - 1);
|
||||
}, [applications, range, f.today]);
|
||||
|
||||
const scoreDistribution = useMemo(() => {
|
||||
const bands = [['0–39', 0, 39], ['40–59', 40, 59], ['60–79', 60, 79], ['80–100', 80, 100]];
|
||||
return bands.map(([label, min, max]) => ({
|
||||
label,
|
||||
count: f.scored.filter((a) => a.ai_score >= min && a.ai_score <= max).length,
|
||||
}));
|
||||
}, [f.scored]);
|
||||
|
||||
const departments = useMemo(() => {
|
||||
const map = new Map();
|
||||
f.byRole.forEach((r) => {
|
||||
const e = map.get(r.category) || { category: r.category, applied: 0, qualified: 0, hired: 0 };
|
||||
e.applied += r.applied; e.qualified += r.qualified; e.hired += r.hired;
|
||||
map.set(r.category, e);
|
||||
});
|
||||
return [...map.values()].sort((a, b) => b.applied - a.applied);
|
||||
}, [f.byRole]);
|
||||
|
||||
const maxApplied = Math.max(...departments.map((d) => d.applied), 1);
|
||||
|
||||
/**
|
||||
* Days from application to decision, per role that actually closed a hire.
|
||||
*
|
||||
* Only hired applications carry a real duration — an open application's
|
||||
* created-to-updated span measures how long ago it was touched, not how long it
|
||||
* took to fill anything.
|
||||
*/
|
||||
const byRoleSpeed = useMemo(() => {
|
||||
const map = new Map();
|
||||
f.hired.forEach((a) => {
|
||||
const days = Math.max(
|
||||
1,
|
||||
Math.round((new Date(a.updated_date) - new Date(a.created_date)) / 86400000)
|
||||
);
|
||||
const key = a.job_title || 'Unspecified';
|
||||
const e = map.get(key) || { title: key, days: [] };
|
||||
e.days.push(days);
|
||||
map.set(key, e);
|
||||
});
|
||||
return [...map.values()]
|
||||
.map((e) => ({
|
||||
title: e.title,
|
||||
days: Math.round(e.days.reduce((x, y) => x + y, 0) / e.days.length),
|
||||
}))
|
||||
.sort((a, b) => a.days - b.days);
|
||||
}, [f.hired]);
|
||||
|
||||
const slowestHire = Math.max(...byRoleSpeed.map((r) => r.days), 1);
|
||||
|
||||
const insights = [
|
||||
f.bottleneck && f.bottleneck.lost > 0 && {
|
||||
severity: 'warning',
|
||||
title: `Conversion drops at ${f.bottleneck.from} → ${f.bottleneck.to}`,
|
||||
detail: `Only ${f.bottleneck.rate}% pass through, losing ${f.bottleneck.lost} candidates. The largest recoverable loss in the funnel.`,
|
||||
metric: `${f.bottleneck.rate}%`,
|
||||
metricLabel: 'pass-through',
|
||||
},
|
||||
f.standardizedPct < 80 && {
|
||||
severity: 'warning',
|
||||
title: 'Screening coverage below target',
|
||||
detail: `${f.unscreened.length} applicants carry no score, so those decisions are not comparable.`,
|
||||
metric: `${f.standardizedPct}%`,
|
||||
metricLabel: 'scored',
|
||||
},
|
||||
f.hiredAvgScore > f.avgScore && {
|
||||
severity: 'info',
|
||||
title: 'Selection is working',
|
||||
detail: `Hires average ${f.hiredAvgScore} against a pool average of ${f.avgScore} — a ${f.hiredAvgScore - f.avgScore} point lift.`,
|
||||
metric: `+${f.hiredAvgScore - f.avgScore}`,
|
||||
metricLabel: 'quality lift',
|
||||
},
|
||||
f.interviewCompletion < 100 && f.interviews.length > 0 && {
|
||||
severity: 'info',
|
||||
title: 'Interviews started but not scored',
|
||||
detail: `${f.interviews.length - f.completedInterviews.length} of ${f.interviews.length} interviews were abandoned before scoring.`,
|
||||
metric: `${f.interviewCompletion}%`,
|
||||
metricLabel: 'completed',
|
||||
},
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title="Analytics"
|
||||
subtitle="Hiring performance, conversion and workforce quality."
|
||||
actions={
|
||||
<SegmentedToggle
|
||||
options={[{ value: '7d', label: '7d' }, { value: '30d', label: '30d' }, { value: '90d', label: '90d' }]}
|
||||
value={range}
|
||||
onChange={setRange}
|
||||
size="sm"
|
||||
ariaLabel="Date range"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MetricStrip
|
||||
columns={6}
|
||||
items={[
|
||||
{ label: 'Applicants', value: f.total },
|
||||
{ label: 'Conversion', value: `${f.hireRate}%`, tone: 'brand', sub: 'applicant to hire' },
|
||||
{ label: 'Time to hire', value: f.timeToHire ? `${f.timeToHire}d` : '—', delta: -38, invertTrend: true },
|
||||
{ label: 'Screening', value: `${f.standardizedPct}%`, tone: f.standardizedPct >= 80 ? 'success' : 'warning', sub: 'coverage' },
|
||||
{ label: 'Avg score', value: f.avgScore || '—' },
|
||||
{ label: 'Quality of hire', value: f.hiredAvgScore || '—', tone: 'success' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Hiring trend — open section, no card frame around the chart. */}
|
||||
<section aria-labelledby="trend" className="space-y-3">
|
||||
<SectionTitle id="trend" title="Hiring trend" meta="Applications received against candidates screened" />
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={trend}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
|
||||
<XAxis dataKey="label" {...AXIS_PROPS} interval="preserveStartEnd" />
|
||||
<YAxis {...AXIS_PROPS} allowDecimals={false} width={28} />
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
<Line type="monotone" dataKey="applied" name="Applied" stroke={CHART_TONES.brand} strokeWidth={2} dot={false} />
|
||||
<Line type="monotone" dataKey="screened" name="Screened" stroke={CHART_TONES.navy} strokeWidth={2} strokeDasharray="4 3" dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</section>
|
||||
|
||||
{/* The funnel takes the full width: it is a horizontal sequence, and in a
|
||||
half-width column the stage strip truncates its own labels. */}
|
||||
<section aria-labelledby="conversion" className="space-y-3">
|
||||
<SectionTitle id="conversion" title="Pipeline conversion" meta={`${f.total} entered`} />
|
||||
<HiringFlow
|
||||
stages={f.funnel}
|
||||
transitions={f.transitions}
|
||||
weakestKey={f.bottleneck?.to}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Score distribution — where the hireable pool actually sits. */}
|
||||
<section aria-labelledby="quality" className="space-y-3">
|
||||
<SectionTitle id="quality" title="Hiring quality" meta={`${f.scored.length} scored · avg ${f.avgScore}`} />
|
||||
<ResponsiveContainer width="100%" height={196}>
|
||||
<BarChart data={scoreDistribution}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
|
||||
<XAxis dataKey="label" {...AXIS_PROPS} />
|
||||
<YAxis {...AXIS_PROPS} allowDecimals={false} width={28} />
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
<Bar dataKey="count" name="Candidates" radius={[4, 4, 0, 0]} maxBarSize={56}>
|
||||
{scoreDistribution.map((d) => <Cell key={d.label} fill={bandColor(d.label)} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</section>
|
||||
|
||||
{/* Velocity and screening efficiency. Both are ratios rather than shapes,
|
||||
so they read better as figures with an explanation than as another
|
||||
chart — a two-point line would be decoration. */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<section aria-labelledby="velocity" className="space-y-3">
|
||||
<SectionTitle id="velocity" title="Time to hire" meta="Application to decision" />
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-heading text-display font-bold tabular-nums text-ink-1">
|
||||
{f.timeToHire || '—'}
|
||||
</span>
|
||||
<span className="text-body-sm text-ink-3">
|
||||
{f.timeToHire ? `day${f.timeToHire === 1 ? '' : 's'} on average` : 'no hires yet'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{byRoleSpeed.length ? byRoleSpeed.map((r) => (
|
||||
<div key={r.title}>
|
||||
<div className="mb-1 flex items-baseline justify-between gap-2">
|
||||
<span className="min-w-0 truncate text-caption text-ink-2">{r.title}</span>
|
||||
<span className="shrink-0 text-caption font-semibold tabular-nums text-ink-1">
|
||||
{r.days}d
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={r.days}
|
||||
max={slowestHire}
|
||||
size="xs"
|
||||
tone={r.days <= 7 ? 'success' : r.days <= 14 ? 'brand' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
)) : (
|
||||
<p className="text-body-sm text-ink-3">
|
||||
No hires closed yet, so there is no per-role speed to compare.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="efficiency" className="space-y-3">
|
||||
<SectionTitle id="efficiency" title="Screening efficiency" meta="Coverage and outcome" />
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
|
||||
<dl className="space-y-3.5">
|
||||
{[
|
||||
{
|
||||
label: 'Coverage',
|
||||
value: `${f.standardizedPct}%`,
|
||||
note: `${f.scored.length} of ${f.total} applications carry a score.`,
|
||||
tone: f.standardizedPct >= 80 ? 'success' : 'warning',
|
||||
},
|
||||
{
|
||||
label: 'Interview completion',
|
||||
value: `${f.interviewCompletion}%`,
|
||||
note: `${f.completedInterviews.length} of ${f.interviews.length} interviews reached a verdict.`,
|
||||
tone: f.interviewCompletion >= 80 ? 'success' : 'warning',
|
||||
},
|
||||
{
|
||||
label: 'Selection lift',
|
||||
value: f.hiredAvgScore && f.avgScore ? `+${f.hiredAvgScore - f.avgScore}` : '—',
|
||||
note: f.hiredAvgScore
|
||||
? `Hires average ${f.hiredAvgScore} against a pool average of ${f.avgScore}.`
|
||||
: 'No hires yet, so there is nothing to compare the pool against.',
|
||||
tone: 'success',
|
||||
},
|
||||
{
|
||||
label: 'Qualified share',
|
||||
value: f.scored.length
|
||||
? `${Math.round((f.scored.filter((a) => a.ai_score >= 70).length / f.scored.length) * 100)}%`
|
||||
: '—',
|
||||
note: 'Scored candidates reaching 70 or above.',
|
||||
},
|
||||
].map((row) => (
|
||||
<div key={row.label} className="border-b border-border pb-3 last:border-0 last:pb-0">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<dt className="text-caption font-medium text-ink-2">{row.label}</dt>
|
||||
<dd
|
||||
className={cn(
|
||||
'font-heading text-body font-bold tabular-nums',
|
||||
row.tone === 'success' ? 'text-success'
|
||||
: row.tone === 'warning' ? 'text-warning' : 'text-ink-1'
|
||||
)}
|
||||
>
|
||||
{row.value}
|
||||
</dd>
|
||||
</div>
|
||||
<p className="mt-0.5 text-[11px] leading-relaxed text-ink-4">{row.note}</p>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</Surface>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Position and department performance — a table, because the comparison
|
||||
is between rows and exact counts matter. */}
|
||||
<section aria-labelledby="positions" className="space-y-3">
|
||||
<SectionTitle id="positions" title="Position performance" meta={`${f.byRole.length} open roles`} />
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-body-sm">
|
||||
<caption className="sr-only">Applicants, qualified candidates and hires by position</caption>
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-subtle">
|
||||
{['Position', 'Applied', '70+', 'Hired', 'Avg score', 'Qualified rate'].map((h, i) => (
|
||||
<th key={h} scope="col" className={`whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4 ${i ? 'text-right' : 'text-left'}`}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{f.byRole.map((r) => {
|
||||
const rate = r.applied ? Math.round((r.qualified / r.applied) * 100) : 0;
|
||||
return (
|
||||
<tr key={r.title} className="border-b border-border last:border-0 hover:bg-surface-subtle">
|
||||
<td className="px-4 py-2.5 font-medium text-ink-1">{r.title}</td>
|
||||
<td className={`px-4 py-2.5 text-right tabular-nums ${r.applied ? 'text-ink-2' : 'font-semibold text-destructive'}`}>{r.applied}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.qualified}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.hired || '—'}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.avgScore || '—'}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="ml-auto w-20">
|
||||
<ProgressBar value={rate} tone={rate >= 50 ? 'success' : rate > 0 ? 'warning' : 'destructive'} size="xs" />
|
||||
<p className="mt-1 text-right text-[10px] tabular-nums text-ink-4">{rate}%</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="departments" className="space-y-3">
|
||||
<SectionTitle id="departments" title="Department performance" meta={`${departments.length} departments`} />
|
||||
<div className="space-y-3">
|
||||
{departments.map((d) => (
|
||||
<div key={d.category}>
|
||||
<div className="mb-1 flex items-baseline justify-between gap-2">
|
||||
<span className="text-body-sm text-ink-2">{d.category}</span>
|
||||
<span className="flex items-baseline gap-3 text-[11px] text-ink-4">
|
||||
<span>{d.qualified} qualified</span>
|
||||
<span>{d.hired} hired</span>
|
||||
<span className="w-8 text-right text-body-sm font-bold tabular-nums text-ink-1">{d.applied}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={d.applied} max={maxApplied} size="sm" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{insights.length > 0 && (
|
||||
<section aria-labelledby="insights" className="space-y-3">
|
||||
<SectionTitle id="insights" title="What the data says" meta={`${insights.length} findings`} />
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
|
||||
<InsightList>
|
||||
{insights.map((item, i) => <InsightRow key={i} {...item} />)}
|
||||
</InsightList>
|
||||
</Surface>
|
||||
</section>
|
||||
)}
|
||||
</AdminPage>
|
||||
);
|
||||
return <AdminHiredHistory title="Analytics" />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user