update ui design for all pages
This commit is contained in:
@@ -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" />;
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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.'}
|
||||
|
||||
@@ -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}
|
||||
|
||||
28
src/pages/admin/permissions.js
Normal file
28
src/pages/admin/permissions.js
Normal 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' },
|
||||
];
|
||||
Reference in New Issue
Block a user