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:
@@ -1,5 +1,5 @@
|
||||
import { SkillMarketplace } from "~/components/analytics/SkillMarketplace.js";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function SkillMarketplacePage() {
|
||||
return <SkillMarketplace />;
|
||||
redirect("/analytics/skills?tab=search");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SkillsAnalytics } from "~/components/analytics/SkillsAnalytics.js";
|
||||
import { SkillsHub } from "~/components/analytics/SkillsHub.js";
|
||||
|
||||
export default function SkillsAnalyticsPage() {
|
||||
return <SkillsAnalytics />;
|
||||
export default function SkillsHubPage() {
|
||||
return <SkillsHub />;
|
||||
}
|
||||
|
||||
@@ -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 · {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+ · Demand = unfilled demand requirements · 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 "{debouncedSearch}" 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" });
|
||||
}
|
||||
@@ -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"] },
|
||||
|
||||
Reference in New Issue
Block a user