Only {weakest.rate}% pass through and{' '}
{weakest.lost} candidates are lost at this transition. Recovering this conversion yields the highest ROI.
)}
);
}
/* ── 5. Action center ───────────────────────────────────────────────────── */
const SEVERITY = {
critical: { dot: 'bg-destructive', label: 'Critical' },
warning: { dot: 'bg-warning', label: 'Warning' },
info: { dot: 'bg-krow-blue', label: 'For review' },
};
/** One operational queue, divided rows. Not five cards in a grid. */
function ActionQueue({ items }) {
if (!items.length) {
return (
Nothing needs intervention. Every role has candidates, every applicant is scored,
and no hire is awaiting review.
);
}
return (
{items.map((item) => (
))}
);
}
/* ── Page ───────────────────────────────────────────────────────────────── */
export default function ControlCenter() {
const navigate = useNavigate();
const [range, setRange] = useState('30d');
const { data: applications = [], isLoading } = useApplications();
const { data: postings = [] } = useJobPostings();
const { data: interviews = [] } = useInterviews();
const { data: staff = [] } = useStaff();
const { data: profiles = [] } = useWorkerProfiles();
const { data: activity = [] } = useUserActivity();
const f = useMemo(
() => buildFacts({ applications, postings, interviews, staff, profiles, activity }),
[applications, postings, interviews, staff, profiles, activity]
);
/* 1. Operations snapshot. */
const metrics = [
{ label: 'Open positions', value: f.openPositions.length, sub: `${f.postings.length} total` },
{ label: 'Candidates', value: f.total, sub: `${f.scored.length} scored` },
{
label: 'In review',
value: f.unscreened.length + f.interviewing.length,
tone: f.unscreened.length ? 'warning' : 'default',
sub: `${f.unscreened.length} unscreened`,
},
{ label: 'AI screened', value: f.scored.length, sub: `${f.standardizedPct}% coverage` },
{ label: 'Hired', value: f.hired.length, tone: 'brand', sub: `${f.hireRate}% of applicants` },
{
label: 'Avg KROW score',
value: f.avgScore || '—',
sub: f.avgScore ? `across ${f.scored.length}` : 'none scored',
},
];
/**
* 2. Hiring activity, bucketed by day over the selected window.
*
* The range control belongs to this chart and nothing else — it changes what this
* series covers, not what the page is about, so a global toolbar would have
* implied the whole console re-scoped along with it.
*/
const activitySeries = useMemo(() => {
const { days, step } = RANGES[range];
const buckets = new Map();
for (let i = days - 1; i >= 0; i -= 1) {
const d = new Date(f.today);
d.setDate(d.getDate() - i);
buckets.set(d.toISOString().slice(0, 10), {
label: `${d.getMonth() + 1}/${d.getDate()}`,
applications: 0, screened: 0, interviews: 0, hires: 0,
});
}
applications.forEach((a) => {
const applied = String(a.created_date).slice(0, 10);
if (buckets.has(applied)) buckets.get(applied).applications += 1;
if (a.ai_score > 0) {
const scored = String(a.updated_date).slice(0, 10);
if (buckets.has(scored)) buckets.get(scored).screened += 1;
}
});
interviews.forEach((i) => {
const key = String(i.created_date).slice(0, 10);
if (buckets.has(key)) buckets.get(key).interviews += 1;
});
staff.forEach((s) => {
const key = String(s.hire_date).slice(0, 10);
if (buckets.has(key)) buckets.get(key).hires += 1;
});
/* A daily axis cannot carry 30 or 90 ticks, so longer ranges group into
buckets of `step` days.
Grouped, not sampled. Sampling — keeping every third day and discarding the
rest — quietly lost real events: all three hires in this dataset fall on one
day, and if that day was not a multiple of the step it simply never
appeared. A chart that drops data to stay tidy is worse than a crowded one. */
const rows = [...buckets.values()];
if (step === 1) return rows;
const grouped = [];
for (let i = 0; i < rows.length; i += step) {
const group = rows.slice(i, i + step);
grouped.push(group.reduce((acc, day) => ({
label: acc.label,
applications: acc.applications + day.applications,
screened: acc.screened + day.screened,
interviews: acc.interviews + day.interviews,
hires: acc.hires + day.hires,
}), { ...group[0] }));
}
return grouped;
}, [applications, interviews, staff, range, f.today]);
/* 4. Position performance. */
const positionPerformance = useMemo(
() => f.byRole
.slice()
.sort((a, b) => b.applied - a.applied)
.slice(0, 6)
.map((r) => ({
name: r.title.split(' – ')[0],
applicants: r.applied,
qualified: r.qualified,
hired: r.hired,
})),
[f.byRole]
);
/* Strongest and weakest by qualified conversion, so the chart can mark both
without a legend explaining which is which. */
const { strongest, weakest: weakestRole } = useMemo(() => {
const withApplicants = positionPerformance.filter((r) => r.applicants > 0);
if (!withApplicants.length) return { strongest: null, weakest: null };
const rate = (r) => r.qualified / r.applicants;
const sorted = [...withApplicants].sort((a, b) => rate(b) - rate(a));
return {
strongest: sorted[0].name,
weakest: sorted.length > 1 ? sorted[sorted.length - 1].name : null,
};
}, [positionPerformance]);
/* 5. Action center — ordered by what it costs to leave each one. */
const actions = useMemo(() => [
f.stalled.length && {
severity: 'critical',
title: 'Strong candidates not actioned',
detail: `${f.stalled.map((a) => a.applicant_name).join(', ')} scored 80 or above and ${f.stalled.length === 1 ? 'is' : 'are'} still at AI Screened.`,
metric: f.stalled.length,
metricLabel: 'at 80+',
onClick: () => navigate('/admin/candidates'),
},
f.starvedPositions.length && {
severity: 'critical',
title: 'Open roles with no applicants',
detail: `${f.starvedPositions.map((p) => p.title).join(', ')} — live and attracting nobody.`,
metric: f.starvedPositions.length,
metricLabel: 'roles',
onClick: () => navigate('/admin/positions'),
},
f.unscreened.length && {
severity: 'warning',
title: 'Candidates waiting for screening',
detail: 'Unscored applications are decisions made without a standard.',
metric: f.unscreened.length,
metricLabel: 'waiting',
onClick: () => navigate('/admin/candidates'),
},
f.bottleneck && f.bottleneck.lost > 0 && {
severity: 'warning',
title: `${f.bottleneck.from} → ${f.bottleneck.to} bottleneck`,
detail: `Only ${f.bottleneck.rate}% pass through this step, the weakest transition in the funnel.`,
metric: `−${f.bottleneck.lost}`,
metricLabel: 'lost',
onClick: () => navigate('/admin/analytics'),
},
f.unscoredProfiles.length && {
severity: 'info',
title: 'Talent profiles without a score',
detail: 'Unscored workers are invisible to matching, so employers cannot find them.',
metric: f.unscoredProfiles.length,
metricLabel: 'profiles',
onClick: () => navigate('/admin/talent-pool'),
},
f.unratedStaff.length && {
severity: 'info',
title: 'Hires awaiting review',
detail: 'Employer ratings are the only verified performance signal feeding scores.',
metric: f.unratedStaff.length,
metricLabel: 'unrated',
onClick: () => navigate('/admin/hired'),
},
].filter(Boolean), [f, navigate]);
/* 6. Active positions — live demand, biggest first. */
const activePositions = useMemo(
() => f.byRole.slice().sort((a, b) => b.applied - a.applied).slice(0, 5),
[f.byRole]
);
/* 7. Top talent — real applicants, real avatars, real scores. */
const topTalent = useMemo(() => f.ranked.slice(0, 5), [f.ranked]);
/* 8. Recent activity. */
const recent = useMemo(() => activity.slice(0, 6), [activity]);
/** Export the snapshot and the funnel — what gets pasted into a weekly review. */
const exportSnapshot = () => {
const rows = [
['Metric', 'Value'],
...metrics.map((m) => [m.label, m.value]),
[],
['Pipeline stage', 'Candidates', 'Conversion in'],
...f.funnel.map((s, i) => [s.label, s.count, i === 0 ? '' : `${f.transitions[i - 1].rate}%`]),
];
const csv = rows.map((r) => r.map((cell) => `"${String(cell ?? '')}"`).join(',')).join('\n');
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
const link = document.createElement('a');
link.href = url;
link.download = `krow-control-center-${f.today.toISOString().slice(0, 10)}.csv`;
link.click();
URL.revokeObjectURL(url);
toast.success('Operations snapshot exported');
};
return (
Export
)}
>
{/* 1 ── Operations snapshot */}
{/* 2 ── Hiring activity */}
({ value, label: r.label }))}
value={range}
onChange={setRange}
size="sm"
ariaLabel="Hiring activity range"
/>
}
>
{isLoading ? (
) : f.total ? (
} />
{/* Applications are the volume everything else is drawn from, so
they take the filled area and the downstream stages are lines
over it. */}
{/* Hires are sparse and are the outcome, so they take the accent
and a bar — a line at this volume reads as flat. */}
) : (
{positionPerformance.length ? (
<>
} />
{/* The best converter takes the accent at full strength and the
worst is left pale, so both ends of the comparison are
visible without a callout. */}
{positionPerformance.map((r) => (
))}
{weakestRole && (
{strongest} converts applicants
into qualified candidates best;{' '}
{weakestRole} converts worst and
is where sourcing — or the bar itself — is worth a look.
)}
>
) : (
)}
{/* 5 ── Action center */}
{/* 6 / 7 ── Active positions and top talent. Two lists of comparable weight,
so they share a row rather than each taking one. */}