diff --git a/apps/web/src/app/(app)/analytics/skill-marketplace/page.tsx b/apps/web/src/app/(app)/analytics/skill-marketplace/page.tsx index 57f5ef0..a57b7ae 100644 --- a/apps/web/src/app/(app)/analytics/skill-marketplace/page.tsx +++ b/apps/web/src/app/(app)/analytics/skill-marketplace/page.tsx @@ -1,5 +1,5 @@ -import { SkillMarketplace } from "~/components/analytics/SkillMarketplace.js"; +import { redirect } from "next/navigation"; export default function SkillMarketplacePage() { - return ; + redirect("/analytics/skills?tab=search"); } diff --git a/apps/web/src/app/(app)/analytics/skills/page.tsx b/apps/web/src/app/(app)/analytics/skills/page.tsx index e2fbbeb..225165f 100644 --- a/apps/web/src/app/(app)/analytics/skills/page.tsx +++ b/apps/web/src/app/(app)/analytics/skills/page.tsx @@ -1,5 +1,5 @@ -import { SkillsAnalytics } from "~/components/analytics/SkillsAnalytics.js"; +import { SkillsHub } from "~/components/analytics/SkillsHub.js"; -export default function SkillsAnalyticsPage() { - return ; +export default function SkillsHubPage() { + return ; } diff --git a/apps/web/src/components/analytics/SkillsHub.tsx b/apps/web/src/components/analytics/SkillsHub.tsx new file mode 100644 index 0000000..31dbbd4 --- /dev/null +++ b/apps/web/src/components/analytics/SkillsHub.tsx @@ -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: () =>
, +}); +const SearchTab = dynamic(() => import("./skills/SearchTab.js").then((m) => ({ default: m.SearchTab })), { + loading: () =>
, +}); +const GapsTab = dynamic(() => import("./skills/GapsTab.js").then((m) => ({ default: m.GapsTab })), { + loading: () =>
, +}); +const PeopleFinderTab = dynamic(() => import("./skills/PeopleFinderTab.js").then((m) => ({ default: m.PeopleFinderTab })), { + loading: () =>
, +}); + +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 ( +
+
+
+
+ ); + } + + if (error) { + return ( +
+
+ {error.message} +
+
+ ); + } + + return ( +
+ {/* Header */} +
+

Skills Hub

+

+ {data?.totalResources} active resources · {data?.totalSkillEntries} distinct skills +

+
+ + {/* Tab bar */} +
+ {TABS.map((tab) => ( + + ))} +
+ + {/* Tab content */} + {activeTab === "overview" && data && ( + + )} + {activeTab === "search" && } + {activeTab === "gaps" && } + {activeTab === "people" && data && ( + e.skill)} + allChapters={data.allChapters} + /> + )} +
+ ); +} diff --git a/apps/web/src/components/analytics/skills/GapsTab.tsx b/apps/web/src/components/analytics/skills/GapsTab.tsx new file mode 100644 index 0000000..6d21704 --- /dev/null +++ b/apps/web/src/components/analytics/skills/GapsTab.tsx @@ -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 ( +
+
+
+
+ ); + } + + return ( +
+
+
+

Supply vs Demand

+

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

+
+ + {sorted.length === 0 ? ( +

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

+ ) : ( +
+ + + + + + + + + + + + {sorted.map((row) => { + const maxBar = Math.max(row.supply, row.demand, 1); + return ( + + + + + + + + ); + })} + +
Visual
{row.skill}{row.supply}{row.demand} +
+
0 ? 4 : 0 }} + title={`Supply: ${row.supply}`} + /> +
0 ? 4 : 0 }} + title={`Demand: ${row.demand}`} + /> +
+
+
+
+ Supply (prof. 3+) +
+
+ Demand (unfilled) +
+
+
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/analytics/skills/OverviewTab.tsx b/apps/web/src/components/analytics/skills/OverviewTab.tsx new file mode 100644 index 0000000..5109054 --- /dev/null +++ b/apps/web/src/components/analytics/skills/OverviewTab.tsx @@ -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: () =>
}, +); + +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 ( +
+ {/* KPI Cards */} +
+ {[ + { 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) => ( +
+

{kpi.label}

+

{kpi.value}

+
+ ))} +
+ + {/* Filters + Export */} +
+ + + + + {filtered.length} skills shown + + +
+ + {/* Distribution Chart */} + {top10.length > 0 && ( +
+

Top 10 Skills by Resource Count

+ +

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

+
+ )} + + {/* Skills Table */} +
+ + + + + + + + + + + + {sorted.map((e) => ( + + + + + + + + ))} + {sorted.length === 0 && ( + + + + )} + +
Chapters
{e.skill}{e.category}{e.count}{e.chapters.join(", ") || "---"}
+ No skills found matching the filters. +
+
+
+ ); +} diff --git a/apps/web/src/components/analytics/skills/PeopleFinderTab.tsx b/apps/web/src/components/analytics/skills/PeopleFinderTab.tsx new file mode 100644 index 0000000..99e1bc3 --- /dev/null +++ b/apps/web/src/components/analytics/skills/PeopleFinderTab.tsx @@ -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([]); + 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) { + 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 ( +
+
+
+

People Finder

+ Find resources that match skill criteria +
+ + {/* Datalist */} + + {allSkillNames.map((s) => + + {/* Rules */} +
+ {rules.map((rule, idx) => ( +
+ {idx > 0 ? ( + + ) : ( + knows + )} + + 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" + /> + +
+ min. +
+ {[1, 2, 3, 4, 5].map((lvl) => ( + + ))} +
+
+ + +
+ ))} +
+ + {/* Controls row */} +
+ + + {rules.length > 1 && ( +
+ Match: + {(["AND", "OR"] as const).map((op) => ( + + ))} +
+ )} + + {allChapters.length > 0 && ( +
+ Chapter: + +
+ )} + + {results && results.length > 0 && ( + + )} +
+ + {/* Results */} + {activeRules.length > 0 && ( +
+ {isFetching ? ( +
Searching...
+ ) : results && results.length === 0 ? ( +

No resources match these criteria.

+ ) : results && results.length > 0 ? ( + <> +

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

+
+ {results.map((person) => ( +
+
+
+ + {person.displayName} + + {person.eid && ( + {person.eid} + )} + {person.chapter && ( + {person.chapter} + )} +
+
+ {person.matchedSkills.map((s) => ( + + {s.skill} {s.proficiency} + + ))} +
+
+ + View + +
+ ))} +
+ + ) : null} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/analytics/skills/SearchTab.tsx b/apps/web/src/components/analytics/skills/SearchTab.tsx new file mode 100644 index 0000000..9a22ad8 --- /dev/null +++ b/apps/web/src/components/analytics/skills/SearchTab.tsx @@ -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 ( +
+ {/* Filters */} +
+
+ {/* 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 */} + +
+ + {/* Results */} + {debouncedSearch.trim().length > 0 && ( +
+ {isLoading ? ( +
Searching...
+ ) : sorted.length === 0 ? ( +

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

+ ) : ( + <> +

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

+
+ + + + + + + + + + + + + {sorted.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)}
+
+ + )} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/analytics/skills/shared.tsx b/apps/web/src/components/analytics/skills/shared.tsx new file mode 100644 index 0000000..2f826e9 --- /dev/null +++ b/apps/web/src/components/analytics/skills/shared.tsx @@ -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 ( + + {value} {PROFICIENCY_LABELS[value] ?? ""} + + ); +} + +export function GapIndicator({ gap }: { gap: number }) { + if (gap > 0) { + return ( + + -{gap} shortage + + ); + } + if (gap < 0) { + return ( + + +{Math.abs(gap)} surplus + + ); + } + return ( + + balanced + + ); +} + +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" }); +} diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index 5cef27a..2066930 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -144,8 +144,7 @@ const navSections: NavSection[] = [ { label: "Analytics", items: [ - { href: "/analytics/skills", label: "Skills Analytics", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] }, - { href: "/analytics/skill-marketplace", label: "Skill Marketplace", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, + { href: "/analytics/skills", label: "Skills Hub", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] }, { href: "/reports/chargeability", label: "Chargeability", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, { href: "/reports/builder", label: "Report Builder", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, { href: "/analytics/computation-graph", label: "Computation Graph", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, diff --git a/plan.md b/plan.md index 15aa887..02a57d7 100644 --- a/plan.md +++ b/plan.md @@ -1,265 +1,125 @@ -# Planarchy — Product Owner Strategic Plan - -> Consolidated analysis from 4 expert agents: Roadmap, API Surface, Frontend UX, and Test Infrastructure. -> Date: 2026-03-19 - ---- - -## Executive Summary - -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. - -**However, the product has critical gaps preventing production readiness and growth:** - -| Dimension | Score | Verdict | -|-----------|-------|---------| -| 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 | -| CI/CD & DevOps | 10% | No pipeline, no prod Docker, no monitoring | -| UX polish | 75% | Deep timeline/estimates, but gaps in staffing workflow | -| Growth readiness | 40% | No scenario planning, no integrations, no mobile | - ---- - -## Part 1: Bottlenecks - -### 1.1 Production Readiness Blockers (Critical) - -| # | Bottleneck | Impact | Severity | -|---|-----------|--------|----------| -| B1 | **No CI/CD pipeline** — tests, lint, tsc not automated on PR | Regressions ship undetected | CRITICAL | -| 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 | -| B4 | **No health check endpoints** — `/health`, `/ready` missing | Cannot detect/recover from failures | HIGH | -| B5 | **API router test coverage ~5%** — 28 routers, almost no unit tests | Mutations untested at API boundary | HIGH | - -### 1.2 UX Bottlenecks - -| # | Bottleneck | Impact | Severity | -|---|-----------|--------|----------| -| B6 | **Staffing -> Allocation gap** — match results don't link to allocation creation | Users must manually recreate allocations after finding matches | HIGH | -| B7 | **Reporting is thin** — only 2 report types (chargeability, PDF allocations) | Finance/PMs can't self-serve custom reports | MEDIUM | -| 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 | -| B10 | **Timeline 3.3K LOC ecosystem** — ResourcePanel 1035, ProjectPanel 1315 LOC | Hard to maintain, risky to modify | LOW | - -### 1.3 Architecture Bottlenecks - -| # | Bottleneck | Impact | Severity | -|---|-----------|--------|----------| -| B11 | **Prisma client cache invalidation** — dev server restart required after schema changes | Developer friction, CI complexity | MEDIUM | -| B12 | **No webhook/event outbound** — SSE event bus exists but no external subscriptions | Cannot notify external systems (Slack, Jira) | MEDIUM | -| B13 | **No soft-delete strategy** — mixed approach (isActive, status, hard delete) | Data loss risk, no audit trail on deletions | LOW | -| B14 | **Rate card lookup manual in estimates** — no auto-lookup by resource chapter/level | Estimate creation slower than needed | LOW | - ---- - -## Part 2: Growth Potential - -### 2.1 High-Value Feature Opportunities - -#### Tier 1 — Quick Wins (1-3 days each) - -| # | Feature | Value | Effort | -|---|---------|-------|--------| -| G1 | **Staffing "Assign" button** — pre-populate allocation modal from match result | Closes biggest UX gap, saves 5+ clicks per staffing decision | 1-2 days | -| G2 | **Dashboard caching** — pre-compute metrics, invalidate on SSE events | 3-5x dashboard load speed improvement | 1-2 days | -| G3 | **Bulk list operations** — multi-select + context menu on resources/projects | Enables batch edit, export, status change | 2-3 days | -| G4 | **Health check endpoints** — `/api/health` (liveness), `/api/ready` (DB + Redis) | Production deployment prerequisite | 0.5 day | - -#### Tier 2 — Strategic Features (1-2 weeks each) - -| # | Feature | Value | Effort | -|---|---------|-------|--------| -| G5 | **Scenario/What-If Planning** — alternate staffing mixes, cost simulations | Differentiation for PMs and finance; leverages existing engine | 1-2 weeks | -| G6 | **Skill Marketplace** — searchable skill inventory, gap heat map, hiring priorities | High leverage from existing skill matrix; enables org-wide planning | 1 week | -| G7 | **Custom Report Builder** — drag columns, pivot, grouping, scheduled exports | Unlocks self-service analytics for finance and executives | 1-2 weeks | -| G8 | **Collaboration Layer** — inline comments on estimates, @mention, approval feedback | Enables cross-functional workflows (finance, PM, staffing) | 1-2 weeks | - -#### Tier 3 — Market Differentiators (2-4 weeks each) - -| # | Feature | Value | Effort | -|---|---------|-------|--------| -| G9 | **AI-Powered Insights** — auto-suggest staffing, anomaly detection, narrative reports | Leverages existing Azure OpenAI integration; executive decision support | 2-3 weeks | -| 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 | -| 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 | - -### 2.2 Missing Dashboard Widgets - -| Widget | Purpose | Effort | -|--------|---------|--------| -| Budget spend forecast | Forward-looking actuals vs budget trend line | 2 days | -| Team utilization heatmap | Resource x week grid with color intensity | 2 days | -| Skill gap analysis | Required vs available skills across open demands | 3 days | -| Project health scorecard | On-time, on-budget, quality composite score | 2 days | -| Hiring pipeline | Forecast unfilled demand 3-6 months out | 3 days | - ---- - -## Part 3: Automation Potential - -### 3.1 Development Workflow Automation - -| # | Automation | Current State | Target | Effort | -|---|-----------|--------------|--------|--------| -| A1 | **CI/CD Pipeline** | None | GitHub Actions: test + lint + tsc on PR, build + deploy on merge | 1-2 days | -| 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 | -| A4 | **API integration tests** | ~5% router coverage | 80% coverage with mock DB layer | 1-2 weeks | -| A5 | **Coverage gates** | Engine 95%, staffing 90%, others none | All packages minimum 80% | 2 days config | - -### 3.2 Business Process Automation - -| # | Automation | Current Manual Process | Automated Process | Effort | -|---|-----------|----------------------|-------------------|--------| -| A6 | **Auto-staffing suggestions** | PM manually searches for resources per demand | System proposes top-3 matches when demand is created | 3 days | -| A7 | **Vacation conflict alerts** | Manager manually checks team calendar before approving | Auto-detect overlap > threshold, flag in approval flow | 2 days | -| A8 | **Budget overrun notifications** | Finance checks dashboards manually | SSE-triggered notification when project hits 80%/100% budget | 1 day | -| 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 | -| 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 | -| A12 | **Public holiday auto-import** | Admin manually batch-creates per year | Auto-generate on year rollover based on country/state config | 1 day | - -### 3.3 Monitoring & Observability Automation - -| # | 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) | +# Unified Skills Hub — Plan + +## Anforderungsanalyse + +**Was:** Die zwei getrennten Skill-Seiten (`/analytics/skills` = SkillsAnalytics, `/analytics/skill-marketplace` = SkillMarketplace) zu **einer einzigen, nutzerfreundlichen Skills-Hub-Seite** zusammenfuehren. + +**Problem heute:** +- **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!) +- **Ueberlappung:** Beide haben `ProficiencyBadge`, `PROFICIENCY_CLASSES`, `SkillDistributionChart`, aehnliche Tabellen +- **Verwirrung:** User muss zwei Seiten besuchen fuer zusammenhaengende Informationen +- **Inkonsistenz:** Analytics hat kein Dark-Theme auf manchen Elementen, Marketplace hat es + +**Ziel:** Eine Seite `/analytics/skills` mit Tab-basiertem Layout: + +``` ++-------------------------------------------------------------+ +| Skills Hub [Export] | +| 125 resources . 47 distinct skills | ++----------+----------+----------+-------------+--------------+ +| Overview | Search | Gaps | People | Distribution | ++----------+----------+----------+-------------+--------------+ +| | +| [Tab content area] | +| | ++--------------------------------------------------------------+ +``` + +### Betroffene Pakete & Dateien + +| Paket | Dateien | Art der Aenderung | +|-------|---------|------------------| +| `apps/web` | `src/components/analytics/SkillsHub.tsx` | **create** — neue unified component | +| `apps/web` | `src/components/analytics/skills/OverviewTab.tsx` | **create** — KPI cards + distribution chart | +| `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) | +| `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 | +| `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 | +| `apps/web` | `src/components/layout/AppShell.tsx` | **edit** — remove "Skill Marketplace" nav link | +| `packages/api` | `src/router/resource.ts` | **edit** — add unified getSkillsHub query | + +### Task-Liste + +- [ ] **Task 1:** Shared utilities extrahieren -> `skills/shared.tsx` + - `ProficiencyBadge`, `GapIndicator`, `PROFICIENCY_CLASSES`, `PROFICIENCY_LABELS`, `proficiencyClasses()` + - Einmal definieren, ueberall nutzen + +- [ ] **Task 2:** API: neuen `getSkillsHub` Query -> `resource.ts` + - Kombiniert alle Daten in einem Call: + - `aggregated` (from getSkillsAnalytics) + - `searchResults` (from getSkillMarketplace) + - `gapData` (from getSkillMarketplace) + - `distribution` (from both, dedupliziert) + - `totalResources`, `totalSkillEntries` + - Alte Queries behalten (AI Assistant nutzt sie) + +- [ ] **Task 3:** OverviewTab bauen -> `skills/OverviewTab.tsx` + - KPI Cards: Total Resources, Total Skills, Avg Proficiency, Skill Gaps Count + - Top 10 Skills Tabelle (sortierbar) + - Skill Distribution Chart (lazy-loaded) + - Quick filters: Category, Min Count + +- [ ] **Task 4:** SearchTab bauen -> `skills/SearchTab.tsx` + - Skill name Suche (debounced) + - Min Proficiency Filter (1-5 Buttons) + - "Available in 30 days" Toggle + - Ergebnis-Tabelle: Resource, Chapter, Skill, Proficiency, Utilization, Available From + - Links zu `/resources/[id]` + +- [ ] **Task 5:** GapsTab bauen -> `skills/GapsTab.tsx` + - Supply vs Demand Tabelle + - Supply/Demand Bar Visualisierung + - Gap Indicator (shortage/surplus/balanced) + - Sortierbar nach groesstem Gap + - Click auf Skill -> fuellt Search Tab + +- [ ] **Task 6:** PeopleFinderTab bauen -> `skills/PeopleFinderTab.tsx` + - Multi-rule Builder: Skill + Min Proficiency pro Regel + - AND/OR Operator Toggle + - Chapter Filter + - Ergebnis-Tabelle mit Match Score + - XLSX Export Button + +- [ ] **Task 7:** SkillsHub zusammenfuegen -> `SkillsHub.tsx` + - Tab Navigation (Overview, Search, Gaps, People Finder) + - Header mit KPI Summary + Export Button + - Tab State via URL search params + - Lazy-load Tabs fuer Performance + +- [ ] **Task 8:** Routing + Navigation aktualisieren + - `/analytics/skills/page.tsx` -> rendert `` + - `/analytics/skill-marketplace/page.tsx` -> redirect zu `/analytics/skills?tab=search` + - AppShell: "Skill Marketplace" entfernen, "Skills Analytics" umbenennen zu "Skills Hub" + +- [ ] **Task 9:** Dark Theme durchgaengig + - Alle Elemente mit `dark:` Varianten + - Konsistenz mit dem Rest der App + +### Abhaengigkeiten +- Task 1 muss zuerst (shared utilities fuer alle Tabs) +- Task 2 kann parallel zu Task 1 (API aendern) +- Tasks 3-6 koennen parallel nach Task 1 (4 Tabs, unabhaengige Dateien) +- Task 7 benoetigt Tasks 3-6 (importiert alle Tabs) +- Task 8 benoetigt Task 7 (Routing zeigt auf neue Komponente) +- Task 9 kann parallel zu Task 8 + +### Akzeptanzkriterien +- [ ] `pnpm --filter @planarchy/web exec tsc --noEmit` — keine neuen Errors +- [ ] `/analytics/skills` zeigt die vereinte Seite mit 4 Tabs +- [ ] `/analytics/skill-marketplace` redirected zu `/analytics/skills?tab=search` +- [ ] Alle Features beider Seiten sind auf der neuen Seite verfuegbar +- [ ] Dark Theme funktioniert durchgehend +- [ ] Sidebar zeigt nur noch "Skills Hub" statt zwei Links +- [ ] XLSX Export funktioniert weiterhin +- [ ] People Finder AND/OR Suche funktioniert +- [ ] Skill Gap Heat Map mit Supply/Demand funktioniert +- [ ] Availability Filter (30 Tage) funktioniert + +### Risiken & offene Fragen +- **API Performance:** Ein kombinierter Query koennte langsamer sein -> Loesung: Lazy-load per Tab, Query nur wenn Tab aktiv +- **URL State:** Aktiver Tab via `?tab=search` Query Param persistiert +- **Export:** Nur aktiver Tab exportierbar +- **Backwards-Kompatibilitaet:** AI Assistant Tools nutzen alte Queries -> behalten