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:
2026-03-22 21:33:21 +01:00
parent 1f4c230b8b
commit c7b76e086d
10 changed files with 901 additions and 272 deletions
@@ -1,5 +1,5 @@
import { SkillMarketplace } from "~/components/analytics/SkillMarketplace.js"; import { redirect } from "next/navigation";
export default function SkillMarketplacePage() { 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() { export default function SkillsHubPage() {
return <SkillsAnalytics />; 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 &middot; {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+ &middot; Demand = unfilled demand requirements &middot; 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 &quot;{debouncedSearch}&quot; 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" });
}
+1 -2
View File
@@ -144,8 +144,7 @@ const navSections: NavSection[] = [
{ {
label: "Analytics", label: "Analytics",
items: [ items: [
{ href: "/analytics/skills", label: "Skills Analytics", icon: <SkillsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] }, { href: "/analytics/skills", label: "Skills Hub", icon: <SkillsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
{ href: "/analytics/skill-marketplace", label: "Skill Marketplace", icon: <MarketplaceIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/reports/chargeability", label: "Chargeability", icon: <ChargeabilityIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, { href: "/reports/chargeability", label: "Chargeability", icon: <ChargeabilityIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/reports/builder", label: "Report Builder", icon: <ReportBuilderIcon />, 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"] }, { href: "/analytics/computation-graph", label: "Computation Graph", icon: <GraphIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
+125 -265
View File
@@ -1,265 +1,125 @@
# Planarchy — Product Owner Strategic Plan # Unified Skills Hub — Plan
> Consolidated analysis from 4 expert agents: Roadmap, API Surface, Frontend UX, and Test Infrastructure. ## Anforderungsanalyse
> Date: 2026-03-19
**Was:** Die zwei getrennten Skill-Seiten (`/analytics/skills` = SkillsAnalytics, `/analytics/skill-marketplace` = SkillMarketplace) zu **einer einzigen, nutzerfreundlichen Skills-Hub-Seite** zusammenfuehren.
---
**Problem heute:**
## Executive Summary - **SkillsAnalytics** (496 LOC): Skill-Tabelle mit Filtern, People Finder (AND/OR Suche), XLSX Export, Skill Distribution Chart, Skill Gap Alerts
- **SkillMarketplace** (346 LOC): Skill-Suche mit Verfuegbarkeitsfilter, Skill Gap Heat Map (Supply vs Demand), Skill Distribution Chart (dupliziert!)
Planarchy has reached **Phase 9** with a mature core: timeline planning, allocation management, estimating, vacation pro, skill matrix, RBAC, and chargeability reporting. The product covers 34 routes, 47 DB models, ~200 tRPC procedures, and 109+ domain components. - **Ueberlappung:** Beide haben `ProficiencyBadge`, `PROFICIENCY_CLASSES`, `SkillDistributionChart`, aehnliche Tabellen
- **Verwirrung:** User muss zwei Seiten besuchen fuer zusammenhaengende Informationen
**However, the product has critical gaps preventing production readiness and growth:** - **Inkonsistenz:** Analytics hat kein Dark-Theme auf manchen Elementen, Marketplace hat es
| Dimension | Score | Verdict | **Ziel:** Eine Seite `/analytics/skills` mit Tab-basiertem Layout:
|-----------|-------|---------|
| Feature completeness | 85% | Strong core, thin edges (staffing, reporting) | ```
| Code quality | 90% | Zero TODOs, clean architecture, typed end-to-end | +-------------------------------------------------------------+
| Test coverage | 55% | Engine excellent, API routers ~5%, no integration tests | | Skills Hub [Export] |
| CI/CD & DevOps | 10% | No pipeline, no prod Docker, no monitoring | | 125 resources . 47 distinct skills |
| UX polish | 75% | Deep timeline/estimates, but gaps in staffing workflow | +----------+----------+----------+-------------+--------------+
| Growth readiness | 40% | No scenario planning, no integrations, no mobile | | Overview | Search | Gaps | People | Distribution |
+----------+----------+----------+-------------+--------------+
--- | |
| [Tab content area] |
## Part 1: Bottlenecks | |
+--------------------------------------------------------------+
### 1.1 Production Readiness Blockers (Critical) ```
| # | Bottleneck | Impact | Severity | ### Betroffene Pakete & Dateien
|---|-----------|--------|----------|
| B1 | **No CI/CD pipeline** — tests, lint, tsc not automated on PR | Regressions ship undetected | CRITICAL | | Paket | Dateien | Art der Aenderung |
| B2 | **No production Docker image** — only dev Dockerfile exists | Cannot deploy containerized | CRITICAL | |-------|---------|------------------|
| B3 | **No monitoring/logging** — no Sentry, no Pino, no APM | Blind in production, cannot debug | CRITICAL | | `apps/web` | `src/components/analytics/SkillsHub.tsx` | **create** — neue unified component |
| B4 | **No health check endpoints**`/health`, `/ready` missing | Cannot detect/recover from failures | HIGH | | `apps/web` | `src/components/analytics/skills/OverviewTab.tsx` | **create** — KPI cards + distribution chart |
| B5 | **API router test coverage ~5%** — 28 routers, almost no unit tests | Mutations untested at API boundary | HIGH | | `apps/web` | `src/components/analytics/skills/SearchTab.tsx` | **create** — skill search + availability (from Marketplace) |
| `apps/web` | `src/components/analytics/skills/GapsTab.tsx` | **create** — supply/demand gap analysis (from Marketplace) |
### 1.2 UX Bottlenecks | `apps/web` | `src/components/analytics/skills/PeopleFinderTab.tsx` | **create** — AND/OR skill search (from Analytics) |
| `apps/web` | `src/components/analytics/skills/shared.tsx` | **create** — ProficiencyBadge, GapIndicator, constants |
| # | Bottleneck | Impact | Severity | | `apps/web` | `src/app/(app)/analytics/skills/page.tsx` | **edit** — render SkillsHub statt SkillsAnalytics |
|---|-----------|--------|----------| | `apps/web` | `src/app/(app)/analytics/skill-marketplace/page.tsx` | **edit** — redirect to /analytics/skills |
| B6 | **Staffing -> Allocation gap** — match results don't link to allocation creation | Users must manually recreate allocations after finding matches | HIGH | | `apps/web` | `src/components/layout/AppShell.tsx` | **edit** — remove "Skill Marketplace" nav link |
| B7 | **Reporting is thin** — only 2 report types (chargeability, PDF allocations) | Finance/PMs can't self-serve custom reports | MEDIUM | | `packages/api` | `src/router/resource.ts` | **edit** — add unified getSkillsHub query |
| B8 | **No bulk operations in list views** — no multi-select outside timeline | Slow to manage 10+ resources/projects at once | MEDIUM |
| B9 | **Dashboard metrics computed live** — no caching/pre-computation | Slow dashboard load with growing data | MEDIUM | ### Task-Liste
| B10 | **Timeline 3.3K LOC ecosystem** — ResourcePanel 1035, ProjectPanel 1315 LOC | Hard to maintain, risky to modify | LOW |
- [ ] **Task 1:** Shared utilities extrahieren -> `skills/shared.tsx`
### 1.3 Architecture Bottlenecks - `ProficiencyBadge`, `GapIndicator`, `PROFICIENCY_CLASSES`, `PROFICIENCY_LABELS`, `proficiencyClasses()`
- Einmal definieren, ueberall nutzen
| # | Bottleneck | Impact | Severity |
|---|-----------|--------|----------| - [ ] **Task 2:** API: neuen `getSkillsHub` Query -> `resource.ts`
| B11 | **Prisma client cache invalidation** — dev server restart required after schema changes | Developer friction, CI complexity | MEDIUM | - Kombiniert alle Daten in einem Call:
| B12 | **No webhook/event outbound** — SSE event bus exists but no external subscriptions | Cannot notify external systems (Slack, Jira) | MEDIUM | - `aggregated` (from getSkillsAnalytics)
| B13 | **No soft-delete strategy** — mixed approach (isActive, status, hard delete) | Data loss risk, no audit trail on deletions | LOW | - `searchResults` (from getSkillMarketplace)
| B14 | **Rate card lookup manual in estimates** — no auto-lookup by resource chapter/level | Estimate creation slower than needed | LOW | - `gapData` (from getSkillMarketplace)
- `distribution` (from both, dedupliziert)
--- - `totalResources`, `totalSkillEntries`
- Alte Queries behalten (AI Assistant nutzt sie)
## Part 2: Growth Potential
- [ ] **Task 3:** OverviewTab bauen -> `skills/OverviewTab.tsx`
### 2.1 High-Value Feature Opportunities - KPI Cards: Total Resources, Total Skills, Avg Proficiency, Skill Gaps Count
- Top 10 Skills Tabelle (sortierbar)
#### Tier 1 — Quick Wins (1-3 days each) - Skill Distribution Chart (lazy-loaded)
- Quick filters: Category, Min Count
| # | Feature | Value | Effort |
|---|---------|-------|--------| - [ ] **Task 4:** SearchTab bauen -> `skills/SearchTab.tsx`
| G1 | **Staffing "Assign" button** — pre-populate allocation modal from match result | Closes biggest UX gap, saves 5+ clicks per staffing decision | 1-2 days | - Skill name Suche (debounced)
| G2 | **Dashboard caching** — pre-compute metrics, invalidate on SSE events | 3-5x dashboard load speed improvement | 1-2 days | - Min Proficiency Filter (1-5 Buttons)
| G3 | **Bulk list operations** — multi-select + context menu on resources/projects | Enables batch edit, export, status change | 2-3 days | - "Available in 30 days" Toggle
| G4 | **Health check endpoints**`/api/health` (liveness), `/api/ready` (DB + Redis) | Production deployment prerequisite | 0.5 day | - Ergebnis-Tabelle: Resource, Chapter, Skill, Proficiency, Utilization, Available From
- Links zu `/resources/[id]`
#### Tier 2 — Strategic Features (1-2 weeks each)
- [ ] **Task 5:** GapsTab bauen -> `skills/GapsTab.tsx`
| # | Feature | Value | Effort | - Supply vs Demand Tabelle
|---|---------|-------|--------| - Supply/Demand Bar Visualisierung
| G5 | **Scenario/What-If Planning** — alternate staffing mixes, cost simulations | Differentiation for PMs and finance; leverages existing engine | 1-2 weeks | - Gap Indicator (shortage/surplus/balanced)
| G6 | **Skill Marketplace** — searchable skill inventory, gap heat map, hiring priorities | High leverage from existing skill matrix; enables org-wide planning | 1 week | - Sortierbar nach groesstem Gap
| G7 | **Custom Report Builder** — drag columns, pivot, grouping, scheduled exports | Unlocks self-service analytics for finance and executives | 1-2 weeks | - Click auf Skill -> fuellt Search Tab
| G8 | **Collaboration Layer** — inline comments on estimates, @mention, approval feedback | Enables cross-functional workflows (finance, PM, staffing) | 1-2 weeks |
- [ ] **Task 6:** PeopleFinderTab bauen -> `skills/PeopleFinderTab.tsx`
#### Tier 3 — Market Differentiators (2-4 weeks each) - Multi-rule Builder: Skill + Min Proficiency pro Regel
- AND/OR Operator Toggle
| # | Feature | Value | Effort | - Chapter Filter
|---|---------|-------|--------| - Ergebnis-Tabelle mit Match Score
| G9 | **AI-Powered Insights** — auto-suggest staffing, anomaly detection, narrative reports | Leverages existing Azure OpenAI integration; executive decision support | 2-3 weeks | - XLSX Export Button
| G10 | **External Integrations** — Jira/Linear sync, Slack notifications, Google Calendar | Stickiness; connects Planarchy into existing workflows | 2-4 weeks |
| G11 | **Mobile Companion** — PWA with quick-view (status, gaps, approvals, push notifications) | Engagement for field PMs and remote staff | 3-4 weeks | - [ ] **Task 7:** SkillsHub zusammenfuegen -> `SkillsHub.tsx`
| G12 | **Dispo V2 Clean-Slate Import** — design doc + tickets exist, ready for implementation | Unblocks migration from legacy system; critical for customer onboarding | 1-2 weeks | - Tab Navigation (Overview, Search, Gaps, People Finder)
- Header mit KPI Summary + Export Button
### 2.2 Missing Dashboard Widgets - Tab State via URL search params
- Lazy-load Tabs fuer Performance
| Widget | Purpose | Effort |
|--------|---------|--------| - [ ] **Task 8:** Routing + Navigation aktualisieren
| Budget spend forecast | Forward-looking actuals vs budget trend line | 2 days | - `/analytics/skills/page.tsx` -> rendert `<SkillsHub />`
| Team utilization heatmap | Resource x week grid with color intensity | 2 days | - `/analytics/skill-marketplace/page.tsx` -> redirect zu `/analytics/skills?tab=search`
| Skill gap analysis | Required vs available skills across open demands | 3 days | - AppShell: "Skill Marketplace" entfernen, "Skills Analytics" umbenennen zu "Skills Hub"
| Project health scorecard | On-time, on-budget, quality composite score | 2 days |
| Hiring pipeline | Forecast unfilled demand 3-6 months out | 3 days | - [ ] **Task 9:** Dark Theme durchgaengig
- Alle Elemente mit `dark:` Varianten
--- - Konsistenz mit dem Rest der App
## Part 3: Automation Potential ### Abhaengigkeiten
- Task 1 muss zuerst (shared utilities fuer alle Tabs)
### 3.1 Development Workflow Automation - Task 2 kann parallel zu Task 1 (API aendern)
- Tasks 3-6 koennen parallel nach Task 1 (4 Tabs, unabhaengige Dateien)
| # | Automation | Current State | Target | Effort | - Task 7 benoetigt Tasks 3-6 (importiert alle Tabs)
|---|-----------|--------------|--------|--------| - Task 8 benoetigt Task 7 (Routing zeigt auf neue Komponente)
| A1 | **CI/CD Pipeline** | None | GitHub Actions: test + lint + tsc on PR, build + deploy on merge | 1-2 days | - Task 9 kann parallel zu Task 8
| A2 | **Dependency scanning** | None | Dependabot + npm audit in CI | 0.5 day |
| A3 | **E2E test suite expansion** | 4 specs (auth, timeline, projects, resources) | 20+ specs covering key user flows | 1 week | ### Akzeptanzkriterien
| A4 | **API integration tests** | ~5% router coverage | 80% coverage with mock DB layer | 1-2 weeks | - [ ] `pnpm --filter @planarchy/web exec tsc --noEmit` — keine neuen Errors
| A5 | **Coverage gates** | Engine 95%, staffing 90%, others none | All packages minimum 80% | 2 days config | - [ ] `/analytics/skills` zeigt die vereinte Seite mit 4 Tabs
- [ ] `/analytics/skill-marketplace` redirected zu `/analytics/skills?tab=search`
### 3.2 Business Process Automation - [ ] Alle Features beider Seiten sind auf der neuen Seite verfuegbar
- [ ] Dark Theme funktioniert durchgehend
| # | Automation | Current Manual Process | Automated Process | Effort | - [ ] Sidebar zeigt nur noch "Skills Hub" statt zwei Links
|---|-----------|----------------------|-------------------|--------| - [ ] XLSX Export funktioniert weiterhin
| A6 | **Auto-staffing suggestions** | PM manually searches for resources per demand | System proposes top-3 matches when demand is created | 3 days | - [ ] People Finder AND/OR Suche funktioniert
| A7 | **Vacation conflict alerts** | Manager manually checks team calendar before approving | Auto-detect overlap > threshold, flag in approval flow | 2 days | - [ ] Skill Gap Heat Map mit Supply/Demand funktioniert
| A8 | **Budget overrun notifications** | Finance checks dashboards manually | SSE-triggered notification when project hits 80%/100% budget | 1 day | - [ ] Availability Filter (30 Tage) funktioniert
| A9 | **Estimate approval reminders** | Verbal follow-up | Scheduled notification after N days in SUBMITTED status | 1 day |
| A10 | **Chargeability alerts** | Monthly manual review | Weekly auto-email when resource chargeability drops below target | 2 days | ### Risiken & offene Fragen
| A11 | **Rate card auto-apply** | Manual rate lookup when creating estimate demand lines | Auto-fill LCR/UCR from rate card by resource chapter + level + client | 2 days | - **API Performance:** Ein kombinierter Query koennte langsamer sein -> Loesung: Lazy-load per Tab, Query nur wenn Tab aktiv
| A12 | **Public holiday auto-import** | Admin manually batch-creates per year | Auto-generate on year rollover based on country/state config | 1 day | - **URL State:** Aktiver Tab via `?tab=search` Query Param persistiert
- **Export:** Nur aktiver Tab exportierbar
### 3.3 Monitoring & Observability Automation - **Backwards-Kompatibilitaet:** AI Assistant Tools nutzen alte Queries -> behalten
| # | Automation | Target | Effort |
|---|-----------|--------|--------|
| A13 | **Structured logging** (Pino) | All API requests logged with correlation ID | 2 days |
| A14 | **Error tracking** (Sentry) | Unhandled exceptions captured with context | 1 day |
| A15 | **Performance monitoring** | Slow query detection, API response time tracking | 2 days |
| A16 | **Uptime monitoring** | External health check probe, alerting | 0.5 day |
---
## Part 4: Prioritized Roadmap
### Sprint 0: Production Foundation (Week 1)
**Goal:** Unblock production deployment.
- [ ] **A1** — GitHub Actions CI pipeline (test + lint + tsc + build)
- [ ] **G4** — Health check endpoints (`/api/health`, `/api/ready`)
- [ ] **A14** — Sentry error tracking integration
- [ ] **A13** — Pino structured logging in API layer
- [ ] Production Dockerfile (multi-stage, distroless base)
- [ ] docker-compose.prod.yml with env-based config
- [ ] Database backup strategy (pg_dump cron + S3)
**Acceptance:** `main` branch has green CI, production image builds, errors are captured.
### Sprint 1: Quick Wins (Week 2)
**Goal:** Close the biggest UX gaps and improve daily workflows.
- [ ] **G1** — Staffing "Assign" button (match -> allocation in 1 click)
- [ ] **G2** — Dashboard metric caching (Redis-backed, SSE-invalidated)
- [ ] **G3** — Bulk operations on resource/project lists
- [ ] **A8** — Budget overrun notifications (80% + 100% thresholds)
- [ ] **A9** — Estimate approval reminders (auto-notify after 3 days)
**Acceptance:** Staffing-to-allocation is 1 click, dashboard loads <500ms, bulk select works.
### Sprint 2: Test Coverage & Stability (Week 3)
**Goal:** Harden the codebase for confident iteration.
- [ ] **A4** — API router integration tests (target 15 most-used routers)
- [ ] **A5** — Coverage gates: api + application packages at 80%
- [ ] **A3** — E2E expansion: 10 new specs (estimate lifecycle, vacation flow, bulk ops, filters)
- [ ] **A2** — Dependabot + npm audit in CI
**Acceptance:** `pnpm test:unit` covers all routers, E2E suite runs in CI, zero high-severity vulnerabilities.
### Sprint 3: Automation & Intelligence (Week 4-5)
**Goal:** Automate repetitive decisions, surface insights proactively.
- [ ] **A6** — Auto-staffing suggestions on demand creation
- [ ] **A7** — Vacation conflict detection in approval flow
- [ ] **A10** — Weekly chargeability alerts
- [ ] **A11** — Rate card auto-apply in estimate demand lines
- [ ] **A12** — Public holiday auto-import on year rollover
- [ ] **G6** — Skill marketplace MVP (searchable inventory + gap heat map)
**Acceptance:** Demands auto-suggest resources, vacation conflicts auto-flagged, rate cards auto-filled.
### Sprint 4: Strategic Features (Week 6-8)
**Goal:** Build differentiation features that create competitive moat.
- [ ] **G5** — Scenario/what-if planning (staffing mix simulator)
- [ ] **G7** — Custom report builder MVP (column picker, filters, export)
- [ ] **G8** — Collaboration layer (comments on estimates, @mention)
- [ ] **G12** — Dispo V2 clean-slate import (leverage existing design docs + tickets)
- [ ] Dashboard new widgets: budget forecast, skill gap, project health scorecard
**Acceptance:** PMs can simulate staffing scenarios, finance can build custom reports, Dispo import onboards first customer.
### Sprint 5: Market Expansion (Week 9-12)
**Goal:** Expand the platform beyond core planning.
- [ ] **G9** — AI insights: auto-staffing, anomaly detection, narrative summaries
- [ ] **G10** — Jira/Linear integration + Slack notifications
- [ ] **G11** — Mobile PWA companion
- [ ] **A15** — Performance monitoring + load testing baseline
- [ ] Advanced: multi-tenant architecture planning
**Acceptance:** AI suggestions active, Jira sync live, mobile app installable.
---
## Part 5: Risk Register
| # | Risk | Probability | Impact | Mitigation |
|---|------|-------------|--------|------------|
| R1 | Production deployment without CI catches regressions | HIGH | CRITICAL | Sprint 0 is mandatory before any feature work |
| R2 | Timeline 3.3K LOC becomes unmaintainable | MEDIUM | HIGH | Decompose into sub-hook modules when next touching timeline |
| R3 | Dashboard performance degrades with data growth | MEDIUM | MEDIUM | G2 (caching) in Sprint 1; monitor query times |
| R4 | Prisma schema changes break dev workflow | HIGH | LOW | Automate restart in dev scripts (already documented) |
| R5 | Skill matrix AI costs grow with usage | LOW | MEDIUM | Add token budget tracking in SystemSettings |
| R6 | No data backup strategy | MEDIUM | CRITICAL | Add pg_dump cron + S3 upload in Sprint 0 |
| R7 | Single-point-of-failure (1 dev, 1 server) | HIGH | CRITICAL | Document architecture, automate deployment, enable team onboarding |
---
## Part 6: Key Metrics to Track
### Product Metrics
- **Time-to-staff**: Minutes from demand creation to resource assignment
- **Estimate turnaround**: Days from estimate creation to approval
- **Vacation approval latency**: Hours from request to decision
- **Dashboard load time**: P95 response time for dashboard page
- **Chargeability accuracy**: Forecast vs actual deviation %
### Engineering Metrics
- **Test coverage**: % by package (target: all >=80%)
- **CI green rate**: % of PRs passing all gates
- **Build time**: Minutes for full `next build`
- **Error rate**: Sentry exceptions per hour
- **API latency**: P95 tRPC procedure response time
---
## Appendix: Current State Snapshot
| Dimension | Count |
|-----------|-------|
| Database models | 47 |
| tRPC routers | 28 |
| tRPC procedures | ~200 (120Q + 80M) |
| Frontend routes | 34 |
| Domain components | 109+ |
| Shared UI components | 20+ |
| Unit test files | 62 |
| E2E test specs | 4 |
| Engine test coverage | 95% (gated) |
| Staffing test coverage | 90% (gated) |
| API router test coverage | ~5% (not gated) |
| CI/CD pipeline | None |
| Production Docker | None |
| Monitoring/APM | None |
| Completed phases | 9 |
| Known pain points | 24 (documented in LEARNINGS.md) |