first commit
This commit is contained in:
115
src/components/charts/HiringFlow.jsx
Normal file
115
src/components/charts/HiringFlow.jsx
Normal file
@@ -0,0 +1,115 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user