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
@@ -0,0 +1,99 @@
import type { PrismaClient } from "@planarchy/db";
import { calculateInclusiveDays, MILLISECONDS_PER_DAY } from "./shared.js";
export interface BudgetForecastRow {
projectName: string;
shortCode: string;
budgetCents: number;
spentCents: number;
burnRate: number;
estimatedExhaustionDate: string | null;
pctUsed: number;
}
export async function getDashboardBudgetForecast(
db: PrismaClient,
): Promise<BudgetForecastRow[]> {
const projects = await db.project.findMany({
where: { status: "ACTIVE" },
select: {
id: true,
name: true,
shortCode: true,
budgetCents: true,
startDate: true,
endDate: true,
},
});
if (projects.length === 0) return [];
const projectIds = projects.map((p) => p.id);
const assignments = await db.assignment.findMany({
where: {
projectId: { in: projectIds },
status: { not: "CANCELLED" },
},
select: {
projectId: true,
startDate: true,
endDate: true,
dailyCostCents: true,
},
});
const now = new Date();
const spentByProject = new Map<string, number>();
const monthlyBurnByProject = new Map<string, number>();
for (const a of assignments) {
const days = calculateInclusiveDays(a.startDate, a.endDate);
const totalCost = (a.dailyCostCents ?? 0) * days;
spentByProject.set(
a.projectId,
(spentByProject.get(a.projectId) ?? 0) + totalCost,
);
// Approximate monthly burn from active assignments that overlap today
if (a.startDate <= now && a.endDate >= now) {
// ~22 working days per month
const monthlyContribution = (a.dailyCostCents ?? 0) * 22;
monthlyBurnByProject.set(
a.projectId,
(monthlyBurnByProject.get(a.projectId) ?? 0) + monthlyContribution,
);
}
}
const rows: BudgetForecastRow[] = projects.map((p) => {
const spentCents = spentByProject.get(p.id) ?? 0;
const burnRate = monthlyBurnByProject.get(p.id) ?? 0;
const pctUsed =
p.budgetCents > 0 ? Math.round((spentCents / p.budgetCents) * 100) : 0;
let estimatedExhaustionDate: string | null = null;
if (burnRate > 0 && p.budgetCents > spentCents) {
const remainingCents = p.budgetCents - spentCents;
const monthsRemaining = remainingCents / burnRate;
const exhaustionDate = new Date(
now.getTime() + monthsRemaining * 30 * MILLISECONDS_PER_DAY,
);
estimatedExhaustionDate = exhaustionDate.toISOString().slice(0, 10);
}
return {
projectName: p.name,
shortCode: p.shortCode,
budgetCents: p.budgetCents,
spentCents,
burnRate,
estimatedExhaustionDate,
pctUsed,
};
});
rows.sort((a, b) => b.pctUsed - a.pctUsed);
return rows;
}