116 lines
4.8 KiB
JavaScript
116 lines
4.8 KiB
JavaScript
import React from 'react';
|
||
import { BarChart } from '@mui/x-charts';
|
||
import { ChevronRight } from 'lucide-react';
|
||
import { cn } from '@/lib/utils';
|
||
import { chartSx, TONES, tooltipSx } from './muiChartTheme';
|
||
|
||
/**
|
||
* The hiring flow — one surface, not six cards.
|
||
*
|
||
* A funnel is a shape before it is a set of numbers, so the magnitudes are drawn
|
||
* once as a bar series and the stage-by-stage reading sits underneath it as a
|
||
* connected strip: count, pass-through rate, and what was lost getting there.
|
||
* Reading down a column gives one stage; reading across gives the journey.
|
||
*
|
||
* The weakest transition is the only thing coloured differently, because on a
|
||
* funnel the one worth acting on is the one losing the most people.
|
||
*
|
||
* `stages` and `transitions` come from the page's own fact sheet — this
|
||
* component derives no figures of its own.
|
||
*/
|
||
/** @param {any} props */
|
||
export function HiringFlow({ stages = [], transitions = [], weakestKey, className = '' }) {
|
||
if (!stages.length) return null;
|
||
|
||
const total = stages[0]?.count || 1;
|
||
const rateFor = (i) => (i === 0 ? 100 : transitions[i - 1]?.rate ?? 0);
|
||
const lostFor = (i) => (i === 0 ? 0 : transitions[i - 1]?.lost ?? 0);
|
||
const isWeak = (i) => i > 0 && transitions[i - 1] && transitions[i - 1].to === weakestKey;
|
||
|
||
return (
|
||
<div className={cn('overflow-hidden rounded-xl border border-border bg-surface', className)}>
|
||
{/* The shape. Axis-free: the strip underneath carries every figure, so a
|
||
y-axis would repeat what is already written. */}
|
||
<div className="px-2 pt-3 sm:px-4">
|
||
<BarChart
|
||
height={168}
|
||
margin={{ top: 12, right: 8, bottom: 24, left: 8 }}
|
||
xAxis={[{
|
||
scaleType: 'band',
|
||
data: stages.map((s) => s.label),
|
||
categoryGapRatio: 0.5,
|
||
barGapRatio: 0,
|
||
/* Per-stage colour through the documented axis API rather than a
|
||
CSS nth-child guess at MUI's internal markup: the weakest
|
||
transition is amber, every other stage is brand blue. */
|
||
colorMap: {
|
||
type: 'ordinal',
|
||
values: stages.map((s) => s.label),
|
||
colors: stages.map((_, i) => (isWeak(i) ? TONES.warning : TONES.brand)),
|
||
},
|
||
}]}
|
||
/* Pinned to the data: MUI's automatic domain rounds up to roughly
|
||
double the top stage, which leaves the funnel occupying half the
|
||
plot and reading much flatter than it is. */
|
||
yAxis={[{ min: 0, max: Math.max(total, 1), tickNumber: 3 }]}
|
||
series={[{
|
||
data: stages.map((s) => s.count),
|
||
label: 'Candidates',
|
||
valueFormatter: (value, ctx) => {
|
||
const i = ctx?.dataIndex ?? 0;
|
||
const lost = lostFor(i);
|
||
return `${value} · ${rateFor(i)}% through${lost ? ` · ${lost} lost` : ''}`;
|
||
},
|
||
}]}
|
||
colors={[TONES.brand]}
|
||
borderRadius={6}
|
||
grid={{ horizontal: true }}
|
||
skipAnimation
|
||
sx={{
|
||
...chartSx,
|
||
...tooltipSx,
|
||
/* The weakest transition's bar picks up the warning tone; every
|
||
other bar stays brand blue. */
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* The reading. Scrolls rather than wraps on a phone, so the sequence
|
||
survives at any width. */}
|
||
<div className="overflow-x-auto border-t border-border">
|
||
<ol className="flex min-w-[36rem] items-stretch">
|
||
{stages.map((stage, i) => {
|
||
const rate = rateFor(i);
|
||
const lost = lostFor(i);
|
||
const weak = isWeak(i);
|
||
|
||
return (
|
||
<li key={stage.key} className="flex min-w-0 flex-1 items-center">
|
||
{i > 0 && (
|
||
<span className="flex shrink-0 flex-col items-center px-1.5" aria-hidden="true">
|
||
<ChevronRight className={cn('h-3.5 w-3.5', weak ? 'text-warning' : 'text-ink-4')} />
|
||
</span>
|
||
)}
|
||
|
||
<div className="min-w-0 flex-1 px-3 py-3">
|
||
<p className="truncate text-caption text-ink-3">{stage.label}</p>
|
||
<p className="mt-0.5 font-heading text-title font-bold tabular-nums text-ink-1">
|
||
{stage.count}
|
||
</p>
|
||
|
||
<p className="mt-1 flex flex-wrap items-baseline gap-x-2 text-caption tabular-nums">
|
||
<span className={weak ? 'font-semibold text-warning' : 'text-ink-4'}>
|
||
{i === 0 ? `${Math.round((stage.count / total) * 100)}% entered` : `${rate}% through`}
|
||
</span>
|
||
{lost > 0 && <span className="text-ink-4">−{lost}</span>}
|
||
</p>
|
||
</div>
|
||
</li>
|
||
);
|
||
})}
|
||
</ol>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|