fix talent pool ui
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -3,10 +3,10 @@ import {
|
||||
respondAnalytics, respondCandidates, respondOverview,
|
||||
} from './capabilities/employer';
|
||||
import {
|
||||
ACTIVITY_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES, CANDIDATE_LIST_CAPABILITIES,
|
||||
CONTROL_CENTER_CAPABILITIES, POSITIONS_CAPABILITIES,
|
||||
respondActivity, respondAdminCandidates, respondCandidateList, respondControlCenter,
|
||||
respondPositions,
|
||||
ACTIVITY_CAPABILITIES, ADMIN_ANALYTICS_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES,
|
||||
CANDIDATE_LIST_CAPABILITIES, CONTROL_CENTER_CAPABILITIES, POSITIONS_CAPABILITIES,
|
||||
respondActivity, respondAdminAnalytics, respondAdminCandidates, respondCandidateList,
|
||||
respondControlCenter, respondPositions,
|
||||
} from './capabilities/admin';
|
||||
|
||||
/**
|
||||
@@ -66,6 +66,14 @@ export const ASSISTANT_CONTEXTS = {
|
||||
capabilities: ADMIN_CANDIDATE_CAPABILITIES,
|
||||
respond: respondAdminCandidates,
|
||||
},
|
||||
/* Analytics reads the same records as the Control Center, but as performance
|
||||
over time rather than as a state to act on — hence its own capabilities. */
|
||||
'admin.analytics': {
|
||||
id: 'admin.analytics',
|
||||
page: 'Analytics',
|
||||
capabilities: ADMIN_ANALYTICS_CAPABILITIES,
|
||||
respond: respondAdminAnalytics,
|
||||
},
|
||||
'admin.activity': {
|
||||
id: 'admin.activity',
|
||||
page: 'Activity',
|
||||
|
||||
@@ -122,6 +122,17 @@ const GREETINGS = {
|
||||
return `All ${f.total} candidates are scored, credentialed and comparable.`;
|
||||
},
|
||||
|
||||
'admin.analytics': (f) => {
|
||||
if (!f.total) return 'Nothing has been applied for yet, so the charts have nothing to report.';
|
||||
if (f.bottleneck && f.bottleneck.lost > 0) {
|
||||
return `Conversion is weakest between ${f.bottleneck.from} and ${f.bottleneck.to}, at ${f.bottleneck.rate}%.`;
|
||||
}
|
||||
if (f.standardizedPct < 80) {
|
||||
return `Screening covers ${f.standardizedPct}% of applications, so the rest of these figures rest on a partial pool.`;
|
||||
}
|
||||
return `${plural(f.hired.length, 'hire')} from ${plural(f.total, 'applicant')} — ${f.hireRate}% conversion.`;
|
||||
},
|
||||
|
||||
'admin.activity': (f) => {
|
||||
if (!f.activity.length) return 'No activity logged yet.';
|
||||
const patterns = f.activitySignals.flags.length;
|
||||
@@ -155,6 +166,7 @@ const TITLES = {
|
||||
'admin.positions': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.candidatesList': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.candidates': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.analytics': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.activity': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
};
|
||||
|
||||
@@ -172,6 +184,8 @@ const DESCRIPTIONS = {
|
||||
`Reading ${plural(f.total, 'candidate')} across ${plural(f.postings.length, 'position')} — match quality, screening and hiring readiness.`,
|
||||
'admin.candidates': (f) =>
|
||||
`${plural(f.total, 'candidate')} and ${plural(f.profiles.length, 'talent profile')} — supply, quality and risk.`,
|
||||
'admin.analytics': (f) =>
|
||||
`Reading hiring performance across ${plural(f.openPositions.length, 'position')}, ${plural(f.total, 'candidate')} and ${plural(new Set(f.byRole.map((r) => r.category)).size, 'department')} — conversion, speed and outcomes.`,
|
||||
'admin.activity': (f) => `${plural(f.activity.length, 'logged event')}, reconciled and profiled.`,
|
||||
};
|
||||
|
||||
@@ -218,6 +232,11 @@ const PLACEHOLDERS = {
|
||||
'Ask Owliver about screening gaps…',
|
||||
'Ask Owliver where the risk is…',
|
||||
],
|
||||
'admin.analytics': () => [
|
||||
'Ask Owliver about hiring performance…',
|
||||
'Ask Owliver about the hiring trend…',
|
||||
'Ask Owliver where the bottleneck is…',
|
||||
],
|
||||
'admin.activity': () => [
|
||||
'Ask Owliver about recent activity…',
|
||||
'Ask Owliver about unusual activity…',
|
||||
@@ -333,6 +352,19 @@ const PROMPTS = {
|
||||
{ label: 'Recruitment insights', prompt: 'Show recruitment insights across the platform', capability: 'recruitment-insights' },
|
||||
],
|
||||
|
||||
'admin.analytics': (f) => [
|
||||
{ label: 'What is the hiring trend?', prompt: 'What is the hiring trend?', capability: 'hiring-trend' },
|
||||
{ label: 'Which department is performing best?', prompt: 'Which department is performing best?', capability: 'department-performance' },
|
||||
f.bottleneck && f.bottleneck.lost > 0
|
||||
? { label: `Biggest drop at ${f.bottleneck.to}`, prompt: `Where is the biggest hiring bottleneck — why do candidates drop between ${f.bottleneck.from} and ${f.bottleneck.to}?`, capability: 'pipeline-health' }
|
||||
: { label: 'Where is the biggest bottleneck?', prompt: 'Where is the biggest hiring bottleneck?', capability: 'pipeline-health' },
|
||||
{ label: 'Which positions are converting best?', prompt: 'Which positions are converting best?', capability: 'position-conversion' },
|
||||
{ label: 'Summarize hiring performance', prompt: 'Summarize hiring performance', capability: 'hiring-operations' },
|
||||
f.unscreened.length
|
||||
? { label: `${f.unscreened.length} unscored applications`, prompt: 'What needs attention?', capability: 'attention-required' }
|
||||
: { label: 'What needs attention?', prompt: 'What needs attention?', capability: 'attention-required' },
|
||||
],
|
||||
|
||||
'admin.activity': (f) => [
|
||||
f.activitySignals.flags.length
|
||||
? { label: `${f.activitySignals.flags.length} unusual patterns`, prompt: 'Show unusual activity on the platform', capability: 'unusual-activity' }
|
||||
|
||||
@@ -17,6 +17,7 @@ const PLACEMENT = {
|
||||
'/admin/positions': 'admin.positions',
|
||||
'/admin/candidates': 'admin.candidatesList',
|
||||
'/admin/candidates-analysis': 'admin.candidates',
|
||||
'/admin/analytics': 'admin.analytics',
|
||||
'/admin/activity': 'admin.activity',
|
||||
},
|
||||
};
|
||||
@@ -26,8 +27,8 @@ const PLACEMENT = {
|
||||
* documented rather than implied by omission.
|
||||
*
|
||||
* Admin: the reporting and reference pages, plus login. The working surfaces —
|
||||
* Control Center, Positions, Candidates, Candidates Analysis and
|
||||
* Activity — all carry the panel.
|
||||
* Control Center, Positions, Candidates, Candidates Analysis, Analytics
|
||||
* and Activity — all carry the panel.
|
||||
* Employer: every page. The contextual panel is an Admin capability.
|
||||
* Talent: every page. The talent portal has the standalone Owliver product,
|
||||
* with its own voice experience, branding and workflow.
|
||||
@@ -39,8 +40,7 @@ export const EXCLUDED_ROUTES = [
|
||||
'/university', '/university/:id', '/me', '/identity', '/employee',
|
||||
'/owliver', '/design-system',
|
||||
// Admin pages that are a report or a form rather than a workflow.
|
||||
'/admin/hired', '/admin/talent-pool', '/admin/analytics',
|
||||
'/admin/profile', '/admin/login',
|
||||
'/admin/hired', '/admin/talent-pool', '/admin/profile', '/admin/login',
|
||||
// The full candidate profile is one person's record, not a page-level question.
|
||||
'/admin/candidates/:id',
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user