"use client"; import { useState, useMemo } from "react"; import dynamic from "next/dynamic"; 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"; const SkillDistributionChart = dynamic( () => import("~/components/analytics/SkillDistributionChart.js"), { ssr: false, loading: () =>
}, ); const PROFICIENCY_LABELS = ["", "Aware", "Basic", "Intermediate", "Advanced", "Expert"]; 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", ]; function proficiencyClasses(level: number): string { const idx = Math.max(0, Math.min(4, Math.round(level) - 1)); return PROFICIENCY_CLASSES[idx] ?? PROFICIENCY_CLASSES[0]!; } function ProficiencyBadge({ value }: { value: number }) { return ( {value} {PROFICIENCY_LABELS[value] ?? ""} ); } function GapIndicator({ gap }: { gap: number }) { if (gap > 0) { return ( -{gap} shortage ); } if (gap < 0) { return ( +{Math.abs(gap)} surplus ); } return ( balanced ); } 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" }); } export function SkillMarketplace() { const [searchSkill, setSearchSkill] = useState(""); const [minProficiency, setMinProficiency] = useState(1); const [availableOnly, setAvailableOnly] = useState(false); const debouncedSearch = useDebounce(searchSkill, 300); const { data, isLoading, error } = trpc.resource.getSkillMarketplace.useQuery( { searchSkill: debouncedSearch || undefined, minProficiency, availableOnly, }, { staleTime: 30_000 }, ); const { sorted: sortedSearch, sortField: searchSortField, sortDir: searchSortDir, toggle: searchToggle, } = useTableSort(data?.searchResults ?? []); const gapData = useMemo(() => data?.gapData ?? [], [data?.gapData]); const { sorted: sortedGap, sortField: gapSortField, sortDir: gapSortDir, toggle: gapToggle, } = useTableSort(gapData); if (isLoading) { return (
); } if (error) { return (
{error.message}
); } return (
{/* Header */}

Skill Marketplace

{data?.totalResources ?? 0} active resources · Search skills, identify gaps, plan capacity

{/* ── Section 1: Skill Search ──────────────────────────────────────────── */}

Skill Search

{/* Search input */}
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" />
{/* Min proficiency */}
Min. proficiency:
{[1, 2, 3, 4, 5].map((lvl) => ( ))}
{/* Available only */}
{/* Search results table */} {debouncedSearch && debouncedSearch.trim().length > 0 && (
{sortedSearch.length === 0 ? (

No resources found with "{debouncedSearch}" at proficiency {minProficiency}+.

) : ( <>

{sortedSearch.length} resource{sortedSearch.length !== 1 ? "s" : ""} found

{sortedSearch.map((r) => ( ))}
{r.displayName} {r.chapter ?? "---"} {r.skillName} = 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}% {formatDate(r.availableFrom)}
)}
)}
{/* ── Section 2: Skill Gap Heat Map ────────────────────────────────────── */}

Skill Gap Analysis

Supply = resources with proficiency 3+ · Demand = unfilled demand requirements · Sorted by largest gap

{sortedGap.length === 0 ? (

No gap data available. Gaps appear when projects have unfilled demand requirements with required skills.

) : (
{sortedGap.map((row) => { const maxBar = Math.max(row.supply, row.demand, 1); return ( ); })}
Visual
{row.supply} {row.demand}
0 ? 4 : 0 }} title={`Supply: ${row.supply}`} />
0 ? 4 : 0 }} title={`Demand: ${row.demand}`} />
Supply (prof. 3+)
Demand (unfilled)
)}
{/* ── Section 3: Skill Distribution ────────────────────────────────────── */} {(data?.distribution ?? []).length > 0 && (

Top 20 Skills by Resource Count

Bar color = average proficiency (light to dark = low to high)

)}
); }