Files
nextcraft/apps/web/components/employer/talent-search.tsx
T
CIAgent 810485ecc8 docs(milestone): complete v0.1-nextcraft-ui-prototype
---ci---
phase: 7
milestone: v0.1
status: complete
requirements:
  covered: [REQ-001, REQ-002, REQ-003, REQ-004, REQ-005, REQ-006, REQ-007, REQ-008, REQ-009, REQ-010, REQ-011, REQ-012, REQ-013, REQ-014, REQ-015, REQ-016, REQ-017, REQ-018, REQ-019, REQ-020, REQ-021, REQ-022, REQ-023, REQ-024, REQ-025, REQ-026, REQ-027, REQ-028]
  partial: []
---/ci---

Milestone v0.1 (nextcraft-ui-prototype) complete.

Summary:
- 7 phases (P0 pre-execution + P1-P6 execution + P7 final review)
- 28 requirements covered (all complete)
- 4 surfaces: Learner (7 pages), Marketplace (5), Employer Dashboard (4), Admin (4)
- 19 routes, 104 TypeScript files
- Shared component library (5 primitives + design tokens)
- Mock data: 5 competency stacks (70 competencies), 20 jobs, 15 candidates, 10 employers
- Tech: Next.js 15, Tailwind CSS v4, lucide-react, recharts, @xyflow/react, Inter font
- Storybook with 6 stories
- Dark mode, breadcrumbs, role switcher, responsive design
- Build passes, typecheck passes, Storybook build passes

Phases:
  P0 pre-execution     → v0.0.1
  P1 project-scaffolding → v0.0.2
  P2 learner-surface   → v0.0.3
  P3 marketplace-surface → v0.0.4
  P4 employer-dashboard → v0.0.5
  P5 admin-surface     → v0.0.6
  P6 polish-integration → v0.0.7
  P7 final-review-ship → v0.1.0 (milestone release)
2026-09-10 22:47:56 +00:00

213 lines
8.7 KiB
TypeScript

