Files
CapaKraken/packages/application/src/use-cases/dashboard/get-project-health.ts
T
Hartmut e1368c7ef7 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>
2026-03-19 21:47:47 +01:00

105 lines
2.8 KiB
TypeScript

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;
}