feat: Sprint 3 — automation, intelligence, skill marketplace

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>
This commit is contained in:
2026-03-19 21:39:05 +01:00
parent 6e5b9ec85b
commit 6f34659587
16 changed files with 1906 additions and 4 deletions
@@ -0,0 +1,346 @@
"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 &middot; 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 &quot;{debouncedSearch}&quot; 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+ &middot; Demand = unfilled demand requirements &middot; 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>
);
}