c7b76e086d
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>
220 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|