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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user