Files
doormilxpress_astryx/src/pages/admin/Analytics.jsx
2026-08-14 17:33:16 +05:30

365 lines
16 KiB
JavaScript

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() {
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>
);
}