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>
This commit is contained in:
2026-03-22 21:33:21 +01:00
parent 1f4c230b8b
commit c7b76e086d
10 changed files with 901 additions and 272 deletions
@@ -0,0 +1,110 @@
"use client";
import { useSearchParams, useRouter } from "next/navigation";
import dynamic from "next/dynamic";
import { trpc } from "~/lib/trpc/client.js";
const OverviewTab = dynamic(() => import("./skills/OverviewTab.js").then((m) => ({ default: m.OverviewTab })), {
loading: () => <div className="h-64 shimmer-skeleton rounded-xl" />,
});
const SearchTab = dynamic(() => import("./skills/SearchTab.js").then((m) => ({ default: m.SearchTab })), {
loading: () => <div className="h-64 shimmer-skeleton rounded-xl" />,
});
const GapsTab = dynamic(() => import("./skills/GapsTab.js").then((m) => ({ default: m.GapsTab })), {
loading: () => <div className="h-64 shimmer-skeleton rounded-xl" />,
});
const PeopleFinderTab = dynamic(() => import("./skills/PeopleFinderTab.js").then((m) => ({ default: m.PeopleFinderTab })), {
loading: () => <div className="h-64 shimmer-skeleton rounded-xl" />,
});
const TABS = [
{ key: "overview", label: "Overview" },
{ key: "search", label: "Search" },
{ key: "gaps", label: "Gaps" },
{ key: "people", label: "People Finder" },
] as const;
type TabKey = (typeof TABS)[number]["key"];
export function SkillsHub() {
const searchParams = useSearchParams();
const router = useRouter();
const rawTab = searchParams.get("tab");
const activeTab: TabKey = TABS.some((t) => t.key === rawTab) ? (rawTab as TabKey) : "overview";
function setTab(tab: TabKey) {
const params = new URLSearchParams(searchParams.toString());
params.set("tab", tab);
router.replace(`/analytics/skills?${params.toString()}` as `/analytics/skills`, { scroll: false });
}
const { data, isLoading, error } = trpc.resource.getSkillsAnalytics.useQuery(undefined, { staleTime: 60_000 });
if (isLoading) {
return (
<div className="p-6 space-y-4">
<div className="h-8 shimmer-skeleton rounded w-64" />
<div className="h-64 shimmer-skeleton rounded-xl" />
</div>
);
}
if (error) {
return (
<div className="p-6">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4 text-sm text-red-700 dark:text-red-300">
{error.message}
</div>
</div>
);
}
return (
<div className="p-6 pb-24 space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Skills Hub</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{data?.totalResources} active resources &middot; {data?.totalSkillEntries} distinct skills
</p>
</div>
{/* Tab bar */}
<div className="flex gap-1 border-b border-gray-200 dark:border-slate-700">
{TABS.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setTab(tab.key)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px ${
activeTab === tab.key
? "border-brand-600 text-brand-600 dark:text-brand-400 dark:border-brand-400"
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-slate-600"
}`}
>
{tab.label}
</button>
))}
</div>
{/* Tab content */}
{activeTab === "overview" && data && (
<OverviewTab
aggregated={data.aggregated}
categories={data.categories}
totalResources={data.totalResources}
totalSkillEntries={data.totalSkillEntries}
/>
)}
{activeTab === "search" && <SearchTab />}
{activeTab === "gaps" && <GapsTab />}
{activeTab === "people" && data && (
<PeopleFinderTab
allSkillNames={data.aggregated.map((e) => e.skill)}
allChapters={data.allChapters}
/>
)}
</div>
);
}
@@ -0,0 +1,94 @@
"use client";
import { useMemo } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { useTableSort } from "~/hooks/useTableSort.js";
import { GapIndicator } from "./shared.js";
export function GapsTab() {
const { data, isLoading } = trpc.resource.getSkillMarketplace.useQuery(
{ searchSkill: undefined, minProficiency: 1, availableOnly: false },
{ staleTime: 60_000 },
);
const gapData = useMemo(() => data?.gapData ?? [], [data?.gapData]);
const { sorted, sortField, sortDir, toggle } = useTableSort(gapData);
if (isLoading) {
return (
<div className="space-y-4">
<div className="h-8 shimmer-skeleton rounded w-48" />
<div className="h-64 shimmer-skeleton rounded-xl" />
</div>
);
}
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>
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200">Supply vs Demand</h2>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Supply = resources with proficiency 3+ &middot; Demand = unfilled demand requirements &middot; Sorted by largest gap
</p>
</div>
{sorted.length === 0 ? (
<p className="text-sm text-gray-400 dark:text-gray-500 italic py-4">
No gap data available. Gaps appear when projects have unfilled demand requirements with required skills.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-slate-800 border-b border-gray-200 dark:border-slate-700">
<tr>
<SortableColumnHeader label="Skill" field="skill" sortField={sortField} sortDir={sortDir} onSort={toggle} />
<SortableColumnHeader label="Supply" field="supply" sortField={sortField} sortDir={sortDir} onSort={toggle} align="right" />
<SortableColumnHeader label="Demand" field="demand" sortField={sortField} sortDir={sortDir} onSort={toggle} align="right" />
<SortableColumnHeader label="Gap" field="gap" sortField={sortField} sortDir={sortDir} onSort={toggle} align="center" />
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Visual</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-700">
{sorted.map((row) => {
const maxBar = Math.max(row.supply, row.demand, 1);
return (
<tr key={row.skill} className="hover:bg-gray-50 dark:hover:bg-slate-800/50">
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-gray-100">{row.skill}</td>
<td className="px-4 py-2.5 text-right text-gray-700 dark:text-gray-300">{row.supply}</td>
<td className="px-4 py-2.5 text-right text-gray-700 dark:text-gray-300">{row.demand}</td>
<td className="px-4 py-2.5 text-center"><GapIndicator gap={row.gap} /></td>
<td className="px-4 py-2.5 w-48">
<div className="flex items-center gap-1 h-4">
<div
className="h-3 rounded-sm bg-green-400 dark:bg-green-500 transition-all"
style={{ width: `${(row.supply / maxBar) * 100}%`, minWidth: row.supply > 0 ? 4 : 0 }}
title={`Supply: ${row.supply}`}
/>
<div
className="h-3 rounded-sm bg-red-400 dark:bg-red-500 transition-all"
style={{ width: `${(row.demand / maxBar) * 100}%`, minWidth: row.demand > 0 ? 4 : 0 }}
title={`Demand: ${row.demand}`}
/>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
<div className="flex items-center gap-4 mt-3 px-4">
<div className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span className="w-3 h-3 rounded-sm bg-green-400 dark:bg-green-500 inline-block" /> Supply (prof. 3+)
</div>
<div className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span className="w-3 h-3 rounded-sm bg-red-400 dark:bg-red-500 inline-block" /> Demand (unfilled)
</div>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,157 @@
"use client";
import { useState } from "react";
import dynamic from "next/dynamic";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { useTableSort } from "~/hooks/useTableSort.js";
import { ProficiencyBadge } from "./shared.js";
const SkillDistributionChart = dynamic(
() => import("~/components/analytics/SkillDistributionChart.js"),
{ ssr: false, loading: () => <div className="h-80 shimmer-skeleton rounded-xl" /> },
);
interface AggregatedSkill {
skill: string;
category: string;
count: number;
avgProficiency: number;
chapters: string[];
}
interface OverviewTabProps {
aggregated: AggregatedSkill[];
categories: string[];
totalResources: number;
totalSkillEntries: number;
}
export function OverviewTab({ aggregated, categories, totalResources, totalSkillEntries }: OverviewTabProps) {
const [categoryFilter, setCategoryFilter] = useState("");
const [minCount, setMinCount] = useState(1);
const filtered = aggregated.filter((e) => {
if (categoryFilter && e.category !== categoryFilter) return false;
if (e.count < minCount) return false;
return true;
});
const { sorted, sortField, sortDir, toggle } = useTableSort(filtered);
const top10 = filtered.slice(0, 10);
const avgProf = aggregated.length > 0
? Math.round(aggregated.reduce((s, e) => s + e.avgProficiency, 0) / aggregated.length * 10) / 10
: 0;
const gapCount = aggregated.filter((e) => e.count < 3 && e.avgProficiency >= 3).length;
async function exportXlsx() {
const XLSX = await import("xlsx");
const rows = sorted.map((e) => ({
Skill: e.skill,
Category: e.category,
"# Resources": e.count,
"Avg Proficiency": e.avgProficiency,
Chapters: e.chapters.join(", "),
}));
const ws = XLSX.utils.json_to_sheet(rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Skills Overview");
XLSX.writeFile(wb, `skills-overview-${Date.now()}.xlsx`);
}
return (
<div className="space-y-6">
{/* KPI Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ label: "Total Resources", value: totalResources, color: "text-brand-600 dark:text-brand-400" },
{ label: "Distinct Skills", value: totalSkillEntries, color: "text-indigo-600 dark:text-indigo-400" },
{ label: "Avg Proficiency", value: avgProf, color: "text-amber-600 dark:text-amber-400" },
{ label: "Scarce Skills", value: gapCount, color: "text-red-600 dark:text-red-400" },
].map((kpi) => (
<div key={kpi.label} className="bg-white dark:bg-slate-900 rounded-xl border border-gray-200 dark:border-slate-700 p-4">
<p className="text-xs text-gray-500 dark:text-gray-400">{kpi.label}</p>
<p className={`text-2xl font-bold mt-1 ${kpi.color}`}>{kpi.value}</p>
</div>
))}
</div>
{/* Filters + Export */}
<div className="flex flex-wrap gap-3 items-center">
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="px-3 py-2 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 Categories</option>
{categories.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
Min. resources:
<input
type="number"
min={1}
max={50}
value={minCount}
onChange={(e) => setMinCount(Math.max(1, parseInt(e.target.value, 10) || 1))}
className="w-16 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"
/>
</label>
<span className="text-sm text-gray-400 dark:text-gray-500">{filtered.length} skills shown</span>
<button
type="button"
onClick={exportXlsx}
className="ml-auto 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>
{/* Distribution Chart */}
{top10.length > 0 && (
<div className="bg-white dark:bg-slate-900 rounded-xl border border-gray-200 dark:border-slate-700 p-5">
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-4">Top 10 Skills by Resource Count</h2>
<SkillDistributionChart data={top10} />
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2">Bar color = average proficiency (light to dark = low to high)</p>
</div>
)}
{/* Skills Table */}
<div className="bg-white dark:bg-slate-900 rounded-xl border border-gray-200 dark:border-slate-700 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-slate-800 border-b border-gray-200 dark:border-slate-700">
<tr>
<SortableColumnHeader label="Skill" field="skill" sortField={sortField} sortDir={sortDir} onSort={toggle} />
<SortableColumnHeader label="Category" field="category" sortField={sortField} sortDir={sortDir} onSort={toggle} />
<SortableColumnHeader label="Resources" field="count" sortField={sortField} sortDir={sortDir} onSort={toggle} align="right" />
<SortableColumnHeader label="Avg Prof." field="avgProficiency" sortField={sortField} sortDir={sortDir} onSort={toggle} align="right" />
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Chapters</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-700">
{sorted.map((e) => (
<tr key={e.skill} className="hover:bg-gray-50 dark:hover:bg-slate-800/50">
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-gray-100">{e.skill}</td>
<td className="px-4 py-2.5 text-gray-500 dark:text-gray-400">{e.category}</td>
<td className="px-4 py-2.5 text-right text-gray-700 dark:text-gray-300">{e.count}</td>
<td className="px-4 py-2.5 text-right"><ProficiencyBadge value={e.avgProficiency} /></td>
<td className="px-4 py-2.5 text-gray-400 dark:text-gray-500 text-xs">{e.chapters.join(", ") || "---"}</td>
</tr>
))}
{sorted.length === 0 && (
<tr>
<td colSpan={5} className="text-center py-10 text-gray-400 dark:text-gray-500 text-sm">
No skills found matching the filters.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,219 @@
"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>
);
}
@@ -0,0 +1,140 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { trpc } from "~/lib/trpc/client.js";
import { useDebounce } from "~/hooks/useDebounce.js";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { useTableSort } from "~/hooks/useTableSort.js";
import { ProficiencyBadge, PROFICIENCY_LABELS, formatDate } from "./shared.js";
export function SearchTab() {
const [searchSkill, setSearchSkill] = useState("");
const [minProficiency, setMinProficiency] = useState(1);
const [availableOnly, setAvailableOnly] = useState(false);
const debouncedSearch = useDebounce(searchSkill, 300);
const { data, isLoading } = trpc.resource.getSkillMarketplace.useQuery(
{ searchSkill: debouncedSearch || undefined, minProficiency, availableOnly },
{ staleTime: 30_000, enabled: debouncedSearch.trim().length > 0 },
);
const { sorted, sortField, sortDir, toggle } = useTableSort(data?.searchResults ?? []);
return (
<div className="space-y-4">
{/* Filters */}
<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 flex-wrap gap-3 items-center">
{/* Search input */}
<div className="relative">
<svg
className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none"
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z" />
</svg>
<input
type="text"
placeholder="Search by skill name..."
value={searchSkill}
onChange={(e) => setSearchSkill(e.target.value)}
className="pl-8 pr-3 py-2 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 w-60"
/>
</div>
{/* Min proficiency */}
<div className="flex items-center gap-1.5">
<span className="text-xs text-gray-500 dark:text-gray-400">Min. proficiency:</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={() => setMinProficiency(lvl)}
className={`px-2 py-1 text-xs font-medium transition-colors ${
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>
{/* Available only */}
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300 cursor-pointer select-none">
<input
type="checkbox"
checked={availableOnly}
onChange={(e) => setAvailableOnly(e.target.checked)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Available in next 30 days
</label>
</div>
{/* Results */}
{debouncedSearch.trim().length > 0 && (
<div className="border-t border-gray-100 dark:border-slate-700 pt-4">
{isLoading ? (
<div className="text-sm text-gray-400 animate-pulse">Searching...</div>
) : sorted.length === 0 ? (
<p className="text-sm text-gray-400 dark:text-gray-500 italic">
No resources found with &quot;{debouncedSearch}&quot; at proficiency {minProficiency}+.
</p>
) : (
<>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
{sorted.length} resource{sorted.length !== 1 ? "s" : ""} found
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-slate-800 border-b border-gray-200 dark:border-slate-700">
<tr>
<SortableColumnHeader label="Resource" field="displayName" sortField={sortField} sortDir={sortDir} onSort={toggle} />
<SortableColumnHeader label="Chapter" field="chapter" sortField={sortField} sortDir={sortDir} onSort={toggle} />
<SortableColumnHeader label="Skill" field="skillName" sortField={sortField} sortDir={sortDir} onSort={toggle} />
<SortableColumnHeader label="Proficiency" field="skillProficiency" sortField={sortField} sortDir={sortDir} onSort={toggle} align="center" />
<SortableColumnHeader label="Utilization" field="utilizationPercent" sortField={sortField} sortDir={sortDir} onSort={toggle} align="right" />
<SortableColumnHeader label="Available From" field="availableFrom" sortField={sortField} sortDir={sortDir} onSort={toggle} />
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-700">
{sorted.map((r) => (
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-slate-800/50">
<td className="px-4 py-2.5">
<Link href={`/resources/${r.id}`} className="font-medium text-gray-900 dark:text-gray-100 hover:text-brand-600 transition-colors">
{r.displayName}
</Link>
</td>
<td className="px-4 py-2.5 text-gray-500 dark:text-gray-400">{r.chapter ?? "---"}</td>
<td className="px-4 py-2.5 text-gray-700 dark:text-gray-300">{r.skillName}</td>
<td className="px-4 py-2.5 text-center"><ProficiencyBadge value={r.skillProficiency} /></td>
<td className="px-4 py-2.5 text-right">
<span className={`text-sm font-medium ${
r.utilizationPercent >= 90 ? "text-red-600 dark:text-red-400"
: r.utilizationPercent >= 70 ? "text-amber-600 dark:text-amber-400"
: "text-green-600 dark:text-green-400"
}`}>
{r.utilizationPercent}%
</span>
</td>
<td className="px-4 py-2.5 text-gray-500 dark:text-gray-400 text-sm">{formatDate(r.availableFrom)}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,50 @@
export const PROFICIENCY_LABELS = ["", "Aware", "Basic", "Intermediate", "Advanced", "Expert"];
export const PROFICIENCY_CLASSES = [
"bg-gray-100 text-gray-700 border-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-500",
"bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-900/60 dark:text-blue-200 dark:border-blue-600",
"bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-900/60 dark:text-indigo-200 dark:border-indigo-500",
"bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-900/60 dark:text-amber-200 dark:border-amber-500",
"bg-green-100 text-green-800 border-green-300 dark:bg-green-900/60 dark:text-green-200 dark:border-green-500",
];
export function proficiencyClasses(level: number): string {
const idx = Math.max(0, Math.min(4, Math.round(level) - 1));
return PROFICIENCY_CLASSES[idx] ?? PROFICIENCY_CLASSES[0]!;
}
export function ProficiencyBadge({ value }: { value: number }) {
return (
<span className={`inline-block px-2 py-0.5 text-xs rounded font-medium border ${proficiencyClasses(value)}`}>
{value} {PROFICIENCY_LABELS[value] ?? ""}
</span>
);
}
export function GapIndicator({ gap }: { gap: number }) {
if (gap > 0) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded font-semibold bg-red-100 text-red-700 border border-red-200 dark:bg-red-900/40 dark:text-red-300 dark:border-red-700">
-{gap} shortage
</span>
);
}
if (gap < 0) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded font-semibold bg-green-100 text-green-700 border border-green-200 dark:bg-green-900/40 dark:text-green-300 dark:border-green-700">
+{Math.abs(gap)} surplus
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded font-semibold bg-gray-100 text-gray-500 border border-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600">
balanced
</span>
);
}
export function formatDate(iso: string | null): string {
if (!iso) return "Not within 30d";
const d = new Date(iso);
return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" });
}
+1 -2
View File
@@ -144,8 +144,7 @@ const navSections: NavSection[] = [
{
label: "Analytics",
items: [
{ href: "/analytics/skills", label: "Skills Analytics", icon: <SkillsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
{ href: "/analytics/skill-marketplace", label: "Skill Marketplace", icon: <MarketplaceIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/analytics/skills", label: "Skills Hub", icon: <SkillsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
{ href: "/reports/chargeability", label: "Chargeability", icon: <ChargeabilityIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/reports/builder", label: "Report Builder", icon: <ReportBuilderIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/analytics/computation-graph", label: "Computation Graph", icon: <GraphIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },