Files
CapaKraken/apps/web/src/components/analytics/skills/OverviewTab.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

158 lines
7.0 KiB
TypeScript

"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>
);
}