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,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}` });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user