"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 ({data?.totalResources ?? 0} active resources · Search skills, identify gaps, plan capacity
No resources found with "{debouncedSearch}" at proficiency {minProficiency}+.
) : ( <>{sortedSearch.length} resource{sortedSearch.length !== 1 ? "s" : ""} found
| {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)} |
Supply = resources with proficiency 3+ · Demand = unfilled demand requirements · Sorted by largest gap
No gap data available. Gaps appear when projects have unfilled demand requirements with required skills.
) : (| Visual | ||||
|---|---|---|---|---|
| {row.supply} | {row.demand} |
|
0 ? 4 : 0 }}
title={`Supply: ${row.supply}`}
/>
0 ? 4 : 0 }}
title={`Demand: ${row.demand}`}
/>
|
Bar color = average proficiency (light to dark = low to high)