fix talent pool ui

This commit is contained in:
2026-08-11 17:09:53 +05:30
parent 7427359967
commit 8a256a4de7
12 changed files with 473 additions and 256 deletions

View File

@@ -1,6 +1,5 @@
import React, { useMemo, useState } from 'react';
import { Brain } from 'lucide-react';
import { Button, SearchInput, toast } from '@/components/ds';
import { Button, SearchInput } from '@/components/ds';
import { useApplications, useInterviews, useJobPostings } from '@/lib/krowHooks';
import { AdminPage, Toolbar } from '@/pages/admin/_shell';
import { FilterSelect } from '@/pages/admin/Positions';
@@ -43,8 +42,8 @@ export default function AdminCandidates() {
.filter((a) => {
if (q && !`${a.applicant_name} ${a.email} ${a.job_title} ${(a.skills || []).join(' ')}`.toLowerCase().includes(q)) return false;
if (position !== 'all' && a.job_title !== position) return false;
if (stage !== 'all' && a.status !== stage) return false;
if (!SCORE_BANDS[band](a.ai_score || 0)) return false;
if (stage !== 'all' && a.stage !== stage) return false;
if (band !== 'all' && !SCORE_BANDS[band](a.ai_score)) return false;
return true;
})
.sort(SORTS[sort].compare);
@@ -60,11 +59,6 @@ export default function AdminCandidates() {
<AdminPage
title="Candidates"
subtitle="Review, compare and manage the candidate pipeline."
actions={
<Button variant="outline" size="sm" shape="rounded" onClick={() => toast.info('Batch screening is disabled in the demo')}>
<Brain aria-hidden="true" /> Screen unscored
</Button>
}
>
<Toolbar
search={<SearchInput value={search} onChange={setSearch} placeholder="Search candidates..." size="sm" />}

View File

@@ -1,8 +1,8 @@
import React, { useMemo, useState } from 'react';
import { Bookmark, GitCompare, Star, UserRound } from 'lucide-react';
import { Bookmark, Star, UserRound } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Avatar, Badge, Button, DataTable, IconButton, Modal, ProgressBar, ProgressRing,
SearchInput, Surface, toast,
Avatar, Badge, DataTable, IconButton, ProgressRing, SearchInput, toast,
} from '@/components/ds';
import { useWorkerProfiles } from '@/lib/krowHooks';
import { getScoreBand, toFICO } from '@/lib/talentHome';
@@ -44,7 +44,6 @@ export default function AdminTalentPool() {
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(),
@@ -109,15 +108,6 @@ export default function AdminTalentPool() {
}));
}, [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];
@@ -130,17 +120,6 @@ export default function AdminTalentPool() {
<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">
@@ -149,41 +128,62 @@ export default function AdminTalentPool() {
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>}
</>
{/* Five compact cards, one per band, across the page's content width — the
same measure the directory below uses, so the two sections share a left
and right edge. A band is read one at a time, so each gets its own small
surface with the count directly above the detail that qualifies it. */}
<div>
<ul className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5">
{segments.map((s) => {
const actionable = s.tone === 'risk';
const empty = s.count === 0;
return (
<li
key={s.label}
className="rounded-xl border border-border bg-surface px-3.5 py-3 transition-colors duration-base hover:border-ink-4/40"
>
<div className="flex items-baseline gap-1.5">
<span className="truncate text-body-sm font-semibold text-ink-1">{s.label}</span>
<span className="truncate text-caption text-ink-4">{s.hint}</span>
{/* The one accent in the card: a 5px dot, warning where the
band is the actionable one. */}
{!empty && (
<span
className={cn(
'ml-auto h-1.5 w-1.5 shrink-0 translate-y-[-1px] rounded-full',
actionable ? 'bg-warning' : s.tone === 'brand' ? 'bg-krow-blue' : 'bg-ink-4/40'
)}
aria-hidden="true"
/>
)}
<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">
</div>
<p
className={cn(
'mt-2 font-heading text-title-lg font-bold leading-none tabular-nums',
empty ? 'text-ink-4' : actionable ? 'text-warning' : 'text-ink-1'
)}
>
{s.count}
</p>
<p className="mt-1.5 truncate text-[11px] text-ink-4">
{empty ? 'No profiles' : [
`${s.available} available`,
actionable ? 'needs verification' : s.avgExperience > 0 ? `${s.avgExperience}y avg` : null,
].filter(Boolean).join(' · ')}
</p>
</li>
);
})}
</ul>
<p className="mt-3 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>
</div>
</section>
<SectionTitle title="Talent directory" meta={`${profiles.length} profiles`} />
@@ -302,83 +302,6 @@ export default function AdminTalentPool() {
},
]}
/>
{/* 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>
);
}