365 lines
16 KiB
JavaScript
365 lines
16 KiB
JavaScript
import React, { useMemo } from 'react';
|
|
import {
|
|
Activity, Award, Clock, Gauge, Lightbulb, Target, TrendingUp, TriangleAlert,
|
|
} from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { Badge, MetricStrip, Surface } from '@/components/ds';
|
|
import { useApplications, useJobPostings, useStaff } from '@/lib/krowHooks';
|
|
import { DepartmentPerformance } from '@/components/charts/DepartmentPerformance';
|
|
import { HiringFlow } from '@/components/charts/HiringFlow';
|
|
import { HiringTrendChart } from '@/components/charts/HiringTrendChart';
|
|
import { useSize } from '@/hooks/use-size';
|
|
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
|
|
import { SkillSurface } from '@/components/skills/SkillSurface';
|
|
import {
|
|
buildEfficiency, buildFunnel, buildInsights, buildTrend, byDepartment, byPosition,
|
|
hiresWithFill, summarise,
|
|
} from '@/lib/hiringRecords';
|
|
|
|
/**
|
|
* Admin Analytics — how hiring is performing.
|
|
*
|
|
* This page and Hired History used to be the same component rendered twice with
|
|
* a different title, which is why they read as duplicates: identical sections,
|
|
* identical tabs, identical figures. They are not the same question.
|
|
*
|
|
* **Analytics asks "how is our hiring performing?"** — it is metrics, trends,
|
|
* comparison and what to do about them. Every section here is an aggregate: a
|
|
* rate, a distribution, a shape over time. No individual hire is named anywhere
|
|
* on this page, because a name is a record and records are Hired History's job.
|
|
*
|
|
* It is also structurally different, not just differently coloured. Analytics is
|
|
* one scrolling dashboard — the seven readings are meant to be taken together,
|
|
* and a comparison you have to remember across a tab switch is not a comparison.
|
|
* Hired History is a filtered record you search, so it keeps its controls and its
|
|
* table. Same design system, same blue, same cards; different jobs.
|
|
*
|
|
* Both read `lib/hiringRecords.js`, so a total here and the same total there
|
|
* cannot disagree.
|
|
*/
|
|
|
|
const INSIGHT_TONE = {
|
|
success: { ring: 'border-emerald-500/30 bg-emerald-50/40 dark:bg-emerald-950/20', dot: 'text-emerald-600 dark:text-emerald-400', Icon: TrendingUp },
|
|
warning: { ring: 'border-amber-500/30 bg-amber-50/40 dark:bg-amber-950/20', dot: 'text-amber-600 dark:text-amber-400', Icon: TriangleAlert },
|
|
risk: { ring: 'border-red-500/30 bg-red-50/40 dark:bg-red-950/20', dot: 'text-red-600 dark:text-red-400', Icon: TriangleAlert },
|
|
info: { ring: 'border-border bg-surface', dot: 'text-krow-blue', Icon: Lightbulb },
|
|
};
|
|
|
|
/** One finding, with the evidence underneath it. */
|
|
function Insight({ item }) {
|
|
const tone = INSIGHT_TONE[item.tone] || INSIGHT_TONE.info;
|
|
const { Icon } = tone;
|
|
|
|
return (
|
|
<li className={cn('flex items-start gap-2.5 rounded-xl border p-3.5', tone.ring)}>
|
|
<Icon className={cn('mt-0.5 h-4 w-4 shrink-0', tone.dot)} aria-hidden="true" />
|
|
<div className="min-w-0">
|
|
<p className="font-heading text-body-sm font-semibold text-ink-1">{item.title}</p>
|
|
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{item.body}</p>
|
|
</div>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The funnel, drawn once its container can be measured.
|
|
*
|
|
* `HiringFlow` renders an MUI bar chart with no explicit width, which means the
|
|
* chart measures its parent on mount. On this page that first measure lands
|
|
* before the layout has resolved a width — the shell's `main` is a `flex-1`
|
|
* column beside the Owliver panel — and the chart warns that it has nothing to
|
|
* size itself against. Gating on the measured width means the chart mounts once,
|
|
* already knowing how wide it is, instead of mounting into nothing and
|
|
* recovering.
|
|
*
|
|
* The reserved height keeps the section from collapsing and reflowing the page
|
|
* on the frame between measure and draw.
|
|
*/
|
|
function MeasuredFunnel({ funnel }) {
|
|
const ref = React.useRef(null);
|
|
const size = useSize(ref);
|
|
|
|
return (
|
|
<div ref={ref} className="w-full">
|
|
{size?.width ? (
|
|
<HiringFlow
|
|
stages={funnel.stages}
|
|
transitions={funnel.transitions}
|
|
weakestKey={funnel.weakestKey}
|
|
/>
|
|
) : (
|
|
<div className="h-[280px] rounded-xl border border-border bg-surface" aria-hidden="true" />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** A ranked comparison row — used for both fastest and slowest to fill. */
|
|
function VelocityList({ items, median, tone }) {
|
|
if (!items.length) {
|
|
return (
|
|
<p className="rounded-lg border border-dashed border-border px-3 py-3 text-body-sm text-ink-3">
|
|
No role has enough dated hires to measure velocity yet.
|
|
</p>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ul className="space-y-2">
|
|
{items.map((role) => {
|
|
const delta = median ? role.avgDays - median : 0;
|
|
return (
|
|
<li key={role.role} className="flex items-center justify-between gap-3 rounded-xl bg-surface-subtle px-3 py-2.5">
|
|
<div className="min-w-0">
|
|
<p className="truncate text-body-sm font-medium text-ink-1">{role.role}</p>
|
|
<p className="text-[10px] text-ink-4">
|
|
{role.count} hire{role.count === 1 ? '' : 's'}
|
|
</p>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<span className="font-heading text-body-sm font-bold tabular-nums text-ink-1">
|
|
{role.avgDays}d
|
|
</span>
|
|
{median > 0 && delta !== 0 && (
|
|
<Badge variant={tone === 'fast' ? 'success' : 'warning'} size="sm" className="tabular-nums">
|
|
{delta > 0 ? '+' : ''}{delta}d
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
);
|
|
}
|
|
|
|
export default function AdminAnalytics() {
|
|
const { data: staff = [], isLoading } = useStaff();
|
|
const { data: applications = [] } = useApplications();
|
|
const { data: postings = [] } = useJobPostings();
|
|
|
|
const hires = useMemo(
|
|
() => hiresWithFill({ staff, applications, postings }),
|
|
[staff, applications, postings]
|
|
);
|
|
|
|
const summary = useMemo(() => summarise(hires), [hires]);
|
|
const funnel = useMemo(() => buildFunnel(applications), [applications]);
|
|
const trend = useMemo(() => buildTrend(hires), [hires]);
|
|
const departments = useMemo(() => byDepartment(hires), [hires]);
|
|
const positions = useMemo(() => byPosition(hires), [hires]);
|
|
const efficiency = useMemo(() => buildEfficiency(hires), [hires]);
|
|
const insights = useMemo(
|
|
() => buildInsights({ hires, departments, positions, funnel, efficiency }),
|
|
[hires, departments, positions, funnel, efficiency]
|
|
);
|
|
|
|
return (
|
|
<AdminPage
|
|
title="Analytics"
|
|
subtitle="How hiring is performing — rates, trends, comparison and what to act on."
|
|
>
|
|
<SkillSurface page="analytics" placement="after-header" />
|
|
|
|
{/* 1. Hiring performance — the four figures the rest of the page explains. */}
|
|
<section aria-labelledby="performance" className="space-y-3">
|
|
<SectionTitle id="performance" title="Hiring performance" meta="Across the workspace" />
|
|
<MetricStrip
|
|
columns={4}
|
|
loading={isLoading}
|
|
items={[
|
|
{ label: 'Total hires', value: summary.total, icon: Award, tone: 'brand' },
|
|
{
|
|
label: 'Avg time-to-hire',
|
|
value: summary.speed ? `${summary.speed}d` : '—',
|
|
icon: Clock,
|
|
sub: efficiency.median ? `${efficiency.median}d median` : undefined,
|
|
},
|
|
{
|
|
label: 'Quality of hire',
|
|
value: summary.quality || '—',
|
|
icon: TrendingUp,
|
|
tone: summary.quality >= 80 ? 'success' : 'default',
|
|
sub: 'avg AI score',
|
|
},
|
|
{
|
|
label: 'Conversion rate',
|
|
value: `${funnel.conversion}%`,
|
|
icon: Target,
|
|
sub: `${funnel.stages[4].count} of ${funnel.stages[0].count} applicants`,
|
|
},
|
|
]}
|
|
/>
|
|
</section>
|
|
|
|
{/* 2. The funnel — where candidates are lost, which is the finding this
|
|
page exists to surface. */}
|
|
<section aria-labelledby="funnel" className="space-y-3">
|
|
<SectionTitle
|
|
id="funnel"
|
|
title="Hiring funnel"
|
|
meta="Applied → Screened → Shortlisted → Interview → Hired"
|
|
/>
|
|
{funnel.stages[0].count ? (
|
|
<MeasuredFunnel funnel={funnel} />
|
|
) : (
|
|
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
|
<p className="text-body-sm leading-relaxed text-ink-3">
|
|
No applications on file yet. The funnel appears once the first candidate applies.
|
|
</p>
|
|
</Surface>
|
|
)}
|
|
</section>
|
|
|
|
{/* 3. Trend over time. */}
|
|
<section aria-labelledby="trend" className="space-y-3">
|
|
<SectionTitle id="trend" title="Hiring trend" meta="Cumulative hires by month" />
|
|
<HiringTrendChart
|
|
points={trend}
|
|
emptyState={(
|
|
<div className="px-4 py-8 text-center">
|
|
<p className="font-heading text-body font-semibold text-ink-1">
|
|
Not enough history for a trend
|
|
</p>
|
|
<p className="mx-auto mt-1 max-w-md text-body-sm leading-relaxed text-ink-3">
|
|
{hires.length
|
|
? `All ${hires.length} hire${hires.length === 1 ? '' : 's'} closed in ${trend[0]?.label || 'a single month'}. A month-on-month line appears once hiring spans a second month.`
|
|
: 'No hires recorded yet. Hiring volume over time appears here once the first role closes.'}
|
|
</p>
|
|
</div>
|
|
)}
|
|
/>
|
|
</section>
|
|
|
|
{/* 4. Department comparison. */}
|
|
<section aria-labelledby="departments" className="space-y-3">
|
|
<SectionTitle
|
|
id="departments"
|
|
title="Department performance"
|
|
meta={`${departments.length} department${departments.length === 1 ? '' : 's'} compared`}
|
|
/>
|
|
<DepartmentPerformance items={departments} />
|
|
</section>
|
|
|
|
{/* 5. Position comparison — the same question one level down. */}
|
|
<section aria-labelledby="positions" className="space-y-3">
|
|
<SectionTitle
|
|
id="positions"
|
|
title="Position performance"
|
|
meta={`${positions.length} role${positions.length === 1 ? '' : 's'} filled`}
|
|
/>
|
|
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden border border-border">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full border-collapse text-body-sm">
|
|
<caption className="sr-only">Hires, quality, speed and review outcome by role</caption>
|
|
<thead>
|
|
<tr className="border-b border-border bg-surface-subtle">
|
|
{['Role', 'Hires', 'Avg score', 'Avg days', 'Reviewed', 'Rating'].map((h, i) => (
|
|
<th
|
|
key={h}
|
|
scope="col"
|
|
className={cn(
|
|
'whitespace-nowrap px-3.5 py-2.5 text-[10px] font-bold uppercase tracking-wider text-ink-4',
|
|
i ? 'text-right' : 'text-left'
|
|
)}
|
|
>
|
|
{h}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{positions.map((r) => (
|
|
<tr key={r.role} className="border-b border-border/60 last:border-0 transition-colors hover:bg-surface-subtle/80">
|
|
<td className="px-3.5 py-2.5 font-medium text-ink-1">{r.role}</td>
|
|
<td className="px-3.5 py-2.5 text-right font-semibold tabular-nums text-ink-2">{r.count}</td>
|
|
<td className="px-3.5 py-2.5 text-right font-bold tabular-nums text-blue-600 dark:text-blue-400">{r.avgScore || '—'}</td>
|
|
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-2">{r.avgDays ? `${r.avgDays}d` : '—'}</td>
|
|
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-3">{r.rated}/{r.count}</td>
|
|
<td className="px-3.5 py-2.5 text-right">
|
|
{r.avgRating != null
|
|
? <Badge variant="success" size="sm" className="font-bold">{r.avgRating}/5</Badge>
|
|
: <Badge variant="warning" size="sm" className="font-bold">Pending</Badge>}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Surface>
|
|
</section>
|
|
|
|
{/* 6. Efficiency — velocity against this workspace's own median, so the
|
|
comparison is one an operator can check. */}
|
|
<section aria-labelledby="efficiency" className="space-y-3">
|
|
<SectionTitle
|
|
id="efficiency"
|
|
title="Hiring efficiency"
|
|
meta={efficiency.median ? `${efficiency.median}d median time-to-hire` : 'Not enough dated hires'}
|
|
/>
|
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
|
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
|
<div className="flex items-center gap-2 text-krow-blue">
|
|
<Gauge className="h-4 w-4" aria-hidden="true" />
|
|
<h3 className="font-heading text-body-sm font-bold text-ink-1">Velocity</h3>
|
|
</div>
|
|
<p className="mt-2 font-heading text-title font-bold tabular-nums text-ink-1">
|
|
{efficiency.median ? `${efficiency.median}d` : '—'}
|
|
<span className="ml-1.5 text-caption font-normal text-ink-3">median</span>
|
|
</p>
|
|
<p className="mt-1 text-caption leading-relaxed text-ink-3">
|
|
{efficiency.within48h
|
|
? `${efficiency.within48h}% of roles close within 48 hours.`
|
|
: 'Velocity appears once hires carry an application date.'}
|
|
</p>
|
|
</Surface>
|
|
|
|
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
|
<div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400">
|
|
<Activity className="h-4 w-4" aria-hidden="true" />
|
|
<h3 className="font-heading text-body-sm font-bold text-ink-1">Fastest to fill</h3>
|
|
</div>
|
|
<div className="mt-3">
|
|
<VelocityList items={efficiency.fastest} median={efficiency.median} tone="fast" />
|
|
</div>
|
|
</Surface>
|
|
|
|
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
|
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
|
<Clock className="h-4 w-4" aria-hidden="true" />
|
|
<h3 className="font-heading text-body-sm font-bold text-ink-1">Slowest to fill</h3>
|
|
</div>
|
|
<div className="mt-3">
|
|
<VelocityList items={efficiency.slowest} median={efficiency.median} tone="slow" />
|
|
</div>
|
|
</Surface>
|
|
</div>
|
|
</section>
|
|
|
|
{/* 7. What the numbers mean. Only findings the data actually supports —
|
|
an insight list that is always the same length is decoration. */}
|
|
<section aria-labelledby="insights" className="space-y-3">
|
|
<SectionTitle
|
|
id="insights"
|
|
title="AI hiring insights"
|
|
meta={insights.length ? `${insights.length} finding${insights.length === 1 ? '' : 's'}` : undefined}
|
|
/>
|
|
{insights.length ? (
|
|
<ul className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
|
{insights.map((item) => <Insight key={item.title} item={item} />)}
|
|
</ul>
|
|
) : (
|
|
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
|
<p className="text-body-sm leading-relaxed text-ink-3">
|
|
There is not enough hiring on file to draw a finding from yet. Insights appear as
|
|
applications, hires and reviews accumulate.
|
|
</p>
|
|
</Surface>
|
|
)}
|
|
</section>
|
|
|
|
<SkillSurface page="analytics" placement="before-footer" />
|
|
</AdminPage>
|
|
);
|
|
}
|