update owliver skill
This commit is contained in:
@@ -1,6 +1,364 @@
|
||||
import React from 'react';
|
||||
import AdminHiredHistory from './HiredHistory';
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
Activity, Award, Clock, Gauge, Lightbulb, Target, TrendingUp, TriangleAlert,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge, MetricStrip, Surface } from '@/components/ds';
|
||||
import { useApplications, useJobPostings, useStaff } from '@/lib/krowHooks';
|
||||
import { DepartmentPerformance } from '@/components/charts/DepartmentPerformance';
|
||||
import { HiringFlow } from '@/components/charts/HiringFlow';
|
||||
import { HiringTrendChart } from '@/components/charts/HiringTrendChart';
|
||||
import { useSize } from '@/hooks/use-size';
|
||||
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import {
|
||||
buildEfficiency, buildFunnel, buildInsights, buildTrend, byDepartment, byPosition,
|
||||
hiresWithFill, summarise,
|
||||
} from '@/lib/hiringRecords';
|
||||
|
||||
/**
|
||||
* Admin Analytics — how hiring is performing.
|
||||
*
|
||||
* This page and Hired History used to be the same component rendered twice with
|
||||
* a different title, which is why they read as duplicates: identical sections,
|
||||
* identical tabs, identical figures. They are not the same question.
|
||||
*
|
||||
* **Analytics asks "how is our hiring performing?"** — it is metrics, trends,
|
||||
* comparison and what to do about them. Every section here is an aggregate: a
|
||||
* rate, a distribution, a shape over time. No individual hire is named anywhere
|
||||
* on this page, because a name is a record and records are Hired History's job.
|
||||
*
|
||||
* It is also structurally different, not just differently coloured. Analytics is
|
||||
* one scrolling dashboard — the seven readings are meant to be taken together,
|
||||
* and a comparison you have to remember across a tab switch is not a comparison.
|
||||
* Hired History is a filtered record you search, so it keeps its controls and its
|
||||
* table. Same design system, same blue, same cards; different jobs.
|
||||
*
|
||||
* Both read `lib/hiringRecords.js`, so a total here and the same total there
|
||||
* cannot disagree.
|
||||
*/
|
||||
|
||||
const INSIGHT_TONE = {
|
||||
success: { ring: 'border-emerald-500/30 bg-emerald-50/40 dark:bg-emerald-950/20', dot: 'text-emerald-600 dark:text-emerald-400', Icon: TrendingUp },
|
||||
warning: { ring: 'border-amber-500/30 bg-amber-50/40 dark:bg-amber-950/20', dot: 'text-amber-600 dark:text-amber-400', Icon: TriangleAlert },
|
||||
risk: { ring: 'border-red-500/30 bg-red-50/40 dark:bg-red-950/20', dot: 'text-red-600 dark:text-red-400', Icon: TriangleAlert },
|
||||
info: { ring: 'border-border bg-surface', dot: 'text-krow-blue', Icon: Lightbulb },
|
||||
};
|
||||
|
||||
/** One finding, with the evidence underneath it. */
|
||||
function Insight({ item }) {
|
||||
const tone = INSIGHT_TONE[item.tone] || INSIGHT_TONE.info;
|
||||
const { Icon } = tone;
|
||||
|
||||
return (
|
||||
<li className={cn('flex items-start gap-2.5 rounded-xl border p-3.5', tone.ring)}>
|
||||
<Icon className={cn('mt-0.5 h-4 w-4 shrink-0', tone.dot)} aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-heading text-body-sm font-semibold text-ink-1">{item.title}</p>
|
||||
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{item.body}</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The funnel, drawn once its container can be measured.
|
||||
*
|
||||
* `HiringFlow` renders an MUI bar chart with no explicit width, which means the
|
||||
* chart measures its parent on mount. On this page that first measure lands
|
||||
* before the layout has resolved a width — the shell's `main` is a `flex-1`
|
||||
* column beside the Owliver panel — and the chart warns that it has nothing to
|
||||
* size itself against. Gating on the measured width means the chart mounts once,
|
||||
* already knowing how wide it is, instead of mounting into nothing and
|
||||
* recovering.
|
||||
*
|
||||
* The reserved height keeps the section from collapsing and reflowing the page
|
||||
* on the frame between measure and draw.
|
||||
*/
|
||||
function MeasuredFunnel({ funnel }) {
|
||||
const ref = React.useRef(null);
|
||||
const size = useSize(ref);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="w-full">
|
||||
{size?.width ? (
|
||||
<HiringFlow
|
||||
stages={funnel.stages}
|
||||
transitions={funnel.transitions}
|
||||
weakestKey={funnel.weakestKey}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-[280px] rounded-xl border border-border bg-surface" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A ranked comparison row — used for both fastest and slowest to fill. */
|
||||
function VelocityList({ items, median, tone }) {
|
||||
if (!items.length) {
|
||||
return (
|
||||
<p className="rounded-lg border border-dashed border-border px-3 py-3 text-body-sm text-ink-3">
|
||||
No role has enough dated hires to measure velocity yet.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
{items.map((role) => {
|
||||
const delta = median ? role.avgDays - median : 0;
|
||||
return (
|
||||
<li key={role.role} className="flex items-center justify-between gap-3 rounded-xl bg-surface-subtle px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm font-medium text-ink-1">{role.role}</p>
|
||||
<p className="text-[10px] text-ink-4">
|
||||
{role.count} hire{role.count === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="font-heading text-body-sm font-bold tabular-nums text-ink-1">
|
||||
{role.avgDays}d
|
||||
</span>
|
||||
{median > 0 && delta !== 0 && (
|
||||
<Badge variant={tone === 'fast' ? 'success' : 'warning'} size="sm" className="tabular-nums">
|
||||
{delta > 0 ? '+' : ''}{delta}d
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminAnalytics() {
|
||||
return <AdminHiredHistory title="Analytics" />;
|
||||
const { data: staff = [], isLoading } = useStaff();
|
||||
const { data: applications = [] } = useApplications();
|
||||
const { data: postings = [] } = useJobPostings();
|
||||
|
||||
const hires = useMemo(
|
||||
() => hiresWithFill({ staff, applications, postings }),
|
||||
[staff, applications, postings]
|
||||
);
|
||||
|
||||
const summary = useMemo(() => summarise(hires), [hires]);
|
||||
const funnel = useMemo(() => buildFunnel(applications), [applications]);
|
||||
const trend = useMemo(() => buildTrend(hires), [hires]);
|
||||
const departments = useMemo(() => byDepartment(hires), [hires]);
|
||||
const positions = useMemo(() => byPosition(hires), [hires]);
|
||||
const efficiency = useMemo(() => buildEfficiency(hires), [hires]);
|
||||
const insights = useMemo(
|
||||
() => buildInsights({ hires, departments, positions, funnel, efficiency }),
|
||||
[hires, departments, positions, funnel, efficiency]
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title="Analytics"
|
||||
subtitle="How hiring is performing — rates, trends, comparison and what to act on."
|
||||
>
|
||||
<SkillSurface page="analytics" placement="after-header" />
|
||||
|
||||
{/* 1. Hiring performance — the four figures the rest of the page explains. */}
|
||||
<section aria-labelledby="performance" className="space-y-3">
|
||||
<SectionTitle id="performance" title="Hiring performance" meta="Across the workspace" />
|
||||
<MetricStrip
|
||||
columns={4}
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{ label: 'Total hires', value: summary.total, icon: Award, tone: 'brand' },
|
||||
{
|
||||
label: 'Avg time-to-hire',
|
||||
value: summary.speed ? `${summary.speed}d` : '—',
|
||||
icon: Clock,
|
||||
sub: efficiency.median ? `${efficiency.median}d median` : undefined,
|
||||
},
|
||||
{
|
||||
label: 'Quality of hire',
|
||||
value: summary.quality || '—',
|
||||
icon: TrendingUp,
|
||||
tone: summary.quality >= 80 ? 'success' : 'default',
|
||||
sub: 'avg AI score',
|
||||
},
|
||||
{
|
||||
label: 'Conversion rate',
|
||||
value: `${funnel.conversion}%`,
|
||||
icon: Target,
|
||||
sub: `${funnel.stages[4].count} of ${funnel.stages[0].count} applicants`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* 2. The funnel — where candidates are lost, which is the finding this
|
||||
page exists to surface. */}
|
||||
<section aria-labelledby="funnel" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="funnel"
|
||||
title="Hiring funnel"
|
||||
meta="Applied → Screened → Shortlisted → Interview → Hired"
|
||||
/>
|
||||
{funnel.stages[0].count ? (
|
||||
<MeasuredFunnel funnel={funnel} />
|
||||
) : (
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
||||
<p className="text-body-sm leading-relaxed text-ink-3">
|
||||
No applications on file yet. The funnel appears once the first candidate applies.
|
||||
</p>
|
||||
</Surface>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 3. Trend over time. */}
|
||||
<section aria-labelledby="trend" className="space-y-3">
|
||||
<SectionTitle id="trend" title="Hiring trend" meta="Cumulative hires by month" />
|
||||
<HiringTrendChart
|
||||
points={trend}
|
||||
emptyState={(
|
||||
<div className="px-4 py-8 text-center">
|
||||
<p className="font-heading text-body font-semibold text-ink-1">
|
||||
Not enough history for a trend
|
||||
</p>
|
||||
<p className="mx-auto mt-1 max-w-md text-body-sm leading-relaxed text-ink-3">
|
||||
{hires.length
|
||||
? `All ${hires.length} hire${hires.length === 1 ? '' : 's'} closed in ${trend[0]?.label || 'a single month'}. A month-on-month line appears once hiring spans a second month.`
|
||||
: 'No hires recorded yet. Hiring volume over time appears here once the first role closes.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* 4. Department comparison. */}
|
||||
<section aria-labelledby="departments" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="departments"
|
||||
title="Department performance"
|
||||
meta={`${departments.length} department${departments.length === 1 ? '' : 's'} compared`}
|
||||
/>
|
||||
<DepartmentPerformance items={departments} />
|
||||
</section>
|
||||
|
||||
{/* 5. Position comparison — the same question one level down. */}
|
||||
<section aria-labelledby="positions" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="positions"
|
||||
title="Position performance"
|
||||
meta={`${positions.length} role${positions.length === 1 ? '' : 's'} 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={cn(
|
||||
'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">
|
||||
{positions.map((r) => (
|
||||
<tr key={r.role} className="border-b border-border/60 last:border-0 transition-colors hover:bg-surface-subtle/80">
|
||||
<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 font-semibold tabular-nums text-ink-2">{r.count}</td>
|
||||
<td className="px-3.5 py-2.5 text-right font-bold tabular-nums text-blue-600 dark:text-blue-400">{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>
|
||||
|
||||
{/* 6. Efficiency — velocity against this workspace's own median, so the
|
||||
comparison is one an operator can check. */}
|
||||
<section aria-labelledby="efficiency" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="efficiency"
|
||||
title="Hiring efficiency"
|
||||
meta={efficiency.median ? `${efficiency.median}d median time-to-hire` : 'Not enough dated hires'}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
||||
<div className="flex items-center gap-2 text-krow-blue">
|
||||
<Gauge className="h-4 w-4" aria-hidden="true" />
|
||||
<h3 className="font-heading text-body-sm font-bold text-ink-1">Velocity</h3>
|
||||
</div>
|
||||
<p className="mt-2 font-heading text-title font-bold tabular-nums text-ink-1">
|
||||
{efficiency.median ? `${efficiency.median}d` : '—'}
|
||||
<span className="ml-1.5 text-caption font-normal text-ink-3">median</span>
|
||||
</p>
|
||||
<p className="mt-1 text-caption leading-relaxed text-ink-3">
|
||||
{efficiency.within48h
|
||||
? `${efficiency.within48h}% of roles close within 48 hours.`
|
||||
: 'Velocity appears once hires carry an application date.'}
|
||||
</p>
|
||||
</Surface>
|
||||
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
||||
<div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400">
|
||||
<Activity className="h-4 w-4" aria-hidden="true" />
|
||||
<h3 className="font-heading text-body-sm font-bold text-ink-1">Fastest to fill</h3>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<VelocityList items={efficiency.fastest} median={efficiency.median} tone="fast" />
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||
<Clock className="h-4 w-4" aria-hidden="true" />
|
||||
<h3 className="font-heading text-body-sm font-bold text-ink-1">Slowest to fill</h3>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<VelocityList items={efficiency.slowest} median={efficiency.median} tone="slow" />
|
||||
</div>
|
||||
</Surface>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 7. What the numbers mean. Only findings the data actually supports —
|
||||
an insight list that is always the same length is decoration. */}
|
||||
<section aria-labelledby="insights" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="insights"
|
||||
title="AI hiring insights"
|
||||
meta={insights.length ? `${insights.length} finding${insights.length === 1 ? '' : 's'}` : undefined}
|
||||
/>
|
||||
{insights.length ? (
|
||||
<ul className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{insights.map((item) => <Insight key={item.title} item={item} />)}
|
||||
</ul>
|
||||
) : (
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
||||
<p className="text-body-sm leading-relaxed text-ink-3">
|
||||
There is not enough hiring on file to draw a finding from yet. Insights appear as
|
||||
applications, hires and reviews accumulate.
|
||||
</p>
|
||||
</Surface>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<SkillSurface page="analytics" placement="before-footer" />
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user