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 ; }