6f34659587
Auto-Staffing Suggestions (A6): - generateAutoSuggestions() ranks top-3 resources on demand creation - Uses existing staffing engine (skill 40%, availability 30%, cost 20%, util 10%) - Creates in-app notification with match scores for managers - Triggered after createDemandRequirement and partial fillDemandRequirement Vacation Conflict Detection (A7): - checkVacationConflicts() warns when >50% chapter absent on same days - Returns warnings array in approve/batchApprove responses (advisory, non-blocking) - Creates VACATION_CONFLICT_WARNING notification for approver Weekly Chargeability Alerts (A10): - checkChargeabilityAlerts() finds resources >15pp below target - Cron endpoint: GET /api/cron/chargeability-alerts - Duplicate-safe by resourceId + month composite key Rate Card Auto-Apply (A11): - lookupRate() finds best matching rate card line (weighted scoring) - Auto-fills demand line rates in estimate create/updateDraft when rates are 0 - Marks auto-filled lines with metadata.autoAppliedRateCard - New lookupDemandLineRate query for on-demand UI lookups Public Holiday Auto-Import (A12): - autoImportPublicHolidays() generates holidays by resource federal state - Cron endpoint: GET /api/cron/public-holidays?year=2027 - Duplicate-safe, uses existing getPublicHolidays() from shared Skill Marketplace MVP (G6): - New page: /analytics/skill-marketplace with 3 sections - Skill Search: filter by name, proficiency, availability, sortable results - Skill Gap Heat Map: supply vs demand per skill, shortage/surplus indicators - Skill Distribution: top-20 horizontal bar chart (reuses SkillDistributionChart) - New getSkillMarketplace query in resource router - Sidebar nav link under Analytics for ADMIN/MANAGER/CONTROLLER Co-Authored-By: claude-flow <ruv@ruv.net>
347 lines
17 KiB
TypeScript
347 lines
17 KiB
TypeScript
"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: () => <div className="h-80 shimmer-skeleton rounded-xl" /> },
|
|
);
|
|
|
|
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 (
|
|
<span className={`inline-block px-2 py-0.5 text-xs rounded font-medium border ${proficiencyClasses(value)}`}>
|
|
{value} {PROFICIENCY_LABELS[value] ?? ""}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="p-6 space-y-4">
|
|
<div className="h-8 shimmer-skeleton rounded w-64" />
|
|
<div className="h-16 shimmer-skeleton rounded-xl" />
|
|
<div className="h-64 shimmer-skeleton rounded-xl" />
|
|
<div className="h-80 shimmer-skeleton rounded-xl" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="p-6">
|
|
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700 dark:bg-red-900/20 dark:border-red-800 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">Skill Marketplace</h1>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
{data?.totalResources ?? 0} active resources · Search skills, identify gaps, plan capacity
|
|
</p>
|
|
</div>
|
|
|
|
{/* ── Section 1: Skill Search ──────────────────────────────────────────── */}
|
|
<div className="bg-white dark:bg-slate-900 rounded-xl border border-gray-200 dark:border-slate-700 p-5 space-y-4">
|
|
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200">Skill Search</h2>
|
|
|
|
<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>
|
|
|
|
{/* Search results table */}
|
|
{debouncedSearch && debouncedSearch.trim().length > 0 && (
|
|
<div className="border-t border-gray-100 dark:border-slate-700 pt-4">
|
|
{sortedSearch.length === 0 ? (
|
|
<p className="text-sm text-gray-400 italic">
|
|
No resources found with "{debouncedSearch}" at proficiency {minProficiency}+.
|
|
</p>
|
|
) : (
|
|
<>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
|
{sortedSearch.length} resource{sortedSearch.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={searchSortField} sortDir={searchSortDir} onSort={searchToggle} />
|
|
<SortableColumnHeader label="Chapter" field="chapter" sortField={searchSortField} sortDir={searchSortDir} onSort={searchToggle} />
|
|
<SortableColumnHeader label="Skill" field="skillName" sortField={searchSortField} sortDir={searchSortDir} onSort={searchToggle} />
|
|
<SortableColumnHeader label="Proficiency" field="skillProficiency" sortField={searchSortField} sortDir={searchSortDir} onSort={searchToggle} align="center" />
|
|
<SortableColumnHeader label="Utilization" field="utilizationPercent" sortField={searchSortField} sortDir={searchSortDir} onSort={searchToggle} align="right" />
|
|
<SortableColumnHeader label="Available From" field="availableFrom" sortField={searchSortField} sortDir={searchSortDir} onSort={searchToggle} />
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100 dark:divide-slate-700">
|
|
{sortedSearch.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>
|
|
|
|
{/* ── Section 2: Skill Gap Heat Map ────────────────────────────────────── */}
|
|
<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">Skill Gap Analysis</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>
|
|
|
|
{sortedGap.length === 0 ? (
|
|
<p className="text-sm text-gray-400 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={gapSortField} sortDir={gapSortDir} onSort={gapToggle} />
|
|
<SortableColumnHeader label="Supply" field="supply" sortField={gapSortField} sortDir={gapSortDir} onSort={gapToggle} align="right" />
|
|
<SortableColumnHeader label="Demand" field="demand" sortField={gapSortField} sortDir={gapSortDir} onSort={gapToggle} align="right" />
|
|
<SortableColumnHeader label="Gap" field="gap" sortField={gapSortField} sortDir={gapSortDir} onSort={gapToggle} 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">
|
|
{sortedGap.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">
|
|
<button
|
|
type="button"
|
|
className="hover:text-brand-600 transition-colors text-left"
|
|
onClick={() => {
|
|
setSearchSkill(row.skill);
|
|
setMinProficiency(3);
|
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
}}
|
|
title={`Search for "${row.skill}"`}
|
|
>
|
|
{row.skill}
|
|
</button>
|
|
</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>
|
|
|
|
{/* ── Section 3: Skill Distribution ────────────────────────────────────── */}
|
|
{(data?.distribution ?? []).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 20 Skills by Resource Count
|
|
</h2>
|
|
<SkillDistributionChart data={data?.distribution ?? []} />
|
|
<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>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|