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 (
);
}
/** The composed page. */
function CandidatesAnalysisComposition({ context }) {
const editing = useUiEditing();
const tree = editing?.tree || composePage('candidates-analysis').tree;
return ;
}