984 lines
40 KiB
JavaScript
984 lines
40 KiB
JavaScript
import React, { useMemo, useState } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import {
|
||
Area, Bar, CartesianGrid, Cell, ComposedChart, Legend, Line, ResponsiveContainer,
|
||
Tooltip, XAxis, YAxis,
|
||
} from 'recharts';
|
||
import {
|
||
AlertTriangle, ArrowRight, Building2, CalendarCheck, CheckCircle2, ChevronRight, Clock, Download, Filter, Sparkles, Users, Zap,
|
||
} from 'lucide-react';
|
||
import { cn } from '@/lib/utils';
|
||
import {
|
||
AXIS_PROPS, Avatar, Badge, Button, CHART_TONES, ChartTooltip, EmptyState, SegmentedToggle,
|
||
Skeleton, StatusBadge, toast,
|
||
} from '@/components/ds';
|
||
import {
|
||
useApplications, useInterviews, useJobPostings, useStaff, useUserActivity, useWorkerProfiles,
|
||
} from '@/lib/krowHooks';
|
||
import { buildFacts } from '@/components/ai-assistant/insights';
|
||
import { AdminPage } from '@/components/admin/PageShell';
|
||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||
|
||
/**
|
||
* Control Center — the KROW Admin command centre.
|
||
*/
|
||
|
||
const RANGES = {
|
||
'7d': { label: '7D', days: 7, step: 1 },
|
||
'30d': { label: '30D', days: 30, step: 3 },
|
||
'90d': { label: '90D', days: 90, step: 9 },
|
||
};
|
||
|
||
const WEAK_TRANSITION = 60;
|
||
|
||
/** @param {any} props */
|
||
function Band({ id, title, meta = '', children, action = null }) {
|
||
return (
|
||
<section aria-labelledby={id} className="space-y-3">
|
||
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-2">
|
||
<div className="flex items-baseline gap-2">
|
||
<h2 id={id} className="font-heading text-body font-semibold text-ink-1">{title}</h2>
|
||
{meta && <span className="text-caption text-ink-4">{meta}</span>}
|
||
</div>
|
||
{action}
|
||
</div>
|
||
{children}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function ViewAll({ label, onClick }) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
className="inline-flex items-center gap-0.5 rounded text-caption font-semibold text-krow-blue transition-colors
|
||
hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||
>
|
||
{label}
|
||
<ArrowRight className="h-3 w-3" aria-hidden="true" />
|
||
</button>
|
||
);
|
||
}
|
||
|
||
/* ── 1. Operations snapshot ─────────────────────────────────────────────── */
|
||
|
||
const snapshotConfig = {
|
||
'Open positions': {
|
||
icon: Building2,
|
||
bg: 'bg-indigo-50/80 dark:bg-indigo-950/50',
|
||
iconColor: 'text-indigo-600 dark:text-indigo-400',
|
||
border: 'border-indigo-200/50 dark:border-indigo-800/40 hover:border-indigo-400',
|
||
text: 'text-indigo-600 dark:text-indigo-400',
|
||
accent: 'from-indigo-500 to-blue-500',
|
||
},
|
||
Candidates: {
|
||
icon: Users,
|
||
bg: 'bg-blue-50/80 dark:bg-blue-950/50',
|
||
iconColor: 'text-blue-600 dark:text-blue-400',
|
||
border: 'border-blue-200/50 dark:border-blue-800/40 hover:border-blue-400',
|
||
text: 'text-blue-600 dark:text-blue-400',
|
||
accent: 'from-blue-500 to-cyan-500',
|
||
},
|
||
'In review': {
|
||
icon: Clock,
|
||
bg: 'bg-amber-50/80 dark:bg-amber-950/50',
|
||
iconColor: 'text-amber-600 dark:text-amber-400',
|
||
border: 'border-amber-200/50 dark:border-amber-800/40 hover:border-amber-400',
|
||
text: 'text-amber-600 dark:text-amber-400',
|
||
accent: 'from-amber-500 to-orange-500',
|
||
},
|
||
'AI screened': {
|
||
icon: Sparkles,
|
||
bg: 'bg-purple-50/80 dark:bg-purple-950/50',
|
||
iconColor: 'text-purple-600 dark:text-purple-400',
|
||
border: 'border-purple-200/50 dark:border-purple-800/40 hover:border-purple-400',
|
||
text: 'text-purple-600 dark:text-purple-400',
|
||
accent: 'from-purple-500 to-indigo-500',
|
||
},
|
||
Hired: {
|
||
icon: CheckCircle2,
|
||
bg: 'bg-emerald-50/80 dark:bg-emerald-950/50',
|
||
iconColor: 'text-emerald-600 dark:text-emerald-400',
|
||
border: 'border-emerald-200/50 dark:border-emerald-800/40 hover:border-emerald-400',
|
||
text: 'text-emerald-600 dark:text-emerald-400',
|
||
accent: 'from-emerald-500 to-teal-500',
|
||
},
|
||
'Avg KROW score': {
|
||
icon: Zap,
|
||
bg: 'bg-blue-50/80 dark:bg-blue-950/50',
|
||
iconColor: 'text-blue-600 dark:text-blue-400',
|
||
border: 'border-blue-200/50 dark:border-blue-800/40 hover:border-blue-400',
|
||
text: 'text-blue-600 dark:text-blue-400',
|
||
accent: 'from-blue-600 to-indigo-600',
|
||
},
|
||
};
|
||
|
||
function Snapshot({ metrics, loading }) {
|
||
return (
|
||
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-3 xl:grid-cols-6">
|
||
{metrics.map((m) => {
|
||
const cfg = snapshotConfig[m.label] || snapshotConfig['Candidates'];
|
||
const Icon = cfg.icon;
|
||
|
||
return (
|
||
<div
|
||
key={m.label}
|
||
className={cn(
|
||
'group relative flex flex-col justify-between rounded-xl border bg-surface p-4 shadow-xs backdrop-blur-xs transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
||
cfg.border
|
||
)}
|
||
>
|
||
<div>
|
||
<div className="flex items-center justify-between gap-1.5">
|
||
<span className="truncate text-[10px] font-bold uppercase tracking-wider text-ink-4">
|
||
{m.label}
|
||
</span>
|
||
<div className={cn('flex h-7 w-7 shrink-0 items-center justify-center rounded-lg shadow-xs', cfg.bg, cfg.iconColor)}>
|
||
<Icon className="h-3.5 w-3.5" />
|
||
</div>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div className="mt-2.5 h-8 w-16 animate-pulse rounded-lg bg-surface-sunken" />
|
||
) : (
|
||
<div className="mt-2 flex items-baseline gap-1.5">
|
||
<span className={cn('font-heading text-title-xl font-bold leading-none tabular-nums', cfg.text)}>
|
||
{m.value}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mt-3 space-y-2">
|
||
{m.sub && (
|
||
<span className="inline-flex items-center rounded-full bg-surface-subtle px-2 py-0.5 text-[10px] font-semibold text-ink-3 border border-border/60">
|
||
{m.sub}
|
||
</span>
|
||
)}
|
||
|
||
<div className={cn('h-0.5 w-full rounded-full bg-gradient-to-r opacity-40 group-hover:opacity-100 transition-opacity', cfg.accent)} />
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ── 3. Pipeline intelligence ───────────────────────────────────────────── */
|
||
|
||
function PipelineFunnel({ funnel, transitions, weakest, total }) {
|
||
if (!total) {
|
||
return (
|
||
<EmptyState
|
||
title="Nothing in the pipeline"
|
||
description="Once candidates apply, stage conversion and drop-off appear here."
|
||
/>
|
||
);
|
||
}
|
||
|
||
const stageIcons = [Users, Sparkles, Filter, CalendarCheck, CheckCircle2];
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
|
||
{/* 5 Stage Funnel Cards */}
|
||
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-5">
|
||
{funnel.map((stage, i) => {
|
||
const transition = i === 0 ? null : transitions[i - 1];
|
||
const isWeak = transition && weakest
|
||
&& transition.from === weakest.from && transition.to === weakest.to;
|
||
const share = Math.round((stage.count / total) * 100);
|
||
const StageIcon = stageIcons[i] || Users;
|
||
|
||
return (
|
||
<div
|
||
key={stage.key}
|
||
className={cn(
|
||
'group relative flex flex-col justify-between rounded-xl border p-4 shadow-xs backdrop-blur-xs transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
||
isWeak
|
||
? 'border-amber-500/50 bg-gradient-to-br from-amber-50/70 via-surface to-amber-50/20 dark:from-amber-950/40 dark:to-surface'
|
||
: 'border-border/80 bg-surface hover:border-blue-500/40'
|
||
)}
|
||
>
|
||
<div>
|
||
{/* Header: Stage Name & Icon */}
|
||
<div className="flex items-center justify-between gap-1.5">
|
||
<div className="flex items-center gap-1.5 min-w-0">
|
||
<span className="text-[10px] font-extrabold text-ink-4 tabular-nums">0{i + 1}</span>
|
||
<h4 className="truncate font-heading text-body-sm font-bold text-ink-1">
|
||
{stage.label}
|
||
</h4>
|
||
</div>
|
||
<div
|
||
className={cn(
|
||
'flex h-7 w-7 shrink-0 items-center justify-center rounded-lg shadow-xs',
|
||
isWeak
|
||
? 'bg-amber-500 text-white shadow-amber-500/25'
|
||
: i === funnel.length - 1
|
||
? 'bg-emerald-600 text-white shadow-emerald-500/25'
|
||
: 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400'
|
||
)}
|
||
>
|
||
<StageIcon className="h-3.5 w-3.5" />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Main Candidate Count & Pool Share */}
|
||
<div className="mt-3 flex items-baseline justify-between">
|
||
<span className="font-heading text-title-xl font-bold leading-none tabular-nums text-ink-1">
|
||
{stage.count}
|
||
</span>
|
||
<span
|
||
className={cn(
|
||
'rounded-full px-2 py-0.5 text-[10px] font-semibold tabular-nums border',
|
||
isWeak
|
||
? 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950 dark:text-amber-300'
|
||
: 'bg-surface-subtle text-ink-3 border-border/60'
|
||
)}
|
||
>
|
||
{share}% pool
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Stage Fill Bar & Transition Metrics */}
|
||
<div className="mt-4 space-y-2">
|
||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-sunken">
|
||
<div
|
||
className={cn(
|
||
'h-full rounded-full transition-all duration-500',
|
||
isWeak
|
||
? 'bg-gradient-to-r from-amber-500 to-orange-500'
|
||
: i === funnel.length - 1
|
||
? 'bg-gradient-to-r from-emerald-500 to-teal-500'
|
||
: 'bg-gradient-to-r from-blue-600 to-indigo-600'
|
||
)}
|
||
style={{ width: `${Math.max(share, stage.count ? 5 : 0)}%` }}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between text-[10px] text-ink-4 pt-0.5">
|
||
{transition ? (
|
||
<>
|
||
<span className={cn(isWeak ? 'font-bold text-amber-600 dark:text-amber-400' : 'text-ink-3')}>
|
||
{transition.rate}% pass
|
||
</span>
|
||
<span>{transition.lost ? `−${transition.lost} lost` : '0 lost'}</span>
|
||
</>
|
||
) : (
|
||
<span className="text-ink-4">Entry Stage</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Primary Pipeline Bottleneck Alert Banner */}
|
||
{weakest && weakest.lost > 0 && (
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 rounded-xl border border-amber-500/40 bg-gradient-to-r from-amber-50/90 via-surface to-orange-50/40 dark:from-amber-950/40 dark:via-surface dark:to-orange-950/30 p-4 shadow-xs">
|
||
<div className="flex items-start gap-3">
|
||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-amber-500 text-white shadow-md shadow-amber-500/20">
|
||
<AlertTriangle className="h-5 w-5" />
|
||
</div>
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-[10px] font-bold uppercase tracking-wider text-amber-700 dark:text-amber-400">
|
||
Primary Pipeline Bottleneck
|
||
</span>
|
||
<span className="rounded-full bg-amber-200/60 dark:bg-amber-900/60 px-2 py-0.5 text-[10px] font-bold text-amber-800 dark:text-amber-300">
|
||
{weakest.from} → {weakest.to}
|
||
</span>
|
||
</div>
|
||
<p className="mt-0.5 text-body-sm text-ink-2">
|
||
Only <span className="font-bold text-amber-700 dark:text-amber-400">{weakest.rate}%</span> pass through and{' '}
|
||
<span className="font-bold text-ink-1">{weakest.lost} candidates</span> are lost at this transition. Recovering this conversion yields the highest ROI.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ── 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 (
|
||
<div className="rounded-xl border border-border bg-surface px-4 py-8 text-center">
|
||
<p className="text-body-sm text-ink-3">
|
||
Nothing needs intervention. Every role has candidates, every applicant is scored,
|
||
and no hire is awaiting review.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
|
||
{items.map((item) => (
|
||
<li key={item.title}>
|
||
<button
|
||
type="button"
|
||
onClick={item.onClick}
|
||
className="group flex w-full items-center gap-3.5 px-4 py-3 text-left transition-colors
|
||
hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2
|
||
focus-visible:ring-inset focus-visible:ring-krow-blue/50"
|
||
>
|
||
<span
|
||
className={cn('h-2 w-2 shrink-0 rounded-full', SEVERITY[item.severity].dot)}
|
||
aria-hidden="true"
|
||
/>
|
||
<span className="sr-only">{SEVERITY[item.severity].label}.</span>
|
||
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block truncate text-body-sm font-medium text-ink-1">{item.title}</span>
|
||
<span className="block truncate text-caption text-ink-3">{item.detail}</span>
|
||
</span>
|
||
|
||
<span className="shrink-0 text-right">
|
||
<span className="block font-heading text-body font-bold tabular-nums text-ink-1">
|
||
{item.metric}
|
||
</span>
|
||
<span className="block text-[10px] text-ink-4">{item.metricLabel}</span>
|
||
</span>
|
||
|
||
<span className="hidden shrink-0 items-center gap-0.5 text-caption font-semibold text-krow-blue sm:inline-flex">
|
||
Review
|
||
<ChevronRight
|
||
className="h-3 w-3 transition-transform duration-base group-hover:translate-x-0.5"
|
||
aria-hidden="true"
|
||
/>
|
||
</span>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
);
|
||
}
|
||
|
||
/* ── 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 (
|
||
<AdminPage
|
||
title="Control Center"
|
||
subtitle="Workforce operations · hiring intelligence · platform health"
|
||
actions={(
|
||
<Button variant="outline" size="sm" shape="rounded" onClick={exportSnapshot}>
|
||
<Download aria-hidden="true" /> Export
|
||
</Button>
|
||
)}
|
||
>
|
||
<SkillSurface page="control-center" placement="after-header" />
|
||
{/* 1 ── Operations snapshot */}
|
||
<Snapshot metrics={metrics} loading={isLoading} />
|
||
|
||
{/* 2 ── Hiring activity */}
|
||
<Band
|
||
id="cc-activity"
|
||
title="Hiring activity"
|
||
meta={
|
||
RANGES[range].step === 1
|
||
? `Applications, screening, interviews and hires · last ${RANGES[range].days} days, daily`
|
||
: `Applications, screening, interviews and hires · last ${RANGES[range].days} days, ${RANGES[range].step}-day totals`
|
||
}
|
||
action={
|
||
<SegmentedToggle
|
||
options={Object.entries(RANGES).map(([value, r]) => ({ value, label: r.label }))}
|
||
value={range}
|
||
onChange={setRange}
|
||
size="sm"
|
||
ariaLabel="Hiring activity range"
|
||
/>
|
||
}
|
||
>
|
||
<div className="rounded-xl border border-border bg-surface px-2 py-4">
|
||
{isLoading ? (
|
||
<Skeleton className="mx-2 h-[272px] rounded-lg" />
|
||
) : f.total ? (
|
||
<ResponsiveContainer width="100%" height={272}>
|
||
<ComposedChart data={activitySeries} margin={{ top: 4, right: 12, left: 0, bottom: 0 }}>
|
||
<defs>
|
||
<linearGradient id="ccApplications" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stopColor={CHART_TONES.brand} stopOpacity={0.22} />
|
||
<stop offset="100%" stopColor={CHART_TONES.brand} stopOpacity={0} />
|
||
</linearGradient>
|
||
</defs>
|
||
|
||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
|
||
<XAxis dataKey="label" {...AXIS_PROPS} interval="preserveStartEnd" minTickGap={20} />
|
||
<YAxis {...AXIS_PROPS} allowDecimals={false} width={30} />
|
||
<Tooltip content={<ChartTooltip />} />
|
||
<Legend
|
||
verticalAlign="top"
|
||
align="right"
|
||
height={28}
|
||
iconType="circle"
|
||
iconSize={8}
|
||
wrapperStyle={{ fontSize: 12, color: CHART_TONES.axis }}
|
||
/>
|
||
|
||
{/* Applications are the volume everything else is drawn from, so
|
||
they take the filled area and the downstream stages are lines
|
||
over it. */}
|
||
<Area
|
||
type="monotone"
|
||
dataKey="applications"
|
||
name="Applications"
|
||
stroke={CHART_TONES.brand}
|
||
strokeWidth={2}
|
||
fill="url(#ccApplications)"
|
||
activeDot={{ r: 4, strokeWidth: 0 }}
|
||
/>
|
||
<Line
|
||
type="monotone"
|
||
dataKey="screened"
|
||
name="AI Screened"
|
||
stroke={CHART_TONES.navy}
|
||
strokeWidth={2}
|
||
strokeDasharray="4 3"
|
||
dot={false}
|
||
activeDot={{ r: 4, strokeWidth: 0 }}
|
||
/>
|
||
<Line
|
||
type="monotone"
|
||
dataKey="interviews"
|
||
name="Interviews"
|
||
stroke={CHART_TONES.navy}
|
||
strokeWidth={1.5}
|
||
strokeOpacity={0.55}
|
||
dot={false}
|
||
activeDot={{ r: 4, strokeWidth: 0 }}
|
||
/>
|
||
{/* Hires are sparse and are the outcome, so they take the accent
|
||
and a bar — a line at this volume reads as flat. */}
|
||
<Bar
|
||
dataKey="hires"
|
||
name="Hires"
|
||
fill={CHART_TONES.accent}
|
||
radius={[3, 3, 0, 0]}
|
||
maxBarSize={14}
|
||
/>
|
||
</ComposedChart>
|
||
</ResponsiveContainer>
|
||
) : (
|
||
<div className="px-2 py-8">
|
||
<EmptyState
|
||
title="No applications yet"
|
||
description="Once candidates start applying, daily hiring activity appears here."
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Band>
|
||
|
||
{/* 3 ── Pipeline intelligence */}
|
||
<Band
|
||
id="cc-pipeline"
|
||
title="Pipeline intelligence"
|
||
meta={`${f.total} entered · conversion and drop-off by stage`}
|
||
>
|
||
<PipelineFunnel
|
||
funnel={f.funnel}
|
||
transitions={f.transitions}
|
||
weakest={f.bottleneck && f.bottleneck.rate < WEAK_TRANSITION ? f.bottleneck : null}
|
||
total={f.total}
|
||
/>
|
||
</Band>
|
||
|
||
{/* 4 ── Position performance */}
|
||
<Band
|
||
id="cc-positions"
|
||
title="Position performance"
|
||
meta={strongest ? `Strongest conversion: ${strongest}` : undefined}
|
||
action={<ViewAll label="View all positions" onClick={() => navigate('/admin/positions')} />}
|
||
>
|
||
<div className="rounded-xl border border-border bg-surface px-2 py-4">
|
||
{positionPerformance.length ? (
|
||
<>
|
||
<ResponsiveContainer width="100%" height={236}>
|
||
<ComposedChart
|
||
data={positionPerformance}
|
||
barGap={3}
|
||
margin={{ top: 4, right: 12, left: 0, bottom: 0 }}
|
||
>
|
||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
|
||
<XAxis
|
||
dataKey="name"
|
||
{...AXIS_PROPS}
|
||
interval={0}
|
||
tick={{ fontSize: 10, fill: CHART_TONES.axis }}
|
||
/>
|
||
<YAxis {...AXIS_PROPS} allowDecimals={false} width={30} />
|
||
<Tooltip content={<ChartTooltip />} />
|
||
<Legend
|
||
verticalAlign="top"
|
||
align="right"
|
||
height={28}
|
||
iconType="circle"
|
||
iconSize={8}
|
||
wrapperStyle={{ fontSize: 12, color: CHART_TONES.axis }}
|
||
/>
|
||
<Bar
|
||
dataKey="applicants"
|
||
name="Applicants"
|
||
fill={CHART_TONES.brand}
|
||
radius={[3, 3, 0, 0]}
|
||
maxBarSize={24}
|
||
/>
|
||
<Bar dataKey="qualified" name="Qualified (70+)" radius={[3, 3, 0, 0]} maxBarSize={24}>
|
||
{/* 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) => (
|
||
<Cell
|
||
key={r.name}
|
||
fill={r.name === strongest ? CHART_TONES.accent
|
||
: r.name === weakestRole ? CHART_TONES.mint : CHART_TONES.accentPale}
|
||
/>
|
||
))}
|
||
</Bar>
|
||
<Bar
|
||
dataKey="hired"
|
||
name="Hired"
|
||
fill={CHART_TONES.navy}
|
||
radius={[3, 3, 0, 0]}
|
||
maxBarSize={24}
|
||
/>
|
||
</ComposedChart>
|
||
</ResponsiveContainer>
|
||
|
||
{weakestRole && (
|
||
<p className="px-2 pt-2 text-caption leading-relaxed text-ink-3">
|
||
<span className="font-semibold text-ink-1">{strongest}</span> converts applicants
|
||
into qualified candidates best;{' '}
|
||
<span className="font-semibold text-ink-1">{weakestRole}</span> converts worst and
|
||
is where sourcing — or the bar itself — is worth a look.
|
||
</p>
|
||
)}
|
||
</>
|
||
) : (
|
||
<div className="px-2 py-8">
|
||
<EmptyState
|
||
title="No open positions"
|
||
description="Publish a role and its hiring performance appears here."
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Band>
|
||
|
||
{/* 5 ── Action center */}
|
||
<Band
|
||
id="cc-actions"
|
||
title="Action center"
|
||
meta={actions.length ? `${actions.length} items, most consequential first` : 'All clear'}
|
||
>
|
||
<ActionQueue items={actions} />
|
||
</Band>
|
||
|
||
{/* 6 / 7 ── Active positions and top talent. Two lists of comparable weight,
|
||
so they share a row rather than each taking one. */}
|
||
<div className="grid gap-6 xl:grid-cols-2">
|
||
<Band
|
||
id="cc-active"
|
||
title="Active positions"
|
||
meta={`${f.openPositions.length} hiring`}
|
||
action={<ViewAll label="View all" onClick={() => navigate('/admin/positions')} />}
|
||
>
|
||
{activePositions.length ? (
|
||
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
|
||
{activePositions.map((r) => {
|
||
const coverage = r.applied ? Math.round((r.screened / r.applied) * 100) : 0;
|
||
return (
|
||
<li key={r.title}>
|
||
<button
|
||
type="button"
|
||
onClick={() => navigate('/admin/positions')}
|
||
className="group flex w-full items-center gap-3 px-4 py-3 text-left transition-colors
|
||
hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2
|
||
focus-visible:ring-inset focus-visible:ring-krow-blue/50"
|
||
>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block truncate text-body-sm font-medium text-ink-1">
|
||
{r.title}
|
||
</span>
|
||
<span className="block truncate text-caption text-ink-3">
|
||
{r.applied} applicant{r.applied === 1 ? '' : 's'} · {r.screened} screened
|
||
{' · '}{r.hired} hired
|
||
</span>
|
||
</span>
|
||
|
||
{/* Screening coverage as a small bar — the one number that
|
||
says whether this role is actually being worked. */}
|
||
<span className="hidden w-16 shrink-0 sm:block">
|
||
<span className="block h-1.5 overflow-hidden rounded-full bg-surface-sunken">
|
||
<span
|
||
className={cn(
|
||
'block h-full rounded-full',
|
||
coverage === 100 ? 'bg-success'
|
||
: coverage >= 50 ? 'bg-krow-blue' : 'bg-warning'
|
||
)}
|
||
style={{ width: `${Math.max(coverage, r.applied ? 4 : 0)}%` }}
|
||
/>
|
||
</span>
|
||
<span className="mt-1 block text-right text-[10px] tabular-nums text-ink-4">
|
||
{coverage}%
|
||
</span>
|
||
</span>
|
||
|
||
<StatusBadge status={r.posting.status} size="sm" />
|
||
<ChevronRight
|
||
className="h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base group-hover:translate-x-0.5"
|
||
aria-hidden="true"
|
||
/>
|
||
</button>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
) : (
|
||
<EmptyState title="No open positions" description="Publish a role to start hiring." />
|
||
)}
|
||
</Band>
|
||
|
||
<Band
|
||
id="cc-talent"
|
||
title="Top talent"
|
||
meta={topTalent.length ? 'By KROW Score' : undefined}
|
||
action={<ViewAll label="View all" onClick={() => navigate('/admin/candidates')} />}
|
||
>
|
||
{topTalent.length ? (
|
||
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
|
||
{topTalent.map((c) => (
|
||
<li key={c.id}>
|
||
<button
|
||
type="button"
|
||
onClick={() => navigate('/admin/candidates')}
|
||
className="group flex w-full items-center gap-3 px-4 py-3 text-left transition-colors
|
||
hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2
|
||
focus-visible:ring-inset focus-visible:ring-krow-blue/50"
|
||
>
|
||
{/* The seeded portrait where there is one, otherwise the design
|
||
system's initials avatar. Never an invented face. */}
|
||
<Avatar name={c.applicant_name} src={c.selfie_url} size="sm" />
|
||
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block truncate text-body-sm font-medium text-ink-1">
|
||
{c.applicant_name}
|
||
</span>
|
||
<span className="block truncate text-caption text-ink-3">{c.job_title}</span>
|
||
</span>
|
||
|
||
<span className="shrink-0 text-right">
|
||
<span className="block font-heading text-body font-bold tabular-nums text-ink-1">
|
||
{c.ai_score}
|
||
</span>
|
||
<span className="block text-[10px] text-ink-4">KROW</span>
|
||
</span>
|
||
|
||
<StatusBadge status={c.status} size="sm" />
|
||
<ChevronRight
|
||
className="h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base group-hover:translate-x-0.5"
|
||
aria-hidden="true"
|
||
/>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : (
|
||
<EmptyState
|
||
title="Nobody scored yet"
|
||
description="Screen the pipeline and the strongest candidates appear here."
|
||
/>
|
||
)}
|
||
</Band>
|
||
</div>
|
||
|
||
{/* 8 ── Recent activity */}
|
||
<Band
|
||
id="cc-recent"
|
||
title="Recent activity"
|
||
meta={`${activity.length} events logged`}
|
||
action={<ViewAll label="View all" onClick={() => navigate('/admin/activity')} />}
|
||
>
|
||
<div className="overflow-hidden rounded-xl border border-border bg-surface">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full min-w-[36rem] border-collapse">
|
||
<caption className="sr-only">Most recent platform activity</caption>
|
||
<thead>
|
||
<tr className="border-b border-border bg-surface-subtle">
|
||
{['User', 'Action', 'Entity', 'Time', 'Role'].map((h) => (
|
||
<th
|
||
key={h}
|
||
scope="col"
|
||
className={cn(
|
||
'whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4',
|
||
h === 'Role' ? 'text-right' : 'text-left'
|
||
)}
|
||
>
|
||
{h}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{recent.map((event) => (
|
||
<tr key={event.id} className="border-b border-border last:border-0 hover:bg-surface-subtle">
|
||
<td className="px-4 py-2.5">
|
||
<div className="flex items-center gap-2.5">
|
||
<Avatar name={event.user_name || event.user_email} size="xs" />
|
||
<div className="min-w-0">
|
||
<p className="truncate text-body-sm font-medium text-ink-1">
|
||
{event.user_name || '—'}
|
||
</p>
|
||
<p className="truncate text-[10px] text-ink-4">{event.user_email}</p>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td className="whitespace-nowrap px-4 py-2.5 text-body-sm text-ink-2">
|
||
{String(event.event_type).replace(/_/g, ' ')}
|
||
</td>
|
||
<td className="max-w-[24rem] px-4 py-2.5 text-body-sm text-ink-3">
|
||
<span className="line-clamp-1">{event.details || '—'}</span>
|
||
</td>
|
||
<td className="whitespace-nowrap px-4 py-2.5 text-caption text-ink-4">
|
||
{new Date(event.created_date).toLocaleDateString(undefined, {
|
||
month: 'short', day: 'numeric',
|
||
})}
|
||
</td>
|
||
<td className="px-4 py-2.5 text-right">
|
||
<Badge variant={event.account_type === 'employer' ? 'info' : 'neutral'} size="sm">
|
||
{event.account_type || 'unknown'}
|
||
</Badge>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</Band>
|
||
<SkillSurface page="control-center" placement="before-footer" />
|
||
</AdminPage>
|
||
);
|
||
}
|