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:
2026-03-19 21:47:47 +01:00
parent 6f34659587
commit e1368c7ef7
27 changed files with 3889 additions and 1 deletions
+233
View File
@@ -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 } });
}),
});
+33
View File
@@ -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
View File
@@ -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,
});
+387
View File
@@ -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}` });
}
}
+553
View File
@@ -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 };
}),
});
+6
View File
@@ -80,6 +80,12 @@ export {
type GetDashboardTopValueResourcesInput,
type GetDashboardDemandInput,
type GetDashboardChargeabilityOverviewInput,
getDashboardBudgetForecast,
type BudgetForecastRow,
getDashboardSkillGaps,
type SkillGapRow,
getDashboardProjectHealth,
type ProjectHealthRow,
} from "./use-cases/dashboard/index.js";
export {
@@ -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";
+24
View File
@@ -190,6 +190,7 @@ model User {
tasksAssigned Notification[] @relation("taskAssignee")
notificationsSent Notification[] @relation("notificationSender")
broadcasts NotificationBroadcast[] @relation("broadcastSender")
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1481,6 +1482,29 @@ model CalculationRule {
@@map("calculation_rules")
}
// ─── Comment ─────────────────────────────────────────────────────────────────
model Comment {
id String @id @default(cuid())
entityType String // "estimate", "estimate_version", "scope_item", "demand_line"
entityId String
parentId String? // for replies
authorId String
body String @db.Text
mentions String[] // user IDs mentioned
resolved Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
author User @relation(fields: [authorId], references: [id])
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id])
replies Comment[] @relation("CommentReplies")
@@index([entityType, entityId])
@@index([authorId])
@@map("comments")
}
// ─── Audit Log ────────────────────────────────────────────────────────────────
model AuditLog {
@@ -77,6 +77,9 @@ export const dashboardWidgetConfigSchemas = {
"top-value-resources": topValueWidgetConfigSchema,
"chargeability-overview": chargeabilityWidgetConfigSchema,
"my-projects": myProjectsWidgetConfigSchema,
"budget-forecast": z.object({}),
"skill-gap": z.object({}),
"project-health": z.object({}),
} as const;
type DashboardWidgetConfigSchemaMap = typeof dashboardWidgetConfigSchemas;
+39
View File
@@ -42,6 +42,12 @@ export interface MyProjectsWidgetConfig {
showResponsible?: boolean;
}
export interface BudgetForecastWidgetConfig {}
export interface SkillGapWidgetConfig {}
export interface ProjectHealthWidgetConfig {}
export interface DashboardWidgetConfigMap {
"stat-cards": StatCardsWidgetConfig;
"resource-table": ResourceTableWidgetConfig;
@@ -51,6 +57,9 @@ export interface DashboardWidgetConfigMap {
"top-value-resources": TopValueResourcesWidgetConfig;
"chargeability-overview": ChargeabilityOverviewWidgetConfig;
"my-projects": MyProjectsWidgetConfig;
"budget-forecast": BudgetForecastWidgetConfig;
"skill-gap": SkillGapWidgetConfig;
"project-health": ProjectHealthWidgetConfig;
}
export const DASHBOARD_WIDGET_TYPES = [
@@ -62,6 +71,9 @@ export const DASHBOARD_WIDGET_TYPES = [
"top-value-resources",
"chargeability-overview",
"my-projects",
"budget-forecast",
"skill-gap",
"project-health",
] as const;
export type DashboardWidgetType = (typeof DASHBOARD_WIDGET_TYPES)[number];
@@ -182,4 +194,31 @@ export const DASHBOARD_WIDGET_CATALOG = [
showResponsible: true,
},
},
{
type: "budget-forecast",
label: "Budget Forecast",
description: "Budget burn rate and projected exhaustion per active project",
icon: "💰",
defaultSize: { w: 6, h: 5 },
minSize: { w: 4, h: 4 },
defaultConfig: {},
},
{
type: "skill-gap",
label: "Skill Gap Analysis",
description: "Top skill shortages: open demand vs available supply",
icon: "🎯",
defaultSize: { w: 6, h: 5 },
minSize: { w: 4, h: 4 },
defaultConfig: {},
},
{
type: "project-health",
label: "Project Health",
description: "Composite health score per project: budget, staffing, timeline",
icon: "🏥",
defaultSize: { w: 6, h: 5 },
minSize: { w: 4, h: 4 },
defaultConfig: {},
},
] as const satisfies readonly DashboardWidgetCatalogEntry[];