810485ecc8
---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)
213 lines
8.7 KiB
TypeScript
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.
|