+ {/* Delta cards */}
+
+
+
+
+ = 100 ? "text-green-600" : "text-amber-600"}
+ />
+
+
+ {/* Budget progress */}
+ {budgetUsedPct !== null && (
+
+
+ Budget Usage
+ 100 ? "text-red-600" : "text-gray-900 dark:text-gray-100"}`}>
+ {budgetUsedPct}% of {formatMoney(budgetCents)}
+
+
+
+
100 ? "bg-red-500" : budgetUsedPct > 80 ? "bg-amber-500" : "bg-green-500"
+ }`}
+ style={{ width: `${Math.min(budgetUsedPct, 100)}%` }}
+ />
+
+
+ )}
+
+ {/* Warnings */}
+ {warnings.length > 0 && (
+
+
Warnings
+
+ {warnings.map((w, i) => (
+ -
+
+ {w}
+
+ ))}
+
+
+ )}
+
+ {/* Resource utilization impacts */}
+ {resourceImpacts.length > 0 && (
+
+
+ Resource Utilization Impact
+
+
+
+
+
+ | Resource |
+ Current |
+ Scenario |
+ Delta |
+ Target |
+
+
+
+ {resourceImpacts.map((ri) => (
+
+ |
+ {ri.resourceName}
+ {ri.isOverallocated && (
+ over-allocated
+ )}
+ |
+
+ {ri.currentUtilization.toFixed(1)}%
+ |
+
+ {ri.scenarioUtilization.toFixed(1)}%
+ |
+ 0 ? "text-amber-600" : ri.utilizationDelta < 0 ? "text-blue-600" : "text-gray-500"
+ }`}>
+ {ri.utilizationDelta > 0 ? "+" : ""}{ri.utilizationDelta.toFixed(1)}%
+ |
+
+ {ri.chargeabilityTarget}%
+ |
+
+ ))}
+
+
+
+
+ )}
+
+ );
+}
+
+// ── DeltaCard ────────────────────────────────────────────────────────────────
+
+function DeltaCard({
+ label,
+ value,
+ subtitle,
+ color,
+}: {
+ label: string;
+ value: string;
+ subtitle: string;
+ color: string;
+}) {
+ return (
+
+
{label}
+
{value}
+
{subtitle}
+
+ );
+}
diff --git a/apps/web/src/components/reports/ReportBuilder.tsx b/apps/web/src/components/reports/ReportBuilder.tsx
new file mode 100644
index 0000000..83fdccb
--- /dev/null
+++ b/apps/web/src/components/reports/ReportBuilder.tsx
@@ -0,0 +1,564 @@
+"use client";
+
+import { useState, useMemo, useCallback } from "react";
+import { keepPreviousData } from "@tanstack/react-query";
+import { trpc } from "~/lib/trpc/client.js";
+import { clsx } from "clsx";
+
+// ─── Types ──────────────────────────────────────────────────────────────────
+
+type EntityType = "resource" | "project" | "assignment";
+type FilterOp = "eq" | "neq" | "gt" | "lt" | "gte" | "lte" | "contains" | "in";
+
+interface FilterRow {
+ id: string;
+ field: string;
+ op: FilterOp;
+ value: string;
+}
+
+const ENTITY_OPTIONS: { value: EntityType; label: string }[] = [
+ { value: "resource", label: "Resources" },
+ { value: "project", label: "Projects" },
+ { value: "assignment", label: "Assignments" },
+];
+
+const OPERATOR_OPTIONS: { value: FilterOp; label: string }[] = [
+ { value: "eq", label: "equals" },
+ { value: "neq", label: "not equals" },
+ { value: "gt", label: "greater than" },
+ { value: "lt", label: "less than" },
+ { value: "gte", label: ">= (gte)" },
+ { value: "lte", label: "<= (lte)" },
+ { value: "contains", label: "contains" },
+ { value: "in", label: "in (comma-sep)" },
+];
+
+const PAGE_SIZE = 50;
+
+function generateId(): string {
+ return Math.random().toString(36).slice(2, 10);
+}
+
+// ─── Component ──────────────────────────────────────────────────────────────
+
+export function ReportBuilder() {
+ // Config state
+ const [entity, setEntity] = useState
("resource");
+ const [selectedColumns, setSelectedColumns] = useState>(new Set());
+ const [filters, setFilters] = useState([]);
+ const [groupBy, setGroupBy] = useState("");
+ const [sortBy, setSortBy] = useState("");
+ const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
+ const [page, setPage] = useState(0);
+ const [runQuery, setRunQuery] = useState(false);
+
+ // Fetch available columns when entity changes
+ const columnsQuery = trpc.report.getAvailableColumns.useQuery(
+ { entity },
+ { placeholderData: keepPreviousData },
+ );
+
+ const availableColumns = columnsQuery.data ?? [];
+
+ // Scalar columns (for filter/sort/group — only non-relation columns)
+ const scalarColumns = useMemo(
+ () => availableColumns.filter((c) => !c.key.includes(".")),
+ [availableColumns],
+ );
+
+ // Build query input
+ const queryInput = useMemo(() => {
+ if (!runQuery || selectedColumns.size === 0) return null;
+ return {
+ entity,
+ columns: Array.from(selectedColumns),
+ filters: filters
+ .filter((f) => f.field && f.value)
+ .map(({ field, op, value }) => ({ field, op, value })),
+ ...(groupBy ? { groupBy } : {}),
+ ...(sortBy ? { sortBy, sortDir } : {}),
+ limit: PAGE_SIZE,
+ offset: page * PAGE_SIZE,
+ };
+ }, [runQuery, entity, selectedColumns, filters, groupBy, sortBy, sortDir, page]);
+
+ // Fetch report data
+ const reportQuery = trpc.report.getReportData.useQuery(
+ queryInput!,
+ { enabled: queryInput !== null, placeholderData: keepPreviousData },
+ );
+
+ const exportMutation = trpc.report.exportReport.useMutation();
+
+ // ─── Handlers ───────────────────────────────────────────────────────────
+
+ const handleEntityChange = useCallback((newEntity: EntityType) => {
+ setEntity(newEntity);
+ setSelectedColumns(new Set());
+ setFilters([]);
+ setGroupBy("");
+ setSortBy("");
+ setRunQuery(false);
+ setPage(0);
+ }, []);
+
+ const toggleColumn = useCallback((key: string) => {
+ setSelectedColumns((prev) => {
+ const next = new Set(prev);
+ if (next.has(key)) {
+ next.delete(key);
+ } else {
+ next.add(key);
+ }
+ return next;
+ });
+ }, []);
+
+ const selectAllColumns = useCallback(() => {
+ setSelectedColumns(new Set(availableColumns.map((c) => c.key)));
+ }, [availableColumns]);
+
+ const clearAllColumns = useCallback(() => {
+ setSelectedColumns(new Set());
+ }, []);
+
+ const addFilter = useCallback(() => {
+ const firstField = scalarColumns[0]?.key ?? "";
+ setFilters((prev) => [...prev, { id: generateId(), field: firstField, op: "eq", value: "" }]);
+ }, [scalarColumns]);
+
+ const updateFilter = useCallback((id: string, patch: Partial) => {
+ setFilters((prev) => prev.map((f) => (f.id === id ? { ...f, ...patch } : f)));
+ }, []);
+
+ const removeFilter = useCallback((id: string) => {
+ setFilters((prev) => prev.filter((f) => f.id !== id));
+ }, []);
+
+ const handleRun = useCallback(() => {
+ setPage(0);
+ setRunQuery(true);
+ }, []);
+
+ const handleSort = useCallback((column: string) => {
+ if (!column.includes(".")) {
+ if (sortBy === column) {
+ setSortDir((prev) => (prev === "asc" ? "desc" : "asc"));
+ } else {
+ setSortBy(column);
+ setSortDir("asc");
+ }
+ // Re-run with new sort
+ setRunQuery(true);
+ }
+ }, [sortBy]);
+
+ const handleExport = useCallback(async () => {
+ if (selectedColumns.size === 0) return;
+ try {
+ const result = await exportMutation.mutateAsync({
+ entity,
+ columns: Array.from(selectedColumns),
+ filters: filters
+ .filter((f) => f.field && f.value)
+ .map(({ field, op, value }) => ({ field, op, value })),
+ ...(groupBy ? { groupBy } : {}),
+ ...(sortBy ? { sortBy, sortDir } : {}),
+ limit: 5000,
+ });
+
+ // Download CSV
+ const blob = new Blob([result.csv], { type: "text/csv;charset=utf-8;" });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = `report-${entity}-${new Date().toISOString().slice(0, 10)}.csv`;
+ link.click();
+ URL.revokeObjectURL(url);
+ } catch {
+ // Error handled by tRPC
+ }
+ }, [entity, selectedColumns, filters, groupBy, sortBy, sortDir, exportMutation]);
+
+ // ─── Derived ──────────────────────────────────────────────────────────
+
+ const rows = reportQuery.data?.rows ?? [];
+ const totalCount = reportQuery.data?.totalCount ?? 0;
+ const outputColumns = reportQuery.data?.columns ?? [];
+ const totalPages = Math.ceil(totalCount / PAGE_SIZE);
+ const isLoading = reportQuery.isFetching;
+
+ // Column label lookup
+ const columnLabelMap = useMemo(() => {
+ const map = new Map();
+ for (const col of availableColumns) {
+ map.set(col.key, col.label);
+ }
+ return map;
+ }, [availableColumns]);
+
+ // ─── Render ───────────────────────────────────────────────────────────
+
+ return (
+
+ {/* Header */}
+
+
Report Builder
+
+ Build custom reports by selecting an entity, columns, and filters.
+
+
+
+ {/* Config Panel */}
+
+ {/* Entity Selector */}
+
+
+
+ {ENTITY_OPTIONS.map((opt) => (
+
+ ))}
+
+
+
+ {/* Column Picker */}
+
+
+
+
+
+ |
+
+
+
+ {columnsQuery.isLoading ? (
+
Loading columns...
+ ) : (
+
+ {availableColumns.map((col) => (
+
+ ))}
+
+ )}
+
+
+ {/* Filter Builder */}
+
+
+
+
+
+ {filters.length === 0 ? (
+
No filters applied.
+ ) : (
+
+ {filters.map((filter) => (
+
+ {/* Field */}
+
+
+ {/* Operator */}
+
+
+ {/* Value */}
+
updateFilter(filter.id, { value: e.target.value })}
+ placeholder="Value..."
+ className="min-w-0 flex-1 rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-700 placeholder:text-gray-400 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-900 dark:text-gray-300 dark:placeholder:text-gray-600"
+ />
+
+ {/* Remove */}
+
+
+ ))}
+
+ )}
+
+
+ {/* Sort & Group */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Run button */}
+
+
+ {selectedColumns.size === 0 && (
+
Select at least one column
+ )}
+
+
+
+ {/* Results */}
+ {runQuery && (
+
+ {/* Results Header */}
+
+
+
Results
+ {!isLoading && (
+
+ {totalCount.toLocaleString()} row{totalCount !== 1 ? "s" : ""}
+
+ )}
+
+
+
+
+ {/* Table */}
+
+ {isLoading ? (
+
+ ) : rows.length === 0 ? (
+
+ No data found. Try adjusting your filters.
+
+ ) : (
+
+
+
+ {outputColumns.map((col) => {
+ const isSortable = !col.includes(".");
+ const isSorted = sortBy === col;
+ return (
+ | handleSort(col) : undefined}
+ className={clsx(
+ "whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400",
+ isSortable && "cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-200",
+ )}
+ >
+
+ {columnLabelMap.get(col) ?? col}
+ {isSorted && (
+
+ )}
+
+ |
+ );
+ })}
+
+
+
+ {rows.map((row, idx) => (
+
+ {outputColumns.map((col) => (
+ |
+ {formatCellValue(row[col])}
+ |
+ ))}
+
+ ))}
+
+
+ )}
+
+
+ {/* Pagination */}
+ {totalPages > 1 && (
+
+
+ Page {page + 1} of {totalPages}
+
+
+
+
+
+
+ )}
+
+ )}
+
+ );
+}
+
+// ─── Helpers ────────────────────────────────────────────────────────────────
+
+function formatCellValue(value: unknown): string {
+ if (value === null || value === undefined) return "--";
+ if (typeof value === "boolean") return value ? "Yes" : "No";
+ if (typeof value === "string") {
+ // ISO date detection
+ if (/^\d{4}-\d{2}-\d{2}T/.test(value)) {
+ return new Date(value).toLocaleDateString("de-DE", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ });
+ }
+ return value;
+ }
+ if (typeof value === "number") {
+ return value.toLocaleString("de-DE");
+ }
+ return String(value);
+}
diff --git a/packages/api/src/router/comment.ts b/packages/api/src/router/comment.ts
new file mode 100644
index 0000000..d7246d5
--- /dev/null
+++ b/packages/api/src/router/comment.ts
@@ -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 {
+ 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();
+ 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 } });
+ }),
+});
diff --git a/packages/api/src/router/dashboard.ts b/packages/api/src/router/dashboard.ts
index e9b9136..c0f70a7 100644
--- a/packages/api/src/router/dashboard.ts
+++ b/packages/api/src/router/dashboard.ts
@@ -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>>(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>>(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>>(cacheKey);
+ if (cached) return cached;
+
+ const result = await getDashboardProjectHealth(ctx.db);
+ await cacheSet(cacheKey, result, DEFAULT_TTL);
+ return result;
+ }),
});
diff --git a/packages/api/src/router/index.ts b/packages/api/src/router/index.ts
index 0240b23..20868ae 100644
--- a/packages/api/src/router/index.ts
+++ b/packages/api/src/router/index.ts
@@ -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,
});
diff --git a/packages/api/src/router/report.ts b/packages/api/src/router/report.ts
new file mode 100644
index 0000000..2060b37
--- /dev/null
+++ b/packages/api/src/router/report.ts
@@ -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 = {
+ 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> = {
+ 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 {
+ const entityColumns = COLUMN_MAP[entity];
+ const select: Record = { 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 }).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 {
+ const where: Record = {};
+
+ 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, prefix = ""): Record {
+ const result: Record = {};
+ 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, 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 | 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[]).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 | 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[]).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}` });
+ }
+}
diff --git a/packages/api/src/router/scenario.ts b/packages/api/src/router/scenario.ts
new file mode 100644
index 0000000..cb028b4
--- /dev/null
+++ b/packages/api/src/router/scenario.ts
@@ -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();
+ 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();
+ 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();
+ 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 => 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();
+ const scenarioSkills = new Set();
+
+ 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 };
+ }),
+});
diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts
index 069470f..faafd81 100644
--- a/packages/application/src/index.ts
+++ b/packages/application/src/index.ts
@@ -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 {
diff --git a/packages/application/src/use-cases/dashboard/get-budget-forecast.ts b/packages/application/src/use-cases/dashboard/get-budget-forecast.ts
new file mode 100644
index 0000000..fdd1814
--- /dev/null
+++ b/packages/application/src/use-cases/dashboard/get-budget-forecast.ts
@@ -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 {
+ 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();
+ const monthlyBurnByProject = new Map();
+
+ 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;
+}
diff --git a/packages/application/src/use-cases/dashboard/get-project-health.ts b/packages/application/src/use-cases/dashboard/get-project-health.ts
new file mode 100644
index 0000000..9169230
--- /dev/null
+++ b/packages/application/src/use-cases/dashboard/get-project-health.ts
@@ -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 {
+ 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();
+ 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;
+}
diff --git a/packages/application/src/use-cases/dashboard/get-skill-gaps.ts b/packages/application/src/use-cases/dashboard/get-skill-gaps.ts
new file mode 100644
index 0000000..8cb331e
--- /dev/null
+++ b/packages/application/src/use-cases/dashboard/get-skill-gaps.ts
@@ -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 {
+ // 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();
+
+ for (const d of openDemands) {
+ // Try to extract required skills from metadata
+ const meta = d.metadata as Record | 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();
+ 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);
+}
diff --git a/packages/application/src/use-cases/dashboard/index.ts b/packages/application/src/use-cases/dashboard/index.ts
index cfda7d9..640e530 100644
--- a/packages/application/src/use-cases/dashboard/index.ts
+++ b/packages/application/src/use-cases/dashboard/index.ts
@@ -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";
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 543753d..dc83eef 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -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 {
diff --git a/packages/shared/src/schemas/dashboard.schema.ts b/packages/shared/src/schemas/dashboard.schema.ts
index acc4beb..ef8ffc4 100644
--- a/packages/shared/src/schemas/dashboard.schema.ts
+++ b/packages/shared/src/schemas/dashboard.schema.ts
@@ -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;
diff --git a/packages/shared/src/types/dashboard.ts b/packages/shared/src/types/dashboard.ts
index 90d954f..71da67f 100644
--- a/packages/shared/src/types/dashboard.ts
+++ b/packages/shared/src/types/dashboard.ts
@@ -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[];