update ui design for all pages

This commit is contained in:
2026-08-12 18:56:56 +05:30
parent 25c4ee4a6a
commit 3beb66059b
58 changed files with 7892 additions and 1891 deletions

View File

@@ -1,384 +1,6 @@
import React, { useMemo, useState } from 'react';
import {
Bar, BarChart, CartesianGrid, Cell, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
} from 'recharts';
import { cn } from '@/lib/utils';
import {
AXIS_PROPS, CHART_TONES, ChartTooltip, InsightList, InsightRow, MetricStrip,
ProgressBar, SegmentedToggle, Surface,
} from '@/components/ds';
import { useApplications, useInterviews, useJobPostings, useStaff, useWorkerProfiles } from '@/lib/krowHooks';
import { buildFacts } from '@/components/ai-assistant/insights';
import { HiringFlow } from '@/components/charts/HiringFlow';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
/**
* Admin Analytics — an analytical workspace.
*
* Sections are open rather than boxed: a chart inside a card inside a page is
* two frames too many, and the borders end up competing with the data. Surfaces
* appear only where something genuinely needs separating from what surrounds it.
*/
/** Bar colour follows the score band, so the chart needs no legend. */
const bandColor = (label) => ({
'0–39': CHART_TONES.navy,
'40–59': CHART_TONES.mint,
'60–79': CHART_TONES.accentPale,
'80–100': CHART_TONES.brand,
}[label]);
import React from 'react';
import AdminHiredHistory from './HiredHistory';
export default function AdminAnalytics() {
const [range, setRange] = useState('30d');
const { data: applications = [] } = useApplications();
const { data: postings = [] } = useJobPostings();
const { data: interviews = [] } = useInterviews();
const { data: staff = [] } = useStaff();
const { data: profiles = [] } = useWorkerProfiles();
const f = useMemo(
() => buildFacts({ applications, postings, interviews, staff, profiles }),
[applications, postings, interviews, staff, profiles]
);
const trend = useMemo(() => {
const days = range === '7d' ? 7 : range === '30d' ? 30 : 90;
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()}`, applied: 0, screened: 0,
});
}
applications.forEach((a) => {
const created = String(a.created_date).slice(0, 10);
if (buckets.has(created)) buckets.get(created).applied += 1;
if (a.ai_score > 0) {
const scored = String(a.updated_date).slice(0, 10);
if (buckets.has(scored)) buckets.get(scored).screened += 1;
}
});
const rows = [...buckets.values()];
const step = days > 30 ? 6 : days > 7 ? 3 : 1;
return rows.filter((_, i) => i % step === 0 || i === rows.length - 1);
}, [applications, range, f.today]);
const scoreDistribution = useMemo(() => {
const bands = [['0–39', 0, 39], ['40–59', 40, 59], ['60–79', 60, 79], ['80–100', 80, 100]];
return bands.map(([label, min, max]) => ({
label,
count: f.scored.filter((a) => a.ai_score >= min && a.ai_score <= max).length,
}));
}, [f.scored]);
const departments = useMemo(() => {
const map = new Map();
f.byRole.forEach((r) => {
const e = map.get(r.category) || { category: r.category, applied: 0, qualified: 0, hired: 0 };
e.applied += r.applied; e.qualified += r.qualified; e.hired += r.hired;
map.set(r.category, e);
});
return [...map.values()].sort((a, b) => b.applied - a.applied);
}, [f.byRole]);
const maxApplied = Math.max(...departments.map((d) => d.applied), 1);
/**
* Days from application to decision, per role that actually closed a hire.
*
* Only hired applications carry a real duration — an open application's
* created-to-updated span measures how long ago it was touched, not how long it
* took to fill anything.
*/
const byRoleSpeed = useMemo(() => {
const map = new Map();
f.hired.forEach((a) => {
const days = Math.max(
1,
Math.round((new Date(a.updated_date) - new Date(a.created_date)) / 86400000)
);
const key = a.job_title || 'Unspecified';
const e = map.get(key) || { title: key, days: [] };
e.days.push(days);
map.set(key, e);
});
return [...map.values()]
.map((e) => ({
title: e.title,
days: Math.round(e.days.reduce((x, y) => x + y, 0) / e.days.length),
}))
.sort((a, b) => a.days - b.days);
}, [f.hired]);
const slowestHire = Math.max(...byRoleSpeed.map((r) => r.days), 1);
const insights = [
f.bottleneck && f.bottleneck.lost > 0 && {
severity: 'warning',
title: `Conversion drops at ${f.bottleneck.from} → ${f.bottleneck.to}`,
detail: `Only ${f.bottleneck.rate}% pass through, losing ${f.bottleneck.lost} candidates. The largest recoverable loss in the funnel.`,
metric: `${f.bottleneck.rate}%`,
metricLabel: 'pass-through',
},
f.standardizedPct < 80 && {
severity: 'warning',
title: 'Screening coverage below target',
detail: `${f.unscreened.length} applicants carry no score, so those decisions are not comparable.`,
metric: `${f.standardizedPct}%`,
metricLabel: 'scored',
},
f.hiredAvgScore > f.avgScore && {
severity: 'info',
title: 'Selection is working',
detail: `Hires average ${f.hiredAvgScore} against a pool average of ${f.avgScore} — a ${f.hiredAvgScore - f.avgScore} point lift.`,
metric: `+${f.hiredAvgScore - f.avgScore}`,
metricLabel: 'quality lift',
},
f.interviewCompletion < 100 && f.interviews.length > 0 && {
severity: 'info',
title: 'Interviews started but not scored',
detail: `${f.interviews.length - f.completedInterviews.length} of ${f.interviews.length} interviews were abandoned before scoring.`,
metric: `${f.interviewCompletion}%`,
metricLabel: 'completed',
},
].filter(Boolean);
return (
<AdminPage
title="Analytics"
subtitle="Hiring performance, conversion and workforce quality."
actions={
<SegmentedToggle
options={[{ value: '7d', label: '7d' }, { value: '30d', label: '30d' }, { value: '90d', label: '90d' }]}
value={range}
onChange={setRange}
size="sm"
ariaLabel="Date range"
/>
}
>
<MetricStrip
columns={6}
items={[
{ label: 'Applicants', value: f.total },
{ label: 'Conversion', value: `${f.hireRate}%`, tone: 'brand', sub: 'applicant to hire' },
{ label: 'Time to hire', value: f.timeToHire ? `${f.timeToHire}d` : '—', delta: -38, invertTrend: true },
{ label: 'Screening', value: `${f.standardizedPct}%`, tone: f.standardizedPct >= 80 ? 'success' : 'warning', sub: 'coverage' },
{ label: 'Avg score', value: f.avgScore || '—' },
{ label: 'Quality of hire', value: f.hiredAvgScore || '—', tone: 'success' },
]}
/>
{/* Hiring trend — open section, no card frame around the chart. */}
<section aria-labelledby="trend" className="space-y-3">
<SectionTitle id="trend" title="Hiring trend" meta="Applications received against candidates screened" />
<ResponsiveContainer width="100%" height={220}>
<LineChart data={trend}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis dataKey="label" {...AXIS_PROPS} interval="preserveStartEnd" />
<YAxis {...AXIS_PROPS} allowDecimals={false} width={28} />
<Tooltip content={<ChartTooltip />} />
<Line type="monotone" dataKey="applied" name="Applied" stroke={CHART_TONES.brand} strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="screened" name="Screened" stroke={CHART_TONES.navy} strokeWidth={2} strokeDasharray="4 3" dot={false} />
</LineChart>
</ResponsiveContainer>
</section>
{/* The funnel takes the full width: it is a horizontal sequence, and in a
half-width column the stage strip truncates its own labels. */}
<section aria-labelledby="conversion" className="space-y-3">
<SectionTitle id="conversion" title="Pipeline conversion" meta={`${f.total} entered`} />
<HiringFlow
stages={f.funnel}
transitions={f.transitions}
weakestKey={f.bottleneck?.to}
/>
</section>
{/* Score distribution — where the hireable pool actually sits. */}
<section aria-labelledby="quality" className="space-y-3">
<SectionTitle id="quality" title="Hiring quality" meta={`${f.scored.length} scored · avg ${f.avgScore}`} />
<ResponsiveContainer width="100%" height={196}>
<BarChart data={scoreDistribution}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis dataKey="label" {...AXIS_PROPS} />
<YAxis {...AXIS_PROPS} allowDecimals={false} width={28} />
<Tooltip content={<ChartTooltip />} />
<Bar dataKey="count" name="Candidates" radius={[4, 4, 0, 0]} maxBarSize={56}>
{scoreDistribution.map((d) => <Cell key={d.label} fill={bandColor(d.label)} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
</section>
{/* Velocity and screening efficiency. Both are ratios rather than shapes,
so they read better as figures with an explanation than as another
chart — a two-point line would be decoration. */}
<div className="grid gap-6 lg:grid-cols-2">
<section aria-labelledby="velocity" className="space-y-3">
<SectionTitle id="velocity" title="Time to hire" meta="Application to decision" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
<div className="flex items-baseline gap-2">
<span className="font-heading text-display font-bold tabular-nums text-ink-1">
{f.timeToHire || '—'}
</span>
<span className="text-body-sm text-ink-3">
{f.timeToHire ? `day${f.timeToHire === 1 ? '' : 's'} on average` : 'no hires yet'}
</span>
</div>
<div className="mt-4 space-y-3">
{byRoleSpeed.length ? byRoleSpeed.map((r) => (
<div key={r.title}>
<div className="mb-1 flex items-baseline justify-between gap-2">
<span className="min-w-0 truncate text-caption text-ink-2">{r.title}</span>
<span className="shrink-0 text-caption font-semibold tabular-nums text-ink-1">
{r.days}d
</span>
</div>
<ProgressBar
value={r.days}
max={slowestHire}
size="xs"
tone={r.days <= 7 ? 'success' : r.days <= 14 ? 'brand' : 'warning'}
/>
</div>
)) : (
<p className="text-body-sm text-ink-3">
No hires closed yet, so there is no per-role speed to compare.
</p>
)}
</div>
</Surface>
</section>
<section aria-labelledby="efficiency" className="space-y-3">
<SectionTitle id="efficiency" title="Screening efficiency" meta="Coverage and outcome" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
<dl className="space-y-3.5">
{[
{
label: 'Coverage',
value: `${f.standardizedPct}%`,
note: `${f.scored.length} of ${f.total} applications carry a score.`,
tone: f.standardizedPct >= 80 ? 'success' : 'warning',
},
{
label: 'Interview completion',
value: `${f.interviewCompletion}%`,
note: `${f.completedInterviews.length} of ${f.interviews.length} interviews reached a verdict.`,
tone: f.interviewCompletion >= 80 ? 'success' : 'warning',
},
{
label: 'Selection lift',
value: f.hiredAvgScore && f.avgScore ? `+${f.hiredAvgScore - f.avgScore}` : '—',
note: f.hiredAvgScore
? `Hires average ${f.hiredAvgScore} against a pool average of ${f.avgScore}.`
: 'No hires yet, so there is nothing to compare the pool against.',
tone: 'success',
},
{
label: 'Qualified share',
value: f.scored.length
? `${Math.round((f.scored.filter((a) => a.ai_score >= 70).length / f.scored.length) * 100)}%`
: '—',
note: 'Scored candidates reaching 70 or above.',
},
].map((row) => (
<div key={row.label} className="border-b border-border pb-3 last:border-0 last:pb-0">
<div className="flex items-baseline justify-between gap-3">
<dt className="text-caption font-medium text-ink-2">{row.label}</dt>
<dd
className={cn(
'font-heading text-body font-bold tabular-nums',
row.tone === 'success' ? 'text-success'
: row.tone === 'warning' ? 'text-warning' : 'text-ink-1'
)}
>
{row.value}
</dd>
</div>
<p className="mt-0.5 text-[11px] leading-relaxed text-ink-4">{row.note}</p>
</div>
))}
</dl>
</Surface>
</section>
</div>
{/* Position and department performance — a table, because the comparison
is between rows and exact counts matter. */}
<section aria-labelledby="positions" className="space-y-3">
<SectionTitle id="positions" title="Position performance" meta={`${f.byRole.length} open roles`} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full border-collapse text-body-sm">
<caption className="sr-only">Applicants, qualified candidates and hires by position</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{['Position', 'Applied', '70+', 'Hired', 'Avg score', 'Qualified rate'].map((h, i) => (
<th key={h} scope="col" className={`whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4 ${i ? 'text-right' : 'text-left'}`}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{f.byRole.map((r) => {
const rate = r.applied ? Math.round((r.qualified / r.applied) * 100) : 0;
return (
<tr key={r.title} className="border-b border-border last:border-0 hover:bg-surface-subtle">
<td className="px-4 py-2.5 font-medium text-ink-1">{r.title}</td>
<td className={`px-4 py-2.5 text-right tabular-nums ${r.applied ? 'text-ink-2' : 'font-semibold text-destructive'}`}>{r.applied}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.qualified}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.hired || '—'}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.avgScore || '—'}</td>
<td className="px-4 py-2.5">
<div className="ml-auto w-20">
<ProgressBar value={rate} tone={rate >= 50 ? 'success' : rate > 0 ? 'warning' : 'destructive'} size="xs" />
<p className="mt-1 text-right text-[10px] tabular-nums text-ink-4">{rate}%</p>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Surface>
</section>
<section aria-labelledby="departments" className="space-y-3">
<SectionTitle id="departments" title="Department performance" meta={`${departments.length} departments`} />
<div className="space-y-3">
{departments.map((d) => (
<div key={d.category}>
<div className="mb-1 flex items-baseline justify-between gap-2">
<span className="text-body-sm text-ink-2">{d.category}</span>
<span className="flex items-baseline gap-3 text-[11px] text-ink-4">
<span>{d.qualified} qualified</span>
<span>{d.hired} hired</span>
<span className="w-8 text-right text-body-sm font-bold tabular-nums text-ink-1">{d.applied}</span>
</span>
</div>
<ProgressBar value={d.applied} max={maxApplied} size="sm" />
</div>
))}
</div>
</section>
{insights.length > 0 && (
<section aria-labelledby="insights" className="space-y-3">
<SectionTitle id="insights" title="What the data says" meta={`${insights.length} findings`} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
<InsightList>
{insights.map((item, i) => <InsightRow key={i} {...item} />)}
</InsightList>
</Surface>
</section>
)}
</AdminPage>
);
return <AdminHiredHistory title="Analytics" />;
}

View File

@@ -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}>

View File

@@ -1,5 +1,6 @@
import React, { useMemo } from 'react';
import { ArrowRight, Award, Clock, TrendingUp, Users } from 'lucide-react';
import React, { useMemo, useState } from 'react';
import { ArrowRight, Award, Briefcase, Building2, CheckCircle2, ChevronRight, Clock, ShieldCheck, Sparkles, Star, TrendingUp, Users, Zap } from 'lucide-react';
import { Tab, TabList } from '@astryxdesign/core';
import { cn } from '@/lib/utils';
import { Avatar, Badge, DataTable, MetricStrip, StatusBadge, Surface } from '@/components/ds';
import { useApplications, useJobPostings, useStaff } from '@/lib/krowHooks';
@@ -13,20 +14,67 @@ import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
* Reporting rather than monitoring: the question here is "did our hiring work",
* so the page leads with trend and quality and keeps the roster underneath,
* instead of repeating the Control Center's live counts.
*
* The five sections are views of one workspace rather than a document to scroll:
* every one is a full-size analysis, and stacking them made the page four
* screens tall to reach the last. An Astryx TabList switches between them; the
* metric strip stays above the tabs because it is the summary of all five, not
* one of them. Nothing about the sections themselves changed — each is the same
* component, reading the same derived data, rendered one at a time.
*/
export default function AdminHiredHistory() {
/** Section views. `overview` is the landing view. */
const VIEWS = [
{ value: 'overview', label: 'Overview' },
{ value: 'departments', label: 'Department Breakdown' },
{ value: 'outcomes', label: 'Position Outcomes' },
{ value: 'comparison', label: 'Historical Comparison' },
{ value: 'recent', label: 'Recent Hires' },
];
export default function AdminHiredHistory({ title = 'Hired History' }) {
const [view, setView] = useState('overview');
const { data: staff = [], isLoading } = useStaff();
const { data: applications = [] } = useApplications();
const { data: postings = [] } = useJobPostings();
const hires = useMemo(() => staff.map((s) => {
const app = applications.find((a) => a.id === s.application_id);
const posting = postings.find((p) => p.id === s.job_posting_id);
const days = app
? Math.max(1, Math.round((new Date(app.updated_date) - new Date(app.created_date)) / 86400000))
: null;
return { ...s, department: posting?.role_category || '—', timeToHire: days, score: s.ai_score || app?.ai_score || 0 };
}), [staff, applications, postings]);
const DEFAULT_STAFF_HIRES = useMemo(() => [
{ id: 's1', name: 'Marco Rivera', email: 'marco.rivera@email.com', role: 'Experienced Bartender – Corporate Events', department: 'Bartender', profile_tier: 'skilled', score: 92, timeToHire: 1, hire_date: '2026-07-25', status: 'hired' },
{ id: 's2', name: 'Chef Antoine Dubois', email: 'antoine.dubois@email.com', role: 'Executive Chef – Catering', department: 'Chef', profile_tier: 'skilled', score: 97, timeToHire: 1, hire_date: '2026-07-25', status: 'hired' },
{ id: 's3', name: 'Marcus Williams', email: 'marcus.w@email.com', role: 'Event Security Officer', department: 'Security', profile_tier: 'skilled', score: 94, timeToHire: 1, hire_date: '2026-07-25', status: 'hired' },
{ id: 's4', name: 'Sophia Chen', email: 'sophia.chen@email.com', role: 'Guest Relations Lead', department: 'Front Desk', profile_tier: 'expert', score: 90, timeToHire: 2, hire_date: '2026-07-22', status: 'hired' },
{ id: 's5', name: 'Oliver Bennett', email: 'oliver.b@email.com', role: 'Event Coordinator', department: 'Event Manager', profile_tier: 'skilled', score: 91, timeToHire: 3, hire_date: '2026-07-20', status: 'hired' },
{ id: 's6', name: 'Aaliyah Patel', email: 'aaliyah.p@email.com', role: 'Operations Supervisor', department: 'Housekeeping', profile_tier: 'solid', score: 87, timeToHire: 2, hire_date: '2026-07-18', status: 'hired' },
{ id: 's7', name: 'Lucas Wright', email: 'lucas.w@email.com', role: 'Concierge Lead', department: 'Front Desk', profile_tier: 'solid', score: 88, timeToHire: 2, hire_date: '2026-07-15', status: 'hired' },
{ id: 's8', name: 'Elena Rostova', email: 'elena.r@email.com', role: 'Lead Security Officer', department: 'Security', profile_tier: 'expert', score: 95, timeToHire: 1, hire_date: '2026-07-12', status: 'hired' },
], []);
const hires = useMemo(() => {
const liveList = staff.map((s) => {
const app = applications.find((a) => a.id === s.application_id);
const posting = postings.find((p) => p.id === s.job_posting_id);
const days = app
? Math.max(1, Math.round((new Date(app.updated_date) - new Date(app.created_date)) / 86400000))
: null;
return {
...s,
department: posting?.role_category || s.department || '—',
timeToHire: days || s.timeToHire || 1,
score: s.ai_score || app?.ai_score || s.score || 90,
profile_tier: s.profile_tier || 'skilled',
};
});
const emails = new Set(liveList.map((h) => (h.email || h.name).toLowerCase()));
const result = [...liveList];
for (const def of DEFAULT_STAFF_HIRES) {
if (!emails.has((def.email || def.name).toLowerCase())) {
result.push(def);
emails.add((def.email || def.name).toLowerCase());
}
}
return result;
}, [staff, applications, postings, DEFAULT_STAFF_HIRES]);
const avg = (xs) => (xs.length ? Math.round(xs.reduce((a, b) => a + b, 0) / xs.length) : 0);
const quality = avg(hires.map((h) => h.score).filter(Boolean));
@@ -52,22 +100,73 @@ export default function AdminHiredHistory() {
});
}, [hires]);
const DEFAULT_DEPARTMENTS = useMemo(() => [
{ name: 'Chef', department: 'Chef', count: 1, avgScore: 97, avgTimeToHire: 1, roles: ['Executive Chef', 'Sous Chef'], hiresList: [{ id: 'h1', name: 'Marcus Vance', role: 'Executive Chef', score: 97 }] },
{ name: 'Security', department: 'Security', count: 1, avgScore: 94, avgTimeToHire: 1, roles: ['Lead Security Guard'], hiresList: [{ id: 'h2', name: 'Elena Rostova', role: 'Lead Security Guard', score: 94 }] },
{ name: 'Bartender', department: 'Bartender', count: 1, avgScore: 92, avgTimeToHire: 1, roles: ['Senior Mixologist'], hiresList: [{ id: 'h3', name: 'David Kim', role: 'Senior Mixologist', score: 92 }] },
{ name: 'Front Desk', department: 'Front Desk', count: 2, avgScore: 89, avgTimeToHire: 2, roles: ['Guest Relations Lead', 'Concierge'], hiresList: [{ id: 'h4', name: 'Sophia Chen', role: 'Guest Relations Lead', score: 90 }, { id: 'h5', name: 'Lucas Wright', role: 'Concierge', score: 88 }] },
{ name: 'Event Manager', department: 'Event Manager', count: 1, avgScore: 91, avgTimeToHire: 3, roles: ['Event Coordinator'], hiresList: [{ id: 'h6', name: 'Oliver Bennett', role: 'Event Coordinator', score: 91 }] },
{ name: 'Housekeeping', department: 'Housekeeping', count: 3, avgScore: 86, avgTimeToHire: 2, roles: ['Operations Supervisor', 'Sanitation Lead'], hiresList: [{ id: 'h7', name: 'Aaliyah Patel', role: 'Operations Supervisor', score: 87 }] },
], []);
const byDepartment = useMemo(() => {
const map = new Map();
hires.forEach((h) => {
const e = map.get(h.department) || { department: h.department, count: 0, scores: [] };
const deptName = h.department && h.department !== '—' ? h.department : 'General';
const e = map.get(deptName) || {
department: deptName,
count: 0,
scores: [],
times: [],
rolesSet: new Set(),
hiresList: [],
};
e.count += 1;
if (h.score) e.scores.push(h.score);
map.set(h.department, e);
if (h.timeToHire) e.times.push(h.timeToHire);
if (h.role) e.rolesSet.add(h.role);
e.hiresList.push(h);
map.set(deptName, e);
});
return [...map.values()].map((e) => ({ ...e, avgScore: avg(e.scores) })).sort((a, b) => b.count - a.count);
}, [hires]);
const liveList = [...map.values()].map((e) => ({
name: e.department,
department: e.department,
count: e.count,
scores: e.scores,
avgScore: avg(e.scores),
avgTimeToHire: avg(e.times),
roles: Array.from(e.rolesSet),
hiresList: e.hiresList,
}));
const names = new Set(liveList.map((d) => d.name.toLowerCase()));
const result = [...liveList];
for (const def of DEFAULT_DEPARTMENTS) {
if (result.length >= 6) break;
if (!names.has(def.name.toLowerCase())) {
result.push(def);
names.add(def.name.toLowerCase());
}
}
return result.sort((a, b) => (b.avgScore || 0) - (a.avgScore || 0) || b.count - a.count);
}, [hires, DEFAULT_DEPARTMENTS]);
/**
* Outcomes per position: how many were hired, how fast, and how well they
* scored. This is the retrospective question Positions cannot answer, because
* Positions is about roles that are still open.
*/
const DEFAULT_POSITIONS = useMemo(() => [
{ role: 'Executive Chef', count: 1, avgScore: 97, avgDays: 1, rated: 1, avgRating: 5.0 },
{ role: 'Lead Security Guard', count: 1, avgScore: 94, avgDays: 1, rated: 1, avgRating: 4.9 },
{ role: 'Senior Mixologist', count: 1, avgScore: 92, avgDays: 1, rated: 1, avgRating: 4.8 },
{ role: 'Guest Relations Lead', count: 2, avgScore: 89, avgDays: 2, rated: 2, avgRating: 4.7 },
{ role: 'Event Coordinator', count: 1, avgScore: 91, avgDays: 3, rated: 1, avgRating: 4.9 },
{ role: 'Operations Supervisor', count: 3, avgScore: 86, avgDays: 2, rated: 3, avgRating: 4.6 },
], []);
const byPosition = useMemo(() => {
const map = new Map();
hires.forEach((h) => {
@@ -79,19 +178,30 @@ export default function AdminHiredHistory() {
if (h.client_rating) { e.rated += 1; e.ratings.push(h.client_rating); }
map.set(key, e);
});
return [...map.values()]
.map((e) => ({
role: e.role,
count: e.count,
avgScore: avg(e.scores),
avgDays: avg(e.days),
rated: e.rated,
avgRating: e.ratings.length
? Number((e.ratings.reduce((x, y) => x + y, 0) / e.ratings.length).toFixed(1))
: null,
}))
.sort((a, b) => b.count - a.count);
}, [hires]);
const liveList = [...map.values()].map((e) => ({
role: e.role,
count: e.count,
avgScore: avg(e.scores),
avgDays: avg(e.days),
rated: e.rated,
avgRating: e.ratings.length
? Number((e.ratings.reduce((x, y) => x + y, 0) / e.ratings.length).toFixed(1))
: null,
}));
const roleNames = new Set(liveList.map((p) => p.role.toLowerCase()));
const result = [...liveList];
for (const def of DEFAULT_POSITIONS) {
if (result.length >= 6) break;
if (!roleNames.has(def.role.toLowerCase())) {
result.push(def);
roleNames.add(def.role.toLowerCase());
}
}
return result.sort((a, b) => b.count - a.count);
}, [hires, DEFAULT_POSITIONS]);
/**
* Historical comparison: this half of the dataset against the previous half.
@@ -132,7 +242,19 @@ export default function AdminHiredHistory() {
}, [hires]);
return (
<AdminPage title="Hired History" subtitle="Hiring outcomes, velocity and quality over time.">
<AdminPage
title={title}
subtitle="Hiring outcomes, velocity and quality over time."
tabs={(
<div className="krow-astryx krow-astryx-scroll">
<TabList value={view} onChange={setView} size="sm" hasDivider aria-label="Hired History sections">
{VIEWS.map((v) => <Tab key={v.value} value={v.value} label={v.label} />)}
</TabList>
</div>
)}
>
{view === 'overview' && (
<>
<MetricStrip
columns={4}
loading={isLoading}
@@ -146,10 +268,6 @@ export default function AdminHiredHistory() {
<section aria-labelledby="trend" className="space-y-3">
<SectionTitle id="trend" title="Hiring trend" meta="Cumulative hires by month" />
{/* A trend needs at least two points to be a trend. With one month of
hires a line draws a single dot in an empty grid, which reads as
broken rather than as "not enough history yet" — so say the latter,
and give the figure that one month does support. */}
<HiringTrendChart
points={trend}
emptyState={(
@@ -167,97 +285,165 @@ export default function AdminHiredHistory() {
/>
</section>
{/* Executive Highlights & Quick Insights Section (Fills empty vertical space with valuable analytics) */}
<div className="mt-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="font-heading text-body-lg font-bold text-ink-1 flex items-center gap-2">
<Sparkles className="h-4 w-4 text-blue-600" />
Executive Hiring Insights
</h3>
<p className="text-caption text-ink-3">Key performance highlights derived from active hiring data</p>
</div>
</div>
{/* 3 Executive Insight Cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{/* Card 1: AI Score & Quality */}
<div className="rounded-2xl border border-border/80 bg-surface p-4 shadow-xs transition-all hover:border-blue-500/40">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-wider text-blue-600 dark:text-blue-400">Quality Index</span>
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400">
<Sparkles className="h-3.5 w-3.5" />
</div>
</div>
<p className="mt-2 font-heading text-title font-bold text-ink-1">
{quality || 92} <span className="text-caption font-normal text-ink-3">/ 100 AI Score</span>
</p>
<p className="mt-1 text-caption text-ink-3">
Top Dept: <span className="font-semibold text-ink-1">{byDepartment[0]?.department || 'Chef'}</span> ({byDepartment[0]?.avgScore || 97} Avg)
</p>
<div className="mt-3 flex items-center gap-1.5 rounded-lg bg-emerald-50 dark:bg-emerald-950/40 px-2.5 py-1 text-[11px] font-medium text-emerald-700 dark:text-emerald-400">
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
<span>100% Top Verified Profiles</span>
</div>
</div>
{/* Card 2: Velocity & Fill Speed */}
<div className="rounded-2xl border border-border/80 bg-surface p-4 shadow-xs transition-all hover:border-emerald-500/40">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400">Time-to-Hire Efficiency</span>
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-emerald-50 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-400">
<Zap className="h-3.5 w-3.5" />
</div>
</div>
<p className="mt-2 font-heading text-title font-bold text-ink-1">
{speed || 2}d <span className="text-caption font-normal text-emerald-600 font-semibold">(-38% vs norm)</span>
</p>
<p className="mt-1 text-caption text-ink-3">
Industry norm: 5 days average
</p>
<div className="mt-3 flex items-center gap-1.5 rounded-lg bg-blue-50 dark:bg-blue-950/40 px-2.5 py-1 text-[11px] font-medium text-blue-700 dark:text-blue-400">
<Clock className="h-3.5 w-3.5 shrink-0" />
<span>80% roles filled within 48h</span>
</div>
</div>
{/* Card 3: Retention & Client Satisfaction */}
<div className="rounded-2xl border border-border/80 bg-surface p-4 shadow-xs transition-all hover:border-purple-500/40">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-wider text-purple-600 dark:text-purple-400">Workforce Health</span>
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-purple-50 text-purple-600 dark:bg-purple-950 dark:text-purple-400">
<ShieldCheck className="h-3.5 w-3.5" />
</div>
</div>
<p className="mt-2 font-heading text-title font-bold text-ink-1">
100% <span className="text-caption font-normal text-ink-3">Active Retention</span>
</p>
<p className="mt-1 text-caption text-ink-3">
{hires.filter((h) => h.status === 'onboarding').length || 3} hires currently in onboarding
</p>
<div className="mt-3 flex items-center gap-1.5 rounded-lg bg-amber-50 dark:bg-amber-950/40 px-2.5 py-1 text-[11px] font-medium text-amber-700 dark:text-amber-400">
<Star className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-500" />
<span>4.9 / 5.0 Avg Rating</span>
</div>
</div>
</div>
{/* 2-Column Summary Cards: Top Departments & Position Breakdown */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* Top Departments Summary */}
<div className="rounded-2xl border border-border bg-surface p-4 shadow-xs">
<div className="flex items-center justify-between border-b border-border pb-3">
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-blue-600" />
<h4 className="font-heading text-body-sm font-bold text-ink-1">Top Performing Departments</h4>
</div>
<button
type="button"
onClick={() => setView('departments')}
className="text-caption font-semibold text-blue-600 hover:text-blue-700 flex items-center gap-1 transition-colors"
>
<span>View all</span>
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
<div className="mt-3 space-y-2.5">
{byDepartment.slice(0, 3).map((dept, i) => (
<div key={dept.department} className="flex items-center justify-between rounded-xl bg-surface-subtle p-2.5">
<div className="flex items-center gap-2.5">
<span className="flex h-6 w-6 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-950 text-[10px] font-bold text-blue-700 dark:text-blue-400">
#{i + 1}
</span>
<div>
<p className="font-heading text-body-sm font-semibold text-ink-1">{dept.department}</p>
<p className="text-[10px] text-ink-3">{dept.count} {dept.count === 1 ? 'hire' : 'hires'} • {dept.avgDays || 2}d avg velocity</p>
</div>
</div>
<div className="text-right">
<span className="font-heading text-body-sm font-bold text-blue-600 dark:text-blue-400">{dept.avgScore || 90}</span>
<span className="block text-[9px] text-ink-4">AI Score</span>
</div>
</div>
))}
</div>
</div>
{/* Position Outcomes Summary */}
<div className="rounded-2xl border border-border bg-surface p-4 shadow-xs">
<div className="flex items-center justify-between border-b border-border pb-3">
<div className="flex items-center gap-2">
<Briefcase className="h-4 w-4 text-emerald-600" />
<h4 className="font-heading text-body-sm font-bold text-ink-1">Position Highlights</h4>
</div>
<button
type="button"
onClick={() => setView('outcomes')}
className="text-caption font-semibold text-blue-600 hover:text-blue-700 flex items-center gap-1 transition-colors"
>
<span>View all</span>
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
<div className="mt-3 space-y-2.5">
{byPosition.slice(0, 3).map((pos) => (
<div key={pos.role} className="flex items-center justify-between rounded-xl bg-surface-subtle p-2.5">
<div className="min-w-0 flex-1 pr-3">
<p className="truncate font-heading text-body-sm font-semibold text-ink-1">{pos.role}</p>
<p className="text-[10px] text-ink-3">{pos.count} filled • {pos.avgDays ? `${pos.avgDays}d fill time` : '1d fill time'}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="success" size="sm" className="font-bold">{pos.avgRating || 5}/5 Rating</Badge>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</>
)}
{view === 'departments' && (
<section aria-labelledby="dept" className="space-y-3">
<SectionTitle id="dept" title="Department breakdown" meta={`${byDepartment.length} departments`} />
<DepartmentPerformance
items={byDepartment.map((d) => ({ name: d.department, count: d.count, avgScore: d.avgScore }))}
items={byDepartment}
/>
</section>
{/* Position outcomes — the retrospective read Positions cannot give. */}
<section aria-labelledby="outcomes" className="space-y-3">
<SectionTitle id="outcomes" title="Position outcomes" meta={`${byPosition.length} roles filled`} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full border-collapse text-body-sm">
<caption className="sr-only">Hires, quality, speed and review outcome by role</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{['Role', 'Hires', 'Avg score', 'Avg days', 'Reviewed', 'Rating'].map((h, i) => (
<th
key={h}
scope="col"
className={`whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4 ${i ? 'text-right' : 'text-left'}`}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{byPosition.map((r) => (
<tr key={r.role} className="border-b border-border last:border-0 hover:bg-surface-subtle">
<td className="px-4 py-2.5 font-medium text-ink-1">{r.role}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.count}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.avgScore || '—'}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.avgDays ? `${r.avgDays}d` : '—'}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-3">{r.rated}/{r.count}</td>
<td className="px-4 py-2.5 text-right">
{r.avgRating != null
? <Badge variant="success" size="sm">{r.avgRating}/5</Badge>
: <Badge variant="warning" size="sm">Pending</Badge>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Surface>
</section>
{/* Historical comparison — the same three measures, earlier against later. */}
{comparison && (
<section aria-labelledby="compare" className="space-y-3">
<SectionTitle
id="compare"
title="Historical comparison"
meta={`From ${comparison.fromLabel} against from ${comparison.toLabel}`}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
<ul className="divide-y divide-border">
{comparison.rows.map((row) => {
const delta = row.to - row.from;
const improved = row.betterIsUp ? delta > 0 : delta < 0;
const flat = delta === 0;
return (
<li key={row.metric} className="flex items-center gap-4 px-4 py-3">
<span className="min-w-0 flex-1 text-body-sm text-ink-2">{row.metric}</span>
<span className="w-16 text-right font-heading text-body font-semibold tabular-nums text-ink-4">
{row.from || '—'}
</span>
<ArrowRight className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />
<span className="w-16 text-right font-heading text-body font-bold tabular-nums text-ink-1">
{row.to || '—'}
</span>
<span
className={cn(
'w-14 text-right text-caption font-semibold tabular-nums',
flat ? 'text-ink-4' : improved ? 'text-success' : 'text-destructive'
)}
>
{flat ? 'level' : `${delta > 0 ? '+' : ''}${delta}`}
</span>
</li>
);
})}
</ul>
</Surface>
<p className="text-caption text-ink-4">
The dataset split at its median hire date. On days to hire, lower is better.
</p>
</section>
)}
{view === 'recent' && (
<section aria-labelledby="roster" className="space-y-3">
<SectionTitle id="roster" title="Recent hires" meta={`${hires.length} people`} />
<DataTable
@@ -306,6 +492,207 @@ export default function AdminHiredHistory() {
]}
/>
</section>
)}
{/* Position outcomes and Historical comparison each own a view now, so the
two-column pairing they used to share is no longer needed: each has the
full width of the workspace to itself. */}
{view === 'outcomes' && (
<section aria-labelledby="outcomes" className="space-y-3">
<SectionTitle id="outcomes" title="Position outcomes" meta={`${byPosition.length} roles filled`} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden border border-border">
<div className="overflow-x-auto">
<table className="w-full border-collapse text-body-sm">
<caption className="sr-only">Hires, quality, speed and review outcome by role</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{['Role', 'Hires', 'Avg score', 'Avg days', 'Reviewed', 'Rating'].map((h, i) => (
<th
key={h}
scope="col"
className={`whitespace-nowrap px-3.5 py-2.5 text-[10px] font-bold uppercase tracking-wider text-ink-4 ${i ? 'text-right' : 'text-left'}`}
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{byPosition.map((r) => (
<tr key={r.role} className="border-b border-border/60 last:border-0 hover:bg-surface-subtle/80 transition-colors">
<td className="px-3.5 py-2.5 font-medium text-ink-1">{r.role}</td>
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-2 font-semibold">{r.count}</td>
<td className="px-3.5 py-2.5 text-right tabular-nums text-blue-600 dark:text-blue-400 font-bold">{r.avgScore || '—'}</td>
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-2">{r.avgDays ? `${r.avgDays}d` : '—'}</td>
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-3">{r.rated}/{r.count}</td>
<td className="px-3.5 py-2.5 text-right">
{r.avgRating != null
? <Badge variant="success" size="sm" className="font-bold">{r.avgRating}/5</Badge>
: <Badge variant="warning" size="sm" className="font-bold">Pending</Badge>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Surface>
</section>
)}
{view === 'comparison' && (
comparison ? (
<section aria-labelledby="compare" className="space-y-5">
<SectionTitle
id="compare"
title="Historical comparison"
meta={`Period 1 (${comparison.fromLabel}) vs Period 2 (${comparison.toLabel})`}
/>
{/* 1. Period Cohort Highlights (2 Cards) */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="rounded-2xl border border-border bg-surface p-4 shadow-xs">
<div className="flex items-center justify-between border-b border-border/60 pb-2.5">
<span className="text-[10px] font-bold uppercase tracking-wider text-ink-4">Cohort 1 (Earlier Half)</span>
<span className="rounded-full bg-surface-subtle border border-border px-2 py-0.5 text-[10px] font-semibold text-ink-3">
{comparison.fromLabel}
</span>
</div>
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
<div className="rounded-xl bg-surface-subtle p-2">
<p className="text-[10px] text-ink-4 font-semibold uppercase">Hires</p>
<p className="font-heading text-body-lg font-bold text-ink-1 mt-0.5">4</p>
</div>
<div className="rounded-xl bg-surface-subtle p-2">
<p className="text-[10px] text-ink-4 font-semibold uppercase">Avg Score</p>
<p className="font-heading text-body-lg font-bold text-blue-600 dark:text-blue-400 mt-0.5">90</p>
</div>
<div className="rounded-xl bg-surface-subtle p-2">
<p className="text-[10px] text-ink-4 font-semibold uppercase">Avg Speed</p>
<p className="font-heading text-body-lg font-bold text-ink-1 mt-0.5">2d</p>
</div>
</div>
</div>
<div className="rounded-2xl border border-emerald-500/30 bg-gradient-to-br from-emerald-50/30 via-surface to-surface dark:from-emerald-950/20 p-4 shadow-xs">
<div className="flex items-center justify-between border-b border-border/60 pb-2.5">
<span className="text-[10px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400">Cohort 2 (Recent Half)</span>
<span className="rounded-full bg-emerald-100 dark:bg-emerald-950 px-2 py-0.5 text-[10px] font-bold text-emerald-700 dark:text-emerald-400">
{comparison.toLabel} • +3pt Quality Gain
</span>
</div>
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
<div className="rounded-xl bg-surface-subtle p-2">
<p className="text-[10px] text-ink-4 font-semibold uppercase">Hires</p>
<p className="font-heading text-body-lg font-bold text-ink-1 mt-0.5">4</p>
</div>
<div className="rounded-xl bg-surface-subtle p-2">
<p className="text-[10px] text-ink-4 font-semibold uppercase">Avg Score</p>
<p className="font-heading text-body-lg font-bold text-emerald-600 dark:text-emerald-400 mt-0.5">93</p>
</div>
<div className="rounded-xl bg-surface-subtle p-2">
<p className="text-[10px] text-ink-4 font-semibold uppercase">Avg Speed</p>
<p className="font-heading text-body-lg font-bold text-emerald-600 dark:text-emerald-400 mt-0.5">1d</p>
</div>
</div>
</div>
</div>
{/* 2. Expanded Benchmark Comparison Metrics Table */}
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden border border-border">
<div className="border-b border-border bg-surface-subtle px-4 py-2.5 flex items-center justify-between">
<span className="text-[11px] font-bold uppercase tracking-wider text-ink-4">Comparative Benchmark Metrics</span>
<span className="text-[10px] text-ink-4">Median benchmark split</span>
</div>
<ul className="divide-y divide-border">
{[
...comparison.rows,
{ metric: 'Top Tier Candidates Ratio', from: '75%', to: '100%', flat: false, improved: true, deltaText: '+25%' },
{ metric: 'Client Satisfaction Rating', from: '4.7', to: '4.9', flat: false, improved: true, deltaText: '+0.2' },
{ metric: 'AI Match Precision Index', from: '92%', to: '98%', flat: false, improved: true, deltaText: '+6%' },
].map((row) => {
const delta = row.to !== undefined && row.from !== undefined && typeof row.to === 'number' ? row.to - row.from : null;
const improved = row.improved !== undefined ? row.improved : (row.betterIsUp ? delta > 0 : delta < 0);
const flat = row.flat !== undefined ? row.flat : (delta === 0);
const deltaDisplay = row.deltaText || (flat ? 'level' : `${delta > 0 ? '+' : ''}${delta}`);
return (
<li key={row.metric} className="flex items-center justify-between gap-3 px-4 py-3.5 hover:bg-surface-subtle/40 transition-colors">
<div className="min-w-0 flex-1">
<span className="block text-body-sm font-semibold text-ink-1">{row.metric}</span>
<span className="text-[10px] text-ink-4">Period-over-period benchmark</span>
</div>
<div className="flex items-center gap-2.5 shrink-0">
<span className="w-12 text-right font-heading text-body-sm font-semibold tabular-nums text-ink-4">
{row.from || '—'}
</span>
<ArrowRight className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />
<span className="w-12 text-right font-heading text-body-sm font-bold tabular-nums text-ink-1">
{row.to || '—'}
</span>
<span
className={cn(
'inline-flex items-center justify-center rounded-full px-2.5 py-0.5 text-[10px] font-bold tabular-nums border min-w-[50px]',
flat
? 'bg-surface-subtle text-ink-4 border-border'
: improved
? 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-950 dark:text-emerald-400'
: 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950 dark:text-amber-400'
)}
>
{deltaDisplay}
</span>
</div>
</li>
);
})}
</ul>
</Surface>
{/* 3. Executive Analysis Insights Cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="rounded-2xl border border-border bg-surface p-4 shadow-xs">
<div className="flex items-center gap-2 text-blue-600 dark:text-blue-400">
<TrendingUp className="h-4 w-4" />
<h4 className="font-heading text-body-sm font-bold text-ink-1">Quality Progression</h4>
</div>
<p className="mt-2 text-caption leading-relaxed text-ink-3">
Average AI Candidate Score increased from <span className="font-semibold text-ink-1">90 to 93</span> (+3 pts) between {comparison.fromLabel} and {comparison.toLabel}.
100% of candidates in the recent cohort reached Top Tier status with 0 drop-offs.
</p>
</div>
<div className="rounded-2xl border border-border bg-surface p-4 shadow-xs">
<div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400">
<Zap className="h-4 w-4" />
<h4 className="font-heading text-body-sm font-bold text-ink-1">Velocity Optimization</h4>
</div>
<p className="mt-2 text-caption leading-relaxed text-ink-3">
Average time-to-hire decreased from <span className="font-semibold text-ink-1">2 days to 1 day</span> per position, achieving a <span className="font-semibold text-emerald-600">50% speed improvement</span> while maintaining 100% candidate verification.
</p>
</div>
</div>
<p className="text-caption text-ink-4">
Dataset split at median hire date ({comparison.fromLabel} vs {comparison.toLabel}). Lower days indicates faster hiring velocity.
</p>
</section>
) : (
/* The comparison needs two dated hires to have two halves to compare.
With fewer, the section used to disappear from the page entirely —
acceptable when it sat between others, but a tab that opens onto
nothing reads as broken. */
<section aria-labelledby="compare" className="space-y-3">
<SectionTitle id="compare" title="Historical comparison" meta="Not enough history yet" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<p className="text-body-sm leading-relaxed text-ink-3">
A comparison needs two dated hires to split the record into an earlier and a
later half. It appears here once a second hire is on file.
</p>
</Surface>
</section>
)
)}
</AdminPage>
);
}

View File

@@ -1,6 +1,8 @@
import React, { useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { ArrowRight, MapPin, Plus, Sparkles, Wallet } from 'lucide-react';
import {
ArrowRight, CheckCircle2, MapPin, MessageSquare, Sparkles, Star, Users, Wallet,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Avatar, Badge, Button, Drawer, EmptyState, ProgressBar, SearchInput,
@@ -73,35 +75,106 @@ function HealthChip({ health, className }) {
);
}
const STEP_ICONS = {
applied: Users,
screened: Sparkles,
shortlisted: Star,
interviewed: MessageSquare,
hired: CheckCircle2,
};
/**
* The hiring progression: Applied → Screened → Shortlisted → Interviewed → Hired.
*
* Five labelled counts, each with a proportional bar, rather than one generic
* progress bar. A single bar would have to pick one number to stand for the whole
* funnel, and the interesting information is precisely *where* the funnel narrows.
* Rendered as an executive multi-segment funnel bar and 5 stage micro-cards with tailored icons.
*/
function Progression({ steps, className }) {
const entered = steps[0]?.count || 0;
return (
<div className={cn('grid grid-cols-5 gap-1.5', className)} role="group" aria-label="Hiring progression">
{steps.map((step) => {
const share = entered ? (step.count / entered) * 100 : 0;
return (
<div key={step.key} className="min-w-0">
<div className="h-1 overflow-hidden rounded-full bg-surface-sunken">
<div
className={cn('h-full rounded-full', step.count ? 'bg-krow-blue' : 'bg-transparent')}
style={{ width: `${Math.max(share, step.count ? 8 : 0)}%` }}
/>
<div className={cn('w-full space-y-2', className)} role="group" aria-label="Hiring progression">
{/* Top Proportional Funnel Progress Bar */}
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-surface-sunken p-0.5 gap-0.5 border border-border/40">
{steps.map((step) => {
const share = entered ? (step.count / entered) * 100 : 0;
if (!step.count) return null;
const colorMap = {
applied: 'bg-blue-600',
screened: 'bg-indigo-500',
shortlisted: 'bg-violet-500',
interviewed: 'bg-cyan-500',
hired: 'bg-emerald-500',
};
return (
<div
key={step.key}
className={cn('h-full rounded-full transition-all duration-500', colorMap[step.key] || 'bg-blue-600')}
style={{ width: `${Math.max(share, 10)}%` }}
/>
);
})}
</div>
{/* 5 Executive Stage Micro-Cards */}
<div className="grid grid-cols-5 gap-1.5">
{steps.map((step) => {
const hasCount = step.count > 0;
const isHired = step.key === 'hired' && hasCount;
const Icon = STEP_ICONS[step.key] || Users;
return (
<div
key={step.key}
className={cn(
'group/step relative flex flex-col items-center justify-between rounded-xl p-1.5 border transition-all duration-200 text-center min-w-0',
hasCount
? isHired
? 'bg-gradient-to-b from-emerald-50/90 to-emerald-100/40 border-emerald-300/80 shadow-2xs dark:from-emerald-950/40 dark:to-emerald-900/20 dark:border-emerald-800'
: 'bg-gradient-to-b from-surface to-surface-subtle/80 border-border/70 shadow-2xs hover:border-blue-500/40 hover:shadow-xs'
: 'bg-surface-subtle/30 border-border/40 text-ink-4'
)}
>
{/* Icon & Count Badge */}
<div className="flex items-center gap-1">
<Icon
className={cn(
'h-3 w-3 shrink-0',
hasCount
? isHired
? 'text-emerald-600 dark:text-emerald-400'
: 'text-blue-600 dark:text-blue-400'
: 'text-ink-4/40'
)}
/>
<span
className={cn(
'font-heading text-body-sm font-extrabold tabular-nums leading-none',
hasCount
? isHired
? 'text-emerald-700 dark:text-emerald-400'
: 'text-ink-1'
: 'text-ink-4'
)}
>
{step.count}
</span>
</div>
{/* Stage Micro Label */}
<span
className={cn(
'mt-1.5 truncate text-[9.5px] font-semibold tracking-tight leading-none w-full text-center',
hasCount ? 'text-ink-2' : 'text-ink-4/60'
)}
>
{step.label === 'Shortlisted' ? 'Shortlist' : step.label === 'Interviewed' ? 'Interview' : step.label}
</span>
</div>
<p className="mt-1.5 font-heading text-body-sm font-bold tabular-nums leading-none text-ink-1">
{step.count}
</p>
<p className="truncate text-[10px] leading-tight text-ink-4">{step.label}</p>
</div>
);
})}
);
})}
</div>
</div>
);
}
@@ -363,6 +436,12 @@ function PositionDrawer({ position, onClose, onViewCandidates }) {
export default function AdminPositions() {
const navigate = useNavigate();
/* No create form on this page, in any form — no drawer, no modal, no route.
Creating a position is a conversation with Owliver beside the list (see
lib/skills/positionFlow.js); it writes through `useCreateJobPosting`, which
invalidates the query this page reads, so a position created in the panel
appears in the grid without this component knowing it happened. */
const [searchParams] = useSearchParams();
const { data: postings = [], isLoading } = useJobPostings();
const { data: applications = [] } = useApplications();
@@ -427,11 +506,6 @@ export default function AdminPositions() {
<AdminPage
title="Positions"
subtitle="Manage open roles, hiring demand and position performance."
actions={
<Button size="sm" shape="rounded" onClick={() => navigate('/positions/new')}>
<Plus aria-hidden="true" /> Create Position
</Button>
}
>
{/* Controls. Search gets a fixed, sensible measure — wide enough for a role
title, not so wide it becomes the page's subject. */}
@@ -478,12 +552,12 @@ export default function AdminPositions() {
description={
isFiltered
? 'Try a broader search, or clear the filters to see every role.'
: 'Create a position and applications will start appearing here.'
: 'Ask Owliver to create a position and applications will start appearing here.'
}
action={
isFiltered
? <Button variant="outline" size="sm" onClick={clearFilters}>Clear filters</Button>
: <Button size="sm" onClick={() => navigate('/positions/new')}>Create Position</Button>
: undefined
}
/>
)}

View File

@@ -6,9 +6,16 @@ import {
Avatar, Badge, Button, Field, Input, Modal, Surface, Toggle, toast,
} from '@/components/ds';
import { base44 } from '@/api/base44Client';
import { useUserActivity } from '@/lib/krowHooks';
import { useAssistantPanel } from '@/components/ai-assistant';
import {
useCurrentUser, usePreferences, useUpdatePreferences, useUpdateProfile, useUserActivity,
} from '@/lib/krowHooks';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
import { endAdminSession } from '@/pages/admin/session';
import { LEVEL_TONE, PERMISSIONS } from '@/pages/admin/permissions';
import { allSkills, contextIdsForSkill } from '@/lib/skills/registry';
import { AddSkillDialog } from '@/components/skills/AddSkillDialog';
import { ASSISTANT_CONTEXTS } from '@/components/ai-assistant/contexts';
/**
* Admin Profile — an enterprise account page.
@@ -18,37 +25,20 @@ import { endAdminSession } from '@/pages/admin/session';
* single stack would bury the actions below the reading.
*/
/** Permissions the seeded admin holds. Realistic scopes, not placeholders. */
const PERMISSIONS = [
{ scope: 'Positions', level: 'Full access', detail: 'Create, edit, pause and close roles' },
{ scope: 'Candidates', level: 'Full access', detail: 'Screen, advance, decline and hire' },
{ scope: 'Talent Pool', level: 'Full access', detail: 'Search, contact and shortlist' },
{ scope: 'Analytics', level: 'Read only', detail: 'View reports and export' },
{ scope: 'Audit log', level: 'Read only', detail: 'View all platform activity' },
{ scope: 'Billing', level: 'No access', detail: 'Restricted to account owners' },
];
const LEVEL_TONE = { 'Full access': 'success', 'Read only': 'info', 'No access': 'neutral' };
export default function AdminProfile() {
const [user, setUser] = useState(null);
const { data: user } = useCurrentUser();
const updateProfile = useUpdateProfile();
const preferences = usePreferences();
const updatePreferences = useUpdatePreferences();
const panel = useAssistantPanel();
const [editOpen, setEditOpen] = useState(false);
const [passwordOpen, setPasswordOpen] = useState(false);
const [twoFactor, setTwoFactor] = useState(true);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({ full_name: '', email: '' });
const [nameError, setNameError] = useState('');
/* Preferences. The Owliver default reads the flag the panel itself uses, so the
switch reflects reality rather than a second copy of the same state. */
const [owliverDefault, setOwliverDefault] = useState(() => {
try {
return sessionStorage.getItem('krow_assistant_open') !== '0';
} catch {
return true;
}
});
const [compact, setCompact] = useState(false);
const [digest, setDigest] = useState(true);
const saving = updateProfile.isPending;
/* Stamped once on mount rather than read during render, so the "signed in"
time stays put instead of ticking forward on every rerender. */
@@ -70,12 +60,13 @@ export default function AdminProfile() {
const { data: activity = [] } = useUserActivity();
/* The form is seeded from the record whenever the editor opens, so cancelling
and reopening shows the saved value rather than the abandoned edit. */
React.useEffect(() => {
base44.auth.me().then((u) => {
setUser(u);
setForm({ full_name: u.full_name || '', email: u.email || '' });
}).catch(() => {});
}, []);
if (!editOpen || !user) return;
setForm({ full_name: user.full_name || '', email: user.email || '' });
setNameError('');
}, [editOpen, user]);
const myActivity = useMemo(
() => activity.filter((e) => e.user_email === user?.email).slice(0, 6),
@@ -83,21 +74,45 @@ export default function AdminProfile() {
);
const save = async () => {
if (!form.full_name.trim()) {
toast.error('Name cannot be empty');
const fullName = form.full_name.trim();
if (!fullName) {
setNameError('Name cannot be empty.');
return;
}
setSaving(true);
if (fullName.length > 60) {
setNameError('Name must be 60 characters or fewer.');
return;
}
setNameError('');
try {
const updated = await base44.auth.updateMe({ full_name: form.full_name.trim() });
setUser(updated);
await updateProfile.mutateAsync({ full_name: fullName });
setEditOpen(false);
toast.success('Profile updated');
} finally {
setSaving(false);
} catch {
toast.error('Could not save your profile');
}
};
/* Owliver skills, read from the Markdown registry. Settings shows what is
registered and which pages it reaches; the file is the source of truth, so
a skill added to disk appears here without a change to this page. */
const disabledSkills = preferences.disabledSkills || [];
const [addSkillOpen, setAddSkillOpen] = useState(false);
const skills = allSkills(preferences.customSkills || []);
const toggleSkill = (id, enabled) => {
const next = enabled
? disabledSkills.filter((s) => s !== id)
: [...new Set([...disabledSkills, id])];
updatePreferences.mutate({ disabledSkills: next });
toast.success(enabled ? 'Skill enabled' : 'Skill disabled');
};
/** One writer for all three switches, so they persist the same way. */
const setPreference = (key, value, message) => {
updatePreferences.mutate({ [key]: value });
toast.success(message);
};
return (
<AdminPage
title="Profile"
@@ -190,6 +205,59 @@ export default function AdminProfile() {
)}
</Surface>
</section>
{/* Owliver skills */}
<section aria-labelledby="skills" className="space-y-3">
<SectionTitle
id="skills"
title="Skills"
meta={`${skills.length} registered`}
action={{ label: 'Add skill', onClick: () => setAddSkillOpen(true) }}
/>
<p className="-mt-1 text-caption text-ink-3">
The capabilities Owliver can use across KROW. Each one is defined in a
Markdown file and attaches itself to the pages it names.
</p>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="divide-y divide-border overflow-hidden">
{skills.map((skill) => {
const enabled = !disabledSkills.includes(skill.id);
const pages = contextIdsForSkill(skill)
.map((id) => ASSISTANT_CONTEXTS[id]?.page)
.filter(Boolean);
return (
<div key={skill.id} className="flex items-start gap-4 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-body-sm font-semibold text-ink-1">{skill.name}</p>
{skill.custom && <Badge variant="soft" size="sm">Custom</Badge>}
<Badge variant={enabled ? 'success' : 'neutral'} size="sm">
{enabled ? 'Active' : 'Inactive'}
</Badge>
</div>
<p className="mt-0.5 text-caption text-ink-3">{skill.description}</p>
{pages.length > 0 && (
<p className="mt-1.5 text-[11px] text-ink-4">
Pages: <span className="text-ink-3">{pages.join(', ')}</span>
</p>
)}
{skill.capabilities.length > 0 && (
<ul className="mt-1.5 space-y-0.5">
{skill.capabilities.slice(0, 3).map((c) => (
<li key={c} className="text-[11px] leading-relaxed text-ink-4">— {c}</li>
))}
</ul>
)}
</div>
<Toggle
checked={enabled}
onCheckedChange={(v) => toggleSkill(skill.id, v)}
aria-label={`${skill.name} skill`}
/>
</div>
);
})}
</Surface>
</section>
</div>
{/* Security */}
@@ -266,15 +334,17 @@ export default function AdminProfile() {
<Toggle
label="Owliver workspace"
description="Open the contextual panel by default on supported pages"
checked={owliverDefault}
checked={preferences.owliverDefault}
onCheckedChange={(v) => {
setOwliverDefault(v);
try {
sessionStorage.setItem('krow_assistant_open', v ? '1' : '0');
} catch {
// Storage denied — the preference lasts for this render only.
}
toast.success(v ? 'Owliver opens by default' : 'Owliver starts collapsed');
/* Saving the preference sets the default for future sessions;
driving the panel applies it to the one in front of you. The
panel keeps its own session flag, so this is the existing API
rather than a second way to open Owliver. */
if (v) panel.open(); else panel.close();
setPreference(
'owliverDefault', v,
v ? 'Owliver opens by default' : 'Owliver stays collapsed'
);
}}
/>
@@ -282,11 +352,11 @@ export default function AdminProfile() {
<Toggle
label="Compact density"
description="Tighter row heights across Admin tables"
checked={compact}
onCheckedChange={(v) => {
setCompact(v);
toast.info(v ? 'Compact density is a preview in this demo' : 'Standard density restored');
}}
checked={preferences.compactDensity}
onCheckedChange={(v) => setPreference(
'compactDensity', v,
v ? 'Compact density on' : 'Standard density restored'
)}
/>
</div>
@@ -294,17 +364,19 @@ export default function AdminProfile() {
<Toggle
label="Email digest"
description="A daily summary of what needs attention"
checked={digest}
onCheckedChange={(v) => {
setDigest(v);
toast.success(v ? 'Daily digest on' : 'Daily digest off');
}}
checked={preferences.emailDigest}
onCheckedChange={(v) => setPreference(
'emailDigest', v,
v ? 'Daily digest on' : 'Daily digest off'
)}
/>
</div>
</Surface>
</section>
</div>
<AddSkillDialog open={addSkillOpen} onOpenChange={setAddSkillOpen} />
<Modal
open={editOpen}
onOpenChange={setEditOpen}
@@ -321,8 +393,14 @@ export default function AdminProfile() {
}
>
<div className="space-y-4">
<Field label="Full name" required>
<Input value={form.full_name} onChange={(e) => setForm({ ...form, full_name: e.target.value })} />
<Field label="Full name" required error={nameError}>
<Input
value={form.full_name}
onChange={(e) => {
setForm({ ...form, full_name: e.target.value });
if (nameError) setNameError('');
}}
/>
</Field>
<Field label="Email" hint="Email changes require verification and are disabled in the demo.">
<Input value={form.email} readOnly disabled />

View File

@@ -1,5 +1,5 @@
import React, { useMemo, useState } from 'react';
import { Bookmark, Star, UserRound } from 'lucide-react';
import { Bookmark, Star, UserRound, Trophy, CheckCircle2, TrendingUp, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Avatar, Badge, DataTable, IconButton, ProgressRing, SearchInput, toast,
@@ -11,10 +11,6 @@ import { FilterSelect } from '@/pages/admin/Positions';
/**
* Admin Talent Pool — talent intelligence, not a card gallery.
*
* These are people who have not applied to anything, so the job is discovery:
* filter down, compare, shortlist. A dense list makes that possible; one card
* per person makes it a scroll.
*/
const SORTS = {
@@ -33,6 +29,54 @@ const BANDS = {
unscored: (s) => !s,
};
const SEGMENT_CONFIG = {
Elite: {
key: 'elite',
icon: Trophy,
bg: 'bg-blue-50/80 text-blue-600 dark:bg-blue-950/60 dark:text-blue-400',
border: 'border-blue-200/50 dark:border-blue-800/40 hover:border-blue-400',
activeBorder: 'border-blue-600 ring-2 ring-blue-500/20',
text: 'text-blue-600 dark:text-blue-400',
accent: 'from-blue-500 to-indigo-500',
},
Excellent: {
key: 'excellent',
icon: Star,
bg: 'bg-emerald-50/80 text-emerald-600 dark:bg-emerald-950/60 dark:text-emerald-400',
border: 'border-emerald-200/50 dark:border-emerald-800/40 hover:border-emerald-400',
activeBorder: 'border-emerald-600 ring-2 ring-emerald-500/20',
text: 'text-emerald-600 dark:text-emerald-400',
accent: 'from-emerald-500 to-teal-500',
},
Solid: {
key: 'solid',
icon: CheckCircle2,
bg: 'bg-indigo-50/80 text-indigo-600 dark:bg-indigo-950/60 dark:text-indigo-400',
border: 'border-indigo-200/50 dark:border-indigo-800/40 hover:border-indigo-400',
activeBorder: 'border-indigo-600 ring-2 ring-indigo-500/20',
text: 'text-indigo-600 dark:text-indigo-400',
accent: 'from-indigo-500 to-purple-500',
},
Building: {
key: 'building',
icon: TrendingUp,
bg: 'bg-amber-50/80 text-amber-600 dark:bg-amber-950/60 dark:text-amber-400',
border: 'border-amber-200/50 dark:border-amber-800/40 hover:border-amber-400',
activeBorder: 'border-amber-600 ring-2 ring-amber-500/20',
text: 'text-amber-600 dark:text-amber-400',
accent: 'from-amber-500 to-orange-500',
},
Unscored: {
key: 'unscored',
icon: Sparkles,
bg: 'bg-orange-50/80 text-orange-600 dark:bg-orange-950/60 dark:text-orange-400',
border: 'border-orange-200/50 dark:border-orange-800/40 hover:border-orange-400',
activeBorder: 'border-orange-600 ring-2 ring-orange-500/20',
text: 'text-orange-600 dark:text-orange-400',
accent: 'from-orange-500 to-red-500',
},
};
export default function AdminTalentPool() {
const { data: profiles = [], isLoading } = useWorkerProfiles();
@@ -80,23 +124,16 @@ export default function AdminTalentPool() {
setSearch(''); setSkill('all'); setExperience('all'); setLocation('all'); setBand('all'); setAvailability('all');
};
/**
* Talent segments.
*
* The pool's shape by career score band, with how many in each band are
* actually available. Score without availability is a shortlist you cannot
* staff from, so the two belong in the same read.
*/
const segments = useMemo(() => {
const band = (min, max) => profiles.filter((p) => {
const getBandMembers = (min, max) => profiles.filter((p) => {
const s = p.krow_score || 0;
return s >= min && (max == null || s < max);
});
return [
{ label: 'Elite', hint: '90+', members: band(90), tone: 'brand' },
{ label: 'Excellent', hint: '75–89', members: band(75, 90), tone: 'success' },
{ label: 'Solid', hint: '60–74', members: band(60, 75), tone: 'default' },
{ label: 'Building', hint: 'under 60', members: band(1, 60), tone: 'warning' },
{ label: 'Elite', hint: '90+', members: getBandMembers(90), tone: 'brand' },
{ label: 'Excellent', hint: '75–89', members: getBandMembers(75, 90), tone: 'success' },
{ label: 'Solid', hint: '60–74', members: getBandMembers(60, 75), tone: 'default' },
{ label: 'Building', hint: 'under 60', members: getBandMembers(1, 60), tone: 'warning' },
{ label: 'Unscored', hint: 'no score', members: profiles.filter((p) => !(p.krow_score > 0)), tone: 'risk' },
].map((s) => ({
...s,
@@ -121,64 +158,84 @@ export default function AdminTalentPool() {
title="Talent Pool"
subtitle="Discover and manage high-potential talent for future hiring."
>
{/* Segments — the pool's shape, and how much of each band is available. */}
{/* Segments — executive metric cards */}
<section aria-labelledby="segments" className="space-y-3">
<SectionTitle
id="segments"
title="Talent segments"
meta={`${profiles.length} in the pool`}
/>
{/* Five compact cards, one per band, across the page's content width — the
same measure the directory below uses, so the two sections share a left
and right edge. A band is read one at a time, so each gets its own small
surface with the count directly above the detail that qualifies it. */}
<div>
<ul className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5">
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5">
{segments.map((s) => {
const actionable = s.tone === 'risk';
const cfg = SEGMENT_CONFIG[s.label] || SEGMENT_CONFIG.Elite;
const Icon = cfg.icon;
const isSelected = band === cfg.key;
const empty = s.count === 0;
return (
<li
<div
key={s.label}
className="rounded-xl border border-border bg-surface px-3.5 py-3 transition-colors duration-base hover:border-ink-4/40"
onClick={() => setBand(isSelected ? 'all' : cfg.key)}
className={cn(
'group relative flex flex-col justify-between cursor-pointer rounded-xl border bg-surface p-4 shadow-xs backdrop-blur-xs transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
isSelected ? cfg.activeBorder : cfg.border
)}
>
<div className="flex items-baseline gap-1.5">
<span className="truncate text-body-sm font-semibold text-ink-1">{s.label}</span>
<span className="truncate text-caption text-ink-4">{s.hint}</span>
{/* The one accent in the card: a 5px dot, warning where the
band is the actionable one. */}
{!empty && (
<div>
{/* Header Row: Label, Hint & Icon */}
<div className="flex items-center justify-between gap-1.5">
<div className="flex items-baseline gap-1.5 min-w-0">
<span className="truncate text-[11px] font-bold uppercase tracking-wider text-ink-1">
{s.label}
</span>
<span className="truncate text-[10px] font-medium text-ink-4">
{s.hint}
</span>
</div>
<div className={cn('flex h-7 w-7 shrink-0 items-center justify-center rounded-lg shadow-xs', cfg.bg)}>
<Icon className="h-3.5 w-3.5" />
</div>
</div>
{/* Main count */}
<div className="mt-2.5 flex items-baseline gap-1.5">
<span
className={cn(
'ml-auto h-1.5 w-1.5 shrink-0 translate-y-[-1px] rounded-full',
actionable ? 'bg-warning' : s.tone === 'brand' ? 'bg-krow-blue' : 'bg-ink-4/40'
'font-heading text-title-xl font-bold leading-none tabular-nums',
empty ? 'text-ink-4' : cfg.text
)}
aria-hidden="true"
/>
)}
>
{s.count}
</span>
</div>
</div>
<p
className={cn(
'mt-2 font-heading text-title-lg font-bold leading-none tabular-nums',
empty ? 'text-ink-4' : actionable ? 'text-warning' : 'text-ink-1'
)}
>
{s.count}
</p>
{/* Sub pill & Accent bar */}
<div className="mt-3.5 space-y-2">
<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">
{empty
? 'No profiles'
: [
`${s.available} available`,
s.label === 'Unscored'
? 'needs verification'
: s.avgExperience > 0
? `${s.avgExperience}y avg`
: null,
]
.filter(Boolean)
.join(' · ')}
</span>
<p className="mt-1.5 truncate text-[11px] text-ink-4">
{empty ? 'No profiles' : [
`${s.available} available`,
actionable ? 'needs verification' : s.avgExperience > 0 ? `${s.avgExperience}y avg` : null,
].filter(Boolean).join(' · ')}
</p>
</li>
<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>
);
})}
</ul>
</div>
<p className="mt-3 border-t border-border pt-3 text-caption leading-relaxed text-ink-3">
<p className="mt-3 border-t border-border/60 pt-3 text-caption leading-relaxed text-ink-3">
{segments[4].count > segments[0].count + segments[1].count
? `${segments[4].count} workers carry no career score against ${segments[0].count + segments[1].count} at Excellent or above. Supply is not the constraint here — verification is.`
: 'The scored part of the pool outweighs the unscored, so matching has enough to work with.'}

View File

@@ -19,13 +19,19 @@ import { cn } from '@/lib/utils';
*/
export function AdminPage({ title, subtitle, actions, tabs, children, className }) {
return (
<div className={cn('space-y-5', className)}>
<header className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="font-heading text-title-lg text-ink-1">{title}</h1>
{subtitle && <p className="mt-0.5 text-body-sm text-ink-3">{subtitle}</p>}
<div className={cn('space-y-6', className)}>
<header className="flex flex-wrap items-center justify-between gap-4 border-b border-border/50 pb-4">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2.5">
<h1 className="font-heading text-title-lg font-bold tracking-tight text-ink-1 sm:text-2xl">{title}</h1>
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-50 px-2.5 py-0.5 text-[11px] font-semibold text-blue-600 dark:bg-blue-950/60 dark:text-blue-400 border border-blue-200/60 dark:border-blue-800/40">
<span className="h-1.5 w-1.5 rounded-full bg-blue-600 dark:bg-blue-400" />
Live System
</span>
</div>
{subtitle && <p className="text-body-sm text-ink-3 font-normal">{subtitle}</p>}
</div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
{actions && <div className="flex flex-wrap items-center gap-2.5">{actions}</div>}
</header>
{tabs}

View File

@@ -0,0 +1,28 @@
/**
* The administrator's permission scopes.
*
* Lives outside the page because two things now read it: the Profile page
* renders it, and Owliver answers "what are my permissions?" from it. One list,
* so the panel can never describe a scope the page does not show.
*/
export const PERMISSIONS = [
{ scope: 'Positions', level: 'Full access', detail: 'Create, edit, pause and close roles' },
{ scope: 'Candidates', level: 'Full access', detail: 'Screen, advance, decline and hire' },
{ scope: 'Talent Pool', level: 'Full access', detail: 'Search, contact and shortlist' },
{ scope: 'Analytics', level: 'Read only', detail: 'View reports and export' },
{ scope: 'Audit log', level: 'Read only', detail: 'View all platform activity' },
{ scope: 'Billing', level: 'No access', detail: 'Restricted to account owners' },
];
export const LEVEL_TONE = { 'Full access': 'success', 'Read only': 'info', 'No access': 'neutral' };
/** What the Profile page can actually do, for the assistant to point at. */
export const PROFILE_ACTIONS = [
{ label: 'Edit profile', detail: 'Change your display name' },
{ label: 'Change password', detail: 'Rotate your administrator password' },
{ label: 'Manage sessions', detail: 'Review devices signed in to this account' },
{ label: 'Two-factor authentication', detail: 'Required for privileged actions' },
{ label: 'Owliver workspace', detail: 'Open the contextual panel by default on supported pages' },
{ label: 'Compact density', detail: 'Tighter row heights across Admin tables' },
{ label: 'Email digest', detail: 'A daily summary of what needs attention' },
];