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
@@ -66,6 +66,11 @@ const ExportsTab = dynamic(
{ loading: TabSkeleton },
);
const CommentThread = dynamic(
() => import("~/components/comments/CommentThread.js").then((mod) => ({ default: mod.CommentThread })),
{ loading: TabSkeleton },
);
const TABS: Array<{ id: WorkspaceTab; label: string }> = [
{ id: "overview", label: "Overview" },
{ id: "assumptions", label: "Assumptions" },
@@ -75,6 +80,7 @@ const TABS: Array<{ id: WorkspaceTab; label: string }> = [
{ id: "phasing", label: "Phasing" },
{ id: "versions", label: "Versions" },
{ id: "exports", label: "Exports" },
{ id: "comments", label: "Comments" },
];
function EmptyState({ children }: { children: React.ReactNode }) {
@@ -127,6 +133,12 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
const createExportMutation = trpc.estimate.createExport.useMutation();
const createPlanningHandoffMutation = trpc.estimate.createPlanningHandoff.useMutation();
const commentCountQuery = trpc.comment.count.useQuery(
{ entityType: "estimate", entityId: estimateId },
{ staleTime: 30_000 },
);
const commentCount = commentCountQuery.data ?? 0;
const estimate = (detailQuery.data as EstimateWorkspaceView | undefined) ?? null;
const hasWorkingVersion = estimate?.versions.some((version) => version.status === "WORKING") ?? false;
const editableTab = tab === "overview" || tab === "assumptions" || tab === "scope" || tab === "staffing";
@@ -296,6 +308,11 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
)}
>
{item.label}
{item.id === "comments" && commentCount > 0 && (
<span className="ml-1.5 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-brand-100 dark:bg-sky-800 px-1.5 text-xs font-semibold text-brand-700 dark:text-sky-200">
{commentCount}
</span>
)}
</button>
))}
</div>
@@ -342,6 +359,14 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
isCreatingExport={createExportMutation.isPending}
/>
)}
{tab === "comments" && (
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6">
<h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-50">
Comments
</h2>
<CommentThread entityType="estimate" entityId={estimate.id} />
</div>
)}
</>
)}
</>