e1368c7ef7
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>
100 lines
2.7 KiB
TypeScript
100 lines
2.7 KiB
TypeScript
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;
|
|
}
|