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 (

{title}

{meta && {meta}}
{action}
{children}
); } function ViewAll({ label, onClick }) { return ( ); } /* ── 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 (
{metrics.map((m) => { const cfg = snapshotConfig[m.label] || snapshotConfig['Candidates']; const Icon = cfg.icon; return (
{m.label}
{loading ? (
) : (
{m.value}
)}
{m.sub && ( {m.sub} )}
); })}
); } /* ── 3. Pipeline intelligence ───────────────────────────────────────────── */ function PipelineFunnel({ funnel, transitions, weakest, total }) { if (!total) { return ( ); } const stageIcons = [Users, Sparkles, Filter, CalendarCheck, CheckCircle2]; return (
{/* 5 Stage Funnel Cards */}
{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 (
{/* Header: Stage Name & Icon */}
0{i + 1}

{stage.label}

{/* Main Candidate Count & Pool Share */}
{stage.count} {share}% pool
{/* Stage Fill Bar & Transition Metrics */}
{transition ? ( <> {transition.rate}% pass {transition.lost ? `−${transition.lost} lost` : '0 lost'} ) : ( Entry Stage )}
); })}
{/* Primary Pipeline Bottleneck Alert Banner */} {weakest && weakest.lost > 0 && (
Primary Pipeline Bottleneck {weakest.from} → {weakest.to}

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