diff --git a/packages/api/src/router/assistant-tools.ts b/packages/api/src/router/assistant-tools.ts index a8fc8bb..cd417f5 100644 --- a/packages/api/src/router/assistant-tools.ts +++ b/packages/api/src/router/assistant-tools.ts @@ -4,10 +4,11 @@ */ import { prisma } from "@planarchy/db"; +import { calculateAllocation, countWorkingDays } from "@planarchy/engine/allocation"; import { computeBudgetStatus } from "@planarchy/engine"; import type { PermissionKey } from "@planarchy/shared"; import { parseTaskAction } from "@planarchy/shared"; -import { createDalleClient, isDalleConfigured, parseAiError } from "../ai-client.js"; +import { createAiClient, createDalleClient, isAiConfigured, isDalleConfigured, parseAiError } from "../ai-client.js"; import { getTaskAction } from "../lib/task-actions.js"; import { fmtEur } from "../lib/format-utils.js"; import { resolveRecipients } from "../lib/notification-targeting.js"; @@ -1163,6 +1164,193 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ }, }, }, + + // ── INSIGHTS & ANOMALIES ── + { + type: "function", + function: { + name: "detect_anomalies", + description: "Detect anomalies across all active projects: budget burn rate issues, staffing gaps, utilization outliers, and timeline overruns.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "get_skill_gaps", + description: "Analyze skill supply vs demand across all active projects. Returns which skills are in short supply relative to demand requirements.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "get_project_health", + description: "Get health scores for all active projects based on budget utilization, staffing completeness, and timeline status.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "get_budget_forecast", + description: "Get budget utilization and burn rate per active project. Shows total budget, spent, remaining, and whether burn is ahead or behind schedule.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "get_insights_summary", + description: "Get a summary of anomaly counts by category (budget, staffing, timeline, utilization) plus critical count.", + parameters: { type: "object", properties: {} }, + }, + }, + + // ── REPORTS & COMMENTS ── + { + type: "function", + function: { + name: "run_report", + description: "Run a dynamic report query on resources, projects, or assignments with flexible column selection and filtering.", + parameters: { + type: "object", + properties: { + entity: { type: "string", enum: ["resource", "project", "assignment"], description: "Entity type to query" }, + columns: { + type: "array", + items: { type: "string" }, + description: "Column keys to include (e.g. 'displayName', 'chapter', 'country.name')", + }, + filters: { + type: "array", + items: { + type: "object", + properties: { + field: { type: "string", description: "Field to filter on" }, + op: { type: "string", enum: ["eq", "neq", "gt", "lt", "gte", "lte", "contains", "in"], description: "Filter operator" }, + value: { type: "string", description: "Filter value (string)" }, + }, + required: ["field", "op", "value"], + }, + description: "Filters to apply", + }, + limit: { type: "integer", description: "Max results. Default: 50" }, + }, + required: ["entity", "columns"], + }, + }, + }, + { + type: "function", + function: { + name: "list_comments", + description: "List comments (with replies) for a specific entity such as an estimate, scope item, or demand line.", + parameters: { + type: "object", + properties: { + entityType: { type: "string", description: "Entity type (e.g. 'estimate', 'estimate_version', 'scope_item', 'demand_line')" }, + entityId: { type: "string", description: "Entity ID" }, + }, + required: ["entityType", "entityId"], + }, + }, + }, + { + type: "function", + function: { + name: "lookup_rate", + description: "Find the best matching rate card line for given criteria (client, chapter, management level, role, seniority).", + parameters: { + type: "object", + properties: { + clientId: { type: "string", description: "Client ID to find rate card for" }, + chapter: { type: "string", description: "Chapter to match" }, + managementLevelId: { type: "string", description: "Management level ID to match" }, + roleName: { type: "string", description: "Role name to match" }, + seniority: { type: "string", description: "Seniority level to match" }, + }, + }, + }, + }, + + // ── SCENARIO & AI ── + { + type: "function", + function: { + name: "simulate_scenario", + description: "Run a read-only what-if staffing simulation for a project. Shows cost/hours/utilization impact of adding, removing, or changing resource assignments without persisting changes.", + parameters: { + type: "object", + properties: { + projectId: { type: "string", description: "Project ID" }, + changes: { + type: "array", + items: { + type: "object", + properties: { + assignmentId: { type: "string", description: "Existing assignment ID to modify (omit for new)" }, + resourceId: { type: "string", description: "Resource ID" }, + roleId: { type: "string", description: "Role ID" }, + startDate: { type: "string", description: "Start date YYYY-MM-DD" }, + endDate: { type: "string", description: "End date YYYY-MM-DD" }, + hoursPerDay: { type: "number", description: "Hours per day" }, + remove: { type: "boolean", description: "Set true to remove an existing assignment" }, + }, + required: ["startDate", "endDate", "hoursPerDay"], + }, + description: "Array of staffing changes to simulate", + }, + }, + required: ["projectId", "changes"], + }, + }, + }, + { + type: "function", + function: { + name: "generate_project_narrative", + description: "Generate an AI-powered executive narrative for a project covering budget, staffing, timeline risk, and action items. Requires AI to be configured.", + parameters: { + type: "object", + properties: { + projectId: { type: "string", description: "Project ID" }, + }, + required: ["projectId"], + }, + }, + }, + { + type: "function", + function: { + name: "create_comment", + description: "Add a comment to an entity (estimate, scope item, demand line, etc.). Supports @mentions. Always confirm with the user first.", + parameters: { + type: "object", + properties: { + entityType: { type: "string", description: "Entity type (e.g. 'estimate', 'estimate_version', 'scope_item')" }, + entityId: { type: "string", description: "Entity ID" }, + body: { type: "string", description: "Comment body text. Use @[Name](userId) for mentions." }, + }, + required: ["entityType", "entityId", "body"], + }, + }, + }, + { + type: "function", + function: { + name: "resolve_comment", + description: "Mark a comment as resolved (or unresolve it). Only the comment author or an admin can do this.", + parameters: { + type: "object", + properties: { + commentId: { type: "string", description: "Comment ID to resolve" }, + resolved: { type: "boolean", description: "Set to true to resolve, false to unresolve. Default: true" }, + }, + required: ["commentId"], + }, + }, + }, ]; // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -4009,6 +4197,1148 @@ const executors = { recipientCount: recipientIds.length, }; }, + + // ── INSIGHTS & ANOMALIES ────────────────────────────────────────────────── + + async detect_anomalies(_params: Record, ctx: ToolContext) { + const now = new Date(); + const twoWeeksFromNow = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000); + const anomalies: Array<{ + type: string; + severity: string; + entityId: string; + entityName: string; + message: string; + }> = []; + + const projects = await ctx.db.project.findMany({ + where: { status: { in: ["ACTIVE", "DRAFT"] } }, + include: { + demandRequirements: { + select: { + id: true, + headcount: true, + startDate: true, + endDate: true, + status: true, + _count: { select: { assignments: true } }, + }, + }, + assignments: { + select: { + id: true, + resourceId: true, + startDate: true, + endDate: true, + hoursPerDay: true, + dailyCostCents: true, + status: true, + }, + }, + }, + }); + + function countBizDays(start: Date, end: Date): number { + let count = 0; + const d = new Date(start); + while (d <= end) { + const dow = d.getDay(); + if (dow !== 0 && dow !== 6) count++; + d.setDate(d.getDate() + 1); + } + return count; + } + + for (const project of projects) { + // Budget anomaly + if (project.budgetCents > 0) { + const totalDays = countBizDays(project.startDate, project.endDate); + const elapsedDays = countBizDays(project.startDate, now < project.endDate ? now : project.endDate); + if (totalDays > 0 && elapsedDays > 0) { + const expectedBurnRate = elapsedDays / totalDays; + const totalCostCents = project.assignments.reduce((s, a) => { + const aStart = a.startDate < project.startDate ? project.startDate : a.startDate; + const aEnd = a.endDate > now ? now : a.endDate; + if (aEnd < aStart) return s; + return s + a.dailyCostCents * countBizDays(aStart, aEnd); + }, 0); + const actualBurnRate = totalCostCents / project.budgetCents; + if (actualBurnRate > expectedBurnRate * 1.2) { + const overSpendPct = Math.round(((actualBurnRate - expectedBurnRate) / expectedBurnRate) * 100); + anomalies.push({ + type: "budget", + severity: actualBurnRate > expectedBurnRate * 1.5 ? "critical" : "warning", + entityId: project.id, + entityName: project.name, + message: `Burning budget ${overSpendPct}% faster than expected. ${Math.round(actualBurnRate * 100)}% spent at ${Math.round(expectedBurnRate * 100)}% timeline.`, + }); + } + } + } + + // Staffing anomaly + const upcomingDemands = project.demandRequirements.filter( + (d) => d.startDate <= twoWeeksFromNow && d.endDate >= now, + ); + for (const demand of upcomingDemands) { + const unfilledCount = demand.headcount - demand._count.assignments; + const unfillPct = demand.headcount > 0 ? unfilledCount / demand.headcount : 0; + if (unfillPct > 0.3) { + anomalies.push({ + type: "staffing", + severity: unfillPct > 0.6 ? "critical" : "warning", + entityId: project.id, + entityName: project.name, + message: `${unfilledCount} of ${demand.headcount} positions unfilled, starting ${demand.startDate.toISOString().slice(0, 10)}.`, + }); + } + } + + // Timeline anomaly + const overruns = project.assignments.filter( + (a) => a.endDate > project.endDate && (a.status === "ACTIVE" || a.status === "CONFIRMED"), + ); + if (overruns.length > 0) { + anomalies.push({ + type: "timeline", + severity: "warning", + entityId: project.id, + entityName: project.name, + message: `${overruns.length} assignment(s) extend beyond the project end date (${project.endDate.toISOString().slice(0, 10)}).`, + }); + } + } + + // Utilization anomaly + const resources = await ctx.db.resource.findMany({ + where: { isActive: true }, + select: { id: true, displayName: true, availability: true }, + }); + const periodStart = new Date(now.getFullYear(), now.getMonth(), 1); + const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0); + const activeAssignments = await ctx.db.assignment.findMany({ + where: { + status: { in: ["ACTIVE", "CONFIRMED"] }, + startDate: { lte: periodEnd }, + endDate: { gte: periodStart }, + }, + select: { resourceId: true, hoursPerDay: true }, + }); + const resourceHoursMap = new Map(); + for (const a of activeAssignments) { + resourceHoursMap.set(a.resourceId, (resourceHoursMap.get(a.resourceId) ?? 0) + a.hoursPerDay); + } + for (const resource of resources) { + const avail = resource.availability as Record | null; + if (!avail) continue; + const dailyAvail = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5; + if (dailyAvail <= 0) continue; + const booked = resourceHoursMap.get(resource.id) ?? 0; + const pct = Math.round((booked / dailyAvail) * 100); + if (pct > 110) { + anomalies.push({ + type: "utilization", + severity: pct > 130 ? "critical" : "warning", + entityId: resource.id, + entityName: resource.displayName, + message: `Resource at ${pct}% utilization (${booked.toFixed(1)}h/${dailyAvail.toFixed(1)}h per day).`, + }); + } else if (pct < 40 && booked > 0) { + anomalies.push({ + type: "utilization", + severity: "warning", + entityId: resource.id, + entityName: resource.displayName, + message: `Resource at only ${pct}% utilization (${booked.toFixed(1)}h/${dailyAvail.toFixed(1)}h per day).`, + }); + } + } + + anomalies.sort((a, b) => { + if (a.severity !== b.severity) return a.severity === "critical" ? -1 : 1; + return a.type.localeCompare(b.type); + }); + + return { anomalies, count: anomalies.length }; + }, + + async get_skill_gaps(_params: Record, ctx: ToolContext) { + const now = new Date(); + + // Get active demand requirements with their roles + const demands = await ctx.db.demandRequirement.findMany({ + where: { + project: { status: { in: ["ACTIVE", "DRAFT"] } }, + status: { not: "CANCELLED" }, + endDate: { gte: now }, + }, + select: { + id: true, + role: true, + headcount: true, + roleEntity: { select: { name: true } }, + _count: { select: { assignments: true } }, + }, + }); + + // Aggregate demand by role + const demandByRole = new Map(); + for (const d of demands) { + const roleName = d.roleEntity?.name ?? d.role ?? "Unknown"; + const existing = demandByRole.get(roleName) ?? { needed: 0, filled: 0 }; + existing.needed += d.headcount; + existing.filled += Math.min(d._count.assignments, d.headcount); + demandByRole.set(roleName, existing); + } + + // Get active resources with skills + const resources = await ctx.db.resource.findMany({ + where: { isActive: true }, + select: { + id: true, + skills: true, + areaRole: { select: { name: true } }, + }, + }); + + // Count skill supply + const skillSupply = new Map(); + for (const r of resources) { + const skills = (r.skills ?? []) as Array<{ skill: string; level?: number }>; + for (const s of skills) { + const key = s.skill.toLowerCase(); + skillSupply.set(key, (skillSupply.get(key) ?? 0) + 1); + } + } + + // Build role gaps + const roleGaps = [...demandByRole.entries()] + .map(([role, { needed, filled }]) => ({ + role, + needed, + filled, + gap: needed - filled, + fillRate: needed > 0 ? Math.round((filled / needed) * 100) : 100, + })) + .filter((g) => g.gap > 0) + .sort((a, b) => b.gap - a.gap); + + // Count supply by role + const supplyByRole = new Map(); + for (const r of resources) { + const roleName = r.areaRole?.name; + if (roleName) { + supplyByRole.set(roleName, (supplyByRole.get(roleName) ?? 0) + 1); + } + } + + return { + roleGaps, + totalOpenPositions: roleGaps.reduce((s, g) => s + g.gap, 0), + skillSupplyTop10: [...skillSupply.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([skill, count]) => ({ skill, resourceCount: count })), + resourcesByRole: [...supplyByRole.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([role, count]) => ({ role, count })), + }; + }, + + async get_project_health(_params: Record, ctx: ToolContext) { + const now = new Date(); + + function countBizDays(start: Date, end: Date): number { + let count = 0; + const d = new Date(start); + while (d <= end) { + const dow = d.getDay(); + if (dow !== 0 && dow !== 6) count++; + d.setDate(d.getDate() + 1); + } + return count; + } + + const projects = await ctx.db.project.findMany({ + where: { status: { in: ["ACTIVE", "DRAFT"] } }, + include: { + demandRequirements: { + select: { + headcount: true, + _count: { select: { assignments: true } }, + }, + }, + assignments: { + where: { status: { not: "CANCELLED" } }, + select: { + dailyCostCents: true, + startDate: true, + endDate: true, + status: true, + }, + }, + }, + }); + + const healthScores = projects.map((project) => { + // Budget score (0-100) + let budgetScore = 100; + if (project.budgetCents > 0) { + const totalCostCents = project.assignments.reduce((s, a) => { + const days = countBizDays(a.startDate, a.endDate); + return s + a.dailyCostCents * days; + }, 0); + const budgetUsedPct = (totalCostCents / project.budgetCents) * 100; + const totalDays = countBizDays(project.startDate, project.endDate); + const elapsedDays = countBizDays(project.startDate, now < project.endDate ? now : project.endDate); + const timelinePct = totalDays > 0 ? (elapsedDays / totalDays) * 100 : 0; + // Score: penalize if budget burn is significantly ahead of timeline + if (timelinePct > 0 && budgetUsedPct > timelinePct * 1.5) { + budgetScore = Math.max(0, 100 - Math.round(budgetUsedPct - timelinePct)); + } else if (budgetUsedPct > 90) { + budgetScore = Math.max(0, 100 - Math.round((budgetUsedPct - 90) * 5)); + } + } + + // Staffing score (0-100) + const totalDemand = project.demandRequirements.reduce((s, d) => s + d.headcount, 0); + const filledDemand = project.demandRequirements.reduce( + (s, d) => s + Math.min(d._count.assignments, d.headcount), + 0, + ); + const staffingScore = totalDemand > 0 ? Math.round((filledDemand / totalDemand) * 100) : 100; + + // Timeline score (0-100) + const overrunCount = project.assignments.filter( + (a) => a.endDate > project.endDate && (a.status === "ACTIVE" || a.status === "CONFIRMED"), + ).length; + const timelineScore = overrunCount > 0 + ? Math.max(0, 100 - overrunCount * 20) + : 100; + + const overall = Math.round((budgetScore + staffingScore + timelineScore) / 3); + + return { + projectId: project.id, + projectName: project.name, + shortCode: project.shortCode, + status: project.status, + overall, + budget: budgetScore, + staffing: staffingScore, + timeline: timelineScore, + rating: overall >= 80 ? "healthy" : overall >= 50 ? "at_risk" : "critical", + }; + }); + + healthScores.sort((a, b) => a.overall - b.overall); + + return { + projects: healthScores, + summary: { + healthy: healthScores.filter((p) => p.rating === "healthy").length, + atRisk: healthScores.filter((p) => p.rating === "at_risk").length, + critical: healthScores.filter((p) => p.rating === "critical").length, + }, + }; + }, + + async get_budget_forecast(_params: Record, ctx: ToolContext) { + assertPermission(ctx, "viewCosts" as PermissionKey); + + const now = new Date(); + + function countBizDays(start: Date, end: Date): number { + let count = 0; + const d = new Date(start); + while (d <= end) { + const dow = d.getDay(); + if (dow !== 0 && dow !== 6) count++; + d.setDate(d.getDate() + 1); + } + return count; + } + + const projects = await ctx.db.project.findMany({ + where: { + status: { in: ["ACTIVE", "DRAFT"] }, + budgetCents: { gt: 0 }, + }, + include: { + assignments: { + where: { status: { not: "CANCELLED" } }, + select: { + dailyCostCents: true, + startDate: true, + endDate: true, + }, + }, + }, + }); + + const forecasts = projects.map((project) => { + const totalDays = countBizDays(project.startDate, project.endDate); + const elapsedDays = countBizDays(project.startDate, now < project.endDate ? now : project.endDate); + + // Cost spent so far (up to now) + const spentCents = project.assignments.reduce((s, a) => { + const aStart = a.startDate < project.startDate ? project.startDate : a.startDate; + const aEnd = a.endDate > now ? now : a.endDate; + if (aEnd < aStart) return s; + return s + a.dailyCostCents * countBizDays(aStart, aEnd); + }, 0); + + // Total projected cost (full assignment durations) + const projectedCostCents = project.assignments.reduce((s, a) => { + return s + a.dailyCostCents * countBizDays(a.startDate, a.endDate); + }, 0); + + const budgetCents = project.budgetCents; + const utilization = budgetCents > 0 ? Math.round((spentCents / budgetCents) * 100) : 0; + const timelinePct = totalDays > 0 ? Math.round((elapsedDays / totalDays) * 100) : 0; + + const burnStatus = timelinePct > 0 + ? utilization > timelinePct * 1.2 + ? "ahead" + : utilization < timelinePct * 0.8 + ? "behind" + : "on_track" + : "not_started"; + + return { + projectId: project.id, + projectName: project.name, + shortCode: project.shortCode, + budget: fmtEur(budgetCents), + budgetCents, + spent: fmtEur(spentCents), + spentCents, + remaining: fmtEur(budgetCents - spentCents), + remainingCents: budgetCents - spentCents, + projected: fmtEur(projectedCostCents), + projectedCents: projectedCostCents, + utilization: `${utilization}%`, + timelineProgress: `${timelinePct}%`, + burnStatus, + }; + }); + + forecasts.sort((a, b) => b.spentCents - a.spentCents); + + return { forecasts }; + }, + + async get_insights_summary(_params: Record, ctx: ToolContext) { + const now = new Date(); + const twoWeeksFromNow = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000); + + function countBizDays(start: Date, end: Date): number { + let count = 0; + const d = new Date(start); + while (d <= end) { + const dow = d.getDay(); + if (dow !== 0 && dow !== 6) count++; + d.setDate(d.getDate() + 1); + } + return count; + } + + const projects = await ctx.db.project.findMany({ + where: { status: { in: ["ACTIVE", "DRAFT"] } }, + include: { + demandRequirements: { + select: { + headcount: true, + startDate: true, + endDate: true, + _count: { select: { assignments: true } }, + }, + }, + assignments: { + select: { + resourceId: true, + startDate: true, + endDate: true, + hoursPerDay: true, + dailyCostCents: true, + status: true, + }, + }, + }, + }); + + let budgetCount = 0; + let staffingCount = 0; + let timelineCount = 0; + let criticalCount = 0; + + for (const project of projects) { + if (project.budgetCents > 0) { + const totalDays = countBizDays(project.startDate, project.endDate); + const elapsedDays = countBizDays(project.startDate, now < project.endDate ? now : project.endDate); + if (totalDays > 0 && elapsedDays > 0) { + const expectedBurnRate = elapsedDays / totalDays; + const totalCostCents = project.assignments.reduce((s, a) => { + const aStart = a.startDate < project.startDate ? project.startDate : a.startDate; + const aEnd = a.endDate > now ? now : a.endDate; + if (aEnd < aStart) return s; + return s + a.dailyCostCents * countBizDays(aStart, aEnd); + }, 0); + const actualBurnRate = totalCostCents / project.budgetCents; + if (actualBurnRate > expectedBurnRate * 1.2) { + budgetCount++; + if (actualBurnRate > expectedBurnRate * 1.5) criticalCount++; + } + } + } + + const upcomingDemands = project.demandRequirements.filter( + (d) => d.startDate <= twoWeeksFromNow && d.endDate >= now, + ); + for (const demand of upcomingDemands) { + const unfillPct = demand.headcount > 0 ? (demand.headcount - demand._count.assignments) / demand.headcount : 0; + if (unfillPct > 0.3) { + staffingCount++; + if (unfillPct > 0.6) criticalCount++; + } + } + + const overruns = project.assignments.filter( + (a) => a.endDate > project.endDate && (a.status === "ACTIVE" || a.status === "CONFIRMED"), + ); + if (overruns.length > 0) timelineCount++; + } + + // Utilization + const resources = await ctx.db.resource.findMany({ + where: { isActive: true }, + select: { id: true, availability: true }, + }); + const periodStart = new Date(now.getFullYear(), now.getMonth(), 1); + const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0); + const activeAssignments = await ctx.db.assignment.findMany({ + where: { + status: { in: ["ACTIVE", "CONFIRMED"] }, + startDate: { lte: periodEnd }, + endDate: { gte: periodStart }, + }, + select: { resourceId: true, hoursPerDay: true }, + }); + const resourceHoursMap = new Map(); + for (const a of activeAssignments) { + resourceHoursMap.set(a.resourceId, (resourceHoursMap.get(a.resourceId) ?? 0) + a.hoursPerDay); + } + + let utilizationCount = 0; + for (const resource of resources) { + const avail = resource.availability as Record | null; + if (!avail) continue; + const dailyAvail = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5; + if (dailyAvail <= 0) continue; + const booked = resourceHoursMap.get(resource.id) ?? 0; + const pct = Math.round((booked / dailyAvail) * 100); + if (pct > 110) { + utilizationCount++; + if (pct > 130) criticalCount++; + } else if (pct < 40 && booked > 0) { + utilizationCount++; + } + } + + const total = budgetCount + staffingCount + timelineCount + utilizationCount; + + return { total, criticalCount, budget: budgetCount, staffing: staffingCount, timeline: timelineCount, utilization: utilizationCount }; + }, + + async run_report(params: { + entity: string; + columns: string[]; + filters?: Array<{ field: string; op: string; value: string }>; + limit?: number; + }, ctx: ToolContext) { + const entity = params.entity as "resource" | "project" | "assignment"; + if (!["resource", "project", "assignment"].includes(entity)) { + return { error: `Unknown entity: ${params.entity}. Use resource, project, or assignment.` }; + } + + const columns = params.columns; + const filters = params.filters ?? []; + const limit = Math.min(params.limit ?? 50, 200); + + // Build Prisma select from columns + const COLUMN_DEFS: Record> = { + resource: [ + { key: "id" }, { key: "eid" }, { key: "displayName" }, { key: "email" }, { key: "chapter" }, + { key: "resourceType" }, { key: "lcrCents" }, { key: "ucrCents" }, { key: "chargeabilityTarget" }, + { key: "fte" }, { key: "isActive" }, { key: "postalCode" }, { key: "federalState" }, + { key: "country.name", prismaPath: "country" }, { key: "metroCity.name", prismaPath: "metroCity" }, + { key: "orgUnit.name", prismaPath: "orgUnit" }, { key: "areaRole.name", prismaPath: "areaRole" }, + { key: "createdAt" }, { key: "updatedAt" }, + ], + project: [ + { key: "id" }, { key: "shortCode" }, { key: "name" }, { key: "orderType" }, { key: "allocationType" }, + { key: "status" }, { key: "winProbability" }, { key: "budgetCents" }, + { key: "startDate" }, { key: "endDate" }, { key: "responsiblePerson" }, + { key: "client.name", prismaPath: "client" }, { key: "createdAt" }, { key: "updatedAt" }, + ], + assignment: [ + { key: "id" }, { key: "resource.displayName", prismaPath: "resource" }, { key: "resource.eid", prismaPath: "resource" }, + { key: "project.name", prismaPath: "project" }, { key: "project.shortCode", prismaPath: "project" }, + { key: "startDate" }, { key: "endDate" }, { key: "hoursPerDay" }, { key: "percentage" }, + { key: "role" }, { key: "roleEntity.name", prismaPath: "roleEntity" }, + { key: "dailyCostCents" }, { key: "status" }, { key: "createdAt" }, { key: "updatedAt" }, + ], + }; + + const entityDefs = COLUMN_DEFS[entity]!; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const select: Record = { id: true }; + + for (const colKey of columns) { + const def = entityDefs.find((c) => c.key === colKey); + if (!def) continue; + if (colKey.includes(".")) { + const relationName = def.prismaPath ?? colKey.split(".")[0]!; + const fieldName = colKey.split(".").slice(1).join("."); + if (!select[relationName]) { + select[relationName] = { select: {} }; + } + select[relationName].select[fieldName] = true; + } else { + select[colKey] = true; + } + } + + // Build where from filters (only scalar top-level fields) + const SCALAR_FIELDS: Record> = { + resource: new Set(["id", "eid", "displayName", "email", "chapter", "resourceType", "lcrCents", "ucrCents", "chargeabilityTarget", "fte", "isActive", "postalCode", "federalState"]), + project: new Set(["id", "shortCode", "name", "orderType", "allocationType", "status", "winProbability", "budgetCents", "startDate", "endDate", "responsiblePerson"]), + assignment: new Set(["id", "startDate", "endDate", "hoursPerDay", "percentage", "role", "dailyCostCents", "status"]), + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const where: Record = {}; + for (const filter of filters) { + if (!SCALAR_FIELDS[entity]!.has(filter.field)) continue; + let value: unknown = filter.value; + // Try number conversion + const num = Number(filter.value); + if (!Number.isNaN(num) && filter.op !== "contains" && filter.op !== "in") { + value = num; + } + switch (filter.op) { + case "eq": where[filter.field] = value; break; + case "neq": where[filter.field] = { not: value }; break; + case "gt": where[filter.field] = { gt: value }; break; + case "lt": where[filter.field] = { lt: value }; break; + case "gte": where[filter.field] = { gte: value }; break; + case "lte": where[filter.field] = { lte: value }; break; + case "contains": where[filter.field] = { contains: String(filter.value), mode: "insensitive" }; break; + case "in": where[filter.field] = { in: filter.value.split(",").map((v: string) => v.trim()) }; break; + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const model = entity === "resource" ? ctx.db.resource : entity === "project" ? ctx.db.project : ctx.db.assignment; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rows = await (model as any).findMany({ select, where, take: limit }); + + // Flatten nested relations + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const flatRows = (rows as any[]).map((row: Record) => { + const flat: Record = {}; + for (const [key, val] of Object.entries(row)) { + if (val !== null && typeof val === "object" && !(val instanceof Date) && !Array.isArray(val)) { + for (const [subKey, subVal] of Object.entries(val as Record)) { + flat[`${key}.${subKey}`] = subVal; + } + } else { + flat[key] = val; + } + } + return flat; + }); + + return { rows: flatRows, rowCount: flatRows.length, columns: ["id", ...columns.filter((c) => c !== "id")] }; + }, + + async list_comments(params: { entityType: string; entityId: string }, ctx: ToolContext) { + const comments = await ctx.db.comment.findMany({ + where: { + entityType: params.entityType, + entityId: params.entityId, + parentId: null, + }, + include: { + author: { select: { id: true, name: true, email: true } }, + replies: { + include: { + author: { select: { id: true, name: true, email: true } }, + }, + orderBy: { createdAt: "asc" as const }, + }, + }, + orderBy: { createdAt: "asc" as const }, + }); + + return comments.map((c) => ({ + id: c.id, + author: c.author.name ?? c.author.email, + body: c.body, + resolved: c.resolved, + createdAt: c.createdAt.toISOString(), + replyCount: c.replies.length, + replies: c.replies.map((r) => ({ + id: r.id, + author: r.author.name ?? r.author.email, + body: r.body, + resolved: r.resolved, + createdAt: r.createdAt.toISOString(), + })), + })); + }, + + async lookup_rate(params: { + clientId?: string; + chapter?: string; + managementLevelId?: string; + roleName?: string; + seniority?: string; + }, ctx: ToolContext) { + assertPermission(ctx, "viewCosts" as PermissionKey); + + // Find rate cards applicable — prefer client-specific, then generic + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rateCardWhere: Record = { isActive: true }; + if (params.clientId) { + rateCardWhere.OR = [ + { clientId: params.clientId }, + { clientId: null }, + ]; + } + + const rateCards = await ctx.db.rateCard.findMany({ + where: rateCardWhere, + include: { + lines: { + select: { + id: true, + chapter: true, + seniority: true, + costRateCents: true, + billRateCents: true, + role: { select: { id: true, name: true } }, + }, + }, + client: { select: { id: true, name: true } }, + }, + orderBy: [{ effectiveFrom: "desc" }], + }); + + if (rateCards.length === 0) { + return { message: "No active rate cards found." }; + } + + // Resolve role name to ID if needed + let roleId: string | undefined; + if (params.roleName) { + const role = await ctx.db.role.findFirst({ + where: { name: { contains: params.roleName, mode: "insensitive" } }, + select: { id: true, name: true }, + }); + if (role) roleId = role.id; + } + + // Score each line across all rate cards + type ScoredLine = { + rateCardName: string; + clientName: string | null; + lineId: string; + chapter: string | null; + seniority: string | null; + roleName: string | null; + costRate: string; + billRate: string | null; + score: number; + }; + const scoredLines: ScoredLine[] = []; + + for (const card of rateCards) { + for (const line of card.lines) { + let score = 0; + let mismatch = false; + + if (roleId && line.role) { + if (line.role.id === roleId) score += 4; + else mismatch = true; + } + if (params.chapter && line.chapter) { + if (line.chapter.toLowerCase() === params.chapter.toLowerCase()) score += 2; + else mismatch = true; + } + if (params.seniority && line.seniority) { + if (line.seniority.toLowerCase() === params.seniority.toLowerCase()) score += 1; + else mismatch = true; + } + // Prefer client-specific cards + if (params.clientId && card.client?.id === params.clientId) score += 3; + + if (!mismatch) { + scoredLines.push({ + rateCardName: card.name, + clientName: card.client?.name ?? null, + lineId: line.id, + chapter: line.chapter, + seniority: line.seniority, + roleName: line.role?.name ?? null, + costRate: fmtEur(line.costRateCents), + billRate: line.billRateCents ? fmtEur(line.billRateCents) : null, + score, + }); + } + } + } + + scoredLines.sort((a, b) => b.score - a.score); + + const best = scoredLines[0]; + return { + bestMatch: best ?? null, + alternatives: scoredLines.slice(1, 4), + totalCandidates: scoredLines.length, + }; + }, + + // ── SCENARIO & AI ───────────────────────────────────────────────────────── + + async simulate_scenario(params: { + projectId: string; + changes: Array<{ + assignmentId?: string; + resourceId?: string; + roleId?: string; + startDate: string; + endDate: string; + hoursPerDay: number; + remove?: boolean; + }>; + }, ctx: ToolContext) { + assertPermission(ctx, "manageAllocations" as PermissionKey); + + const DEFAULT_AVAILABILITY = { + monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0, + } as const; + + const project = await ctx.db.project.findUnique({ + where: { id: params.projectId }, + select: { id: true, name: true, budgetCents: true, startDate: true, endDate: true }, + }); + if (!project) return { error: "Project not found" }; + + // Load current assignments + const currentAssignments = await ctx.db.assignment.findMany({ + where: { projectId: params.projectId, status: { not: "CANCELLED" } }, + include: { + resource: { + select: { id: true, displayName: true, lcrCents: true, availability: true, chargeabilityTarget: true }, + }, + }, + }); + + // Compute baseline + 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 resource IDs + const resourceIds = new Set(); + for (const c of params.changes) { if (c.resourceId) resourceIds.add(c.resourceId); } + for (const a of currentAssignments) { if (a.resourceId) resourceIds.add(a.resourceId); } + + const resources = await ctx.db.resource.findMany({ + where: { id: { in: [...resourceIds] } }, + select: { id: true, displayName: true, lcrCents: true, availability: true, chargeabilityTarget: true }, + }); + const resourceMap = new Map(resources.map((r) => [r.id, r])); + + // Build scenario entries + const removedIds = new Set(params.changes.filter((c) => c.remove && c.assignmentId).map((c) => c.assignmentId!)); + const modifiedIds = new Set(params.changes.filter((c) => !c.remove && c.assignmentId).map((c) => c.assignmentId!)); + + const scenarioEntries: Array<{ + resourceId: string | null; + lcrCents: number; + hoursPerDay: number; + startDate: Date; + endDate: Date; + availability: typeof DEFAULT_AVAILABILITY; + }> = []; + + for (const a of currentAssignments) { + if (removedIds.has(a.id) || modifiedIds.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, + }); + } + + for (const c of params.changes) { + if (c.remove) continue; + const resource = c.resourceId ? resourceMap.get(c.resourceId) : null; + scenarioEntries.push({ + resourceId: c.resourceId ?? null, + lcrCents: resource?.lcrCents ?? 0, + hoursPerDay: c.hoursPerDay, + startDate: new Date(c.startDate), + endDate: new Date(c.endDate), + availability: (resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY, + }); + } + + // 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; + } + + const warnings: string[] = []; + 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}%`); + } + + return { + baseline: { + totalCost: fmtEur(baselineCostCents), + totalCostCents: baselineCostCents, + totalHours: baselineHours, + headcount: currentAssignments.length, + }, + scenario: { + totalCost: fmtEur(scenarioCostCents), + totalCostCents: scenarioCostCents, + totalHours: scenarioHours, + headcount: scenarioEntries.length, + }, + delta: { + costCents: scenarioCostCents - baselineCostCents, + cost: fmtEur(scenarioCostCents - baselineCostCents), + hours: scenarioHours - baselineHours, + headcount: scenarioEntries.length - currentAssignments.length, + }, + warnings, + budgetCents, + }; + }, + + async generate_project_narrative(params: { projectId: string }, ctx: ToolContext) { + function countBizDays(start: Date, end: Date): number { + let count = 0; + const d = new Date(start); + while (d <= end) { + const dow = d.getDay(); + if (dow !== 0 && dow !== 6) count++; + d.setDate(d.getDate() + 1); + } + return count; + } + + const [project, settings] = await Promise.all([ + ctx.db.project.findUnique({ + where: { id: params.projectId }, + include: { + demandRequirements: { + select: { + headcount: true, + _count: { select: { assignments: true } }, + }, + }, + assignments: { + select: { + status: true, + dailyCostCents: true, + startDate: true, + endDate: true, + resource: { select: { displayName: true } }, + }, + }, + }, + }), + ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }), + ]); + + if (!project) return { error: "Project not found" }; + + if (!isAiConfigured(settings)) { + return { error: "AI is not configured. Please set credentials in Admin > Settings." }; + } + + const now = new Date(); + const totalDays = countBizDays(project.startDate, project.endDate); + const elapsedDays = countBizDays(project.startDate, now < project.endDate ? now : project.endDate); + const progressPercent = totalDays > 0 ? Math.round((elapsedDays / totalDays) * 100) : 0; + + const totalDemandHeadcount = project.demandRequirements.reduce((s, d) => s + d.headcount, 0); + const filledDemandHeadcount = project.demandRequirements.reduce( + (s, d) => s + Math.min(d._count.assignments, d.headcount), 0, + ); + const staffingPercent = totalDemandHeadcount > 0 ? Math.round((filledDemandHeadcount / totalDemandHeadcount) * 100) : 100; + + const totalCostCents = project.assignments.reduce((s, a) => { + const days = countBizDays(a.startDate, a.endDate); + return s + a.dailyCostCents * days; + }, 0); + const budgetCents = project.budgetCents; + const budgetUsedPercent = budgetCents > 0 ? Math.round((totalCostCents / budgetCents) * 100) : 0; + + const overrunCount = project.assignments.filter((a) => a.endDate > project.endDate).length; + + const dataContext = [ + `Project: ${project.name} (${project.shortCode})`, + `Status: ${project.status}`, + `Timeline: ${project.startDate.toISOString().slice(0, 10)} to ${project.endDate.toISOString().slice(0, 10)} (${progressPercent}% elapsed)`, + `Budget: ${fmtEur(budgetCents)} | Estimated cost: ${fmtEur(totalCostCents)} (${budgetUsedPercent}% of budget)`, + `Staffing: ${filledDemandHeadcount}/${totalDemandHeadcount} positions filled (${staffingPercent}%)`, + `Active assignments: ${project.assignments.filter((a) => a.status === "ACTIVE" || a.status === "CONFIRMED").length}`, + overrunCount > 0 + ? `Timeline risk: ${overrunCount} assignment(s) extend beyond project end date` + : "No timeline overruns detected", + ].join("\n"); + + const prompt = `Generate a concise executive summary for this project covering: budget status, staffing completeness, timeline risk, and key action items. Be specific with numbers. Keep it to 3-5 sentences.\n\n${dataContext}`; + + try { + const client = createAiClient(settings!); + const model = settings!.azureOpenAiDeployment!; + const maxTokens = settings!.aiMaxCompletionTokens ?? 300; + const temperature = settings!.aiTemperature ?? 1; + + const completion = await client.chat.completions.create({ + messages: [ + { role: "system", content: "You are a project management analyst providing brief executive summaries. Be factual and action-oriented." }, + { role: "user", content: prompt }, + ], + max_completion_tokens: maxTokens, + model, + ...(temperature !== 1 ? { temperature } : {}), + }); + const narrative = completion.choices[0]?.message?.content?.trim() ?? ""; + + if (!narrative) return { error: "AI returned an empty response." }; + + const generatedAt = new Date().toISOString(); + + // Cache in project dynamicFields + const existingDynamic = (project.dynamicFields as Record) ?? {}; + await ctx.db.project.update({ + where: { id: params.projectId }, + data: { + dynamicFields: { + ...existingDynamic, + aiNarrative: narrative, + aiNarrativeGeneratedAt: generatedAt, + }, + }, + }); + + return { narrative, generatedAt }; + } catch (err) { + return { error: `AI call failed: ${parseAiError(err)}` }; + } + }, + + async create_comment(params: { + entityType: string; + entityId: string; + body: string; + }, ctx: ToolContext) { + // Resolve the DB user from the assistant tool context userId + const comment = await ctx.db.comment.create({ + data: { + entityType: params.entityType, + entityId: params.entityId, + authorId: ctx.userId, + body: params.body, + mentions: [], + }, + include: { + author: { select: { id: true, name: true, email: true } }, + }, + }); + + return { + __action: "invalidate", + scope: ["comment"], + id: comment.id, + author: comment.author.name ?? comment.author.email, + body: comment.body, + createdAt: comment.createdAt.toISOString(), + }; + }, + + async resolve_comment(params: { + commentId: string; + resolved?: boolean; + }, ctx: ToolContext) { + const existing = await ctx.db.comment.findUnique({ + where: { id: params.commentId }, + select: { id: true, authorId: true, body: true }, + }); + + if (!existing) return { error: "Comment not found" }; + + // Only the author or an admin can resolve + const dbUser = await ctx.db.user.findUnique({ + where: { id: ctx.userId }, + select: { systemRole: true }, + }); + if (existing.authorId !== ctx.userId && dbUser?.systemRole !== "ADMIN") { + return { error: "Only the comment author or an admin can resolve comments" }; + } + + const resolved = params.resolved !== false; + const updated = await ctx.db.comment.update({ + where: { id: params.commentId }, + data: { resolved }, + include: { + author: { select: { id: true, name: true, email: true } }, + }, + }); + + return { + __action: "invalidate", + scope: ["comment"], + id: updated.id, + resolved: updated.resolved, + author: updated.author.name ?? updated.author.email, + body: updated.body.slice(0, 100), + }; + }, }; // ─── Executor ───────────────────────────────────────────────────────────────