'use client';
import { useMemo, useState } from 'react';
import { Search, Filter, FileCode, ShieldCheck, Target } from 'lucide-react';
import { Avatar, Badge } from '@nextcraft/ui';
import type { Candidate } from '@nextcraft/types';
import { matchBadgeClasses } from '../../lib/format';
/* -------------------------------------------------------------------------- */
/* Filter config */
/* -------------------------------------------------------------------------- */
const STACK_OPTIONS = [
{ value: 'all', label: 'All stacks' },
{ value: 'stack-orchestration', label: 'AI Orchestration' },
{ value: 'stack-safety', label: 'AI Safety' },
{ value: 'stack-designer', label: 'Product Design' },
{ value: 'stack-operator', label: 'Field Operator' },
{ value: 'stack-science', label: 'Comp Sciences' },
];
const DEFENSE_OPTIONS = [
{ value: 0, label: 'Any defense score' },
{ value: 70, label: 'Defense 70+' },
{ value: 80, label: 'Defense 80+' },
{ value: 90, label: 'Defense 90+' },
];
const ARTIFACT_OPTIONS = [
{ value: 0, label: 'Any artifacts' },
{ value: 1, label: '1+ artifacts' },
{ value: 3, label: '3+ artifacts' },
{ value: 5, label: '5+ artifacts' },
{ value: 10, label: '10+ artifacts' },
];
/** Derive 3 short microcredential badges from a candidate's stack + count. */
function microcredentialBadges(cand: Candidate): string[] {
const stackMap: Record<string, string[]> = {
'stack-orchestration': ['Agent Arch', 'Tool Use', 'RAG', 'Eval', 'Routing'],
'stack-safety': ['Red Team', 'Model Cards', 'Bias Audit', 'Policy', 'Interp'],
'stack-designer': ['Agentic UX', 'Conv UX', 'Transparency', 'Trust', 'Persona'],
'stack-operator': ['Robotics', 'Vision', 'Calibration', 'AR', 'Field Data'],
'stack-science': ['Sci Python', 'HPC', 'PINNs', 'Active Learn', 'Bioinformatics'],
};
const pool = stackMap[cand.competencyStackId] ?? ['MC-1', 'MC-2', 'MC-3'];
// Spread selection deterministically based on id suffix.
const offset = Number(cand.id.replace(/\D/g, '')) % pool.length;
return [pool[offset % pool.length], pool[(offset + 1) % pool.length], pool[(offset + 2) % pool.length]];
}
/* -------------------------------------------------------------------------- */
/* Component */
/* -------------------------------------------------------------------------- */
export interface TalentSearchProps {
candidates: Candidate[];
}
export function TalentSearch({ candidates }: TalentSearchProps) {
const [query, setQuery] = useState('');
const [stack, setStack] = useState('all');
const [minDefense, setMinDefense] = useState(0);
const [minArtifacts, setMinArtifacts] = useState(0);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return candidates.filter((c) => {
if (q) {
const hay = `${c.name} ${c.headline}`.toLowerCase();
if (!hay.includes(q)) return false;
}
if (stack !== 'all' && c.competencyStackId !== stack) return false;
if (c.defenseScore < minDefense) return false;
if (c.artifactCount < minArtifacts) return false;
return true;
});
}, [candidates, query, stack, minDefense, minArtifacts]);
const selectClass =
'h-9 rounded-md border border-slate-300 bg-white px-2.5 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100';
return (
<div className="flex flex-col gap-6">
{/* Filter bar */}
<div className="flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
<div className="relative flex items-center">
<Search className="pointer-events-none absolute left-3 h-4 w-4 text-slate-400" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search candidates by name or headline..."
aria-label="Search candidates"
className="h-10 w-full rounded-md border border-slate-300 bg-white pl-9 pr-3 text-sm text-slate-900 placeholder:text-slate-400 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:placeholder:text-slate-500"
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-slate-500 dark:text-slate-400">
<Filter className="h-3.5 w-3.5" /> Filters:
</span>
<select
aria-label="Competency stack"
value={stack}
onChange={(e) => setStack(e.target.value)}
className={selectClass}
>
{STACK_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<select
aria-label="Minimum defense score"
value={minDefense}
onChange={(e) => setMinDefense(Number(e.target.value))}
className={selectClass}
>
{DEFENSE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<select
aria-label="Minimum artifact count"
value={minArtifacts}
onChange={(e) => setMinArtifacts(Number(e.target.value))}
className={selectClass}
>
{ARTIFACT_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<span className="ml-auto text-sm text-slate-500 dark:text-slate-400">
Showing <span className="font-semibold text-slate-700 dark:text-slate-200">{filtered.length}</span> of{' '}
{candidates.length}
</span>
</div>
</div>
{/* Candidate grid */}
{filtered.length === 0 ? (
<div className="rounded-lg border border-dashed border-slate-300 p-12 text-center text-slate-500 dark:border-slate-700 dark:text-slate-400">
No candidates match your filters.
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{filtered.map((cand) => (
<CandidateCard key={cand.id} cand={cand} />
))}
</div>
)}
</div>
);
}
function CandidateCard({ cand }: { cand: Candidate }) {
const badges = microcredentialBadges(cand);
return (
<article className="flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-5 shadow-sm transition-shadow hover:shadow-md dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-start gap-3">
<Avatar name={cand.name} src={cand.avatar} size="md" />
<div className="flex min-w-0 flex-1 flex-col">
<h3 className="truncate text-base font-semibold text-slate-900 dark:text-slate-100">
{cand.name}
</h3>
<p className="truncate text-sm text-slate-600 dark:text-slate-400">{cand.headline}</p>
</div>
<span
className={`inline-flex h-9 w-12 shrink-0 items-center justify-center rounded-full text-xs font-bold ring-2 ${matchBadgeClasses(
cand.matchScore,
)}`}
title="AI match score"
>
{cand.matchScore}%
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{badges.map((b) => (
<Badge key={b} variant="info">
{b}
</Badge>
))}
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-600 dark:text-slate-400">
<span className="inline-flex items-center gap-1">
<FileCode className="h-3.5 w-3.5" /> {cand.artifactCount} artifacts
</span>
<span className="inline-flex items-center gap-1">
<ShieldCheck className="h-3.5 w-3.5" /> Defense: {cand.defenseScore}
</span>
<span className="inline-flex items-center gap-1">
<Target className="h-3.5 w-3.5" /> Match: {cand.matchScore}%
</span>
</div>
<a
href={`/employer/talent/${cand.id}`}
className="mt-auto inline-flex h-9 w-full items-center justify-center rounded-md border border-slate-300 bg-transparent text-sm font-medium text-slate-800 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:text-slate-100 dark:hover:bg-slate-800"
>
View Profile
</a>
</article>
);
}
// Re-export icon used in card stats so the import is not tree-shaken away.