Files
krow_talent_app/src/pages/admin/CandidatesAnalysis.jsx
Aravind 6249e00a3a
Some checks failed
CI / check (push) Failing after 4m58s
candidates and board ui agent issue
2026-09-05 10:46:06 +05:30

185 lines
7.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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} />;
}