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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user