update ui design for all pages

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

View File

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