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,553 @@
|
||||
import { calculateAllocation, countWorkingDays } from "@planarchy/engine/allocation";
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { createTRPCRouter, controllerProcedure, protectedProcedure } from "../trpc.js";
|
||||
|
||||
const DEFAULT_AVAILABILITY = {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 0,
|
||||
sunday: 0,
|
||||
} as const;
|
||||
|
||||
const ScenarioChangeSchema = z.object({
|
||||
/** Existing assignment to modify — omit to add a new allocation */
|
||||
assignmentId: z.string().optional(),
|
||||
resourceId: z.string().optional(),
|
||||
roleId: z.string().optional(),
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
hoursPerDay: z.number().min(0).max(24),
|
||||
/** Set to true to mark an existing assignment for removal */
|
||||
remove: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const SimulateInputSchema = z.object({
|
||||
projectId: z.string(),
|
||||
changes: z.array(ScenarioChangeSchema).min(1),
|
||||
});
|
||||
|
||||
export const scenarioRouter = createTRPCRouter({
|
||||
/**
|
||||
* Returns current allocations/costs for a project — the baseline for comparison.
|
||||
*/
|
||||
getProjectBaseline: protectedProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const project = await ctx.db.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
shortCode: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
budgetCents: true,
|
||||
orderType: true,
|
||||
},
|
||||
});
|
||||
if (!project) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
||||
}
|
||||
|
||||
const assignments = await ctx.db.assignment.findMany({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
status: { not: "CANCELLED" },
|
||||
},
|
||||
include: {
|
||||
resource: {
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true,
|
||||
eid: true,
|
||||
lcrCents: true,
|
||||
availability: true,
|
||||
chargeabilityTarget: true,
|
||||
skills: true,
|
||||
},
|
||||
},
|
||||
roleEntity: { select: { id: true, name: true, color: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const demands = await ctx.db.demandRequirement.findMany({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
status: { not: "CANCELLED" },
|
||||
},
|
||||
include: {
|
||||
roleEntity: { select: { id: true, name: true, color: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate baseline totals
|
||||
let totalCostCents = 0;
|
||||
let totalHours = 0;
|
||||
|
||||
const baselineAllocations = assignments.map((a) => {
|
||||
const availability = (a.resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY;
|
||||
const lcrCents = a.resource?.lcrCents ?? 0;
|
||||
const result = calculateAllocation({
|
||||
lcrCents,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
availability,
|
||||
});
|
||||
|
||||
totalCostCents += result.totalCostCents;
|
||||
totalHours += result.totalHours;
|
||||
|
||||
return {
|
||||
id: a.id,
|
||||
resourceId: a.resourceId,
|
||||
resourceName: a.resource?.displayName ?? "Unknown",
|
||||
resourceEid: a.resource?.eid ?? "",
|
||||
lcrCents,
|
||||
roleId: a.roleId,
|
||||
roleName: a.roleEntity?.name ?? a.role ?? "",
|
||||
roleColor: a.roleEntity?.color ?? null,
|
||||
startDate: a.startDate.toISOString(),
|
||||
endDate: a.endDate.toISOString(),
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
status: a.status,
|
||||
costCents: result.totalCostCents,
|
||||
totalHours: result.totalHours,
|
||||
workingDays: result.workingDays,
|
||||
};
|
||||
});
|
||||
|
||||
const baselineDemands = demands.map((d) => ({
|
||||
id: d.id,
|
||||
roleId: d.roleId,
|
||||
roleName: d.roleEntity?.name ?? d.role ?? "",
|
||||
roleColor: d.roleEntity?.color ?? null,
|
||||
startDate: d.startDate.toISOString(),
|
||||
endDate: d.endDate.toISOString(),
|
||||
hoursPerDay: d.hoursPerDay,
|
||||
headcount: d.headcount,
|
||||
status: d.status,
|
||||
}));
|
||||
|
||||
return {
|
||||
project,
|
||||
assignments: baselineAllocations,
|
||||
demands: baselineDemands,
|
||||
totalCostCents,
|
||||
totalHours,
|
||||
budgetCents: project.budgetCents,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Pure simulation: computes cost/hours/utilization impact of scenario changes
|
||||
* without persisting anything.
|
||||
*/
|
||||
simulate: controllerProcedure
|
||||
.input(SimulateInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { projectId, changes } = input;
|
||||
|
||||
// Load project
|
||||
const project = await ctx.db.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { id: true, name: true, budgetCents: true, orderType: true, startDate: true, endDate: true },
|
||||
});
|
||||
if (!project) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
||||
}
|
||||
|
||||
// Load current assignments for baseline
|
||||
const currentAssignments = await ctx.db.assignment.findMany({
|
||||
where: { projectId, status: { not: "CANCELLED" } },
|
||||
include: {
|
||||
resource: {
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true,
|
||||
eid: true,
|
||||
lcrCents: true,
|
||||
availability: true,
|
||||
chargeabilityTarget: true,
|
||||
skills: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Compute baseline totals
|
||||
let baselineCostCents = 0;
|
||||
let baselineHours = 0;
|
||||
for (const a of currentAssignments) {
|
||||
const availability = (a.resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY;
|
||||
const result = calculateAllocation({
|
||||
lcrCents: a.resource?.lcrCents ?? 0,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
availability,
|
||||
});
|
||||
baselineCostCents += result.totalCostCents;
|
||||
baselineHours += result.totalHours;
|
||||
}
|
||||
|
||||
// Collect all resource IDs we need to look up (from changes)
|
||||
const resourceIds = new Set<string>();
|
||||
for (const c of changes) {
|
||||
if (c.resourceId) resourceIds.add(c.resourceId);
|
||||
}
|
||||
// Also add resources from existing assignments
|
||||
for (const a of currentAssignments) {
|
||||
if (a.resourceId) resourceIds.add(a.resourceId);
|
||||
}
|
||||
|
||||
// Load resources
|
||||
const resources = await ctx.db.resource.findMany({
|
||||
where: { id: { in: [...resourceIds] } },
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true,
|
||||
eid: true,
|
||||
lcrCents: true,
|
||||
availability: true,
|
||||
chargeabilityTarget: true,
|
||||
skills: true,
|
||||
},
|
||||
});
|
||||
const resourceMap = new Map(resources.map((r) => [r.id, r]));
|
||||
|
||||
// Load roles referenced in changes
|
||||
const roleIds = new Set<string>();
|
||||
for (const c of changes) {
|
||||
if (c.roleId) roleIds.add(c.roleId);
|
||||
}
|
||||
const roles = roleIds.size > 0
|
||||
? await ctx.db.role.findMany({
|
||||
where: { id: { in: [...roleIds] } },
|
||||
select: { id: true, name: true, color: true },
|
||||
})
|
||||
: [];
|
||||
const roleMap = new Map(roles.map((r) => [r.id, r]));
|
||||
|
||||
// Build scenario: start from current assignments, apply changes
|
||||
const removedAssignmentIds = new Set(
|
||||
changes.filter((c) => c.remove && c.assignmentId).map((c) => c.assignmentId!),
|
||||
);
|
||||
const modifiedAssignmentIds = new Set(
|
||||
changes.filter((c) => !c.remove && c.assignmentId).map((c) => c.assignmentId!),
|
||||
);
|
||||
|
||||
// Keep untouched assignments
|
||||
const scenarioEntries: Array<{
|
||||
resourceId: string | null;
|
||||
lcrCents: number;
|
||||
hoursPerDay: number;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
availability: typeof DEFAULT_AVAILABILITY;
|
||||
isNew: boolean;
|
||||
}> = [];
|
||||
|
||||
for (const a of currentAssignments) {
|
||||
if (removedAssignmentIds.has(a.id)) continue;
|
||||
if (modifiedAssignmentIds.has(a.id)) continue;
|
||||
|
||||
scenarioEntries.push({
|
||||
resourceId: a.resourceId,
|
||||
lcrCents: a.resource?.lcrCents ?? 0,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
availability: (a.resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY,
|
||||
isNew: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Add modified and new entries from changes
|
||||
for (const c of changes) {
|
||||
if (c.remove) continue;
|
||||
|
||||
const resource = c.resourceId ? resourceMap.get(c.resourceId) : null;
|
||||
const lcrCents = resource?.lcrCents ?? 0;
|
||||
const availability = (resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY;
|
||||
|
||||
scenarioEntries.push({
|
||||
resourceId: c.resourceId ?? null,
|
||||
lcrCents,
|
||||
hoursPerDay: c.hoursPerDay,
|
||||
startDate: c.startDate,
|
||||
endDate: c.endDate,
|
||||
availability,
|
||||
isNew: !c.assignmentId,
|
||||
});
|
||||
}
|
||||
|
||||
// Compute scenario totals
|
||||
let scenarioCostCents = 0;
|
||||
let scenarioHours = 0;
|
||||
|
||||
for (const entry of scenarioEntries) {
|
||||
const result = calculateAllocation({
|
||||
lcrCents: entry.lcrCents,
|
||||
hoursPerDay: entry.hoursPerDay,
|
||||
startDate: entry.startDate,
|
||||
endDate: entry.endDate,
|
||||
availability: entry.availability,
|
||||
});
|
||||
scenarioCostCents += result.totalCostCents;
|
||||
scenarioHours += result.totalHours;
|
||||
}
|
||||
|
||||
// Compute per-resource utilization impact
|
||||
// Load ALL assignments for affected resources (across all projects) to measure total utilization
|
||||
const affectedResourceIds = [...new Set(scenarioEntries.map((e) => e.resourceId).filter(Boolean))] as string[];
|
||||
|
||||
const allAssignmentsForResources = affectedResourceIds.length > 0
|
||||
? await ctx.db.assignment.findMany({
|
||||
where: {
|
||||
resourceId: { in: affectedResourceIds },
|
||||
status: { not: "CANCELLED" },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
resourceId: true,
|
||||
projectId: true,
|
||||
hoursPerDay: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
// Group by resource
|
||||
const assignmentsByResource = new Map<string, typeof allAssignmentsForResources>();
|
||||
for (const a of allAssignmentsForResources) {
|
||||
if (!a.resourceId) continue;
|
||||
const list = assignmentsByResource.get(a.resourceId) ?? [];
|
||||
list.push(a);
|
||||
assignmentsByResource.set(a.resourceId, list);
|
||||
}
|
||||
|
||||
// Determine analysis window (the widest date range from scenario changes)
|
||||
let windowStart = project.startDate;
|
||||
let windowEnd = project.endDate;
|
||||
for (const e of scenarioEntries) {
|
||||
if (e.startDate < windowStart) windowStart = e.startDate;
|
||||
if (e.endDate > windowEnd) windowEnd = e.endDate;
|
||||
}
|
||||
|
||||
const resourceImpacts = affectedResourceIds.map((resId) => {
|
||||
const resource = resourceMap.get(resId);
|
||||
if (!resource) return null;
|
||||
|
||||
const availability = (resource.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY;
|
||||
const totalWorkDays = countWorkingDays(windowStart, windowEnd, availability);
|
||||
const totalAvailableHours = totalWorkDays * (availability.monday ?? 8);
|
||||
|
||||
// Current utilization on this project
|
||||
const currentProjectAssignments = (assignmentsByResource.get(resId) ?? []).filter(
|
||||
(a) => a.projectId === projectId,
|
||||
);
|
||||
let currentProjectHours = 0;
|
||||
for (const a of currentProjectAssignments) {
|
||||
const r = calculateAllocation({
|
||||
lcrCents: 0,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
availability,
|
||||
});
|
||||
currentProjectHours += r.totalHours;
|
||||
}
|
||||
|
||||
// Scenario hours for this resource on this project
|
||||
const scenarioResourceEntries = scenarioEntries.filter((e) => e.resourceId === resId);
|
||||
let scenarioProjectHours = 0;
|
||||
for (const e of scenarioResourceEntries) {
|
||||
const r = calculateAllocation({
|
||||
lcrCents: 0,
|
||||
hoursPerDay: e.hoursPerDay,
|
||||
startDate: e.startDate,
|
||||
endDate: e.endDate,
|
||||
availability,
|
||||
});
|
||||
scenarioProjectHours += r.totalHours;
|
||||
}
|
||||
|
||||
// Total hours across all projects (excluding this project's current, adding scenario)
|
||||
const otherProjectAssignments = (assignmentsByResource.get(resId) ?? []).filter(
|
||||
(a) => a.projectId !== projectId,
|
||||
);
|
||||
let otherProjectsHours = 0;
|
||||
for (const a of otherProjectAssignments) {
|
||||
const r = calculateAllocation({
|
||||
lcrCents: 0,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
availability,
|
||||
});
|
||||
otherProjectsHours += r.totalHours;
|
||||
}
|
||||
|
||||
const currentTotalHours = otherProjectsHours + currentProjectHours;
|
||||
const scenarioTotalHours = otherProjectsHours + scenarioProjectHours;
|
||||
|
||||
const currentUtilization = totalAvailableHours > 0 ? (currentTotalHours / totalAvailableHours) * 100 : 0;
|
||||
const scenarioUtilization = totalAvailableHours > 0 ? (scenarioTotalHours / totalAvailableHours) * 100 : 0;
|
||||
|
||||
return {
|
||||
resourceId: resId,
|
||||
resourceName: resource.displayName,
|
||||
chargeabilityTarget: resource.chargeabilityTarget,
|
||||
currentUtilization: Math.round(currentUtilization * 10) / 10,
|
||||
scenarioUtilization: Math.round(scenarioUtilization * 10) / 10,
|
||||
utilizationDelta: Math.round((scenarioUtilization - currentUtilization) * 10) / 10,
|
||||
isOverallocated: scenarioUtilization > 100,
|
||||
};
|
||||
}).filter((x): x is NonNullable<typeof x> => x !== null);
|
||||
|
||||
// Build warnings
|
||||
const warnings: string[] = [];
|
||||
for (const impact of resourceImpacts) {
|
||||
if (impact && impact.isOverallocated) {
|
||||
warnings.push(
|
||||
`${impact.resourceName} would be at ${impact.scenarioUtilization.toFixed(1)}% utilization (over-allocated)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const budgetCents = project.budgetCents ?? 0;
|
||||
if (budgetCents > 0 && scenarioCostCents > budgetCents) {
|
||||
const overBudgetPct = Math.round(((scenarioCostCents - budgetCents) / budgetCents) * 100);
|
||||
warnings.push(`Scenario exceeds budget by ${overBudgetPct}%`);
|
||||
}
|
||||
|
||||
// Skill coverage: how many unique skills does the scenario team bring vs. current?
|
||||
const currentSkills = new Set<string>();
|
||||
const scenarioSkills = new Set<string>();
|
||||
|
||||
for (const a of currentAssignments) {
|
||||
const skills = (a.resource?.skills ?? []) as Array<{ skill: string }>;
|
||||
for (const s of skills) currentSkills.add(s.skill.toLowerCase());
|
||||
}
|
||||
|
||||
for (const entry of scenarioEntries) {
|
||||
if (!entry.resourceId) continue;
|
||||
const resource = resourceMap.get(entry.resourceId);
|
||||
const skills = (resource?.skills ?? []) as Array<{ skill: string }>;
|
||||
for (const s of skills) scenarioSkills.add(s.skill.toLowerCase());
|
||||
}
|
||||
|
||||
const baselineSkillCount = currentSkills.size;
|
||||
const scenarioSkillCount = scenarioSkills.size;
|
||||
const skillCoveragePct = baselineSkillCount > 0
|
||||
? Math.round((scenarioSkillCount / baselineSkillCount) * 100)
|
||||
: scenarioSkillCount > 0 ? 100 : 0;
|
||||
|
||||
return {
|
||||
baseline: {
|
||||
totalCostCents: baselineCostCents,
|
||||
totalHours: baselineHours,
|
||||
headcount: currentAssignments.length,
|
||||
skillCount: baselineSkillCount,
|
||||
},
|
||||
scenario: {
|
||||
totalCostCents: scenarioCostCents,
|
||||
totalHours: scenarioHours,
|
||||
headcount: scenarioEntries.length,
|
||||
skillCount: scenarioSkillCount,
|
||||
},
|
||||
delta: {
|
||||
costCents: scenarioCostCents - baselineCostCents,
|
||||
hours: scenarioHours - baselineHours,
|
||||
headcount: scenarioEntries.length - currentAssignments.length,
|
||||
skillCoveragePct,
|
||||
},
|
||||
resourceImpacts,
|
||||
warnings,
|
||||
budgetCents,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Applies a scenario: creates real assignments from scenario changes.
|
||||
* Manager+ access required.
|
||||
*/
|
||||
apply: controllerProcedure
|
||||
.input(SimulateInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { projectId, changes } = input;
|
||||
|
||||
const project = await ctx.db.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!project) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
||||
}
|
||||
|
||||
const created: string[] = [];
|
||||
|
||||
for (const change of changes) {
|
||||
if (change.remove && change.assignmentId) {
|
||||
// Cancel the existing assignment
|
||||
await ctx.db.assignment.update({
|
||||
where: { id: change.assignmentId },
|
||||
data: { status: "CANCELLED" },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (change.assignmentId) {
|
||||
// Modify existing assignment
|
||||
await ctx.db.assignment.update({
|
||||
where: { id: change.assignmentId },
|
||||
data: {
|
||||
startDate: change.startDate,
|
||||
endDate: change.endDate,
|
||||
hoursPerDay: change.hoursPerDay,
|
||||
...(change.resourceId ? { resourceId: change.resourceId } : {}),
|
||||
...(change.roleId ? { roleId: change.roleId } : {}),
|
||||
},
|
||||
});
|
||||
created.push(change.assignmentId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!change.resourceId) {
|
||||
// Skip entries without a resource — cannot create an assignment
|
||||
continue;
|
||||
}
|
||||
|
||||
// Look up the resource LCR for dailyCostCents
|
||||
const resource = await ctx.db.resource.findUnique({
|
||||
where: { id: change.resourceId },
|
||||
select: { lcrCents: true },
|
||||
});
|
||||
const dailyCostCents = Math.round((resource?.lcrCents ?? 0) * change.hoursPerDay);
|
||||
|
||||
const newAssignment = await ctx.db.assignment.create({
|
||||
data: {
|
||||
projectId,
|
||||
resourceId: change.resourceId,
|
||||
...(change.roleId ? { roleId: change.roleId } : {}),
|
||||
startDate: change.startDate,
|
||||
endDate: change.endDate,
|
||||
hoursPerDay: change.hoursPerDay,
|
||||
percentage: 100,
|
||||
dailyCostCents,
|
||||
status: "PROPOSED",
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
created.push(newAssignment.id);
|
||||
}
|
||||
|
||||
return { appliedCount: created.length };
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user