This commit is contained in:
@@ -1,25 +1,19 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Bookmark, Star, UserRound, Trophy, CheckCircle2, TrendingUp, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Avatar, Badge, DataTable, IconButton, ProgressRing, SearchInput, toast,
|
||||
} from '@/components/ds';
|
||||
import { toast } from '@/components/ds';
|
||||
import { useWorkerProfiles } from '@/lib/krowHooks';
|
||||
import { getScoreBand, toFICO } from '@/lib/talentHome';
|
||||
import { AdminPage, SectionTitle, Toolbar } from '@/components/admin/PageShell';
|
||||
import { FilterSelect } from '@/pages/admin/Positions';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
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 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).getTime() - new Date(a.created_date).getTime() },
|
||||
};
|
||||
|
||||
const BANDS = {
|
||||
all: () => true,
|
||||
@@ -30,53 +24,6 @@ const BANDS = {
|
||||
unscored: (s) => !s,
|
||||
};
|
||||
|
||||
const SEGMENT_CONFIG = {
|
||||
Elite: {
|
||||
key: 'elite',
|
||||
icon: Trophy,
|
||||
bg: 'bg-blue-50/80 text-blue-600 dark:bg-blue-950/60 dark:text-blue-400',
|
||||
border: 'border-blue-200/50 dark:border-blue-800/40 hover:border-blue-400',
|
||||
activeBorder: 'border-blue-600 ring-2 ring-blue-500/20',
|
||||
text: 'text-blue-600 dark:text-blue-400',
|
||||
accent: 'from-blue-500 to-indigo-500',
|
||||
},
|
||||
Excellent: {
|
||||
key: 'excellent',
|
||||
icon: Star,
|
||||
bg: 'bg-emerald-50/80 text-emerald-600 dark:bg-emerald-950/60 dark:text-emerald-400',
|
||||
border: 'border-emerald-200/50 dark:border-emerald-800/40 hover:border-emerald-400',
|
||||
activeBorder: 'border-emerald-600 ring-2 ring-emerald-500/20',
|
||||
text: 'text-emerald-600 dark:text-emerald-400',
|
||||
accent: 'from-emerald-500 to-teal-500',
|
||||
},
|
||||
Solid: {
|
||||
key: 'solid',
|
||||
icon: CheckCircle2,
|
||||
bg: 'bg-indigo-50/80 text-indigo-600 dark:bg-indigo-950/60 dark:text-indigo-400',
|
||||
border: 'border-indigo-200/50 dark:border-indigo-800/40 hover:border-indigo-400',
|
||||
activeBorder: 'border-indigo-600 ring-2 ring-indigo-500/20',
|
||||
text: 'text-indigo-600 dark:text-indigo-400',
|
||||
accent: 'from-indigo-500 to-purple-500',
|
||||
},
|
||||
Building: {
|
||||
key: 'building',
|
||||
icon: TrendingUp,
|
||||
bg: 'bg-amber-50/80 text-amber-600 dark:bg-amber-950/60 dark:text-amber-400',
|
||||
border: 'border-amber-200/50 dark:border-amber-800/40 hover:border-amber-400',
|
||||
activeBorder: 'border-amber-600 ring-2 ring-amber-500/20',
|
||||
text: 'text-amber-600 dark:text-amber-400',
|
||||
accent: 'from-amber-500 to-orange-500',
|
||||
},
|
||||
Unscored: {
|
||||
key: 'unscored',
|
||||
icon: Sparkles,
|
||||
bg: 'bg-orange-50/80 text-orange-600 dark:bg-orange-950/60 dark:text-orange-400',
|
||||
border: 'border-orange-200/50 dark:border-orange-800/40 hover:border-orange-400',
|
||||
activeBorder: 'border-orange-600 ring-2 ring-orange-500/20',
|
||||
text: 'text-orange-600 dark:text-orange-400',
|
||||
accent: 'from-orange-500 to-red-500',
|
||||
},
|
||||
};
|
||||
|
||||
export default function AdminTalentPool() {
|
||||
const { data: profiles = [], isLoading } = useWorkerProfiles();
|
||||
@@ -159,214 +106,30 @@ export default function AdminTalentPool() {
|
||||
});
|
||||
};
|
||||
|
||||
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."
|
||||
>
|
||||
<SkillSurface page="talent-pool" placement="after-header" />
|
||||
{/* Segments — executive metric cards */}
|
||||
<section aria-labelledby="segments" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="segments"
|
||||
title="Talent segments"
|
||||
meta={`${profiles.length} in the pool`}
|
||||
/>
|
||||
<div>
|
||||
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5">
|
||||
{segments.map((s) => {
|
||||
const cfg = SEGMENT_CONFIG[s.label] || SEGMENT_CONFIG.Elite;
|
||||
const Icon = cfg.icon;
|
||||
const isSelected = band === cfg.key;
|
||||
const empty = s.count === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={s.label}
|
||||
onClick={() => setBand(isSelected ? 'all' : cfg.key)}
|
||||
className={cn(
|
||||
'group relative flex flex-col justify-between cursor-pointer rounded-xl border bg-surface p-4 shadow-xs backdrop-blur-xs transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
||||
isSelected ? cfg.activeBorder : cfg.border
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{/* Header Row: Label, Hint & Icon */}
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
<div className="flex items-baseline gap-1.5 min-w-0">
|
||||
<span className="truncate text-[11px] font-bold uppercase tracking-wider text-ink-1">
|
||||
{s.label}
|
||||
</span>
|
||||
<span className="truncate text-[10px] font-medium text-ink-4">
|
||||
{s.hint}
|
||||
</span>
|
||||
</div>
|
||||
<div className={cn('flex h-7 w-7 shrink-0 items-center justify-center rounded-lg shadow-xs', cfg.bg)}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main count */}
|
||||
<div className="mt-2.5 flex items-baseline gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'font-heading text-title-xl font-bold leading-none tabular-nums',
|
||||
empty ? 'text-ink-4' : cfg.text
|
||||
)}
|
||||
>
|
||||
{s.count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sub pill & Accent bar */}
|
||||
<div className="mt-3.5 space-y-2">
|
||||
<span className="inline-flex items-center rounded-full bg-surface-subtle px-2 py-0.5 text-[10px] font-semibold text-ink-3 border border-border/60">
|
||||
{empty
|
||||
? 'No profiles'
|
||||
: [
|
||||
`${s.available} available`,
|
||||
s.label === 'Unscored'
|
||||
? 'needs verification'
|
||||
: s.avgExperience > 0
|
||||
? `${s.avgExperience}y avg`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
|
||||
<div className={cn('h-0.5 w-full rounded-full bg-gradient-to-r opacity-40 group-hover:opacity-100 transition-opacity', cfg.accent)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="mt-3 border-t border-border/60 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>
|
||||
</div>
|
||||
</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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<SkillSurface page="talent-pool" placement="before-footer" />
|
||||
<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} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user