update hired page

This commit is contained in:
2026-08-11 19:17:40 +05:30
parent 8a256a4de7
commit 25c4ee4a6a
23 changed files with 2921 additions and 380 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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.
*/