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,233 @@
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { SystemRole } from "@planarchy/shared";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
|
||||
import { emitNotificationCreated } from "../sse/event-bus.js";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Resolve the DB user id from the session email. Throws UNAUTHORIZED if not found. */
|
||||
async function resolveUserId(ctx: {
|
||||
db: {
|
||||
user: {
|
||||
findUnique: (args: {
|
||||
where: { email: string };
|
||||
select: { id: true };
|
||||
}) => Promise<{ id: string } | null>;
|
||||
};
|
||||
};
|
||||
session: { user?: { email?: string | null } | null };
|
||||
}): Promise<string> {
|
||||
const email = ctx.session.user?.email;
|
||||
if (!email) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
const user = await ctx.db.user.findUnique({
|
||||
where: { email },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!user) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
return user.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse @mentions from comment body.
|
||||
* Pattern: @[Display Name](userId)
|
||||
* Returns an array of unique user IDs.
|
||||
*/
|
||||
function parseMentions(body: string): string[] {
|
||||
const regex = /@\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
const ids = new Set<string>();
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(body)) !== null) {
|
||||
ids.add(match[2]!);
|
||||
}
|
||||
return Array.from(ids);
|
||||
}
|
||||
|
||||
// ─── Router ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const commentRouter = createTRPCRouter({
|
||||
/** List comments for a given entity, with author info and 1-level nested replies */
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
entityType: z.string(),
|
||||
entityId: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.db.comment.findMany({
|
||||
where: {
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
parentId: null, // only top-level comments
|
||||
},
|
||||
include: {
|
||||
author: { select: { id: true, name: true, email: true, image: true } },
|
||||
replies: {
|
||||
include: {
|
||||
author: { select: { id: true, name: true, email: true, image: true } },
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
}),
|
||||
|
||||
/** Count comments for a given entity (used for badge) */
|
||||
count: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
entityType: z.string(),
|
||||
entityId: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.db.comment.count({
|
||||
where: {
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
/** Create a comment, parse @mentions, and notify mentioned users */
|
||||
create: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
entityType: z.string(),
|
||||
entityId: z.string(),
|
||||
parentId: z.string().optional(),
|
||||
body: z.string().min(1).max(10_000),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const authorId = await resolveUserId(ctx);
|
||||
const mentions = parseMentions(input.body);
|
||||
|
||||
// If replying, verify the parent exists
|
||||
if (input.parentId) {
|
||||
const parent = await ctx.db.comment.findUnique({
|
||||
where: { id: input.parentId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!parent) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Parent comment not found" });
|
||||
}
|
||||
}
|
||||
|
||||
const comment = await ctx.db.comment.create({
|
||||
data: {
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
...(input.parentId !== undefined ? { parentId: input.parentId } : {}),
|
||||
authorId,
|
||||
body: input.body,
|
||||
mentions,
|
||||
},
|
||||
include: {
|
||||
author: { select: { id: true, name: true, email: true, image: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Create notifications for mentioned users (excluding the author)
|
||||
const mentionedUserIds = mentions.filter((id) => id !== authorId);
|
||||
if (mentionedUserIds.length > 0) {
|
||||
const authorName = comment.author.name ?? comment.author.email;
|
||||
const truncatedBody =
|
||||
input.body.length > 120 ? `${input.body.slice(0, 120)}...` : input.body;
|
||||
|
||||
await Promise.all(
|
||||
mentionedUserIds.map(async (userId) => {
|
||||
const notification = await ctx.db.notification.create({
|
||||
data: {
|
||||
userId,
|
||||
type: "COMMENT_MENTION",
|
||||
title: `${authorName} mentioned you in a comment`,
|
||||
body: truncatedBody,
|
||||
entityId: input.entityId,
|
||||
entityType: input.entityType,
|
||||
senderId: authorId,
|
||||
link: `/estimates/${input.entityId}?tab=comments`,
|
||||
channel: "in_app",
|
||||
},
|
||||
});
|
||||
emitNotificationCreated(userId, notification.id);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return comment;
|
||||
}),
|
||||
|
||||
/** Resolve or unresolve a comment (author or admin only) */
|
||||
resolve: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
resolved: z.boolean().default(true),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = await resolveUserId(ctx);
|
||||
const dbUser = ctx.dbUser;
|
||||
|
||||
const existing = await ctx.db.comment.findUnique({
|
||||
where: { id: input.id },
|
||||
select: { id: true, authorId: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Comment not found" });
|
||||
}
|
||||
|
||||
// Only the author or an admin can resolve
|
||||
const isAdmin = dbUser?.systemRole === SystemRole.ADMIN;
|
||||
if (existing.authorId !== userId && !isAdmin) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only the comment author or an admin can resolve comments",
|
||||
});
|
||||
}
|
||||
|
||||
return ctx.db.comment.update({
|
||||
where: { id: input.id },
|
||||
data: { resolved: input.resolved },
|
||||
include: {
|
||||
author: { select: { id: true, name: true, email: true, image: true } },
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
/** Delete a comment (author or admin only). Hard-deletes, including all replies. */
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = await resolveUserId(ctx);
|
||||
const dbUser = ctx.dbUser;
|
||||
|
||||
const existing = await ctx.db.comment.findUnique({
|
||||
where: { id: input.id },
|
||||
select: { id: true, authorId: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Comment not found" });
|
||||
}
|
||||
|
||||
const isAdmin = dbUser?.systemRole === SystemRole.ADMIN;
|
||||
if (existing.authorId !== userId && !isAdmin) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only the comment author or an admin can delete comments",
|
||||
});
|
||||
}
|
||||
|
||||
// Delete all replies first (they reference this comment as parent)
|
||||
await ctx.db.comment.deleteMany({
|
||||
where: { parentId: input.id },
|
||||
});
|
||||
|
||||
await ctx.db.comment.delete({ where: { id: input.id } });
|
||||
}),
|
||||
});
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
getDashboardOverview,
|
||||
getDashboardPeakTimes,
|
||||
getDashboardTopValueResources,
|
||||
getDashboardBudgetForecast,
|
||||
getDashboardSkillGaps,
|
||||
getDashboardProjectHealth,
|
||||
} from "@planarchy/application";
|
||||
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
|
||||
import { cacheGet, cacheSet } from "../lib/cache.js";
|
||||
@@ -129,4 +132,34 @@ export const dashboardRouter = createTRPCRouter({
|
||||
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
||||
return result;
|
||||
}),
|
||||
|
||||
getBudgetForecast: protectedProcedure.query(async ({ ctx }) => {
|
||||
const cacheKey = "budgetForecast";
|
||||
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardBudgetForecast>>>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const result = await getDashboardBudgetForecast(ctx.db);
|
||||
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
||||
return result;
|
||||
}),
|
||||
|
||||
getSkillGaps: protectedProcedure.query(async ({ ctx }) => {
|
||||
const cacheKey = "skillGaps";
|
||||
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardSkillGaps>>>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const result = await getDashboardSkillGaps(ctx.db);
|
||||
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
||||
return result;
|
||||
}),
|
||||
|
||||
getProjectHealth: protectedProcedure.query(async ({ ctx }) => {
|
||||
const cacheKey = "projectHealth";
|
||||
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardProjectHealth>>>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const result = await getDashboardProjectHealth(ctx.db);
|
||||
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { blueprintRouter } from "./blueprint.js";
|
||||
import { chargeabilityReportRouter } from "./chargeability-report.js";
|
||||
import { computationGraphRouter } from "./computation-graph.js";
|
||||
import { clientRouter } from "./client.js";
|
||||
import { commentRouter } from "./comment.js";
|
||||
import { countryRouter } from "./country.js";
|
||||
import { dashboardRouter } from "./dashboard.js";
|
||||
import { effortRuleRouter } from "./effort-rule.js";
|
||||
@@ -18,8 +19,10 @@ import { notificationRouter } from "./notification.js";
|
||||
import { orgUnitRouter } from "./org-unit.js";
|
||||
import { projectRouter } from "./project.js";
|
||||
import { rateCardRouter } from "./rate-card.js";
|
||||
import { reportRouter } from "./report.js";
|
||||
import { resourceRouter } from "./resource.js";
|
||||
import { roleRouter } from "./role.js";
|
||||
import { scenarioRouter } from "./scenario.js";
|
||||
import { settingsRouter } from "./settings.js";
|
||||
import { staffingRouter } from "./staffing.js";
|
||||
import { systemRoleConfigRouter } from "./system-role-config.js";
|
||||
@@ -54,7 +57,10 @@ export const appRouter = createTRPCRouter({
|
||||
managementLevel: managementLevelRouter,
|
||||
rateCard: rateCardRouter,
|
||||
chargeabilityReport: chargeabilityReportRouter,
|
||||
report: reportRouter,
|
||||
scenario: scenarioRouter,
|
||||
calculationRule: calculationRuleRouter,
|
||||
comment: commentRouter,
|
||||
computationGraph: computationGraphRouter,
|
||||
systemRoleConfig: systemRoleConfigRouter,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
// ─── Column Definitions ──────────────────────────────────────────────────────
|
||||
|
||||
interface ColumnDef {
|
||||
key: string;
|
||||
label: string;
|
||||
dataType: "string" | "number" | "date" | "boolean";
|
||||
/** Prisma select path — nested relations use dot notation */
|
||||
prismaPath?: string;
|
||||
}
|
||||
|
||||
const RESOURCE_COLUMNS: ColumnDef[] = [
|
||||
{ key: "id", label: "ID", dataType: "string" },
|
||||
{ key: "eid", label: "Employee ID", dataType: "string" },
|
||||
{ key: "displayName", label: "Name", dataType: "string" },
|
||||
{ key: "email", label: "Email", dataType: "string" },
|
||||
{ key: "chapter", label: "Chapter", dataType: "string" },
|
||||
{ key: "resourceType", label: "Resource Type", dataType: "string" },
|
||||
{ key: "lcrCents", label: "LCR (cents)", dataType: "number" },
|
||||
{ key: "ucrCents", label: "UCR (cents)", dataType: "number" },
|
||||
{ key: "currency", label: "Currency", dataType: "string" },
|
||||
{ key: "chargeabilityTarget", label: "Chargeability Target (%)", dataType: "number" },
|
||||
{ key: "fte", label: "FTE", dataType: "number" },
|
||||
{ key: "isActive", label: "Active", dataType: "boolean" },
|
||||
{ key: "chgResponsibility", label: "Chg Responsibility", dataType: "boolean" },
|
||||
{ key: "rolledOff", label: "Rolled Off", dataType: "boolean" },
|
||||
{ key: "departed", label: "Departed", dataType: "boolean" },
|
||||
{ key: "postalCode", label: "Postal Code", dataType: "string" },
|
||||
{ key: "federalState", label: "Federal State", dataType: "string" },
|
||||
{ key: "country.name", label: "Country", dataType: "string", prismaPath: "country" },
|
||||
{ key: "metroCity.name", label: "Metro City", dataType: "string", prismaPath: "metroCity" },
|
||||
{ key: "orgUnit.name", label: "Org Unit", dataType: "string", prismaPath: "orgUnit" },
|
||||
{ key: "managementLevelGroup.name", label: "Mgmt Level Group", dataType: "string", prismaPath: "managementLevelGroup" },
|
||||
{ key: "managementLevel.name", label: "Mgmt Level", dataType: "string", prismaPath: "managementLevel" },
|
||||
{ key: "areaRole.name", label: "Area Role", dataType: "string", prismaPath: "areaRole" },
|
||||
{ key: "createdAt", label: "Created At", dataType: "date" },
|
||||
{ key: "updatedAt", label: "Updated At", dataType: "date" },
|
||||
];
|
||||
|
||||
const PROJECT_COLUMNS: ColumnDef[] = [
|
||||
{ key: "id", label: "ID", dataType: "string" },
|
||||
{ key: "shortCode", label: "Short Code", dataType: "string" },
|
||||
{ key: "name", label: "Name", dataType: "string" },
|
||||
{ key: "orderType", label: "Order Type", dataType: "string" },
|
||||
{ key: "allocationType", label: "Allocation Type", dataType: "string" },
|
||||
{ key: "status", label: "Status", dataType: "string" },
|
||||
{ key: "winProbability", label: "Win Probability (%)", dataType: "number" },
|
||||
{ key: "budgetCents", label: "Budget (cents)", dataType: "number" },
|
||||
{ key: "startDate", label: "Start Date", dataType: "date" },
|
||||
{ key: "endDate", label: "End Date", dataType: "date" },
|
||||
{ key: "responsiblePerson", label: "Responsible Person", dataType: "string" },
|
||||
{ key: "client.name", label: "Client", dataType: "string", prismaPath: "client" },
|
||||
{ key: "utilizationCategory.name", label: "Util. Category", dataType: "string", prismaPath: "utilizationCategory" },
|
||||
{ key: "blueprint.name", label: "Blueprint", dataType: "string", prismaPath: "blueprint" },
|
||||
{ key: "createdAt", label: "Created At", dataType: "date" },
|
||||
{ key: "updatedAt", label: "Updated At", dataType: "date" },
|
||||
];
|
||||
|
||||
const ASSIGNMENT_COLUMNS: ColumnDef[] = [
|
||||
{ key: "id", label: "ID", dataType: "string" },
|
||||
{ key: "resource.displayName", label: "Resource", dataType: "string", prismaPath: "resource" },
|
||||
{ key: "resource.eid", label: "Resource EID", dataType: "string", prismaPath: "resource" },
|
||||
{ key: "project.name", label: "Project", dataType: "string", prismaPath: "project" },
|
||||
{ key: "project.shortCode", label: "Project Code", dataType: "string", prismaPath: "project" },
|
||||
{ key: "startDate", label: "Start Date", dataType: "date" },
|
||||
{ key: "endDate", label: "End Date", dataType: "date" },
|
||||
{ key: "hoursPerDay", label: "Hours/Day", dataType: "number" },
|
||||
{ key: "percentage", label: "Percentage", dataType: "number" },
|
||||
{ key: "role", label: "Role (legacy)", dataType: "string" },
|
||||
{ key: "roleEntity.name", label: "Role", dataType: "string", prismaPath: "roleEntity" },
|
||||
{ key: "dailyCostCents", label: "Daily Cost (cents)", dataType: "number" },
|
||||
{ key: "status", label: "Status", dataType: "string" },
|
||||
{ key: "createdAt", label: "Created At", dataType: "date" },
|
||||
{ key: "updatedAt", label: "Updated At", dataType: "date" },
|
||||
];
|
||||
|
||||
const COLUMN_MAP: Record<EntityKey, ColumnDef[]> = {
|
||||
resource: RESOURCE_COLUMNS,
|
||||
project: PROJECT_COLUMNS,
|
||||
assignment: ASSIGNMENT_COLUMNS,
|
||||
};
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
const ENTITY_MAP = {
|
||||
resource: "resource",
|
||||
project: "project",
|
||||
assignment: "assignment",
|
||||
} as const;
|
||||
|
||||
type EntityKey = keyof typeof ENTITY_MAP;
|
||||
|
||||
/** Allowlist of top-level scalar fields per entity that can be filtered/sorted on. */
|
||||
const ALLOWED_SCALAR_FIELDS: Record<EntityKey, Set<string>> = {
|
||||
resource: new Set([
|
||||
"id", "eid", "displayName", "email", "chapter", "resourceType",
|
||||
"lcrCents", "ucrCents", "currency", "chargeabilityTarget", "fte",
|
||||
"isActive", "chgResponsibility", "rolledOff", "departed",
|
||||
"postalCode", "federalState", "createdAt", "updatedAt",
|
||||
]),
|
||||
project: new Set([
|
||||
"id", "shortCode", "name", "orderType", "allocationType", "status",
|
||||
"winProbability", "budgetCents", "startDate", "endDate",
|
||||
"responsiblePerson", "createdAt", "updatedAt",
|
||||
]),
|
||||
assignment: new Set([
|
||||
"id", "startDate", "endDate", "hoursPerDay", "percentage",
|
||||
"role", "dailyCostCents", "status", "createdAt", "updatedAt",
|
||||
]),
|
||||
};
|
||||
|
||||
function getValidScalarField(entity: EntityKey, field: string): string | null {
|
||||
// Only allow top-level scalar fields for filter/sort (no relation traversal in where/orderBy)
|
||||
if (ALLOWED_SCALAR_FIELDS[entity].has(field)) return field;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Prisma `select` object from the requested columns.
|
||||
* Always includes `id`. For relation columns like "country.name",
|
||||
* we include the relation with `select: { name: true }`.
|
||||
*/
|
||||
function buildSelect(entity: EntityKey, columns: string[]): Record<string, unknown> {
|
||||
const entityColumns = COLUMN_MAP[entity];
|
||||
const select: Record<string, unknown> = { id: true };
|
||||
|
||||
for (const colKey of columns) {
|
||||
const def = entityColumns.find((c) => c.key === colKey);
|
||||
if (!def) continue;
|
||||
|
||||
if (colKey.includes(".")) {
|
||||
// Relation column, e.g. "country.name" => select: { country: { select: { name: true } } }
|
||||
const relationName = def.prismaPath ?? colKey.split(".")[0]!;
|
||||
const fieldName = colKey.split(".").slice(1).join(".");
|
||||
const existing = select[relationName];
|
||||
if (existing && typeof existing === "object" && existing !== null && "select" in existing) {
|
||||
(existing as { select: Record<string, boolean> }).select[fieldName] = true;
|
||||
} else {
|
||||
select[relationName] = { select: { [fieldName]: true } };
|
||||
}
|
||||
} else {
|
||||
select[colKey] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return select;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Prisma `where` from the filter array.
|
||||
* Only scalar top-level fields are allowed for safety.
|
||||
*/
|
||||
function buildWhere(
|
||||
entity: EntityKey,
|
||||
filters: Array<{ field: string; op: string; value: string }>,
|
||||
): Record<string, unknown> {
|
||||
const where: Record<string, unknown> = {};
|
||||
|
||||
for (const filter of filters) {
|
||||
const field = getValidScalarField(entity, filter.field);
|
||||
if (!field) continue;
|
||||
|
||||
const entityColumns = COLUMN_MAP[entity];
|
||||
const colDef = entityColumns.find((c) => c.key === field);
|
||||
const dataType = colDef?.dataType ?? "string";
|
||||
|
||||
// Parse value based on data type
|
||||
let parsedValue: unknown = filter.value;
|
||||
if (dataType === "number") {
|
||||
parsedValue = Number(filter.value);
|
||||
if (Number.isNaN(parsedValue as number)) continue;
|
||||
} else if (dataType === "boolean") {
|
||||
parsedValue = filter.value === "true";
|
||||
} else if (dataType === "date") {
|
||||
parsedValue = new Date(filter.value);
|
||||
if (Number.isNaN((parsedValue as Date).getTime())) continue;
|
||||
}
|
||||
|
||||
switch (filter.op) {
|
||||
case "eq":
|
||||
where[field] = parsedValue;
|
||||
break;
|
||||
case "neq":
|
||||
where[field] = { not: parsedValue };
|
||||
break;
|
||||
case "gt":
|
||||
where[field] = { gt: parsedValue };
|
||||
break;
|
||||
case "lt":
|
||||
where[field] = { lt: parsedValue };
|
||||
break;
|
||||
case "gte":
|
||||
where[field] = { gte: parsedValue };
|
||||
break;
|
||||
case "lte":
|
||||
where[field] = { lte: parsedValue };
|
||||
break;
|
||||
case "contains":
|
||||
if (dataType === "string") {
|
||||
where[field] = { contains: filter.value, mode: "insensitive" };
|
||||
}
|
||||
break;
|
||||
case "in":
|
||||
if (dataType === "string") {
|
||||
where[field] = { in: filter.value.split(",").map((v) => v.trim()) };
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return where;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a Prisma result row so nested relations become dot-notation keys.
|
||||
* E.g. { country: { name: "DE" } } => { "country.name": "DE" }
|
||||
*/
|
||||
function flattenRow(row: Record<string, unknown>, prefix = ""): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (value !== null && typeof value === "object" && !(value instanceof Date) && !Array.isArray(value)) {
|
||||
Object.assign(result, flattenRow(value as Record<string, unknown>, fullKey));
|
||||
} else {
|
||||
result[fullKey] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a value for CSV output.
|
||||
*/
|
||||
function csvEscape(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
const str = String(value);
|
||||
if (str.includes(",") || str.includes('"') || str.includes("\n")) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
// ─── Input Schema ───────────────────────────────────────────────────────────
|
||||
|
||||
const FilterSchema = z.object({
|
||||
field: z.string().min(1),
|
||||
op: z.enum(["eq", "neq", "gt", "lt", "gte", "lte", "contains", "in"]),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
const ReportInputSchema = z.object({
|
||||
entity: z.enum(["resource", "project", "assignment"]),
|
||||
columns: z.array(z.string()).min(1),
|
||||
filters: z.array(FilterSchema).default([]),
|
||||
groupBy: z.string().optional(),
|
||||
sortBy: z.string().optional(),
|
||||
sortDir: z.enum(["asc", "desc"]).default("asc"),
|
||||
limit: z.number().int().min(1).max(5000).default(50),
|
||||
offset: z.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
// ─── Router ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const reportRouter = createTRPCRouter({
|
||||
/**
|
||||
* Return available columns for a given entity type.
|
||||
*/
|
||||
getAvailableColumns: controllerProcedure
|
||||
.input(z.object({ entity: z.enum(["resource", "project", "assignment"]) }))
|
||||
.query(({ input }) => {
|
||||
const columns = COLUMN_MAP[input.entity];
|
||||
if (!columns) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown entity: ${input.entity}` });
|
||||
}
|
||||
return columns.map(({ key, label, dataType }) => ({ key, label, dataType }));
|
||||
}),
|
||||
|
||||
/**
|
||||
* Fetch report data with dynamic columns, filters, sorting and pagination.
|
||||
*/
|
||||
getReportData: controllerProcedure
|
||||
.input(ReportInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { entity, columns, filters, sortBy, sortDir, limit, offset } = input;
|
||||
|
||||
const select = buildSelect(entity, columns);
|
||||
const where = buildWhere(entity, filters);
|
||||
|
||||
// Build orderBy (only scalar fields)
|
||||
let orderBy: Record<string, string> | undefined;
|
||||
if (sortBy) {
|
||||
const validField = getValidScalarField(entity, sortBy);
|
||||
if (validField) {
|
||||
orderBy = { [validField]: sortDir };
|
||||
}
|
||||
}
|
||||
|
||||
const modelDelegate = getModelDelegate(ctx.db, entity);
|
||||
|
||||
const [rawRows, totalCount] = await Promise.all([
|
||||
(modelDelegate as any).findMany({
|
||||
select,
|
||||
where,
|
||||
...(orderBy ? { orderBy } : {}),
|
||||
take: limit,
|
||||
skip: offset,
|
||||
}),
|
||||
(modelDelegate as any).count({ where }),
|
||||
]);
|
||||
|
||||
// Flatten nested relations into dot-notation keys
|
||||
const rows = (rawRows as Record<string, unknown>[]).map((row) => flattenRow(row));
|
||||
|
||||
// Ensure column order matches request (plus id)
|
||||
const outputColumns = ["id", ...columns.filter((c) => c !== "id")];
|
||||
|
||||
return { rows, columns: outputColumns, totalCount };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Same as getReportData but returns a CSV string for download.
|
||||
*/
|
||||
exportReport: controllerProcedure
|
||||
.input(ReportInputSchema.omit({ offset: true }).extend({
|
||||
limit: z.number().int().min(1).max(50000).default(5000),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { entity, columns, filters, sortBy, sortDir, limit } = input;
|
||||
|
||||
const select = buildSelect(entity, columns);
|
||||
const where = buildWhere(entity, filters);
|
||||
|
||||
let orderBy: Record<string, string> | undefined;
|
||||
if (sortBy) {
|
||||
const validField = getValidScalarField(entity, sortBy);
|
||||
if (validField) {
|
||||
orderBy = { [validField]: sortDir };
|
||||
}
|
||||
}
|
||||
|
||||
const modelDelegate = getModelDelegate(ctx.db, entity);
|
||||
|
||||
const rawRows = await (modelDelegate as any).findMany({
|
||||
select,
|
||||
where,
|
||||
...(orderBy ? { orderBy } : {}),
|
||||
take: limit,
|
||||
});
|
||||
|
||||
const rows = (rawRows as Record<string, unknown>[]).map((row) => flattenRow(row));
|
||||
const outputColumns = ["id", ...columns.filter((c) => c !== "id")];
|
||||
|
||||
// Build CSV
|
||||
const entityColumns = COLUMN_MAP[entity];
|
||||
const headerLabels = outputColumns.map((key) => {
|
||||
const def = entityColumns.find((c) => c.key === key);
|
||||
return def?.label ?? key;
|
||||
});
|
||||
|
||||
const csvLines = [
|
||||
headerLabels.map(csvEscape).join(","),
|
||||
...rows.map((row) =>
|
||||
outputColumns.map((col) => csvEscape(row[col])).join(","),
|
||||
),
|
||||
];
|
||||
|
||||
return { csv: csvLines.join("\n"), rowCount: rows.length };
|
||||
}),
|
||||
});
|
||||
|
||||
/** Resolve the Prisma model delegate from entity key. */
|
||||
function getModelDelegate(db: any, entity: EntityKey) {
|
||||
switch (entity) {
|
||||
case "resource":
|
||||
return db.resource;
|
||||
case "project":
|
||||
return db.project;
|
||||
case "assignment":
|
||||
return db.assignment;
|
||||
default:
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown entity: ${entity}` });
|
||||
}
|
||||
}
|
||||
@@ -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