ui changes
This commit is contained in:
900
src/pages/admin/ControlCenter.jsx
Normal file
900
src/pages/admin/ControlCenter.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user