update hired page
This commit is contained in:
93
src/components/charts/DepartmentPerformance.jsx
Normal file
93
src/components/charts/DepartmentPerformance.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import { ScatterChart } from '@mui/x-charts';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { chartSx, TONES, tooltipSx } from './muiChartTheme';
|
||||
|
||||
/**
|
||||
* Department performance — a dot plot, not a bar race.
|
||||
*
|
||||
* The bars this replaces encoded hire *count*, which on a dataset where every
|
||||
* department has hired once drew three identical full-width rectangles. Encoding
|
||||
* average score instead does not fix it either: 92, 94 and 97 against a
|
||||
* zero-baseline axis are three more identical bars.
|
||||
*
|
||||
* So the scores are plotted as points on a focused axis. The domain starts below
|
||||
* the lowest score rather than at zero — legitimate here because the axis ticks
|
||||
* are drawn and read, and because the question is "which department hires
|
||||
* better", which is a comparison between 90s, not a comparison against nothing.
|
||||
*
|
||||
* Volume is stated in the list underneath: one hire per department is a fact,
|
||||
* not a shape.
|
||||
*
|
||||
* `items: [{ name, count, avgScore }]` — supplied by the page.
|
||||
*/
|
||||
export function DepartmentPerformance({ items = [], className }) {
|
||||
if (!items.length) return null;
|
||||
|
||||
const scored = items.filter((d) => d.avgScore > 0);
|
||||
/* Highest at the top: MUI plots ascending y upward, so rank ascending. */
|
||||
const plotted = [...scored].sort((a, b) => (a.avgScore || 0) - (b.avgScore || 0));
|
||||
|
||||
const lowest = Math.min(...plotted.map((d) => d.avgScore));
|
||||
const highest = Math.max(...plotted.map((d) => d.avgScore));
|
||||
/* A little air either side, snapped to 5s, so the ticks stay round numbers. */
|
||||
const min = Math.max(0, Math.floor((lowest - 6) / 5) * 5);
|
||||
const max = Math.min(100, Math.ceil((highest + 3) / 5) * 5);
|
||||
|
||||
return (
|
||||
<div className={cn('overflow-hidden rounded-xl border border-border bg-surface', className)}>
|
||||
{plotted.length > 0 ? (
|
||||
<div className="px-2 pt-4 sm:px-3">
|
||||
<ScatterChart
|
||||
height={Math.max(132, plotted.length * 40 + 52)}
|
||||
margin={{ top: 8, right: 24, bottom: 28, left: 8 }}
|
||||
xAxis={[{
|
||||
min,
|
||||
max,
|
||||
tickNumber: 4,
|
||||
valueFormatter: (v) => `${v}`,
|
||||
}]}
|
||||
yAxis={[{
|
||||
min: -0.6,
|
||||
max: plotted.length - 0.4,
|
||||
tickInterval: plotted.map((_, i) => i),
|
||||
valueFormatter: (v) => plotted[Math.round(v)]?.name ?? '',
|
||||
width: 96,
|
||||
}]}
|
||||
series={[{
|
||||
label: 'Average score',
|
||||
data: plotted.map((d, i) => ({ x: d.avgScore, y: i, id: d.name })),
|
||||
markerSize: 7,
|
||||
valueFormatter: (point) => {
|
||||
const d = plotted[Math.round(point.y)];
|
||||
return `${d.name} · avg ${d.avgScore} · ${d.count} ${d.count === 1 ? 'hire' : 'hires'}`;
|
||||
},
|
||||
}]}
|
||||
colors={[TONES.brand]}
|
||||
grid={{ vertical: true }}
|
||||
skipAnimation
|
||||
sx={{ ...chartSx, ...tooltipSx }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="px-4 py-6 text-center text-body-sm text-ink-3">
|
||||
No scores recorded against these hires yet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul className="divide-y divide-border border-t border-border">
|
||||
{[...items].sort((a, b) => (b.avgScore || 0) - (a.avgScore || 0)).map((d) => (
|
||||
<li key={d.name} className="flex items-baseline justify-between gap-3 px-4 py-2">
|
||||
<span className="truncate text-body-sm text-ink-2">{d.name}</span>
|
||||
<span className="flex shrink-0 items-baseline gap-4 text-caption tabular-nums text-ink-4">
|
||||
<span>{d.count} {d.count === 1 ? 'hire' : 'hires'}</span>
|
||||
<span className="w-16 text-right font-semibold text-ink-1">
|
||||
{d.avgScore ? `avg ${d.avgScore}` : '—'}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
src/components/charts/HiringFlow.jsx
Normal file
114
src/components/charts/HiringFlow.jsx
Normal file
@@ -0,0 +1,114 @@
|
||||
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.
|
||||
*/
|
||||
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>
|
||||
);
|
||||
}
|
||||
60
src/components/charts/HiringTrendChart.jsx
Normal file
60
src/components/charts/HiringTrendChart.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { LineChart } from '@mui/x-charts';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { chartSx, TONES, tooltipSx } from './muiChartTheme';
|
||||
|
||||
/**
|
||||
* Cumulative hires over time.
|
||||
*
|
||||
* Deliberately does not draw a line through a single point: with one month of
|
||||
* history there is no trend, and a chart that implies one is worse than a
|
||||
* sentence saying there is not. The empty state is the page's own copy, kept
|
||||
* intact and given the same container as the chart so the section holds its
|
||||
* shape either way.
|
||||
*
|
||||
* `points: [{ label, hires, cumulative }]` — supplied by the page.
|
||||
*/
|
||||
export function HiringTrendChart({ points = [], emptyState, className }) {
|
||||
const enough = points.length >= 2;
|
||||
|
||||
return (
|
||||
<div className={cn('overflow-hidden rounded-xl border border-border bg-surface', className)}>
|
||||
{enough ? (
|
||||
<div className="px-2 pt-3 sm:px-3">
|
||||
<LineChart
|
||||
height={200}
|
||||
margin={{ top: 16, right: 16, bottom: 24, left: 8 }}
|
||||
xAxis={[{
|
||||
scaleType: 'point',
|
||||
data: points.map((p) => p.label),
|
||||
}]}
|
||||
yAxis={[{ width: 32, tickNumber: 4 }]}
|
||||
series={[{
|
||||
data: points.map((p) => p.cumulative),
|
||||
label: 'Cumulative hires',
|
||||
area: true,
|
||||
showMark: true,
|
||||
curve: 'monotoneX',
|
||||
valueFormatter: (value, ctx) => {
|
||||
const p = points[ctx?.dataIndex ?? 0];
|
||||
return `${value} total · ${p?.hires} this month`;
|
||||
},
|
||||
}]}
|
||||
colors={[TONES.brand]}
|
||||
grid={{ horizontal: true }}
|
||||
skipAnimation
|
||||
sx={{
|
||||
...chartSx,
|
||||
...tooltipSx,
|
||||
'& .MuiAreaElement-root': { fill: TONES.brandSoft },
|
||||
'& .MuiLineElement-root': { strokeWidth: 2 },
|
||||
'& .MuiMarkElement-root': { stroke: TONES.brand, fill: '#fff', strokeWidth: 2 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
emptyState
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
src/components/charts/muiChartTheme.js
Normal file
86
src/components/charts/muiChartTheme.js
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* KROW styling for MUI X Charts.
|
||||
*
|
||||
* MUI X is the drawing layer only — the surfaces, spacing and typography around
|
||||
* a chart stay KROW's. This module is what stops the two from disagreeing: the
|
||||
* palette is the same one the Recharts components already use (`CHART_TONES`),
|
||||
* and the `sx` block restyles MUI's axes, grid and tooltip to the design
|
||||
* system's hairlines and type scale rather than Material's defaults.
|
||||
*
|
||||
* Everything here is presentation. No chart in this folder computes a value.
|
||||
*/
|
||||
|
||||
import { CHART_TONES } from '@/components/ds/ChartContainer';
|
||||
|
||||
export const TONES = {
|
||||
brand: CHART_TONES.brand,
|
||||
brandSoft: 'rgba(10, 57, 223, 0.14)',
|
||||
warning: '#D97706',
|
||||
warningSoft: 'rgba(217, 119, 6, 0.16)',
|
||||
success: '#16A34A',
|
||||
grid: CHART_TONES.grid,
|
||||
axis: CHART_TONES.axis,
|
||||
ink: '#0F172A',
|
||||
muted: '#64748B',
|
||||
};
|
||||
|
||||
/** Type scale for chart text — matches the caption/overline sizes around it. */
|
||||
const LABEL_FONT = {
|
||||
fontSize: 11,
|
||||
fontFamily: 'var(--font-body)',
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared `sx` for every KROW chart.
|
||||
*
|
||||
* Axis lines and ticks are removed rather than restyled: on a compact chart the
|
||||
* value labels carry the reading, and a full axis frame is the "chart junk" the
|
||||
* design language avoids. The grid stays as a hairline in the same colour the
|
||||
* rest of the product uses for rules.
|
||||
*/
|
||||
export const chartSx = {
|
||||
'& .MuiChartsAxis-line': { display: 'none' },
|
||||
'& .MuiChartsAxis-tick': { display: 'none' },
|
||||
'& .MuiChartsAxis-tickLabel': {
|
||||
...LABEL_FONT,
|
||||
fill: `${TONES.muted} !important`,
|
||||
},
|
||||
'& .MuiChartsGrid-line': {
|
||||
stroke: TONES.grid,
|
||||
strokeDasharray: '3 3',
|
||||
},
|
||||
'& .MuiChartsLegend-root': { display: 'none' },
|
||||
'& .MuiBarLabel-root': {
|
||||
...LABEL_FONT,
|
||||
fontWeight: 600,
|
||||
fill: `${TONES.ink} !important`,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Tooltip chrome matching `Surface`.
|
||||
*
|
||||
* Passed through `slotProps.tooltip` so MUI's popper carries the design
|
||||
* system's radius, hairline border and shadow instead of Material elevation.
|
||||
*/
|
||||
export const tooltipSx = {
|
||||
'& .MuiChartsTooltip-root, & .MuiPopper-root .MuiPaper-root': {
|
||||
borderRadius: '10px',
|
||||
border: `1px solid ${TONES.grid}`,
|
||||
boxShadow: '0 8px 24px -12px rgba(15, 23, 42, 0.25)',
|
||||
fontFamily: 'var(--font-body)',
|
||||
fontSize: 12,
|
||||
},
|
||||
'& .MuiChartsTooltip-table': { fontSize: 12 },
|
||||
'& .MuiChartsTooltip-labelCell, & .MuiChartsTooltip-valueCell': {
|
||||
fontFamily: 'var(--font-body)',
|
||||
},
|
||||
};
|
||||
|
||||
/*
|
||||
* Entry animation is switched off at every call site rather than gated on
|
||||
* `prefers-reduced-motion`. Two reasons: a chart that grows out of the floor is
|
||||
* the decorative motion this product avoids, and MUI's bar transition leaves
|
||||
* every bar at zero height if the frame loop never advances — which is exactly
|
||||
* what happens in an inactive tab or an automated browser.
|
||||
*/
|
||||
Reference in New Issue
Block a user