136 lines
5.4 KiB
JavaScript
136 lines
5.4 KiB
JavaScript
import React, { useMemo, useState } from 'react';
|
||
import { toast } from '@/components/ds';
|
||
import { useWorkerProfiles } from '@/lib/krowHooks';
|
||
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 { SORTS } from '@/pages/admin/talent-pool/nodes';
|
||
import '@/components/ui-tree/nodeTypes';
|
||
import '@/pages/admin/talent-pool/nodes';
|
||
|
||
/**
|
||
* Admin Talent Pool — talent intelligence, not a card gallery.
|
||
*/
|
||
|
||
|
||
const BANDS = {
|
||
all: () => true,
|
||
elite: (s) => s >= 90,
|
||
excellent: (s) => s >= 75 && s < 90,
|
||
solid: (s) => s >= 60 && s < 75,
|
||
building: (s) => s > 0 && s < 60,
|
||
unscored: (s) => !s,
|
||
};
|
||
|
||
|
||
export default function AdminTalentPool() {
|
||
const { data: profiles = [], isLoading } = useWorkerProfiles();
|
||
|
||
const [search, setSearch] = useState('');
|
||
const [skill, setSkill] = useState('all');
|
||
const [experience, setExperience] = useState('all');
|
||
const [location, setLocation] = useState('all');
|
||
const [band, setBand] = useState('all');
|
||
const [availability, setAvailability] = useState('all');
|
||
const [sort, setSort] = useState('score');
|
||
const [saved, setSaved] = useState([]);
|
||
|
||
const skills = useMemo(
|
||
() => [...new Set(profiles.flatMap((p) => p.skills || []))].sort(),
|
||
[profiles]
|
||
);
|
||
const locations = useMemo(
|
||
() => [...new Set(profiles.map((p) => p.address).filter(Boolean))].sort(),
|
||
[profiles]
|
||
);
|
||
const availabilities = useMemo(
|
||
() => [...new Set(profiles.flatMap((p) => p.availability || []))].sort(),
|
||
[profiles]
|
||
);
|
||
|
||
const filtered = useMemo(() => {
|
||
const q = search.toLowerCase();
|
||
return profiles
|
||
.filter((p) => {
|
||
if (q && !`${p.full_name} ${p.email} ${p.desired_position} ${p.current_position} ${(p.skills || []).join(' ')}`.toLowerCase().includes(q)) return false;
|
||
if (skill !== 'all' && !(p.skills || []).includes(skill)) return false;
|
||
if (location !== 'all' && p.address !== location) return false;
|
||
if (availability !== 'all' && !(p.availability || []).includes(availability)) return false;
|
||
if (!BANDS[band](p.krow_score || 0)) return false;
|
||
if (experience === '5plus' && (p.experience_years || 0) < 5) return false;
|
||
if (experience === '2to5' && ((p.experience_years || 0) < 2 || (p.experience_years || 0) >= 5)) return false;
|
||
if (experience === 'under2' && (p.experience_years || 0) >= 2) return false;
|
||
return true;
|
||
})
|
||
.sort(SORTS[sort].compare);
|
||
}, [profiles, search, skill, experience, location, band, availability, sort]);
|
||
|
||
const isFiltered = Boolean(search) || [skill, experience, location, band, availability].some((v) => v !== 'all');
|
||
const clearFilters = () => {
|
||
setSearch(''); setSkill('all'); setExperience('all'); setLocation('all'); setBand('all'); setAvailability('all');
|
||
};
|
||
|
||
const segments = useMemo(() => {
|
||
const getBandMembers = (min, max) => profiles.filter((p) => {
|
||
const s = p.krow_score || 0;
|
||
return s >= min && (max == null || s < max);
|
||
});
|
||
return [
|
||
{ label: 'Elite', hint: '90+', members: getBandMembers(90), tone: 'brand' },
|
||
{ label: 'Excellent', hint: '75–89', members: getBandMembers(75, 90), tone: 'success' },
|
||
{ label: 'Solid', hint: '60–74', members: getBandMembers(60, 75), tone: 'default' },
|
||
{ label: 'Building', hint: 'under 60', members: getBandMembers(1, 60), tone: 'warning' },
|
||
{ label: 'Unscored', hint: 'no score', members: profiles.filter((p) => !(p.krow_score > 0)), tone: 'risk' },
|
||
].map((s) => ({
|
||
...s,
|
||
count: s.members.length,
|
||
available: s.members.filter((p) => (p.availability || []).length > 0).length,
|
||
avgExperience: (() => {
|
||
/* 0 years is "not stated", not "first year" — the cards that show it
|
||
gate on experience_years > 0, so the average is over the same set. */
|
||
const stated = s.members.filter((p) => (p.experience_years || 0) > 0);
|
||
return stated.length
|
||
? Math.round(stated.reduce((sum, p) => sum + p.experience_years, 0) / stated.length)
|
||
: 0;
|
||
})(),
|
||
}));
|
||
}, [profiles]);
|
||
|
||
const toggleSave = (id, name) => {
|
||
setSaved((prev) => {
|
||
const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id];
|
||
toast.success(prev.includes(id) ? `${name} removed from saved` : `${name} saved`);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const context = useMemo(() => ({
|
||
profiles, segments, filtered, isLoading, isFiltered, clearFilters, saved, toggleSave,
|
||
search, setSearch, skill, setSkill, experience, setExperience, location, setLocation,
|
||
band, setBand, availability, setAvailability, sort, setSort,
|
||
skills, locations, availabilities,
|
||
}), [
|
||
profiles, segments, filtered, isLoading, isFiltered, saved,
|
||
search, skill, experience, location, band, availability, sort, skills, locations, availabilities,
|
||
]);
|
||
|
||
return (
|
||
<AdminPage
|
||
title="Talent Pool"
|
||
subtitle="Discover and manage high-potential talent for future hiring."
|
||
>
|
||
<UiEditor />
|
||
<TalentPoolComposition context={context} />
|
||
</AdminPage>
|
||
);
|
||
}
|
||
|
||
/** The composed page. */
|
||
function TalentPoolComposition({ context }) {
|
||
const editing = useUiEditing();
|
||
const tree = editing?.tree || composePage('talent-pool').tree;
|
||
return <UiTreeRenderer nodes={tree} context={context} />;
|
||
}
|