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 (
  • ); } /** * 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 (
    {size?.width ? ( ) : ( ); } /** A ranked comparison row — used for both fastest and slowest to fill. */ function VelocityList({ items, median, tone }) { if (!items.length) { return (

    No role has enough dated hires to measure velocity yet.

    ); } return ( ); } 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 ( {/* 1. Hiring performance — the four figures the rest of the page explains. */}
    = 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`, }, ]} />
    {/* 2. The funnel — where candidates are lost, which is the finding this page exists to surface. */}
    {funnel.stages[0].count ? ( ) : (

    No applications on file yet. The funnel appears once the first candidate applies.

    )}
    {/* 3. Trend over time. */}

    Not enough history for a trend

    {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.'}

    )} /> {/* 4. Department comparison. */}
    {/* 5. Position comparison — the same question one level down. */}
    {['Role', 'Hires', 'Avg score', 'Avg days', 'Reviewed', 'Rating'].map((h, i) => ( ))} {positions.map((r) => ( ))}
    Hires, quality, speed and review outcome by role
    {h}
    {r.role} {r.count} {r.avgScore || '—'} {r.avgDays ? `${r.avgDays}d` : '—'} {r.rated}/{r.count} {r.avgRating != null ? {r.avgRating}/5 : Pending}
    {/* 6. Efficiency — velocity against this workspace's own median, so the comparison is one an operator can check. */}

    {efficiency.median ? `${efficiency.median}d` : '—'} median

    {efficiency.within48h ? `${efficiency.within48h}% of roles close within 48 hours.` : 'Velocity appears once hires carry an application date.'}

    {/* 7. What the numbers mean. Only findings the data actually supports — an insight list that is always the same length is decoration. */}
    {insights.length ? ( ) : (

    There is not enough hiring on file to draw a finding from yet. Insights appear as applications, hires and reviews accumulate.

    )}
    ); }