ui changes
This commit is contained in:
384
src/pages/admin/TalentPool.jsx
Normal file
384
src/pages/admin/TalentPool.jsx
Normal file
@@ -0,0 +1,384 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Bookmark, GitCompare, Star, UserRound } from 'lucide-react';
|
||||
import {
|
||||
Avatar, Badge, Button, DataTable, IconButton, Modal, ProgressBar, ProgressRing,
|
||||
SearchInput, Surface, toast,
|
||||
} from '@/components/ds';
|
||||
import { useWorkerProfiles } from '@/lib/krowHooks';
|
||||
import { getScoreBand, toFICO } from '@/lib/talentHome';
|
||||
import { AdminPage, SectionTitle, Toolbar } from '@/pages/admin/_shell';
|
||||
import { FilterSelect } from '@/pages/admin/Positions';
|
||||
|
||||
/**
|
||||
* Admin Talent Pool — talent intelligence, not a card gallery.
|
||||
*
|
||||
* These are people who have not applied to anything, so the job is discovery:
|
||||
* filter down, compare, shortlist. A dense list makes that possible; one card
|
||||
* per person makes it a scroll.
|
||||
*/
|
||||
|
||||
const SORTS = {
|
||||
score: { label: 'Highest score', compare: (a, b) => (b.krow_score || 0) - (a.krow_score || 0) },
|
||||
experience: { label: 'Most experience', compare: (a, b) => (b.experience_years || 0) - (a.experience_years || 0) },
|
||||
name: { label: 'Name A–Z', compare: (a, b) => a.full_name.localeCompare(b.full_name) },
|
||||
recent: { label: 'Recently added', compare: (a, b) => new Date(b.created_date) - new Date(a.created_date) },
|
||||
};
|
||||
|
||||
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 [compareOpen, setCompareOpen] = useState(false);
|
||||
|
||||
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');
|
||||
};
|
||||
|
||||
/**
|
||||
* Talent segments.
|
||||
*
|
||||
* The pool's shape by career score band, with how many in each band are
|
||||
* actually available. Score without availability is a shortlist you cannot
|
||||
* staff from, so the two belong in the same read.
|
||||
*/
|
||||
const segments = useMemo(() => {
|
||||
const band = (min, max) => profiles.filter((p) => {
|
||||
const s = p.krow_score || 0;
|
||||
return s >= min && (max == null || s < max);
|
||||
});
|
||||
return [
|
||||
{ label: 'Elite', hint: '90+', members: band(90), tone: 'brand' },
|
||||
{ label: 'Excellent', hint: '75–89', members: band(75, 90), tone: 'success' },
|
||||
{ label: 'Solid', hint: '60–74', members: band(60, 75), tone: 'default' },
|
||||
{ label: 'Building', hint: 'under 60', members: band(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: s.members.length
|
||||
? Math.round(s.members.reduce((sum, p) => sum + (p.experience_years || 0), 0) / s.members.length)
|
||||
: 0,
|
||||
}));
|
||||
}, [profiles]);
|
||||
|
||||
const maxSegment = Math.max(...segments.map((s) => s.count), 1);
|
||||
|
||||
/* Whoever is saved, side by side. Saving is the selection mechanism, so the
|
||||
comparison needs no second one. */
|
||||
const savedProfiles = useMemo(
|
||||
() => profiles.filter((p) => saved.includes(p.id)).sort((a, b) => (b.krow_score || 0) - (a.krow_score || 0)),
|
||||
[profiles, saved]
|
||||
);
|
||||
|
||||
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;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title="Talent Pool"
|
||||
subtitle="Discover and manage high-potential talent for future hiring."
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="rounded"
|
||||
disabled={saved.length < 2}
|
||||
onClick={() => setCompareOpen(true)}
|
||||
>
|
||||
<GitCompare aria-hidden="true" /> Compare{saved.length ? ` (${saved.length})` : ''}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{/* Segments — the pool's shape, and how much of each band is available. */}
|
||||
<section aria-labelledby="segments" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="segments"
|
||||
title="Talent segments"
|
||||
meta={`${profiles.length} in the pool`}
|
||||
/>
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
|
||||
<div className="space-y-3">
|
||||
{segments.map((s) => (
|
||||
<div key={s.label}>
|
||||
<div className="mb-1 flex items-baseline justify-between gap-2">
|
||||
<span className="text-body-sm text-ink-2">
|
||||
{s.label} <span className="text-caption text-ink-4">{s.hint}</span>
|
||||
</span>
|
||||
<span className="flex items-baseline gap-3 text-[11px] text-ink-4">
|
||||
{s.count > 0 && (
|
||||
<>
|
||||
<span>{s.available} available</span>
|
||||
{s.avgExperience > 0 && <span>{s.avgExperience}y avg</span>}
|
||||
</>
|
||||
)}
|
||||
<span className="w-6 text-right text-body-sm font-bold tabular-nums text-ink-1">
|
||||
{s.count}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={s.count}
|
||||
max={maxSegment}
|
||||
size="sm"
|
||||
tone={s.tone === 'risk' ? 'warning' : s.tone === 'warning' ? 'warning' : 'brand'}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-4 border-t border-border pt-3 text-caption leading-relaxed text-ink-3">
|
||||
{segments[4].count > segments[0].count + segments[1].count
|
||||
? `${segments[4].count} workers carry no career score against ${segments[0].count + segments[1].count} at Excellent or above. Supply is not the constraint here — verification is.`
|
||||
: 'The scored part of the pool outweighs the unscored, so matching has enough to work with.'}
|
||||
</p>
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
<SectionTitle title="Talent directory" meta={`${profiles.length} profiles`} />
|
||||
<Toolbar
|
||||
search={<SearchInput value={search} onChange={setSearch} placeholder="Search name, role or skill" size="sm" />}
|
||||
filters={
|
||||
<>
|
||||
<FilterSelect value={skill} onChange={setSkill} label="Skills"
|
||||
options={[{ value: 'all', label: 'All skills' }, ...skills.map((s) => ({ value: s, label: s }))]} />
|
||||
<FilterSelect value={experience} onChange={setExperience} label="Experience" options={[
|
||||
{ value: 'all', label: 'Any experience' }, { value: '5plus', label: '5+ years' },
|
||||
{ value: '2to5', label: '2–5 years' }, { value: 'under2', label: 'Under 2 years' },
|
||||
]} />
|
||||
<FilterSelect value={location} onChange={setLocation} label="Location"
|
||||
options={[{ value: 'all', label: 'All locations' }, ...locations.map((l) => ({ value: l, label: l }))]} />
|
||||
<FilterSelect value={band} onChange={setBand} label="Score" options={[
|
||||
{ value: 'all', label: 'All scores' }, { value: 'elite', label: 'Elite 90+' },
|
||||
{ value: 'excellent', label: 'Excellent 75+' }, { value: 'solid', label: 'Solid 60+' },
|
||||
{ value: 'building', label: 'Building' }, { value: 'unscored', label: 'Unscored' },
|
||||
]} />
|
||||
<FilterSelect value={availability} onChange={setAvailability} label="Availability"
|
||||
options={[{ value: 'all', label: 'Any availability' }, ...availabilities.map((a) => ({ value: a, label: a }))]} />
|
||||
<FilterSelect value={sort} onChange={setSort} label="Sort"
|
||||
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
|
||||
</>
|
||||
}
|
||||
meta={`${filtered.length} of ${profiles.length} talents${isFiltered ? ' · filtered' : ''}${saved.length ? ` · ${saved.length} saved` : ''}`}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
loading={isLoading}
|
||||
rows={filtered}
|
||||
pageSize={15}
|
||||
isFiltered={isFiltered}
|
||||
onClearFilters={clearFilters}
|
||||
caption="Talent pool ranked by career score"
|
||||
columns={[
|
||||
{
|
||||
key: 'full_name', header: 'Candidate', sortable: true,
|
||||
cell: (p) => (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<Avatar name={p.full_name} src={p.selfie_url} size="sm" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-ink-1">{p.full_name}</p>
|
||||
<p className="truncate text-[10px] text-ink-4">{p.address || p.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'role', header: 'Role', hideBelow: 'md',
|
||||
cell: (p) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-ink-2">{p.current_position || p.desired_position || '—'}</p>
|
||||
{p.desired_position && p.current_position && (
|
||||
<p className="truncate text-[10px] text-ink-4">wants {p.desired_position}</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'skills', header: 'Skills', hideBelow: 'lg',
|
||||
cell: (p) => (p.skills?.length ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant="neutral" size="sm">{p.skills[0]}</Badge>
|
||||
{p.skills.length > 1 && <span className="text-[10px] text-ink-4">+{p.skills.length - 1}</span>}
|
||||
</div>
|
||||
) : <span className="text-[11px] text-ink-4">—</span>),
|
||||
},
|
||||
{
|
||||
key: 'experience_years', header: 'Exp', align: 'right', sortable: true, hideBelow: 'md',
|
||||
cell: (p) => (p.experience_years ? `${p.experience_years}y` : '—'),
|
||||
},
|
||||
{
|
||||
key: 'availability', header: 'Availability', hideBelow: 'lg',
|
||||
cell: (p) => (p.availability?.length
|
||||
? <span className="text-[11px] text-ink-3">{p.availability.join(', ')}</span>
|
||||
: <Badge variant="warning" size="sm">Not set</Badge>),
|
||||
},
|
||||
{
|
||||
key: 'krow_score', header: 'Career score', align: 'right', sortable: true,
|
||||
cell: (p) => {
|
||||
const score = p.krow_score || 0;
|
||||
const bandInfo = getScoreBand(score);
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className="text-right">
|
||||
<p className="font-heading text-body font-bold tabular-nums text-ink-1">{toFICO(score)}</p>
|
||||
<p className="text-[10px] text-ink-4">{bandInfo.label}</p>
|
||||
</div>
|
||||
<ProgressRing value={score} size={26} strokeWidth={3} tone="score" label="" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'actions', header: '', align: 'right', width: 92,
|
||||
cell: (p) => (
|
||||
<div className="flex items-center justify-end gap-0.5" onClick={(e) => e.stopPropagation()} role="presentation">
|
||||
<IconButton
|
||||
icon={saved.includes(p.id) ? Star : Bookmark}
|
||||
label={saved.includes(p.id) ? `Remove ${p.full_name} from saved` : `Save ${p.full_name}`}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleSave(p.id, p.full_name)}
|
||||
/>
|
||||
<IconButton
|
||||
icon={UserRound}
|
||||
label={`View ${p.full_name}'s profile`}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toast.info(`Opening ${p.full_name}'s KROW Identity`)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Saved talent, side by side. A table, because the value is reading the
|
||||
same five fields across people — anything more elaborate would be a
|
||||
second product. */}
|
||||
<Modal
|
||||
open={compareOpen}
|
||||
onOpenChange={setCompareOpen}
|
||||
title="Compare saved talent"
|
||||
description={`${savedProfiles.length} saved, ranked by career score.`}
|
||||
icon={GitCompare}
|
||||
size="lg"
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-body-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
{['Talent', 'Career score', 'Experience', 'Skills', 'Availability'].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
scope="col"
|
||||
className="whitespace-nowrap px-3 py-2 text-left text-[10px] font-semibold uppercase tracking-wide text-ink-4"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{savedProfiles.map((p) => (
|
||||
<tr key={p.id} className="border-b border-border last:border-0">
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Avatar name={p.full_name} src={p.selfie_url} size="xs" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-ink-1">{p.full_name}</p>
|
||||
<p className="truncate text-[10px] text-ink-4">
|
||||
{p.desired_position || p.current_position || '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2.5">
|
||||
{p.krow_score ? (
|
||||
<span className="font-heading font-bold tabular-nums text-ink-1">
|
||||
{toFICO(p.krow_score)}
|
||||
<span className="ml-1.5 text-[10px] font-normal text-ink-4">
|
||||
{getScoreBand(p.krow_score).label}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-ink-4">Not scored</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2.5 text-ink-2">
|
||||
{p.experience_years ? `${p.experience_years}y` : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
{p.skills?.length ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant="neutral" size="sm">{p.skills[0]}</Badge>
|
||||
{p.skills.length > 1 && (
|
||||
<span className="text-[10px] text-ink-4">+{p.skills.length - 1}</span>
|
||||
)}
|
||||
</div>
|
||||
) : <span className="text-[11px] text-ink-4">—</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-ink-2">
|
||||
{p.availability?.length ? p.availability.join(', ') : (
|
||||
<Badge variant="warning" size="sm">Not set</Badge>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Modal>
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user