Files
Nexus/apps/web/src/components/analytics/skills/PeopleFinderTab.tsx
T
Hartmut c7b76e086d feat: unified Skills Hub — merge analytics + marketplace into one page
Combines SkillsAnalytics (496 LOC) and SkillMarketplace (346 LOC) into
a single tabbed Skills Hub (770 LOC total, -9% code).

New structure:
- skills/shared.tsx: ProficiencyBadge, GapIndicator, constants (extracted)
- skills/OverviewTab.tsx: KPI cards, top 10 table, distribution chart, export
- skills/SearchTab.tsx: skill search + proficiency + availability filter
- skills/GapsTab.tsx: supply vs demand table with gap indicators
- skills/PeopleFinderTab.tsx: multi-rule AND/OR builder, chapter filter, export
- SkillsHub.tsx: tabbed container with URL-persisted tab state (?tab=)

Routing:
- /analytics/skills renders SkillsHub (was SkillsAnalytics)
- /analytics/skill-marketplace redirects to /analytics/skills?tab=search
- Sidebar: "Skill Marketplace" removed, renamed to "Skills Hub"

No API changes — reuses existing queries with conditional fetching per tab.
Full dark theme support on all components.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-22 21:33:21 +01:00

220 lines
10 KiB
TypeScript

"use client";
import { useState, useId } from "react";
import Link from "next/link";
import { trpc } from "~/lib/trpc/client.js";
import { ProficiencyBadge, PROFICIENCY_LABELS, proficiencyClasses } from "./shared.js";
type SkillRule = { skill: string; minProficiency: number };
interface PeopleFinderTabProps {
allSkillNames: string[];
allChapters: string[];
}
export function PeopleFinderTab({ allSkillNames, allChapters }: PeopleFinderTabProps) {
const datalistId = useId();
const [rules, setRules] = useState<SkillRule[]>([]);
const [operator, setOperator] = useState<"AND" | "OR">("AND");
const [chapter, setChapter] = useState("");
const activeRules = rules.filter((r) => r.skill.trim().length > 0);
const { data: results, isFetching } = trpc.resource.searchBySkills.useQuery(
{ rules: activeRules, operator, ...(chapter ? { chapter } : {}) },
{ enabled: activeRules.length > 0, staleTime: 30_000 },
);
function addRule() { setRules((prev) => [...prev, { skill: "", minProficiency: 1 }]); }
function removeRule(idx: number) { setRules((prev) => prev.filter((_, i) => i !== idx)); }
function updateRule(idx: number, patch: Partial<SkillRule>) {
setRules((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
}
async function exportXlsx() {
if (!results || results.length === 0) return;
const XLSX = await import("xlsx");
const rows = results.map((p) => ({
Name: p.displayName,
EID: p.eid ?? "",
Chapter: p.chapter ?? "",
"Matched Skills": p.matchedSkills.map((s) => `${s.skill} (${s.proficiency})`).join(", "),
}));
const ws = XLSX.utils.json_to_sheet(rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "People Finder");
XLSX.writeFile(wb, `people-finder-${Date.now()}.xlsx`);
}
return (
<div className="space-y-4">
<div className="bg-white dark:bg-slate-900 rounded-xl border border-gray-200 dark:border-slate-700 p-5 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200">People Finder</h2>
<span className="text-xs text-gray-400 dark:text-gray-500">Find resources that match skill criteria</span>
</div>
{/* Datalist */}
<datalist id={datalistId}>
{allSkillNames.map((s) => <option key={s} value={s} />)}
</datalist>
{/* Rules */}
<div className="space-y-2">
{rules.map((rule, idx) => (
<div key={idx} className="flex items-center gap-2 flex-wrap">
{idx > 0 ? (
<button
type="button"
onClick={() => setOperator((op) => (op === "AND" ? "OR" : "AND"))}
className="w-12 text-center text-xs font-bold px-2 py-1.5 rounded-lg border border-brand-200 dark:border-brand-700 bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300 hover:bg-brand-100 dark:hover:bg-brand-900/50 transition-colors shrink-0"
>
{operator}
</button>
) : (
<span className="w-12 text-center text-xs font-medium text-gray-400 dark:text-gray-500 shrink-0">knows</span>
)}
<input
type="text"
list={datalistId}
placeholder="Skill name..."
value={rule.skill}
onChange={(e) => updateRule(idx, { skill: e.target.value })}
className="flex-1 min-w-40 px-3 py-1.5 border border-gray-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
<div className="flex items-center gap-1">
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">min.</span>
<div className="flex rounded-lg overflow-hidden border border-gray-200 dark:border-slate-600">
{[1, 2, 3, 4, 5].map((lvl) => (
<button
key={lvl}
type="button"
title={PROFICIENCY_LABELS[lvl]}
onClick={() => updateRule(idx, { minProficiency: lvl })}
className={`px-2 py-1 text-xs font-medium transition-colors ${
rule.minProficiency === lvl
? "bg-brand-600 text-white"
: "bg-white dark:bg-slate-800 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-slate-700"
}`}
>
{lvl}
</button>
))}
</div>
</div>
<button
type="button"
onClick={() => removeRule(idx)}
className="text-gray-400 hover:text-red-500 transition-colors p-1"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
{/* Controls row */}
<div className="flex flex-wrap items-center gap-3">
<button type="button" onClick={addRule} className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-dashed border-brand-300 dark:border-brand-700 text-brand-600 dark:text-brand-400 hover:bg-brand-50 dark:hover:bg-brand-900/30 transition-colors">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add skill rule
</button>
{rules.length > 1 && (
<div className="flex items-center gap-1.5">
<span className="text-xs text-gray-500 dark:text-gray-400">Match:</span>
{(["AND", "OR"] as const).map((op) => (
<button
key={op}
type="button"
onClick={() => setOperator(op)}
className={`px-2.5 py-1 text-xs font-medium border transition-colors ${
op === "AND" ? "rounded-l-lg" : "rounded-r-lg -ml-px"
} ${operator === op
? "bg-brand-600 border-brand-600 text-white"
: "bg-white dark:bg-slate-800 border-gray-200 dark:border-slate-600 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-slate-700"
}`}
>
{op === "AND" ? "All (AND)" : "Any (OR)"}
</button>
))}
</div>
)}
{allChapters.length > 0 && (
<div className="flex items-center gap-1.5 ml-auto">
<span className="text-xs text-gray-500 dark:text-gray-400">Chapter:</span>
<select
value={chapter}
onChange={(e) => setChapter(e.target.value)}
className="px-2 py-1.5 border border-gray-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-brand-500"
>
<option value="">All chapters</option>
{allChapters.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</div>
)}
{results && results.length > 0 && (
<button type="button" onClick={exportXlsx} className="px-3 py-1.5 text-sm rounded-lg border border-gray-200 dark:border-slate-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors">
Export XLSX
</button>
)}
</div>
{/* Results */}
{activeRules.length > 0 && (
<div className="border-t border-gray-100 dark:border-slate-700 pt-4">
{isFetching ? (
<div className="text-sm text-gray-400 animate-pulse">Searching...</div>
) : results && results.length === 0 ? (
<p className="text-sm text-gray-400 dark:text-gray-500 italic">No resources match these criteria.</p>
) : results && results.length > 0 ? (
<>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
{results.length} resource{results.length !== 1 ? "s" : ""} found
</p>
<div className="space-y-2">
{results.map((person) => (
<div key={person.id} className="flex items-start gap-3 p-3 rounded-lg bg-gray-50 dark:bg-slate-800/50 hover:bg-gray-100 dark:hover:bg-slate-800 transition-colors">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<Link href={`/resources/${person.id}`} className="text-sm font-medium text-gray-900 dark:text-gray-100 hover:text-brand-600 transition-colors">
{person.displayName}
</Link>
{person.eid && (
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300">{person.eid}</span>
)}
{person.chapter && (
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-100">{person.chapter}</span>
)}
</div>
<div className="flex flex-wrap gap-1.5 mt-1.5">
{person.matchedSkills.map((s) => (
<span key={s.skill} className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full border ${proficiencyClasses(s.proficiency)}`}>
{s.skill} <span className="font-semibold">{s.proficiency}</span>
</span>
))}
</div>
</div>
<Link href={`/resources/${person.id}`} className="text-xs text-brand-600 dark:text-brand-400 hover:underline shrink-0 mt-0.5">
View
</Link>
</div>
))}
</div>
</>
) : null}
</div>
)}
</div>
</div>
);
}