185 lines
7.7 KiB
JavaScript
185 lines
7.7 KiB
JavaScript
import React, { useMemo } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
|
||
import { CHART_TONES,
|
||
} from '@/components/ds';
|
||
import { useApplications, useInterviews, useJobPostings, useStaff, useWorkerProfiles } from '@/lib/krowHooks';
|
||
import { buildFacts } from '@/components/ai-assistant/insights';
|
||
import { AdminPage } from '@/components/admin/PageShell';
|
||
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
|
||
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
|
||
import { UiEditor } from '@/components/ui-editor/UiEditor';
|
||
import { composePage } from '@/lib/ui/composition';
|
||
import '@/components/ui-tree/nodeTypes';
|
||
import '@/pages/admin/candidates-analysis/nodes';
|
||
|
||
/**
|
||
* Admin Candidates Analysis — the analytical counterpart to Candidates.
|
||
*
|
||
* Candidates is for working through people one by one; this page is for reading
|
||
* the pool as a whole: supply, quality distribution, and where the risk sits.
|
||
* It is one of the three pages with the contextual Owliver panel, because these
|
||
* are the questions worth asking in language rather than filters.
|
||
*/
|
||
export default function AdminCandidatesAnalysis() {
|
||
const navigate = useNavigate();
|
||
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 bands = useMemo(() => ([
|
||
{ name: 'Top talent (80+)', value: f.ranked.filter((a) => a.ai_score >= 80).length, color: CHART_TONES.brand },
|
||
{ name: 'Strong (60–79)', value: f.ranked.filter((a) => a.ai_score >= 60 && a.ai_score < 80).length, color: CHART_TONES.accentPale },
|
||
{ name: 'Below bar (<60)', value: f.ranked.filter((a) => a.ai_score > 0 && a.ai_score < 60).length, color: CHART_TONES.navy },
|
||
{ name: 'Unscored', value: f.unscreened.length, color: CHART_TONES.mint },
|
||
].filter((b) => b.value > 0)), [f]);
|
||
|
||
const risks = [
|
||
f.missingCredentials.length && {
|
||
severity: 'critical',
|
||
title: 'Candidates with no credentials on file',
|
||
detail: 'Compliance exposure if any are placed on a role that requires certification.',
|
||
metric: f.missingCredentials.length,
|
||
metricLabel: 'candidates',
|
||
onClick: () => navigate('/admin/candidates'),
|
||
},
|
||
f.narrowAvailability.length && {
|
||
severity: 'warning',
|
||
title: 'Narrow availability',
|
||
detail: 'One slot or none — the placements most likely to fall through on the day.',
|
||
metric: f.narrowAvailability.length,
|
||
metricLabel: 'candidates',
|
||
onClick: () => navigate('/admin/candidates'),
|
||
},
|
||
f.flagged.length && {
|
||
severity: 'critical',
|
||
title: 'Interviews flagged for integrity',
|
||
detail: 'Suspiciously fast responses. Worth a human re-interview before progressing.',
|
||
metric: f.flagged.length,
|
||
metricLabel: 'interviews',
|
||
},
|
||
f.singleCandidateRoles.length && {
|
||
severity: 'warning',
|
||
title: 'Roles resting on one viable candidate',
|
||
detail: f.singleCandidateRoles.map((e) => e.posting.title).join(', '),
|
||
metric: f.singleCandidateRoles.length,
|
||
metricLabel: 'roles',
|
||
onClick: () => navigate('/admin/positions'),
|
||
},
|
||
].filter(Boolean);
|
||
|
||
const top = f.ranked.slice(0, 5);
|
||
|
||
/**
|
||
* Skill supply.
|
||
*
|
||
* Counted across the scored pool, with the average score of the people who hold
|
||
* each skill. Supply alone would be misleading: ten candidates claiming "bar
|
||
* setup" is not strength if all ten score in the forties.
|
||
*/
|
||
const skillSupply = useMemo(() => {
|
||
const map = new Map();
|
||
f.scored.forEach((a) => {
|
||
(a.skills || []).forEach((skill) => {
|
||
const e = map.get(skill) || { skill, count: 0, scores: [] };
|
||
e.count += 1;
|
||
e.scores.push(a.ai_score);
|
||
map.set(skill, e);
|
||
});
|
||
});
|
||
return [...map.values()]
|
||
.map((e) => ({
|
||
skill: e.skill,
|
||
count: e.count,
|
||
avgScore: Math.round(e.scores.reduce((x, y) => x + y, 0) / e.scores.length),
|
||
}))
|
||
.sort((a, b) => b.count - a.count)
|
||
.slice(0, 8);
|
||
}, [f.scored]);
|
||
|
||
/** Skills required by open roles that nobody in the scored pool claims. */
|
||
const skillGaps = useMemo(() => {
|
||
const held = new Set(f.scored.flatMap((a) => a.skills || []));
|
||
const required = new Map();
|
||
f.openPositions.forEach((p) => {
|
||
(p.certifications_required || []).forEach((c) => {
|
||
if (!required.has(c)) required.set(c, []);
|
||
required.get(c).push(p.title);
|
||
});
|
||
});
|
||
return [...required.entries()]
|
||
.filter(([name]) => !held.has(name))
|
||
.map(([name, roles]) => ({ name, roles }));
|
||
}, [f.scored, f.openPositions]);
|
||
|
||
/**
|
||
* Position fit — supply against quality, per open role.
|
||
*
|
||
* The two numbers that decide whether a role is actually fillable: how many
|
||
* applied, and how many of those clear the bar. A role with twelve applicants
|
||
* and none qualified is in worse shape than one with two who both do.
|
||
*/
|
||
const positionFit = useMemo(
|
||
() => f.byRole
|
||
.slice()
|
||
.map((r) => ({
|
||
...r,
|
||
fit: r.applied ? Math.round((r.qualified / r.applied) * 100) : 0,
|
||
}))
|
||
.sort((a, b) => b.fit - a.fit),
|
||
[f.byRole]
|
||
);
|
||
|
||
/** Recommendations, ordered by what it costs to leave them. */
|
||
const recommendations = useMemo(() => [
|
||
f.unscreened.length && {
|
||
title: `Screen ${f.unscreened.length} waiting applicant${f.unscreened.length === 1 ? '' : 's'}`,
|
||
body: 'Until they are scored they cannot be ranked against anyone already in the funnel.',
|
||
},
|
||
f.stalled.length && {
|
||
title: `Action ${f.stalled.length} candidate${f.stalled.length === 1 ? '' : 's'} scoring 80 or above`,
|
||
body: `${f.stalled.map((a) => a.applicant_name).join(', ')} ${f.stalled.length === 1 ? 'is' : 'are'} screened and waiting. The delay, not the assessment, is what loses these people.`,
|
||
},
|
||
f.missingCredentials.length && {
|
||
title: `Verify credentials for ${f.missingCredentials.length} scored candidate${f.missingCredentials.length === 1 ? '' : 's'}`,
|
||
body: 'Compliance exposure if any are placed on a role that requires certification.',
|
||
},
|
||
skillGaps.length && {
|
||
title: `Source for ${skillGaps.length} unmet requirement${skillGaps.length === 1 ? '' : 's'}`,
|
||
body: `${skillGaps.map((g) => g.name).join(', ')} — required by an open role and held by nobody in the scored pool.`,
|
||
},
|
||
f.singleCandidateRoles.length && {
|
||
title: `Widen supply on ${f.singleCandidateRoles.length} role${f.singleCandidateRoles.length === 1 ? '' : 's'}`,
|
||
body: `${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} rests on one viable candidate. One withdrawal reopens the search.`,
|
||
},
|
||
].filter(Boolean), [f, skillGaps]);
|
||
|
||
const context = useMemo(() => ({ navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations }), [
|
||
navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations,
|
||
]);
|
||
|
||
return (
|
||
<AdminPage
|
||
title="Candidates Analysis"
|
||
subtitle="Talent supply, quality distribution and pipeline risk."
|
||
>
|
||
<UiEditor />
|
||
<CandidatesAnalysisComposition context={context} />
|
||
</AdminPage>
|
||
);
|
||
}
|
||
|
||
/** The composed page. */
|
||
function CandidatesAnalysisComposition({ context }) {
|
||
const editing = useUiEditing();
|
||
const tree = editing?.tree || composePage('candidates-analysis').tree;
|
||
return <UiTreeRenderer nodes={tree} context={context} />;
|
||
}
|