ui changes

This commit is contained in:
2026-08-10 19:36:45 +05:30
parent 409dffe938
commit 5cd49a1655
49 changed files with 7988 additions and 470 deletions

View File

@@ -0,0 +1,900 @@
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 { ArrowRight, ChevronRight, Download, Plus } 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 '@/pages/admin/_shell';
/**
* Control Center — the KROW Admin command centre.
*
* Composed as one workspace rather than a grid of cards. Sections are separated by
* headings and rules, and a surface appears only where something genuinely needs
* lifting off the page: the metric strip, the two charts, the queue and the lists.
* That is the difference between a console and a dashboard — a dashboard frames
* every thought in its own box, and the boxes end up competing with the data.
*
* The reading order is deliberate, from state to action:
*
* snapshot → activity → where it leaks → which roles work
* → what to do → what is live → who is strongest → what just happened
*
* Every figure comes from the same fact sheet Owliver reads, so the page and the
* panel beside it cannot disagree. Nothing falls back to a placeholder number: an
* empty platform gets an empty state, because a console that invents "22
* candidates" to look populated is worse than one that admits it has none.
*/
/** Ranges for the activity chart. Local to that chart — see `activitySeries`. */
const RANGES = {
'7d': { label: '7D', days: 7, step: 1 },
'30d': { label: '30D', days: 30, step: 3 },
'90d': { label: '90D', days: 90, step: 9 },
};
/** Below this conversion, a stage transition is called out as the leak. */
const WEAK_TRANSITION = 60;
/* ── Section furniture ──────────────────────────────────────────────────── */
/**
* A section heading with an optional right-hand control.
*
* Local to this page rather than shared: the Control Center's sections carry chart
* controls and "view all" links in the same slot, and generalising that across the
* other Admin pages would mean a prop per case.
*/
function Band({ id, title, meta, children, action }) {
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>
);
}
/** The "view all" affordance, consistent across every section that has one. */
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 ─────────────────────────────────────────────── */
/**
* One grouped surface, six figures, hairline separators.
*
* Six cards would be six borders, six shadows and six landing points for the eye,
* all to carry six numbers that belong to a single thought: the current state of
* operations. As one banded strip it reads in a single pass.
*/
function Snapshot({ metrics, loading }) {
return (
<div className="grid grid-cols-2 divide-x divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface sm:grid-cols-3 sm:divide-y-0 xl:grid-cols-6">
{metrics.map((m) => (
<div key={m.label} className="min-w-0 px-4 py-3.5">
<p className="text-[11px] font-medium uppercase leading-tight tracking-wide text-ink-4">
{m.label}
</p>
{loading ? (
<div className="mt-1.5 h-7 w-14 animate-pulse rounded bg-surface-sunken" />
) : (
<p
className={cn(
'mt-1 font-heading text-title-lg font-bold leading-none tabular-nums',
m.tone === 'brand' ? 'text-krow-blue'
: m.tone === 'warning' ? 'text-warning'
: m.tone === 'success' ? 'text-success' : 'text-ink-1'
)}
>
{m.value}
</p>
)}
{m.sub && <p className="mt-1 truncate text-caption text-ink-3">{m.sub}</p>}
</div>
))}
</div>
);
}
/* ── 3. Pipeline intelligence ───────────────────────────────────────────── */
/**
* The funnel as an analytical strip: stage, count, conversion in, drop-off.
*
* Not progress bars in cards. The question here is never "how full is this stage"
* — it is "where are we losing people", which is a property of the *transitions*
* rather than the stages. So each row reports what it received, what it kept and
* what it lost, and the weakest transition is called out in the existing warning
* token so the leak is impossible to miss.
*/
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."
/>
);
}
return (
<div className="overflow-hidden rounded-xl border border-border bg-surface">
{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 = (stage.count / total) * 100;
return (
<div
key={stage.key}
className={cn(
'flex items-center gap-4 border-b border-border px-4 py-3 last:border-0',
isWeak && 'bg-warning-muted'
)}
>
<div className="w-36 shrink-0">
<p className="text-body-sm font-medium text-ink-1">{stage.label}</p>
<p className="text-[10px] text-ink-4">{Math.round(share)}% of applied</p>
</div>
{/* The bar's width is this stage's share of the top of the funnel, so
the narrowing is literal rather than described. */}
<div className="min-w-0 flex-1">
<div className="h-2.5 overflow-hidden rounded-full bg-surface-sunken">
<div
className={cn('h-full rounded-full', isWeak ? 'bg-warning' : 'bg-krow-blue')}
style={{ width: `${Math.max(share, stage.count ? 3 : 0)}%` }}
/>
</div>
</div>
<p className="w-10 shrink-0 text-right font-heading text-body font-bold tabular-nums text-ink-1">
{stage.count}
</p>
<div className="w-24 shrink-0 text-right">
{transition ? (
<>
<p
className={cn(
'text-body-sm font-semibold tabular-nums',
isWeak ? 'text-warning' : transition.rate >= 80 ? 'text-success' : 'text-ink-2'
)}
>
{transition.rate}%
</p>
<p className="text-[10px] tabular-nums text-ink-4">
{transition.lost ? `−${transition.lost} dropped` : 'no drop-off'}
</p>
</>
) : (
<p className="text-[10px] text-ink-4">entry point</p>
)}
</div>
</div>
);
})}
{weakest && weakest.lost > 0 && (
<div className="border-t border-border bg-surface-subtle px-4 py-2.5">
<p className="text-caption leading-relaxed text-ink-2">
<span className="font-semibold text-ink-1">
{weakest.from} → {weakest.to}
</span>{' '}
is the leak: {weakest.rate}% pass through and {weakest.lost}{' '}
{weakest.lost === 1 ? 'candidate is' : 'candidates are'} lost. Every other
transition performs better, so this is the cheapest conversion to recover.
</p>
</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 size="sm" shape="rounded" onClick={() => navigate('/positions/new')}>
<Plus aria-hidden="true" /> Create Position
</Button>
<Button variant="outline" size="sm" shape="rounded" onClick={exportSnapshot}>
<Download aria-hidden="true" /> Export
</Button>
</>
}
>
{/* 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 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>
</AdminPage>
);
}