update ui design for all pages
This commit is contained in:
@@ -4,7 +4,9 @@ import {
|
||||
Area, Bar, CartesianGrid, Cell, ComposedChart, Legend, Line, ResponsiveContainer,
|
||||
Tooltip, XAxis, YAxis,
|
||||
} from 'recharts';
|
||||
import { ArrowRight, ChevronRight, Download, Plus } from 'lucide-react';
|
||||
import {
|
||||
AlertTriangle, ArrowRight, Building2, CalendarCheck, CheckCircle2, ChevronRight, Clock, Download, Filter, Plus, Sparkles, Users, Zap,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
AXIS_PROPS, Avatar, Badge, Button, CHART_TONES, ChartTooltip, EmptyState, SegmentedToggle,
|
||||
@@ -18,43 +20,16 @@ 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">
|
||||
@@ -70,7 +45,6 @@ function Band({ id, title, meta, children, action }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The "view all" affordance, consistent across every section that has one. */
|
||||
function ViewAll({ label, onClick }) {
|
||||
return (
|
||||
<button
|
||||
@@ -87,53 +61,111 @@ function ViewAll({ label, onClick }) {
|
||||
|
||||
/* ── 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.
|
||||
*/
|
||||
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 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'
|
||||
<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>
|
||||
)}
|
||||
>
|
||||
{m.value}
|
||||
</p>
|
||||
)}
|
||||
{m.sub && <p className="mt-1 truncate text-caption text-ink-3">{m.sub}</p>}
|
||||
</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 ───────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
@@ -144,75 +176,126 @@ function PipelineFunnel({ funnel, transitions, weakest, total }) {
|
||||
);
|
||||
}
|
||||
|
||||
const stageIcons = [Users, Sparkles, Filter, CalendarCheck, CheckCircle2];
|
||||
|
||||
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;
|
||||
<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(
|
||||
'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
|
||||
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(
|
||||
'text-body-sm font-semibold tabular-nums',
|
||||
isWeak ? 'text-warning' : transition.rate >= 80 ? 'text-success' : 'text-ink-2'
|
||||
'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'
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
|
||||
{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>
|
||||
@@ -497,7 +580,7 @@ export default function ControlCenter() {
|
||||
subtitle="Workforce operations · hiring intelligence · platform health"
|
||||
actions={
|
||||
<>
|
||||
<Button size="sm" shape="rounded" onClick={() => navigate('/positions/new')}>
|
||||
<Button size="sm" shape="rounded" onClick={() => navigate('/admin/positions/new')}>
|
||||
<Plus aria-hidden="true" /> Create Position
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" shape="rounded" onClick={exportSnapshot}>
|
||||
|
||||
Reference in New Issue
Block a user