fix talent pool ui

This commit is contained in:
2026-08-11 17:09:53 +05:30
parent 7427359967
commit 8a256a4de7
12 changed files with 473 additions and 256 deletions

View File

@@ -1,6 +1,6 @@
import {
Activity, AlertTriangle, ArrowLeftRight, BellRing, Eye, Filter, Gauge, GitBranch,
Lightbulb, ScrollText, ShieldAlert, Target, TrendingUp, UserCheck, Users,
Layers, Lightbulb, ScrollText, ShieldAlert, Target, TrendingUp, UserCheck, Users,
} from 'lucide-react';
import {
actions, answer, doc, funnel, heading, insights, kpis, list, meters, note, status, table,
@@ -1099,6 +1099,136 @@ const candidatePipelineSummary = (f) => {
);
};
/* ── Analytics ──────────────────────────────────────────────────────────── */
const DAY_MS = 1000 * 60 * 60 * 24;
/**
* Hiring trend — direction of travel, not another chart.
*
* The page already draws the line; what it cannot say is whether the line is
* going up. So this compares the last four weeks against the four before them
* and reports the change, using the same created/updated dates the chart buckets.
*/
const hiringTrend = (f) => {
if (!f.total) return answer('No applications yet, so there is no trend to read.');
const inWindow = (value, from, to) => {
const t = new Date(value).getTime();
return t >= f.today.getTime() - from * DAY_MS && t < f.today.getTime() - to * DAY_MS;
};
const applied = (from, to) => f.applications.filter((a) => inWindow(a.created_date, from, to)).length;
/* Screening is dated by the update that carried the score, exactly as the
Hiring trend chart buckets it. */
const screened = (from, to) => f.scored.filter((a) => inWindow(a.updated_date, from, to)).length;
const recent = applied(28, 0);
const previous = applied(56, 28);
const change = previous ? Math.round(((recent - previous) / previous) * 100) : null;
const weeks = [3, 2, 1, 0].map((w) => ({
week: w === 0 ? 'This week' : `${w} week${w === 1 ? '' : 's'} ago`,
applied: applied((w + 1) * 7, w * 7),
screened: screened((w + 1) * 7, w * 7),
}));
return doc(
heading('Hiring trend', 'Last 4 weeks against the 4 before'),
kpis([
{ label: 'Applications', value: recent, sub: `${previous} in the prior period` },
{
label: 'Change',
value: change === null ? '—' : `${change > 0 ? '+' : ''}${change}%`,
tone: change === null ? undefined : change >= 0 ? 'success' : 'warning',
},
{ label: 'Screened', value: screened(28, 0), sub: `${f.standardizedPct}% coverage` },
{ label: 'Hires', value: f.hired.length, sub: f.timeToHire ? `${f.timeToHire}d to close` : 'none closed' },
]),
heading('Week by week'),
table(
[
{ key: 'week', label: 'Week' },
{ key: 'applied', label: 'Applied', align: 'right' },
{ key: 'screened', label: 'Screened', align: 'right' },
],
weeks
),
insights([
change !== null && {
tone: change >= 0 ? 'success' : 'warning',
title: change >= 0
? `Applications up ${change}% on the previous four weeks`
: `Applications down ${Math.abs(change)}% on the previous four weeks`,
body: change >= 0
? 'Supply is growing, so the constraint moves to screening throughput rather than reach.'
: 'Fewer people are arriving. Distribution and pay band are the two things that move this, not screening.',
},
f.unscreened.length && {
tone: 'warning',
title: `${plural(f.unscreened.length, 'application')} still unscored`,
body: 'The screened line lags the applied line by exactly this much — the gap is a queue, not a rejection rate.',
},
]),
note('Windows are counted from today, so this is independent of the 7d/30d/90d control on the chart.')
);
};
/**
* Department performance — which role category is actually converting.
*
* The page's department section ranks by volume. Volume is demand, not
* performance: the category worth copying is the one turning applicants into
* qualified candidates, which is a rate.
*/
const departmentPerformance = (f) => {
if (!f.byRole.length) return answer('No open positions, so there are no departments to compare.');
const rows = Object.values(f.byRole.reduce((acc, r) => {
acc[r.category] ||= { category: r.category, roles: 0, applied: 0, qualified: 0, hired: 0 };
acc[r.category].roles += 1;
acc[r.category].applied += r.applied;
acc[r.category].qualified += r.qualified;
acc[r.category].hired += r.hired;
return acc;
}, {})).map((d) => ({ ...d, rate: d.applied ? Math.round((d.qualified / d.applied) * 100) : 0 }))
.sort((a, b) => b.rate - a.rate || b.applied - a.applied);
const best = rows.find((d) => d.applied > 0);
const starved = rows.filter((d) => d.applied === 0);
return doc(
heading('Department performance', `${plural(rows.length, 'category')}`),
table(
[
{ key: 'category', label: 'Category' },
{ key: 'applied', label: 'Applied', align: 'right' },
{ key: 'qualified', label: '70+', align: 'right' },
{ key: 'hired', label: 'Hired', align: 'right' },
{ key: 'rate', label: 'Qualified', align: 'right' },
],
rows.map((d) => ({
category: d.category,
applied: d.applied ? d.applied : { value: 0, tone: 'risk' },
qualified: d.qualified,
hired: d.hired || '—',
rate: { value: `${d.rate}%`, tone: d.rate >= 50 ? 'success' : d.rate > 0 ? 'warning' : 'risk' },
}))
),
insights([
best && {
tone: 'success',
title: `${best.category} is performing best`,
body: `${best.qualified} of ${best.applied} applicants clear 70 — ${best.rate}%${best.hired ? `, and ${plural(best.hired, 'hire')} closed` : ''}. Whatever sourcing this category uses is the one worth copying.`,
},
starved.length && {
tone: 'risk',
title: `${plural(starved.length, 'category')} attracting nobody`,
body: `${starved.map((d) => d.category).join(', ')}. A conversion rate cannot be read off zero applicants.`,
},
])
);
};
/* ── Free-text responders ───────────────────────────────────────────────── */
/**
@@ -1151,6 +1281,16 @@ export const respondAdminCandidates = (question, f) => {
return recruitmentInsights(f);
};
export const respondAdminAnalytics = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'trend', 'over time', 'growing', 'slow', 'week', 'month')) return hiringTrend(f);
if (has(q, 'department', 'category', 'team', 'best perform')) return departmentPerformance(f);
if (has(q, 'bottleneck', 'funnel', 'drop', 'conversion', 'stuck', 'lose')) return pipelineHealth(f);
if (has(q, 'position', 'role', 'converting', 'strongest')) return positionStrength(f);
if (has(q, 'attention', 'urgent', 'wrong', 'fix', 'risk')) return attentionRequired(f);
return hiringOperations(f);
};
export const respondActivity = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'security', 'breach', 'attributab', 'compliance', 'integrity', 'trace')) return securityInsights(f);
@@ -1196,6 +1336,15 @@ export const ADMIN_CANDIDATE_CAPABILITIES = [
{ id: 'top-candidates', label: 'Compare Top Candidates', icon: ArrowLeftRight, run: topCandidates },
];
export const ADMIN_ANALYTICS_CAPABILITIES = [
{ id: 'hiring-trend', label: 'Hiring Trend', icon: TrendingUp, run: hiringTrend },
{ id: 'department-performance', label: 'Department Performance', icon: Layers, run: departmentPerformance },
{ id: 'pipeline-health', label: 'Hiring Bottlenecks', icon: GitBranch, run: pipelineHealth },
{ id: 'position-conversion', label: 'Position Conversion', icon: Target, run: positionStrength },
{ id: 'hiring-operations', label: 'Hiring Performance', icon: Activity, run: hiringOperations },
{ id: 'attention-required', label: 'Needs Attention', icon: BellRing, run: attentionRequired },
];
export const ACTIVITY_CAPABILITIES = [
{ id: 'audit-summary', label: 'Audit Summary', icon: ScrollText, run: auditSummary },
{ id: 'user-activity', label: 'User Activity', icon: Eye, run: userActivityInsights },