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;
}
@@ -0,0 +1,104 @@
import type { PrismaClient } from "@planarchy/db";
import { calculateInclusiveDays } from "./shared.js";
export interface ProjectHealthRow {
projectName: string;
shortCode: string;
budgetHealth: number;
staffingHealth: number;
timelineHealth: number;
compositeScore: number;
}
export async function getDashboardProjectHealth(
db: PrismaClient,
): Promise<ProjectHealthRow[]> {
const projects = await db.project.findMany({
where: { status: "ACTIVE" },
select: {
id: true,
name: true,
shortCode: true,
budgetCents: true,
endDate: true,
demandRequirements: {
select: {
id: true,
headcount: true,
status: true,
assignments: {
where: { status: { not: "CANCELLED" } },
select: { id: true },
},
},
},
},
});
if (projects.length === 0) return [];
const projectIds = projects.map((p) => p.id);
// Fetch assignments for budget calculation
const assignments = await db.assignment.findMany({
where: {
projectId: { in: projectIds },
status: { not: "CANCELLED" },
},
select: {
projectId: true,
startDate: true,
endDate: true,
dailyCostCents: true,
},
});
const spentByProject = new Map<string, number>();
for (const a of assignments) {
const days = calculateInclusiveDays(a.startDate, a.endDate);
const cost = (a.dailyCostCents ?? 0) * days;
spentByProject.set(a.projectId, (spentByProject.get(a.projectId) ?? 0) + cost);
}
const now = new Date();
const rows: ProjectHealthRow[] = projects.map((p) => {
// Budget health: 100 - pctUsed (capped at 100)
const spentCents = spentByProject.get(p.id) ?? 0;
const pctUsed =
p.budgetCents > 0
? Math.round((spentCents / p.budgetCents) * 100)
: 0;
const budgetHealth = Math.max(0, 100 - Math.min(pctUsed, 100));
// Staffing health: filledDemands / totalDemands * 100
let totalDemands = 0;
let filledDemands = 0;
for (const dr of p.demandRequirements) {
totalDemands += dr.headcount;
filledDemands += Math.min(dr.assignments.length, dr.headcount);
}
const staffingHealth =
totalDemands > 0 ? Math.round((filledDemands / totalDemands) * 100) : 100;
// Timeline health: 100 if end date > today, else 0
const timelineHealth = p.endDate > now ? 100 : 0;
// Composite = average of 3 dimensions
const compositeScore = Math.round(
(budgetHealth + staffingHealth + timelineHealth) / 3,
);
return {
projectName: p.name,
shortCode: p.shortCode,
budgetHealth,
staffingHealth,
timelineHealth,
compositeScore,
};
});
rows.sort((a, b) => a.compositeScore - b.compositeScore);
return rows;
}
@@ -0,0 +1,89 @@
import type { PrismaClient } from "@planarchy/db";
export interface SkillGapRow {
skill: string;
demand: number;
supply: number;
gap: number;
}
interface SkillEntry {
name: string;
level?: number;
}
export async function getDashboardSkillGaps(
db: PrismaClient,
): Promise<SkillGapRow[]> {
// Count open demand requirements grouped by required skill (from role name)
const openDemands = await db.demandRequirement.findMany({
where: {
status: { in: ["PROPOSED", "CONFIRMED"] },
project: { status: "ACTIVE" },
},
select: {
role: true,
roleId: true,
roleEntity: { select: { name: true } },
headcount: true,
metadata: true,
},
});
// Build demand map by skill/role name
const demandMap = new Map<string, number>();
for (const d of openDemands) {
// Try to extract required skills from metadata
const meta = d.metadata as Record<string, unknown> | null;
const requiredSkills = Array.isArray(meta?.requiredSkills)
? (meta.requiredSkills as string[])
: [];
if (requiredSkills.length > 0) {
for (const skill of requiredSkills) {
const normalized = skill.trim();
if (normalized) {
demandMap.set(normalized, (demandMap.get(normalized) ?? 0) + d.headcount);
}
}
} else {
// Fall back to role name as the "skill"
const roleName = d.roleEntity?.name ?? d.role;
if (roleName) {
demandMap.set(roleName, (demandMap.get(roleName) ?? 0) + d.headcount);
}
}
}
if (demandMap.size === 0) return [];
// Count active resources with each skill at proficiency >= 3
const resources = await db.resource.findMany({
where: { isActive: true },
select: { skills: true },
});
const supplyMap = new Map<string, number>();
for (const r of resources) {
const skills = (r.skills ?? []) as unknown as SkillEntry[];
if (!Array.isArray(skills)) continue;
for (const skill of skills) {
if (!skill.name) continue;
if ((skill.level ?? 0) >= 3) {
supplyMap.set(skill.name, (supplyMap.get(skill.name) ?? 0) + 1);
}
}
}
// Build gap rows for demanded skills
const rows: SkillGapRow[] = [];
for (const [skill, demand] of demandMap) {
const supply = supplyMap.get(skill) ?? 0;
rows.push({ skill, demand, supply, gap: supply - demand });
}
// Sort by largest shortage first (most negative gap), take top 10
rows.sort((a, b) => a.gap - b.gap);
return rows.slice(0, 10);
}
@@ -21,3 +21,18 @@ export {
getDashboardChargeabilityOverview,
type GetDashboardChargeabilityOverviewInput,
} from "./get-chargeability-overview.js";
export {
getDashboardBudgetForecast,
type BudgetForecastRow,
} from "./get-budget-forecast.js";
export {
getDashboardSkillGaps,
type SkillGapRow,
} from "./get-skill-gaps.js";
export {
getDashboardProjectHealth,
type ProjectHealthRow,
} from "./get-project-health.js";