ui changes

This commit is contained in:
2026-08-10 19:36:45 +05:30
parent 409dffe938
commit 5cd49a1655
49 changed files with 7988 additions and 470 deletions

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