feat: Sprint 4 — scenario planner, report builder, comments, dashboard widgets

What-If Scenario Planner (G5):
- New /projects/[id]/scenario page with side-by-side baseline vs scenario
- simulate mutation: pure cost/hours/headcount/utilization computation
- apply mutation: creates real PROPOSED assignments from scenario
- Impact cards: cost delta, hours delta, headcount, skill coverage %
- Per-resource utilization impact table with over-allocation warnings
- "What-If" button added to project detail page

Custom Report Builder (G7):
- New /reports/builder page with full config panel
- Entity selector (resource/project/assignment), column picker, filter builder
- Dynamic Prisma query with eq/neq/gt/lt/contains/in operators
- Sortable results table with pagination (50/page)
- CSV export via exportReport mutation
- Sidebar nav link under Analytics

Collaboration Layer (G8):
- Comment model in Prisma (entityType/entityId, replies, @mentions, resolved)
- comment router: list, count, create, resolve, delete
- @mention parsing with notification creation + SSE delivery
- CommentInput with @mention autocomplete (arrow nav, Enter/Tab confirm)
- CommentThread with avatar, timestamp, reply, resolve, delete
- Integrated as "Comments" tab in estimate workspace with count badge

Dashboard Widgets:
- BudgetForecastWidget: progress bars per project, burn rate, exhaustion date
- SkillGapWidget: supply vs demand per skill, shortage/surplus indicators
- ProjectHealthWidget: 3-dimension health circles + composite score
- 3 new application use-cases + dashboard router queries
- All registered in widget-registry with lazy imports

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-19 21:47:47 +01:00
parent 6f34659587
commit e1368c7ef7
27 changed files with 3889 additions and 1 deletions
@@ -53,6 +53,18 @@ export const WIDGET_REGISTRY: Record<DashboardWidgetType, WidgetDefinition> = {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "my-projects")!,
component: lazy(() => import("./widgets/MyProjectsWidget.js").then((m) => ({ default: m.MyProjectsWidget }))),
},
"budget-forecast": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "budget-forecast")!,
component: lazy(() => import("./widgets/BudgetForecastWidget.js").then((m) => ({ default: m.BudgetForecastWidget }))),
},
"skill-gap": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "skill-gap")!,
component: lazy(() => import("./widgets/SkillGapWidget.js").then((m) => ({ default: m.SkillGapWidget }))),
},
"project-health": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "project-health")!,
component: lazy(() => import("./widgets/ProjectHealthWidget.js").then((m) => ({ default: m.ProjectHealthWidget }))),
},
};
export function getWidget(type: DashboardWidgetType): WidgetDefinition {
@@ -0,0 +1,93 @@
"use client";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
function colorClass(pct: number): string {
if (pct > 90) return "bg-red-500";
if (pct > 70) return "bg-amber-400";
return "bg-green-500";
}
function textColorClass(pct: number): string {
if (pct > 90) return "text-red-700";
if (pct > 70) return "text-amber-700";
return "text-green-700";
}
export function BudgetForecastWidget(_props: WidgetProps) {
const { data, isLoading } = trpc.dashboard.getBudgetForecast.useQuery(
undefined,
{ staleTime: 60_000, placeholderData: (prev) => prev },
);
if (isLoading && !data) {
return (
<div className="flex flex-col gap-1 pt-1">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2">
<div className="h-3 w-16 shimmer-skeleton rounded" />
<div className="h-3 flex-1 shimmer-skeleton rounded" />
<div className="h-3 w-12 shimmer-skeleton rounded" />
</div>
))}
</div>
);
}
const rows = data ?? [];
if (rows.length === 0) {
return (
<div className="flex items-center justify-center h-full text-sm text-gray-400">
No active projects with budgets.
</div>
);
}
return (
<div className="overflow-auto h-full">
<table className="w-full text-xs">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500">Project</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">Budget Usage</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">Burn/mo</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">Exhaustion</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{rows.map((row) => (
<tr key={row.shortCode} className="hover:bg-gray-50">
<td className="px-3 py-2 font-medium text-gray-900 max-w-[140px] truncate">
<span className="font-mono text-gray-500 mr-1">{row.shortCode}</span>
{row.projectName}
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${colorClass(row.pctUsed)}`}
style={{ width: `${Math.min(row.pctUsed, 100)}%` }}
/>
</div>
<span className={`text-[11px] font-semibold tabular-nums w-10 text-right ${textColorClass(row.pctUsed)}`}>
{row.pctUsed}%
</span>
</div>
</td>
<td className="px-3 py-2 text-right text-gray-700 tabular-nums">
{row.burnRate > 0
? `${(row.burnRate / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })} \u20AC`
: "\u2014"}
</td>
<td className="px-3 py-2 text-right text-gray-500 tabular-nums">
{row.estimatedExhaustionDate ?? "\u2014"}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,101 @@
"use client";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
function healthDot(value: number): string {
if (value >= 70) return "bg-green-500";
if (value >= 40) return "bg-amber-400";
return "bg-red-500";
}
function scoreBadge(score: number): string {
if (score >= 70) return "bg-green-100 text-green-700";
if (score >= 40) return "bg-amber-100 text-amber-700";
return "bg-red-100 text-red-700";
}
export function ProjectHealthWidget(_props: WidgetProps) {
const { data, isLoading } = trpc.dashboard.getProjectHealth.useQuery(
undefined,
{ staleTime: 60_000, placeholderData: (prev) => prev },
);
if (isLoading && !data) {
return (
<div className="flex flex-col gap-1 pt-1">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2">
<div className="h-3 w-16 shimmer-skeleton rounded" />
<div className="h-3 flex-1 shimmer-skeleton rounded" />
<div className="flex gap-1">
<div className="h-4 w-4 shimmer-skeleton rounded-full" />
<div className="h-4 w-4 shimmer-skeleton rounded-full" />
<div className="h-4 w-4 shimmer-skeleton rounded-full" />
</div>
<div className="h-5 w-10 shimmer-skeleton rounded-full" />
</div>
))}
</div>
);
}
const rows = data ?? [];
if (rows.length === 0) {
return (
<div className="flex items-center justify-center h-full text-sm text-gray-400">
No active projects found.
</div>
);
}
return (
<div className="overflow-auto h-full">
<table className="w-full text-xs">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500">Project</th>
<th className="px-3 py-2 text-center font-medium text-gray-500" title="Budget / Staffing / Timeline">
B / S / T
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">Score</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{rows.map((row) => (
<tr key={row.shortCode} className="hover:bg-gray-50">
<td className="px-3 py-2 font-medium text-gray-900 max-w-[160px] truncate">
<span className="font-mono text-gray-500 mr-1">{row.shortCode}</span>
{row.projectName}
</td>
<td className="px-3 py-2">
<div className="flex items-center justify-center gap-2">
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.budgetHealth)}`}
title={`Budget: ${row.budgetHealth}%`}
/>
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.staffingHealth)}`}
title={`Staffing: ${row.staffingHealth}%`}
/>
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.timelineHealth)}`}
title={`Timeline: ${row.timelineHealth}%`}
/>
</div>
</td>
<td className="px-3 py-2 text-right">
<span
className={`inline-block px-2 py-0.5 rounded-full font-semibold tabular-nums ${scoreBadge(row.compositeScore)}`}
>
{row.compositeScore}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,94 @@
"use client";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
export function SkillGapWidget(_props: WidgetProps) {
const { data, isLoading } = trpc.dashboard.getSkillGaps.useQuery(
undefined,
{ staleTime: 60_000, placeholderData: (prev) => prev },
);
if (isLoading && !data) {
return (
<div className="flex flex-col gap-1 pt-1">
{[...Array(6)].map((_, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2">
<div className="h-3 flex-1 shimmer-skeleton rounded" />
<div className="h-3 w-10 shimmer-skeleton rounded" />
<div className="h-3 w-10 shimmer-skeleton rounded" />
<div className="h-3 w-6 shimmer-skeleton rounded-full" />
</div>
))}
</div>
);
}
const rows = data ?? [];
if (rows.length === 0) {
return (
<div className="flex items-center justify-center h-full text-sm text-gray-400">
No skill gaps detected.
</div>
);
}
return (
<div className="overflow-auto h-full">
<table className="w-full text-xs">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500">Skill</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">Demand</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">Supply</th>
<th className="px-3 py-2 text-center font-medium text-gray-500">Gap</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{rows.map((row) => {
const isShortage = row.gap < 0;
const isSurplus = row.gap > 0;
return (
<tr key={row.skill} className="hover:bg-gray-50">
<td className="px-3 py-2 font-medium text-gray-900 max-w-[180px] truncate">
{row.skill}
</td>
<td className="px-3 py-2 text-right text-gray-700 tabular-nums">
{row.demand}
</td>
<td className="px-3 py-2 text-right text-gray-700 tabular-nums">
{row.supply}
</td>
<td className="px-3 py-2 text-center">
<span className="inline-flex items-center gap-1.5">
<span
className={`inline-block w-2 h-2 rounded-full ${
isShortage
? "bg-red-500"
: isSurplus
? "bg-green-500"
: "bg-gray-400"
}`}
/>
<span
className={`font-semibold tabular-nums ${
isShortage
? "text-red-700"
: isSurplus
? "text-green-700"
: "text-gray-500"
}`}
>
{row.gap > 0 ? `+${row.gap}` : row.gap}
</span>
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}