/** * AI Assistant Tool definitions for OpenAI Function Calling. * Each tool has a JSON schema (for the AI) and an execute function (for the server). */ import { prisma } from "@planarchy/db"; import { calculateAllocation, checkDuplicateAssignment, countWorkingDays } from "@planarchy/engine/allocation"; import { computeBudgetStatus } from "@planarchy/engine"; import type { PermissionKey } from "@planarchy/shared"; import { parseTaskAction } from "@planarchy/shared"; 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"; import { emitNotificationCreated, emitTaskAssigned, emitTaskCompleted, emitTaskStatusChanged, emitBroadcastSent, } from "../sse/event-bus.js"; // ─── Types ────────────────────────────────────────────────────────────────── export type ToolContext = { db: typeof prisma; userId: string; userRole: string; permissions: Set; }; interface ToolDef { type: "function"; function: { name: string; description: string; parameters: Record; }; } // eslint-disable-next-line @typescript-eslint/no-explicit-any type ToolExecutor = (params: any, ctx: ToolContext) => Promise; // ─── Helpers ──────────────────────────────────────────────────────────────── function fmtDate(d: Date | null | undefined): string | null { return d ? d.toISOString().slice(0, 10) : null; } function assertPermission(ctx: ToolContext, perm: PermissionKey): void { if (!ctx.permissions.has(perm)) { throw new Error(`Permission denied: you need the "${perm}" permission to perform this action.`); } } // ─── Tool Definitions ─────────────────────────────────────────────────────── export const TOOL_DEFINITIONS: ToolDef[] = [ // ── READ TOOLS ── { type: "function", function: { name: "search_resources", description: "Search for resources (employees) by name, employee ID, chapter, country, metro city, org unit, or role. Returns a list of matching resources with key details.", parameters: { type: "object", properties: { query: { type: "string", description: "Search term (matches displayName, eid, chapter)" }, country: { type: "string", description: "Filter by country name or code (e.g. 'Spain', 'ES', 'Deutschland', 'DE')" }, metroCity: { type: "string", description: "Filter by metro city name (e.g. 'Madrid', 'München')" }, orgUnit: { type: "string", description: "Filter by org unit name (partial match)" }, roleName: { type: "string", description: "Filter by role name (partial match)" }, isActive: { type: "boolean", description: "Filter by active status. Default: true" }, limit: { type: "integer", description: "Max results. Default: 50" }, }, }, }, }, { type: "function", function: { name: "get_resource", description: "Get detailed information about a single resource by ID, employee ID (eid), or name.", parameters: { type: "object", properties: { identifier: { type: "string", description: "Resource ID, employee ID (eid like EMP-001), or display name" }, }, required: ["identifier"], }, }, }, { type: "function", function: { name: "search_projects", description: "Search for projects by name, short code, status, or client.", parameters: { type: "object", properties: { query: { type: "string", description: "Search term (matches name, shortCode)" }, status: { type: "string", description: "Filter by status: DRAFT, ACTIVE, ON_HOLD, COMPLETED, CANCELLED" }, limit: { type: "integer", description: "Max results. Default: 20" }, }, }, }, }, { type: "function", function: { name: "get_project", description: "Get detailed information about a single project by ID or short code, including top allocations.", parameters: { type: "object", properties: { identifier: { type: "string", description: "Project ID or short code (e.g. Z033T593)" }, }, required: ["identifier"], }, }, }, { type: "function", function: { name: "list_allocations", description: "List assignments/allocations, optionally filtered by resource or project. Shows who is assigned where, hours/day, dates, and cost.", parameters: { type: "object", properties: { resourceId: { type: "string", description: "Filter by resource ID" }, projectId: { type: "string", description: "Filter by project ID" }, resourceName: { type: "string", description: "Filter by resource name (partial match)" }, projectCode: { type: "string", description: "Filter by project short code (partial match)" }, status: { type: "string", description: "Filter by status: PROPOSED, CONFIRMED, ACTIVE, COMPLETED, CANCELLED" }, limit: { type: "integer", description: "Max results. Default: 30" }, }, }, }, }, { type: "function", function: { name: "get_budget_status", description: "Get the budget status of a project: total budget, confirmed/proposed costs, remaining, utilization percentage.", parameters: { type: "object", properties: { projectId: { type: "string", description: "Project ID or short code" }, }, required: ["projectId"], }, }, }, { type: "function", function: { name: "get_vacation_balance", description: "Get vacation/leave balance for a resource: entitlement, taken, remaining days.", parameters: { type: "object", properties: { resourceId: { type: "string", description: "Resource ID or name" }, year: { type: "integer", description: "Year. Default: current year" }, }, required: ["resourceId"], }, }, }, { type: "function", function: { name: "list_vacations_upcoming", description: "List upcoming vacations across all resources, or for a specific resource/team. Shows who is off when.", parameters: { type: "object", properties: { resourceName: { type: "string", description: "Filter by resource name (partial match)" }, chapter: { type: "string", description: "Filter by chapter/team" }, daysAhead: { type: "integer", description: "How many days ahead to look. Default: 30" }, limit: { type: "integer", description: "Max results. Default: 30" }, }, }, }, }, { type: "function", function: { name: "list_roles", description: "List all available roles with their colors.", parameters: { type: "object", properties: {} }, }, }, { type: "function", function: { name: "search_by_skill", description: "Find resources that have a specific skill.", parameters: { type: "object", properties: { skill: { type: "string", description: "Skill name to search for" }, }, required: ["skill"], }, }, }, { type: "function", function: { name: "get_statistics", description: "Get overview statistics: total resources, projects, active allocations, budget summary, projects by status, chapter breakdown.", parameters: { type: "object", properties: {} }, }, }, { type: "function", function: { name: "get_chargeability", description: "Get chargeability data for a resource in a given month: hours booked vs available, chargeability %, target comparison.", parameters: { type: "object", properties: { resourceId: { type: "string", description: "Resource ID, eid, or name" }, month: { type: "string", description: "Month in YYYY-MM format, e.g. 2026-03. Default: current month" }, }, required: ["resourceId"], }, }, }, { type: "function", function: { name: "search_estimates", description: "Search for estimates (cost/effort estimates) by project or name. Returns estimate name, status, version count.", parameters: { type: "object", properties: { projectCode: { type: "string", description: "Project short code to filter by" }, query: { type: "string", description: "Search term (matches estimate name)" }, status: { type: "string", description: "Filter by status: DRAFT, SUBMITTED, APPROVED, REJECTED" }, limit: { type: "integer", description: "Max results. Default: 20" }, }, }, }, }, { type: "function", function: { name: "list_clients", description: "List clients/customers. Can search by name or code.", parameters: { type: "object", properties: { query: { type: "string", description: "Search term (matches name or code)" }, limit: { type: "integer", description: "Max results. Default: 20" }, }, }, }, }, { type: "function", function: { name: "list_org_units", description: "List organizational units (departments, teams) with their hierarchy.", parameters: { type: "object", properties: { level: { type: "integer", description: "Filter by org level (5, 6, or 7)" }, }, }, }, }, // ── NAVIGATION TOOLS ── { type: "function", function: { name: "navigate_to_page", description: "Navigate the user to a specific page in CapaKraken, optionally with filters. Use this when the user wants to see data on a specific page (e.g. 'show me on the timeline', 'open the resources page').", parameters: { type: "object", properties: { page: { type: "string", description: "Page name: timeline, dashboard, resources, projects, allocations, staffing, estimates, vacations, my-vacations, roles, skills-analytics, chargeability, computation-graph", }, eids: { type: "string", description: "Comma-separated employee IDs to filter (for timeline)" }, chapters: { type: "string", description: "Comma-separated chapters to filter (for timeline)" }, projectIds: { type: "string", description: "Comma-separated project IDs to filter (for timeline)" }, clientIds: { type: "string", description: "Comma-separated client IDs to filter (for timeline)" }, countryCodes: { type: "string", description: "Comma-separated country codes to filter (e.g. 'ES,DE' for Spain and Germany, for timeline)" }, startDate: { type: "string", description: "Start date YYYY-MM-DD (for timeline)" }, days: { type: "integer", description: "Number of days to show (for timeline)" }, }, required: ["page"], }, }, }, // ── WRITE TOOLS ── { type: "function", function: { name: "create_allocation", description: "Create a new allocation/booking for a resource on a project. Requires manageAllocations permission. Always confirm with the user before calling this. Created with PROPOSED status.", parameters: { type: "object", properties: { resourceId: { type: "string", description: "Resource ID" }, projectId: { type: "string", description: "Project 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 (e.g. 8)" }, role: { type: "string", description: "Optional role name" }, }, required: ["resourceId", "projectId", "startDate", "endDate", "hoursPerDay"], }, }, }, { type: "function", function: { name: "cancel_allocation", description: "Cancel an existing allocation. Can find by allocation ID, or by resource name + project code + date range. Requires manageAllocations permission. Always confirm with the user before calling this.", parameters: { type: "object", properties: { allocationId: { type: "string", description: "Allocation ID (if known)" }, resourceName: { type: "string", description: "Resource name (partial match)" }, projectCode: { type: "string", description: "Project short code (partial match)" }, startDate: { type: "string", description: "Filter by start date YYYY-MM-DD" }, endDate: { type: "string", description: "Filter by end date YYYY-MM-DD" }, }, }, }, }, { type: "function", function: { name: "update_resource", description: "Update a resource's details. Requires manageResources permission. Always confirm with the user before calling this.", parameters: { type: "object", properties: { id: { type: "string", description: "Resource ID" }, displayName: { type: "string", description: "New display name" }, fte: { type: "number", description: "New FTE (0.0-1.0)" }, lcrCents: { type: "integer", description: "New LCR in cents (e.g. 8500 = 85.00 EUR/h)" }, chapter: { type: "string", description: "New chapter" }, chargeabilityTarget: { type: "number", description: "New chargeability target (0-100)" }, }, required: ["id"], }, }, }, { type: "function", function: { name: "update_allocation_status", description: "Change the status of an existing allocation. Can reactivate cancelled allocations, confirm proposed ones, etc. Requires manageAllocations permission. Always confirm with the user before calling.", parameters: { type: "object", properties: { allocationId: { type: "string", description: "Allocation ID" }, resourceName: { type: "string", description: "Resource name (partial match, used if no allocationId)" }, projectCode: { type: "string", description: "Project short code (partial match, used if no allocationId)" }, startDate: { type: "string", description: "Start date filter YYYY-MM-DD (used if no allocationId)" }, newStatus: { type: "string", description: "New status: PROPOSED, CONFIRMED, ACTIVE, COMPLETED, CANCELLED" }, }, required: ["newStatus"], }, }, }, { type: "function", function: { name: "update_project", description: "Update a project's details. Requires manageProjects permission. Always confirm with the user before calling this.", parameters: { type: "object", properties: { id: { type: "string", description: "Project ID" }, name: { type: "string", description: "New project name" }, budgetCents: { type: "integer", description: "New budget in cents (e.g. 10000000 = 100,000 EUR)" }, winProbability: { type: "integer", description: "Win probability 0-100" }, status: { type: "string", description: "New status: DRAFT, ACTIVE, ON_HOLD, COMPLETED, CANCELLED" }, responsiblePerson: { type: "string", description: "Name of the responsible person. Must match an existing resource's display name (case-insensitive search)." }, }, required: ["id"], }, }, }, { type: "function", function: { name: "create_project", description: "Create a new project. Requires manageProjects permission. Always confirm with the user before calling this. The project is created in DRAFT status by default.", parameters: { type: "object", properties: { shortCode: { type: "string", description: "Unique project code, uppercase alphanumeric with hyphens/underscores (e.g. 'PROJ-001')" }, name: { type: "string", description: "Project name" }, orderType: { type: "string", description: "Order type: BD, CHARGEABLE, INTERNAL, OVERHEAD" }, allocationType: { type: "string", description: "Allocation type: INT or EXT. Default: INT" }, budgetCents: { type: "integer", description: "Budget in cents (e.g. 10000000 = 100,000 EUR)" }, startDate: { type: "string", description: "Start date (YYYY-MM-DD)" }, endDate: { type: "string", description: "End date (YYYY-MM-DD)" }, winProbability: { type: "integer", description: "Win probability 0-100. Default: 100" }, status: { type: "string", description: "Initial status: DRAFT, ACTIVE, ON_HOLD. Default: DRAFT" }, responsiblePerson: { type: "string", description: "Name of the responsible person. Must match an existing resource's display name (case-insensitive search)." }, color: { type: "string", description: "Hex color (e.g. '#3b82f6')" }, blueprintName: { type: "string", description: "Blueprint name to look up and attach (partial match)" }, clientName: { type: "string", description: "Client name to look up and attach (partial match)" }, }, required: ["shortCode", "name", "orderType", "budgetCents", "startDate", "endDate"], }, }, }, // ── RESOURCE MANAGEMENT ── { type: "function", function: { name: "create_resource", description: "Create a new resource (employee). Requires manageResources permission. Always confirm with the user before calling.", parameters: { type: "object", properties: { eid: { type: "string", description: "Employee ID (e.g. EMP-042)" }, displayName: { type: "string", description: "Full name" }, email: { type: "string", description: "Email address" }, fte: { type: "number", description: "FTE 0.0-1.0. Default: 1" }, lcrCents: { type: "integer", description: "Loaded cost rate in cents (e.g. 8500 = 85 EUR/h)" }, ucrCents: { type: "integer", description: "Unloaded cost rate in cents" }, chapter: { type: "string", description: "Chapter/team name" }, chargeabilityTarget: { type: "number", description: "Chargeability target 0-100. Default: 80" }, roleName: { type: "string", description: "Role name (partial match)" }, countryCode: { type: "string", description: "Country code (e.g. DE, ES)" }, orgUnitName: { type: "string", description: "Org unit name (partial match)" }, postalCode: { type: "string", description: "Postal code" }, }, required: ["eid", "displayName", "lcrCents"], }, }, }, { type: "function", function: { name: "deactivate_resource", description: "Deactivate a resource (soft delete). Requires manageResources permission. Always confirm first.", parameters: { type: "object", properties: { identifier: { type: "string", description: "Resource ID, eid, or name" }, }, required: ["identifier"], }, }, }, // ── VACATION MANAGEMENT ── { type: "function", function: { name: "create_vacation", description: "Create a vacation/leave request. Requires manageVacations permission (or self-service). Always confirm with the user.", parameters: { type: "object", properties: { resourceId: { type: "string", description: "Resource ID or name" }, type: { type: "string", description: "Type: VACATION, SICK, PARENTAL, SPECIAL, PUBLIC_HOLIDAY" }, startDate: { type: "string", description: "Start date YYYY-MM-DD" }, endDate: { type: "string", description: "End date YYYY-MM-DD" }, isHalfDay: { type: "boolean", description: "Half day? Default: false" }, halfDayPart: { type: "string", description: "MORNING or AFTERNOON (if half day)" }, note: { type: "string", description: "Optional note" }, }, required: ["resourceId", "type", "startDate", "endDate"], }, }, }, { type: "function", function: { name: "approve_vacation", description: "Approve a pending vacation request. Requires manageVacations permission. Always confirm first.", parameters: { type: "object", properties: { vacationId: { type: "string", description: "Vacation ID" }, }, required: ["vacationId"], }, }, }, { type: "function", function: { name: "reject_vacation", description: "Reject a pending vacation request. Requires manageVacations permission. Always confirm first.", parameters: { type: "object", properties: { vacationId: { type: "string", description: "Vacation ID" }, reason: { type: "string", description: "Rejection reason" }, }, required: ["vacationId"], }, }, }, { type: "function", function: { name: "cancel_vacation", description: "Cancel a vacation. Requires manageVacations permission. Always confirm first.", parameters: { type: "object", properties: { vacationId: { type: "string", description: "Vacation ID" }, }, required: ["vacationId"], }, }, }, { type: "function", function: { name: "get_pending_vacation_approvals", description: "List vacation requests awaiting approval.", parameters: { type: "object", properties: { limit: { type: "integer", description: "Max results. Default: 20" }, }, }, }, }, { type: "function", function: { name: "get_team_vacation_overlap", description: "Check if team members have overlapping vacations in a date range.", parameters: { type: "object", properties: { resourceId: { type: "string", description: "Resource ID to check overlap for" }, startDate: { type: "string", description: "Start date YYYY-MM-DD" }, endDate: { type: "string", description: "End date YYYY-MM-DD" }, }, required: ["resourceId", "startDate", "endDate"], }, }, }, // ── ENTITLEMENT ── { type: "function", function: { name: "get_entitlement_summary", description: "Get vacation entitlement year summary for all resources or a specific resource.", parameters: { type: "object", properties: { year: { type: "integer", description: "Year. Default: current year" }, resourceName: { type: "string", description: "Filter by resource name (optional)" }, }, }, }, }, { type: "function", function: { name: "set_entitlement", description: "Set vacation entitlement for a resource for a year. Requires admin permission. Always confirm first.", parameters: { type: "object", properties: { resourceId: { type: "string", description: "Resource ID or name" }, year: { type: "integer", description: "Year" }, entitledDays: { type: "number", description: "Number of entitled vacation days" }, carryoverDays: { type: "number", description: "Carryover days from previous year" }, }, required: ["resourceId", "year", "entitledDays"], }, }, }, // ── DEMAND / STAFFING ── { type: "function", function: { name: "list_demands", description: "List staffing demand requirements for projects. Shows open positions that need to be filled.", parameters: { type: "object", properties: { projectId: { type: "string", description: "Filter by project ID or short code" }, status: { type: "string", description: "Filter by status: OPEN, PARTIALLY_FILLED, FILLED, CANCELLED" }, limit: { type: "integer", description: "Max results. Default: 30" }, }, }, }, }, { type: "function", function: { name: "create_demand", description: "Create a staffing demand requirement on a project. Requires manageAllocations permission. Always confirm first.", parameters: { type: "object", properties: { projectId: { type: "string", description: "Project ID or short code" }, roleName: { type: "string", description: "Role name for the demand" }, headcount: { type: "integer", description: "Number of people needed. Default: 1" }, hoursPerDay: { type: "number", description: "Hours per day required" }, startDate: { type: "string", description: "Start date YYYY-MM-DD" }, endDate: { type: "string", description: "End date YYYY-MM-DD" }, }, required: ["projectId", "roleName", "hoursPerDay", "startDate", "endDate"], }, }, }, { type: "function", function: { name: "fill_demand", description: "Fill/assign a resource to an open demand requirement. Requires manageAllocations permission. Always confirm first.", parameters: { type: "object", properties: { demandId: { type: "string", description: "Demand requirement ID" }, resourceId: { type: "string", description: "Resource ID or name to assign" }, }, required: ["demandId", "resourceId"], }, }, }, { type: "function", function: { name: "check_resource_availability", description: "Check if a resource is available in a given date range (no conflicting allocations or vacations).", parameters: { type: "object", properties: { resourceId: { type: "string", description: "Resource ID, eid, or name" }, startDate: { type: "string", description: "Start date YYYY-MM-DD" }, endDate: { type: "string", description: "End date YYYY-MM-DD" }, }, required: ["resourceId", "startDate", "endDate"], }, }, }, { type: "function", function: { name: "get_staffing_suggestions", description: "Get AI-powered staffing suggestions for a project based on required skills, availability, and cost.", parameters: { type: "object", properties: { projectId: { type: "string", description: "Project ID or short code" }, roleName: { type: "string", description: "Role to find candidates for" }, startDate: { type: "string", description: "Start date YYYY-MM-DD" }, endDate: { type: "string", description: "End date YYYY-MM-DD" }, limit: { type: "integer", description: "Max suggestions. Default: 5" }, }, required: ["projectId"], }, }, }, { type: "function", function: { name: "find_capacity", description: "Find resources with available capacity in a date range.", parameters: { type: "object", properties: { startDate: { type: "string", description: "Start date YYYY-MM-DD" }, endDate: { type: "string", description: "End date YYYY-MM-DD" }, minHoursPerDay: { type: "number", description: "Minimum available hours/day. Default: 4" }, roleName: { type: "string", description: "Filter by role name" }, chapter: { type: "string", description: "Filter by chapter" }, limit: { type: "integer", description: "Max results. Default: 20" }, }, required: ["startDate", "endDate"], }, }, }, // ── BLUEPRINT ── { type: "function", function: { name: "list_blueprints", description: "List available project blueprints with their field definitions.", parameters: { type: "object", properties: {} }, }, }, { type: "function", function: { name: "get_blueprint", description: "Get detailed blueprint with all field definitions and role presets.", parameters: { type: "object", properties: { identifier: { type: "string", description: "Blueprint ID or name (partial match)" }, }, required: ["identifier"], }, }, }, // ── RATE CARDS ── { type: "function", function: { name: "list_rate_cards", description: "List rate cards with their effective dates and line items.", parameters: { type: "object", properties: { query: { type: "string", description: "Search by name" }, limit: { type: "integer", description: "Max results. Default: 20" }, }, }, }, }, { type: "function", function: { name: "resolve_rate", description: "Look up the applicable rate for a resource, role, or management level from rate cards.", parameters: { type: "object", properties: { resourceId: { type: "string", description: "Resource ID or name" }, roleName: { type: "string", description: "Role name" }, date: { type: "string", description: "Date to check rate for (YYYY-MM-DD). Default: today" }, }, }, }, }, // ── ESTIMATES ── { type: "function", function: { name: "get_estimate_detail", description: "Get detailed estimate with versions, line items, totals, and commercial terms.", parameters: { type: "object", properties: { estimateId: { type: "string", description: "Estimate ID" }, }, required: ["estimateId"], }, }, }, { type: "function", function: { name: "create_estimate", description: "Create a new estimate for a project. Requires manageEstimates permission. Always confirm first.", parameters: { type: "object", properties: { name: { type: "string", description: "Estimate name" }, projectId: { type: "string", description: "Project ID or short code" }, }, required: ["name", "projectId"], }, }, }, // ── ROLES ── { type: "function", function: { name: "create_role", description: "Create a new role. Requires manageResources permission. Always confirm first.", parameters: { type: "object", properties: { name: { type: "string", description: "Role name" }, color: { type: "string", description: "Hex color (e.g. #3b82f6). Default: #6b7280" }, }, required: ["name"], }, }, }, { type: "function", function: { name: "update_role", description: "Update a role's name or color. Requires manageResources permission. Always confirm first.", parameters: { type: "object", properties: { id: { type: "string", description: "Role ID" }, name: { type: "string", description: "New name" }, color: { type: "string", description: "New hex color" }, }, required: ["id"], }, }, }, { type: "function", function: { name: "delete_role", description: "Delete a role. Requires manageResources permission. Always confirm first.", parameters: { type: "object", properties: { id: { type: "string", description: "Role ID" }, }, required: ["id"], }, }, }, // ── CLIENTS ── { type: "function", function: { name: "create_client", description: "Create a new client. Requires manageProjects permission. Always confirm first.", parameters: { type: "object", properties: { name: { type: "string", description: "Client name" }, code: { type: "string", description: "Client code" }, }, required: ["name"], }, }, }, { type: "function", function: { name: "update_client", description: "Update a client. Requires manageProjects permission. Always confirm first.", parameters: { type: "object", properties: { id: { type: "string", description: "Client ID" }, name: { type: "string", description: "New name" }, code: { type: "string", description: "New code" }, }, required: ["id"], }, }, }, // ── ADMIN / CONFIG READ TOOLS ── { type: "function", function: { name: "list_countries", description: "List available countries with their working hours and metro cities.", parameters: { type: "object", properties: {} }, }, }, { type: "function", function: { name: "list_management_levels", description: "List management level groups and their levels with target percentages.", parameters: { type: "object", properties: {} }, }, }, { type: "function", function: { name: "list_utilization_categories", description: "List utilization categories (cost classification for projects).", parameters: { type: "object", properties: {} }, }, }, { type: "function", function: { name: "list_calculation_rules", description: "List calculation rules for cost attribution and chargeability.", parameters: { type: "object", properties: {} }, }, }, { type: "function", function: { name: "list_effort_rules", description: "List effort estimation rules with their formulas and conditions.", parameters: { type: "object", properties: {} }, }, }, { type: "function", function: { name: "list_experience_multipliers", description: "List experience multipliers that adjust effort estimates based on seniority.", parameters: { type: "object", properties: {} }, }, }, { type: "function", function: { name: "list_users", description: "List system users with their roles and linked resources. Requires admin permission.", parameters: { type: "object", properties: { limit: { type: "integer", description: "Max results. Default: 50" }, }, }, }, }, { type: "function", function: { name: "list_notifications", description: "List recent notifications for the current user.", parameters: { type: "object", properties: { unreadOnly: { type: "boolean", description: "Only show unread. Default: false" }, limit: { type: "integer", description: "Max results. Default: 20" }, }, }, }, }, { type: "function", function: { name: "mark_notification_read", description: "Mark a notification as read.", parameters: { type: "object", properties: { notificationId: { type: "string", description: "Notification ID" }, }, required: ["notificationId"], }, }, }, // ── DASHBOARD DETAIL ── { type: "function", function: { name: "get_dashboard_detail", description: "Get detailed dashboard data: peak allocation times, top-value resources, demand pipeline, chargeability overview.", parameters: { type: "object", properties: { section: { type: "string", description: "Which section: peak_times, top_resources, demand_pipeline, chargeability_overview, or all", }, }, }, }, }, // ── PROJECT MANAGEMENT ── { type: "function", function: { name: "delete_project", description: "Delete a project. Only DRAFT projects can be deleted. Requires manageProjects permission. Always confirm first.", parameters: { type: "object", properties: { projectId: { type: "string", description: "Project ID or short code" }, }, required: ["projectId"], }, }, }, // ── ORG UNIT MANAGEMENT ── { type: "function", function: { name: "create_org_unit", description: "Create a new organizational unit. Requires admin permission. Always confirm first.", parameters: { type: "object", properties: { name: { type: "string", description: "Org unit name" }, shortName: { type: "string", description: "Short name/code" }, level: { type: "integer", description: "Level (5, 6, or 7)" }, parentId: { type: "string", description: "Parent org unit ID (optional)" }, }, required: ["name", "level"], }, }, }, { type: "function", function: { name: "update_org_unit", description: "Update an organizational unit. Requires admin permission. Always confirm first.", parameters: { type: "object", properties: { id: { type: "string", description: "Org unit ID" }, name: { type: "string", description: "New name" }, shortName: { type: "string", description: "New short name" }, }, required: ["id"], }, }, }, // ── COVER ART ── { type: "function", function: { name: "generate_project_cover", description: "Generate an AI cover art image for a project. Uses the configured image provider (DALL-E or Google Gemini). The image will be stored as the project's cover. Requires manageProjects permission.", parameters: { type: "object", properties: { projectId: { type: "string", description: "Project ID" }, prompt: { type: "string", description: "Optional custom prompt for the AI image generation (e.g. 'futuristic car in neon cityscape'). If not provided, a default automotive/CGI prompt is used based on the project name." }, }, required: ["projectId"], }, }, }, { type: "function", function: { name: "remove_project_cover", description: "Remove the cover art image from a project. Requires manageProjects permission.", parameters: { type: "object", properties: { projectId: { type: "string", description: "Project ID" }, }, required: ["projectId"], }, }, }, // ── TASK MANAGEMENT ── { type: "function", function: { name: "list_tasks", description: "List open/pending tasks and approvals for the current user. Returns actionable items that need attention.", parameters: { type: "object", properties: { status: { type: "string", enum: ["OPEN", "IN_PROGRESS", "DONE", "DISMISSED"], description: "Filter by status. Default: OPEN" }, limit: { type: "integer", description: "Max results (default 10)" }, }, }, }, }, { type: "function", function: { name: "get_task_detail", description: "Get details of a specific task/notification including linked entity information.", parameters: { type: "object", properties: { taskId: { type: "string", description: "Notification/task ID" }, }, required: ["taskId"], }, }, }, { type: "function", function: { name: "update_task_status", description: "Update the status of a task. Mark as IN_PROGRESS, DONE, or DISMISSED.", parameters: { type: "object", properties: { taskId: { type: "string", description: "Task/notification ID" }, status: { type: "string", enum: ["OPEN", "IN_PROGRESS", "DONE", "DISMISSED"], description: "New status" }, }, required: ["taskId", "status"], }, }, }, { type: "function", function: { name: "execute_task_action", description: "Execute the machine-readable action associated with a task. For example: approve a vacation, confirm an assignment, etc. The action is encoded in the task's taskAction field.", parameters: { type: "object", properties: { taskId: { type: "string", description: "Task/notification ID containing the action to execute" }, }, required: ["taskId"], }, }, }, { type: "function", function: { name: "create_reminder", description: "Create a personal reminder for the current user. Can be one-shot or recurring.", parameters: { type: "object", properties: { title: { type: "string", description: "Reminder title" }, body: { type: "string", description: "Optional details" }, remindAt: { type: "string", format: "date-time", description: "When to remind (ISO 8601 datetime)" }, recurrence: { type: "string", enum: ["daily", "weekly", "monthly"], description: "Optional recurrence pattern" }, entityId: { type: "string", description: "Optional: linked entity ID (project, resource, etc.)" }, entityType: { type: "string", description: "Optional: entity type (project, resource, vacation, etc.)" }, }, required: ["title", "remindAt"], }, }, }, { type: "function", function: { name: "create_task_for_user", description: "Create a task for a specific user. Requires manageProjects or manageResources permission. The task appears in their task list.", parameters: { type: "object", properties: { userId: { type: "string", description: "Target user ID" }, title: { type: "string", description: "Task title" }, body: { type: "string", description: "Task description" }, priority: { type: "string", enum: ["LOW", "NORMAL", "HIGH", "URGENT"], description: "Priority (default NORMAL)" }, dueDate: { type: "string", format: "date-time", description: "Optional due date (ISO 8601)" }, taskAction: { type: "string", description: "Optional machine-readable action (format: action_name:entity_id)" }, entityId: { type: "string", description: "Optional linked entity ID" }, entityType: { type: "string", description: "Optional entity type" }, }, required: ["userId", "title"], }, }, }, { type: "function", function: { name: "send_broadcast", description: "Send a notification to a group of users (by role, project members, org unit, or all). Requires manager permission.", parameters: { type: "object", properties: { title: { type: "string", description: "Notification title" }, body: { type: "string", description: "Notification body" }, targetType: { type: "string", enum: ["user", "role", "project", "orgUnit", "all"], description: "Target audience type" }, targetValue: { type: "string", description: "Target value: user ID, role name (ADMIN/MANAGER/CONTROLLER/USER/VIEWER), project ID, or org unit ID" }, priority: { type: "string", enum: ["LOW", "NORMAL", "HIGH", "URGENT"], description: "Priority (default NORMAL)" }, channel: { type: "string", enum: ["in_app", "email", "both"], description: "Delivery channel (default in_app)" }, link: { type: "string", description: "Optional deep-link URL" }, }, required: ["title", "targetType"], }, }, }, // ── 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"], }, }, }, { type: "function", function: { name: "query_change_history", description: "Search the activity history for changes to projects, resources, allocations, vacations, or any entity. Can filter by entity type, entity name, user, date range, or action type.", parameters: { type: "object", properties: { entityType: { type: "string", description: "Filter by entity type (e.g. 'Project', 'Resource', 'Allocation', 'Vacation', 'Role', 'Estimate')" }, search: { type: "string", description: "Search in entity name or summary text" }, userId: { type: "string", description: "Filter by user ID who made the change" }, daysBack: { type: "integer", description: "How many days back to search. Default: 7" }, action: { type: "string", description: "Filter by action type: CREATE, UPDATE, DELETE, SHIFT, IMPORT" }, limit: { type: "integer", description: "Max results. Default: 20" }, }, }, }, }, { type: "function", function: { name: "get_entity_timeline", description: "Get the complete change history for a specific entity (project, resource, etc). Shows who made what changes and when.", parameters: { type: "object", properties: { entityType: { type: "string", description: "Entity type (e.g. 'Project', 'Resource', 'Allocation')" }, entityId: { type: "string", description: "Entity ID" }, limit: { type: "integer", description: "Max results. Default: 50" }, }, required: ["entityType", "entityId"], }, }, }, { type: "function", function: { name: "get_shoring_ratio", description: "Get the onshore/offshore staffing ratio for a project. Shows the percentage of work hours allocated to each country, whether the project exceeds its nearshore threshold, and a full country breakdown.", parameters: { type: "object", properties: { projectId: { type: "string", description: "Project ID or short code" }, }, required: ["projectId"], }, }, }, ]; // ─── Helpers ──────────────────────────────────────────────────────────────── /** Resolve a responsible person name against existing resources. Returns the exact displayName or an error object. */ async function resolveResponsiblePerson( name: string, db: ToolContext["db"], ): Promise<{ displayName: string } | { error: string }> { // Exact match first (case-insensitive) const exact = await db.resource.findFirst({ where: { displayName: { equals: name, mode: "insensitive" }, isActive: true }, select: { displayName: true }, }); if (exact) return { displayName: exact.displayName }; // Fuzzy: contains search const candidates = await db.resource.findMany({ where: { displayName: { contains: name, mode: "insensitive" }, isActive: true }, select: { displayName: true, eid: true }, take: 5, }); if (candidates.length === 1) return { displayName: candidates[0]!.displayName }; if (candidates.length > 1) { const list = candidates.map((c) => `${c.displayName} (${c.eid})`).join(", "); return { error: `Multiple resources match "${name}": ${list}. Please specify the exact name.` }; } return { error: `No active resource found matching "${name}". The responsible person must be an existing resource.` }; } // ─── Tool Executors ───────────────────────────────────────────────────────── const executors = { async search_resources(params: { query?: string; country?: string; metroCity?: string; orgUnit?: string; roleName?: string; isActive?: boolean; limit?: number; }, ctx: ToolContext) { const limit = Math.min(params.limit ?? 50, 100); // eslint-disable-next-line @typescript-eslint/no-explicit-any const where: Record = {}; if (params.isActive !== false) where.isActive = true; if (params.query) { where.OR = [ { displayName: { contains: params.query, mode: "insensitive" } }, { eid: { contains: params.query, mode: "insensitive" } }, { chapter: { contains: params.query, mode: "insensitive" } }, ]; } if (params.country) { where.country = { OR: [ { code: { equals: params.country, mode: "insensitive" } }, { name: { contains: params.country, mode: "insensitive" } }, ], }; } if (params.metroCity) { where.metroCity = { name: { contains: params.metroCity, mode: "insensitive" } }; } if (params.orgUnit) { where.orgUnit = { name: { contains: params.orgUnit, mode: "insensitive" } }; } if (params.roleName) { where.areaRole = { name: { contains: params.roleName, mode: "insensitive" } }; } const resources = await ctx.db.resource.findMany({ where, select: { id: true, eid: true, displayName: true, chapter: true, fte: true, lcrCents: true, chargeabilityTarget: true, isActive: true, areaRole: { select: { name: true } }, country: { select: { code: true, name: true } }, metroCity: { select: { name: true } }, orgUnit: { select: { name: true } }, }, take: limit, orderBy: { displayName: "asc" }, }); return resources.map((r) => ({ id: r.id, eid: r.eid, name: r.displayName, chapter: r.chapter, role: r.areaRole?.name ?? null, country: r.country?.name ?? r.country?.code ?? null, countryCode: r.country?.code ?? null, metroCity: r.metroCity?.name ?? null, orgUnit: r.orgUnit?.name ?? null, fte: r.fte, lcr: fmtEur(r.lcrCents), chargeabilityTarget: `${r.chargeabilityTarget}%`, active: r.isActive, })); }, async get_resource(params: { identifier: string }, ctx: ToolContext) { const sel = { id: true, eid: true, displayName: true, email: true, chapter: true, fte: true, lcrCents: true, ucrCents: true, chargeabilityTarget: true, isActive: true, availability: true, skills: true, postalCode: true, federalState: true, areaRole: { select: { name: true, color: true } }, country: { select: { code: true, name: true, dailyWorkingHours: true } }, metroCity: { select: { name: true } }, managementLevelGroup: { select: { name: true, targetPercentage: true } }, orgUnit: { select: { name: true, level: true } }, _count: { select: { assignments: true, vacations: true } }, } as const; let resource = await ctx.db.resource.findUnique({ where: { id: params.identifier }, select: sel }); if (!resource) { resource = await ctx.db.resource.findUnique({ where: { eid: params.identifier }, select: sel }); } if (!resource) { resource = await ctx.db.resource.findFirst({ where: { displayName: { contains: params.identifier, mode: "insensitive" } }, select: sel, }); } // Try word-level matching if exact substring fails if (!resource) { const words = params.identifier.split(/[\s,._\-/]+/).filter((w) => w.length >= 2); if (words.length > 0) { const candidates = await ctx.db.resource.findMany({ where: { OR: words.map((w) => ({ displayName: { contains: w, mode: "insensitive" as const } })) }, select: sel, take: 5, }); if (candidates.length === 1) { resource = candidates[0]!; } else if (candidates.length > 1) { return { error: `Resource not found: "${params.identifier}". Did you mean one of these?`, suggestions: candidates.map((r) => ({ id: r.id, eid: r.eid, name: r.displayName })), }; } } } if (!resource) return { error: `Resource not found: ${params.identifier}` }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const skills = (resource.skills as any[]) ?? []; return { id: resource.id, eid: resource.eid, name: resource.displayName, email: resource.email, chapter: resource.chapter, role: resource.areaRole?.name ?? null, country: resource.country?.name ?? resource.country?.code ?? null, countryCode: resource.country?.code ?? null, countryHours: resource.country?.dailyWorkingHours ?? 8, metroCity: resource.metroCity?.name ?? null, fte: resource.fte, lcr: fmtEur(resource.lcrCents), ucr: fmtEur(resource.ucrCents), chargeabilityTarget: `${resource.chargeabilityTarget}%`, managementLevel: resource.managementLevelGroup?.name ?? null, orgUnit: resource.orgUnit?.name ?? null, postalCode: resource.postalCode, federalState: resource.federalState, active: resource.isActive, totalAssignments: resource._count.assignments, totalVacations: resource._count.vacations, skillCount: skills.length, topSkills: skills.slice(0, 10).map((s: { name?: string; level?: number }) => `${s.name ?? "?"} (${s.level ?? "?"})`), }; }, async search_projects(params: { query?: string; status?: string; limit?: number }, ctx: ToolContext) { const limit = Math.min(params.limit ?? 20, 50); const sel = { id: true, shortCode: true, name: true, status: true, budgetCents: true, winProbability: true, startDate: true, endDate: true, client: { select: { name: true } }, _count: { select: { assignments: true, estimates: true } }, } as const; // eslint-disable-next-line @typescript-eslint/no-explicit-any const where: Record = {}; if (params.status) where.status = params.status; if (params.query) { // First try exact substring match where.OR = [ { name: { contains: params.query, mode: "insensitive" } }, { shortCode: { contains: params.query, mode: "insensitive" } }, ]; } let projects = await ctx.db.project.findMany({ where, select: sel, take: limit, orderBy: { name: "asc" } }); // If no results and query has multiple words, try word-level fuzzy matching if (projects.length === 0 && params.query) { const words = params.query.split(/[\s,._\-/]+/).filter((w) => w.length >= 2); if (words.length > 1) { // Search for any word matching in name or shortCode const wordConditions = words.flatMap((word) => [ { name: { contains: word, mode: "insensitive" } }, { shortCode: { contains: word, mode: "insensitive" } }, ]); const fuzzyWhere: Record = { OR: wordConditions }; if (params.status) fuzzyWhere.status = params.status; const candidates = await ctx.db.project.findMany({ where: fuzzyWhere, select: sel, take: limit * 2, orderBy: { name: "asc" }, }); // Rank by number of matching words const ranked = candidates.map((p) => { const lower = `${p.name} ${p.shortCode}`.toLowerCase(); const matchCount = words.filter((w) => lower.includes(w.toLowerCase())).length; return { project: p, matchCount }; }); ranked.sort((a, b) => b.matchCount - a.matchCount); projects = ranked.slice(0, limit).map((r) => r.project); } } const formatted = projects.map((p) => ({ id: p.id, code: p.shortCode, name: p.name, status: p.status, budget: p.budgetCents > 0 ? fmtEur(p.budgetCents) : "Not set", winProbability: `${p.winProbability}%`, start: fmtDate(p.startDate), end: fmtDate(p.endDate), client: p.client?.name ?? null, assignmentCount: p._count.assignments, estimateCount: p._count.estimates, })); // If fuzzy results, indicate that these are suggestions if (projects.length > 0 && params.query) { const exactMatch = projects.some((p) => p.name.toLowerCase().includes(params.query!.toLowerCase()) || p.shortCode.toLowerCase().includes(params.query!.toLowerCase()), ); if (!exactMatch) { return { suggestions: formatted, note: `No exact match for "${params.query}". These projects match some of the search terms:` }; } } return formatted; }, async get_project(params: { identifier: string }, ctx: ToolContext) { const sel = { id: true, shortCode: true, name: true, status: true, orderType: true, allocationType: true, budgetCents: true, winProbability: true, startDate: true, endDate: true, responsiblePerson: true, client: { select: { name: true } }, utilizationCategory: { select: { code: true, name: true } }, _count: { select: { assignments: true, estimates: true } }, } as const; let project = await ctx.db.project.findUnique({ where: { id: params.identifier }, select: sel }); if (!project) { project = await ctx.db.project.findUnique({ where: { shortCode: params.identifier }, select: sel }); } if (!project) return { error: `Project not found: ${params.identifier}` }; // Fetch top allocations for context const topAllocs = await ctx.db.assignment.findMany({ where: { projectId: project.id, status: { not: "CANCELLED" } }, select: { resource: { select: { displayName: true, eid: true } }, role: true, status: true, hoursPerDay: true, startDate: true, endDate: true, }, take: 10, orderBy: { startDate: "desc" }, }); return { id: project.id, code: project.shortCode, name: project.name, status: project.status, orderType: project.orderType, allocationType: project.allocationType, budget: project.budgetCents > 0 ? fmtEur(project.budgetCents) : "Not set", budgetCents: project.budgetCents, winProbability: `${project.winProbability}%`, start: fmtDate(project.startDate), end: fmtDate(project.endDate), responsible: project.responsiblePerson, client: project.client?.name ?? null, category: project.utilizationCategory?.name ?? null, assignmentCount: project._count.assignments, estimateCount: project._count.estimates, topAllocations: topAllocs.map((a) => ({ resource: a.resource.displayName, eid: a.resource.eid, role: a.role ?? null, status: a.status, hoursPerDay: a.hoursPerDay, start: fmtDate(a.startDate), end: fmtDate(a.endDate), })), }; }, async list_allocations(params: { resourceId?: string; projectId?: string; resourceName?: string; projectCode?: string; status?: string; limit?: number; }, ctx: ToolContext) { const limit = Math.min(params.limit ?? 30, 50); // eslint-disable-next-line @typescript-eslint/no-explicit-any const where: Record = {}; if (params.resourceId) where.resourceId = params.resourceId; if (params.projectId) where.projectId = params.projectId; if (params.status) where.status = params.status; if (params.resourceName) { where.resource = { displayName: { contains: params.resourceName, mode: "insensitive" } }; } if (params.projectCode) { where.project = { shortCode: { contains: params.projectCode, mode: "insensitive" } }; } const allocs = await ctx.db.assignment.findMany({ where, select: { id: true, status: true, hoursPerDay: true, dailyCostCents: true, startDate: true, endDate: true, role: true, resource: { select: { displayName: true, eid: true } }, project: { select: { name: true, shortCode: true } }, }, take: limit, orderBy: { startDate: "desc" }, }); return allocs.map((a) => ({ id: a.id, resource: a.resource.displayName, resourceEid: a.resource.eid, project: a.project.name, projectCode: a.project.shortCode, role: a.role ?? null, status: a.status, hoursPerDay: a.hoursPerDay, dailyCost: fmtEur(a.dailyCostCents), start: fmtDate(a.startDate), end: fmtDate(a.endDate), })); }, async get_budget_status(params: { projectId: string }, ctx: ToolContext) { const sel = { id: true, name: true, shortCode: true, budgetCents: true, winProbability: true, startDate: true, endDate: true } as const; let project = await ctx.db.project.findUnique({ where: { id: params.projectId }, select: sel }); if (!project) { project = await ctx.db.project.findUnique({ where: { shortCode: params.projectId }, select: sel }); } if (!project) return { error: `Project not found: ${params.projectId}` }; const allocs = await ctx.db.assignment.findMany({ where: { projectId: project.id }, select: { status: true, dailyCostCents: true, startDate: true, endDate: true, hoursPerDay: true }, }); if (project.budgetCents <= 0) { return { project: project.name, code: project.shortCode, budget: "Not set", note: "No budget defined for this project", totalAllocations: allocs.length, }; } const status = computeBudgetStatus( project.budgetCents, project.winProbability, allocs.map((a) => ({ status: a.status as unknown as string, dailyCostCents: a.dailyCostCents, startDate: a.startDate, endDate: a.endDate, hoursPerDay: a.hoursPerDay, // eslint-disable-next-line @typescript-eslint/no-explicit-any })) as any, project.startDate ?? new Date(), project.endDate ?? new Date(), ); return { project: project.name, code: project.shortCode, budget: fmtEur(project.budgetCents), confirmed: fmtEur(status.confirmedCents), proposed: fmtEur(status.proposedCents), allocated: fmtEur(status.allocatedCents), remaining: fmtEur(status.remainingCents), utilization: `${status.utilizationPercent.toFixed(1)}%`, winWeighted: fmtEur(status.winProbabilityWeightedCents), }; }, async get_vacation_balance(params: { resourceId: string; year?: number }, ctx: ToolContext) { const year = params.year ?? new Date().getFullYear(); let resource = await ctx.db.resource.findUnique({ where: { id: params.resourceId }, select: { id: true, displayName: true }, }); if (!resource) { resource = await ctx.db.resource.findFirst({ where: { displayName: { contains: params.resourceId, mode: "insensitive" } }, select: { id: true, displayName: true }, }); } if (!resource) return { error: `Resource not found: ${params.resourceId}` }; const entitlement = await ctx.db.vacationEntitlement.findUnique({ where: { resourceId_year: { resourceId: resource.id, year } }, }); const vacations = await ctx.db.vacation.findMany({ where: { resourceId: resource.id, status: { in: ["APPROVED", "PENDING"] }, startDate: { gte: new Date(`${year}-01-01`) }, endDate: { lte: new Date(`${year}-12-31`) }, }, select: { type: true, status: true, startDate: true, endDate: true, isHalfDay: true }, }); let takenDays = 0; let pendingDays = 0; for (const v of vacations) { if (v.type === "PUBLIC_HOLIDAY") continue; const days = v.isHalfDay ? 0.5 : Math.ceil((v.endDate.getTime() - v.startDate.getTime()) / (1000 * 60 * 60 * 24)) + 1; if (v.status === "APPROVED") takenDays += days; else pendingDays += days; } return { resource: resource.displayName, year, entitlement: entitlement?.entitledDays ?? "Not set", carryOver: entitlement?.carryoverDays ?? 0, taken: takenDays, pending: pendingDays, remaining: entitlement ? entitlement.entitledDays + (entitlement.carryoverDays ?? 0) - takenDays : "Unknown (no entitlement set)", }; }, async list_vacations_upcoming(params: { resourceName?: string; chapter?: string; daysAhead?: number; limit?: number; }, ctx: ToolContext) { const daysAhead = params.daysAhead ?? 30; const limit = Math.min(params.limit ?? 30, 50); const now = new Date(); const until = new Date(now.getTime() + daysAhead * 24 * 60 * 60 * 1000); // eslint-disable-next-line @typescript-eslint/no-explicit-any const where: Record = { status: "APPROVED", endDate: { gte: now }, startDate: { lte: until }, }; if (params.resourceName) { where.resource = { displayName: { contains: params.resourceName, mode: "insensitive" } }; } if (params.chapter) { where.resource = { ...where.resource, chapter: { contains: params.chapter, mode: "insensitive" } }; } const vacations = await ctx.db.vacation.findMany({ where, select: { id: true, type: true, startDate: true, endDate: true, isHalfDay: true, halfDayPart: true, resource: { select: { displayName: true, eid: true, chapter: true } }, }, take: limit, orderBy: { startDate: "asc" }, }); return vacations.map((v) => ({ resource: v.resource.displayName, eid: v.resource.eid, chapter: v.resource.chapter, type: v.type, start: fmtDate(v.startDate), end: fmtDate(v.endDate), isHalfDay: v.isHalfDay, halfDayPart: v.halfDayPart, })); }, async list_roles(_params: Record, ctx: ToolContext) { const roles = await ctx.db.role.findMany({ select: { id: true, name: true, color: true }, orderBy: { name: "asc" }, }); return roles; }, async search_by_skill(params: { skill: string }, ctx: ToolContext) { const all = await ctx.db.resource.findMany({ where: { isActive: true }, select: { id: true, eid: true, displayName: true, skills: true }, }); const lower = params.skill.toLowerCase(); const matched = all.filter((r) => { const skills = (r.skills as Array<{ name?: string }>) ?? []; return skills.some((s) => s.name?.toLowerCase().includes(lower)); }).slice(0, 20); return matched.map((r) => { const skills = (r.skills as Array<{ name?: string; level?: number }>) ?? []; const match = skills.find((s) => s.name?.toLowerCase().includes(lower)); return { id: r.id, eid: r.eid, name: r.displayName, matchedSkill: match?.name, level: match?.level }; }); }, async get_statistics(_params: Record, ctx: ToolContext) { const [ resourceCount, projectCount, activeProjectCount, allocationCount, vacationCount, estimateCount, projects, chapters, ] = await Promise.all([ ctx.db.resource.count({ where: { isActive: true } }), ctx.db.project.count(), ctx.db.project.count({ where: { status: "ACTIVE" } }), ctx.db.assignment.count(), ctx.db.vacation.count({ where: { status: "APPROVED" } }), ctx.db.estimate.count(), ctx.db.project.findMany({ select: { status: true, budgetCents: true } }), ctx.db.resource.groupBy({ by: ["chapter"], where: { isActive: true }, _count: true, orderBy: { _count: { chapter: "desc" } }, }), ]); const totalBudgetCents = projects.reduce((sum, p) => sum + (p.budgetCents ?? 0), 0); const statusBreakdown: Record = {}; for (const p of projects) { statusBreakdown[p.status] = (statusBreakdown[p.status] ?? 0) + 1; } return { activeResources: resourceCount, totalProjects: projectCount, activeProjects: activeProjectCount, totalAllocations: allocationCount, approvedVacations: vacationCount, totalEstimates: estimateCount, totalBudget: totalBudgetCents > 0 ? fmtEur(totalBudgetCents) : "N/A", projectsByStatus: statusBreakdown, topChapters: chapters.slice(0, 10).map((c) => ({ chapter: c.chapter ?? "Unassigned", count: c._count, })), }; }, async get_chargeability(params: { resourceId: string; month?: string }, ctx: ToolContext) { // Resolve resource const sel = { id: true, displayName: true, eid: true, fte: true, chargeabilityTarget: true, availability: true, country: { select: { dailyWorkingHours: true } }, } as const; let resource = await ctx.db.resource.findUnique({ where: { id: params.resourceId }, select: sel }); if (!resource) { resource = await ctx.db.resource.findUnique({ where: { eid: params.resourceId }, select: sel }); } if (!resource) { resource = await ctx.db.resource.findFirst({ where: { displayName: { contains: params.resourceId, mode: "insensitive" } }, select: sel, }); } if (!resource) return { error: `Resource not found: ${params.resourceId}` }; // Parse month const now = new Date(); const month = params.month ?? `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; const [yearStr, moStr] = month.split("-"); const year = Number(yearStr); const mo = Number(moStr); const start = new Date(year, mo - 1, 1); const end = new Date(year, mo, 0); // last day of month // Get allocations in this month const allocs = await ctx.db.assignment.findMany({ where: { resourceId: resource.id, status: { not: "CANCELLED" }, startDate: { lte: end }, endDate: { gte: start }, }, select: { hoursPerDay: true, startDate: true, endDate: true, status: true, project: { select: { name: true, shortCode: true } }, }, }); // Count working days in month const dailyHours = resource.country?.dailyWorkingHours ?? 8; let workingDays = 0; const d = new Date(start); while (d <= end) { const dow = d.getDay(); if (dow !== 0 && dow !== 6) workingDays++; d.setDate(d.getDate() + 1); } const availableHours = workingDays * dailyHours * resource.fte; // Sum booked hours (simplified: intersection of alloc dates with month) let bookedHours = 0; const allocDetails: Array<{ project: string; code: string; hours: number; status: string }> = []; for (const a of allocs) { const overlapStart = a.startDate > start ? a.startDate : start; const overlapEnd = a.endDate < end ? a.endDate : end; let days = 0; const cur = new Date(overlapStart); while (cur <= overlapEnd) { const dow = cur.getDay(); if (dow !== 0 && dow !== 6) days++; cur.setDate(cur.getDate() + 1); } const hours = days * a.hoursPerDay; bookedHours += hours; allocDetails.push({ project: a.project.name, code: a.project.shortCode, hours: Math.round(hours * 10) / 10, status: a.status, }); } const chargeabilityPercent = availableHours > 0 ? Math.round((bookedHours / availableHours) * 1000) / 10 : 0; return { resource: resource.displayName, eid: resource.eid, month, fte: resource.fte, target: `${resource.chargeabilityTarget}%`, workingDays, availableHours: Math.round(availableHours * 10) / 10, bookedHours: Math.round(bookedHours * 10) / 10, chargeability: `${chargeabilityPercent}%`, onTarget: chargeabilityPercent >= resource.chargeabilityTarget, allocations: allocDetails, }; }, async search_estimates(params: { projectCode?: string; query?: string; status?: string; limit?: number; }, ctx: ToolContext) { const limit = Math.min(params.limit ?? 20, 50); // eslint-disable-next-line @typescript-eslint/no-explicit-any const where: Record = {}; if (params.status) where.status = params.status; if (params.query) { where.name = { contains: params.query, mode: "insensitive" }; } if (params.projectCode) { where.project = { shortCode: { contains: params.projectCode, mode: "insensitive" } }; } const estimates = await ctx.db.estimate.findMany({ where, select: { id: true, name: true, status: true, latestVersionNumber: true, project: { select: { name: true, shortCode: true } }, _count: { select: { versions: true } }, createdAt: true, updatedAt: true, }, take: limit, orderBy: { updatedAt: "desc" }, }); return estimates.map((e) => ({ id: e.id, name: e.name, status: e.status, project: e.project?.name ?? null, projectCode: e.project?.shortCode ?? null, versions: e._count.versions, latestVersion: e.latestVersionNumber, updated: fmtDate(e.updatedAt), })); }, async list_clients(params: { query?: string; limit?: number }, ctx: ToolContext) { const limit = Math.min(params.limit ?? 20, 50); // eslint-disable-next-line @typescript-eslint/no-explicit-any const where: Record = { isActive: true }; if (params.query) { where.OR = [ { name: { contains: params.query, mode: "insensitive" } }, { code: { contains: params.query, mode: "insensitive" } }, ]; } const clients = await ctx.db.client.findMany({ where, select: { id: true, name: true, code: true, _count: { select: { projects: true } }, }, take: limit, orderBy: { name: "asc" }, }); return clients.map((c) => ({ id: c.id, name: c.name, code: c.code, projectCount: c._count.projects, })); }, async list_org_units(params: { level?: number }, ctx: ToolContext) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const where: Record = { isActive: true }; if (params.level !== undefined) where.level = params.level; const units = await ctx.db.orgUnit.findMany({ where, select: { id: true, name: true, shortName: true, level: true, parent: { select: { name: true } }, _count: { select: { resources: true } }, }, orderBy: [{ level: "asc" }, { sortOrder: "asc" }], }); return units.map((u) => ({ id: u.id, name: u.name, shortName: u.shortName, level: u.level, parent: u.parent?.name ?? null, resourceCount: u._count.resources, })); }, // ── NAVIGATION TOOLS ── async navigate_to_page(params: { page: string; eids?: string; chapters?: string; projectIds?: string; clientIds?: string; countryCodes?: string; startDate?: string; days?: number; }, _ctx: ToolContext) { const pageMap: Record = { timeline: "/timeline", dashboard: "/dashboard", resources: "/resources", projects: "/projects", allocations: "/allocations", staffing: "/staffing", estimates: "/estimates", vacations: "/vacations", "my-vacations": "/vacations/my", roles: "/roles", "skills-analytics": "/analytics/skills", chargeability: "/reports/chargeability", "computation-graph": "/analytics/computation-graph", }; const path = pageMap[params.page]; if (!path) return { error: `Unknown page: ${params.page}. Available: ${Object.keys(pageMap).join(", ")}` }; // Build query params for pages that support them const queryParts: string[] = []; if (params.eids) queryParts.push(`eids=${encodeURIComponent(params.eids)}`); if (params.chapters) queryParts.push(`chapters=${encodeURIComponent(params.chapters)}`); if (params.projectIds) queryParts.push(`projectIds=${encodeURIComponent(params.projectIds)}`); if (params.clientIds) queryParts.push(`clientIds=${encodeURIComponent(params.clientIds)}`); if (params.countryCodes) queryParts.push(`countryCodes=${encodeURIComponent(params.countryCodes)}`); if (params.startDate) queryParts.push(`startDate=${encodeURIComponent(params.startDate)}`); if (params.days) queryParts.push(`days=${params.days}`); const url = queryParts.length > 0 ? `${path}?${queryParts.join("&")}` : path; return { __action: "navigate", url, description: `Navigiere zu ${path}`, }; }, // ── WRITE TOOLS ── async create_allocation(params: { resourceId: string; projectId: string; startDate: string; endDate: string; hoursPerDay: number; role?: string; }, ctx: ToolContext) { assertPermission(ctx, "manageAllocations" as PermissionKey); // Validate resource and project exist const [resource, project] = await Promise.all([ ctx.db.resource.findUnique({ where: { id: params.resourceId }, select: { id: true, displayName: true, eid: true, lcrCents: true } }), ctx.db.project.findUnique({ where: { id: params.projectId }, select: { id: true, name: true, shortCode: true } }), ]); if (!resource) return { error: `Resource not found: ${params.resourceId}` }; if (!project) return { error: `Project not found: ${params.projectId}` }; const dailyCostCents = Math.round(resource.lcrCents * params.hoursPerDay); const startDate = new Date(params.startDate); const endDate = new Date(params.endDate); // Check for overlapping duplicate assignments (same resource + project + overlapping dates) const existingAssignments = await ctx.db.assignment.findMany({ where: { resourceId: resource.id, status: { not: "CANCELLED" } }, select: { id: true, resourceId: true, projectId: true, startDate: true, endDate: true, status: true }, }); const dupCheck = checkDuplicateAssignment(resource.id, project.id, startDate, endDate, existingAssignments); if (dupCheck.isDuplicate) { return { error: dupCheck.message + " Use update_allocation_status to modify the existing assignment." }; } // Check for existing CANCELLED allocation with same unique key — reactivate it const existing = await ctx.db.assignment.findUnique({ where: { unique_assignment: { resourceId: resource.id, projectId: project.id, startDate, endDate, }, }, select: { id: true, status: true }, }); if (existing) { if (existing.status === "CANCELLED") { // Reactivate the cancelled allocation const updated = await ctx.db.assignment.update({ where: { id: existing.id }, data: { status: "PROPOSED", hoursPerDay: params.hoursPerDay, percentage: (params.hoursPerDay / 8) * 100, dailyCostCents, ...(params.role ? { role: params.role } : {}), }, select: { id: true, status: true }, }); return { __action: "invalidate", scope: ["allocation", "timeline"], success: true, message: `Reactivated allocation: ${resource.displayName} → ${project.name} (${project.shortCode}), ${params.hoursPerDay}h/day, ${params.startDate} to ${params.endDate}`, allocationId: updated.id, status: updated.status, }; } return { error: `An allocation already exists for this resource/project/dates with status ${existing.status}. No new allocation created.`, }; } const assignment = await ctx.db.assignment.create({ data: { resourceId: resource.id, projectId: project.id, startDate, endDate, hoursPerDay: params.hoursPerDay, percentage: (params.hoursPerDay / 8) * 100, dailyCostCents, status: "PROPOSED", ...(params.role ? { role: params.role } : {}), }, select: { id: true, status: true }, }); return { __action: "invalidate", scope: ["allocation", "timeline"], success: true, message: `Created allocation: ${resource.displayName} → ${project.name} (${project.shortCode}), ${params.hoursPerDay}h/day, ${params.startDate} to ${params.endDate}`, allocationId: assignment.id, status: assignment.status, }; }, async cancel_allocation(params: { allocationId?: string; resourceName?: string; projectCode?: string; startDate?: string; endDate?: string; }, ctx: ToolContext) { assertPermission(ctx, "manageAllocations" as PermissionKey); let assignment; if (params.allocationId) { assignment = await ctx.db.assignment.findUnique({ where: { id: params.allocationId }, select: { id: true, status: true, startDate: true, endDate: true, resource: { select: { displayName: true } }, project: { select: { name: true, shortCode: true } }, }, }); } else if (params.resourceName && params.projectCode) { // Find by resource + project + date overlap // eslint-disable-next-line @typescript-eslint/no-explicit-any const where: Record = { resource: { displayName: { contains: params.resourceName, mode: "insensitive" } }, project: { shortCode: { contains: params.projectCode, mode: "insensitive" } }, status: { not: "CANCELLED" }, }; if (params.startDate) where.startDate = { gte: new Date(params.startDate) }; if (params.endDate) where.endDate = { lte: new Date(params.endDate) }; assignment = await ctx.db.assignment.findFirst({ where, select: { id: true, status: true, startDate: true, endDate: true, resource: { select: { displayName: true } }, project: { select: { name: true, shortCode: true } }, }, orderBy: { startDate: "desc" }, }); } if (!assignment) return { error: "Allocation not found with the given criteria." }; await ctx.db.assignment.update({ where: { id: assignment.id }, data: { status: "CANCELLED" }, }); return { __action: "invalidate", scope: ["allocation", "timeline"], success: true, message: `Cancelled allocation: ${assignment.resource.displayName} → ${assignment.project.name} (${assignment.project.shortCode}), ${fmtDate(assignment.startDate)} to ${fmtDate(assignment.endDate)}`, }; }, async update_allocation_status(params: { allocationId?: string; resourceName?: string; projectCode?: string; startDate?: string; newStatus: string; }, ctx: ToolContext) { assertPermission(ctx, "manageAllocations" as PermissionKey); const validStatuses = ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED", "CANCELLED"]; if (!validStatuses.includes(params.newStatus)) { return { error: `Invalid status: ${params.newStatus}. Valid: ${validStatuses.join(", ")}` }; } let assignment; if (params.allocationId) { assignment = await ctx.db.assignment.findUnique({ where: { id: params.allocationId }, select: { id: true, status: true, startDate: true, endDate: true, resource: { select: { displayName: true } }, project: { select: { name: true, shortCode: true } }, }, }); } else if (params.resourceName && params.projectCode) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const where: Record = { resource: { displayName: { contains: params.resourceName, mode: "insensitive" } }, project: { shortCode: { contains: params.projectCode, mode: "insensitive" } }, }; if (params.startDate) where.startDate = new Date(params.startDate); assignment = await ctx.db.assignment.findFirst({ where, select: { id: true, status: true, startDate: true, endDate: true, resource: { select: { displayName: true } }, project: { select: { name: true, shortCode: true } }, }, orderBy: { startDate: "desc" }, }); } if (!assignment) return { error: "Allocation not found with the given criteria." }; const oldStatus = assignment.status; await ctx.db.assignment.update({ where: { id: assignment.id }, data: { status: params.newStatus as "PROPOSED" | "CONFIRMED" | "ACTIVE" | "COMPLETED" | "CANCELLED" }, }); return { __action: "invalidate", scope: ["allocation", "timeline"], success: true, message: `Updated allocation status: ${assignment.resource.displayName} → ${assignment.project.name} (${assignment.project.shortCode}), ${fmtDate(assignment.startDate)} to ${fmtDate(assignment.endDate)}: ${oldStatus} → ${params.newStatus}`, }; }, async update_resource(params: { id: string; displayName?: string; fte?: number; lcrCents?: number; chapter?: string; chargeabilityTarget?: number; }, ctx: ToolContext) { assertPermission(ctx, "manageResources" as PermissionKey); const data: Record = {}; if (params.displayName !== undefined) data.displayName = params.displayName; if (params.fte !== undefined) data.fte = params.fte; if (params.lcrCents !== undefined) data.lcrCents = params.lcrCents; if (params.chapter !== undefined) data.chapter = params.chapter; if (params.chargeabilityTarget !== undefined) data.chargeabilityTarget = params.chargeabilityTarget; if (Object.keys(data).length === 0) return { error: "No fields to update" }; const resource = await ctx.db.resource.update({ where: { id: params.id }, data, select: { id: true, eid: true, displayName: true }, }); return { __action: "invalidate", scope: ["resource"], success: true, message: `Updated resource ${resource.displayName} (${resource.eid})`, updatedFields: Object.keys(data) }; }, async update_project(params: { id: string; name?: string; budgetCents?: number; winProbability?: number; status?: string; responsiblePerson?: string; }, ctx: ToolContext) { assertPermission(ctx, "manageProjects" as PermissionKey); const data: Record = {}; if (params.name !== undefined) data.name = params.name; if (params.budgetCents !== undefined) data.budgetCents = params.budgetCents; if (params.winProbability !== undefined) data.winProbability = params.winProbability; if (params.status !== undefined) data.status = params.status; // Validate responsible person against existing resources if (params.responsiblePerson !== undefined) { const result = await resolveResponsiblePerson(params.responsiblePerson, ctx.db); if ("error" in result) return { error: result.error }; data.responsiblePerson = result.displayName; } if (Object.keys(data).length === 0) return { error: "No fields to update" }; const project = await ctx.db.project.update({ where: { id: params.id }, data, select: { id: true, shortCode: true, name: true }, }); return { __action: "invalidate", scope: ["project"], success: true, message: `Updated project ${project.name} (${project.shortCode})`, updatedFields: Object.keys(data) }; }, async create_project(params: { shortCode: string; name: string; orderType: string; allocationType?: string; budgetCents: number; startDate: string; endDate: string; winProbability?: number; status?: string; responsiblePerson?: string; color?: string; blueprintName?: string; clientName?: string; }, ctx: ToolContext) { assertPermission(ctx, "manageProjects" as PermissionKey); // Validate enums const validOrderTypes = ["BD", "CHARGEABLE", "INTERNAL", "OVERHEAD"]; if (!validOrderTypes.includes(params.orderType)) { return { error: `Invalid orderType: ${params.orderType}. Valid: ${validOrderTypes.join(", ")}` }; } const allocationType = params.allocationType ?? "INT"; if (!["INT", "EXT"].includes(allocationType)) { return { error: `Invalid allocationType: ${allocationType}. Valid: INT, EXT` }; } const status = params.status ?? "DRAFT"; const validStatuses = ["DRAFT", "ACTIVE", "ON_HOLD", "COMPLETED", "CANCELLED"]; if (!validStatuses.includes(status)) { return { error: `Invalid status: ${status}. Valid: ${validStatuses.join(", ")}` }; } // Validate short code format if (!/^[A-Z0-9_-]+$/.test(params.shortCode)) { return { error: `Invalid shortCode: "${params.shortCode}". Must be uppercase alphanumeric with hyphens/underscores.` }; } // Check uniqueness const existing = await ctx.db.project.findUnique({ where: { shortCode: params.shortCode }, select: { id: true }, }); if (existing) { return { error: `A project with short code "${params.shortCode}" already exists.` }; } // Validate dates const startDate = new Date(params.startDate); const endDate = new Date(params.endDate); if (isNaN(startDate.getTime())) return { error: `Invalid startDate: ${params.startDate}` }; if (isNaN(endDate.getTime())) return { error: `Invalid endDate: ${params.endDate}` }; if (endDate < startDate) return { error: "endDate must be after startDate" }; // Validate responsible person against existing resources let resolvedResponsible: string | undefined; if (params.responsiblePerson) { const result = await resolveResponsiblePerson(params.responsiblePerson, ctx.db); if ("error" in result) return { error: result.error }; resolvedResponsible = result.displayName; } // Optional: look up blueprint by name let blueprintId: string | undefined; if (params.blueprintName) { const bp = await ctx.db.blueprint.findFirst({ where: { name: { contains: params.blueprintName, mode: "insensitive" } }, select: { id: true, name: true }, }); if (!bp) return { error: `Blueprint not found: "${params.blueprintName}"` }; blueprintId = bp.id; } // Optional: look up client by name let clientId: string | undefined; if (params.clientName) { const client = await ctx.db.client.findFirst({ where: { name: { contains: params.clientName, mode: "insensitive" } }, select: { id: true, name: true }, }); if (!client) return { error: `Client not found: "${params.clientName}"` }; clientId = client.id; } const project = await ctx.db.project.create({ data: { shortCode: params.shortCode, name: params.name, orderType: params.orderType, allocationType, budgetCents: params.budgetCents, startDate, endDate, winProbability: params.winProbability ?? 100, status, ...(resolvedResponsible ? { responsiblePerson: resolvedResponsible } : {}), ...(params.color ? { color: params.color } : {}), ...(blueprintId ? { blueprintId } : {}), ...(clientId ? { clientId } : {}), staffingReqs: [], dynamicFields: {}, } as Parameters[0]["data"], select: { id: true, shortCode: true, name: true, status: true }, }); await ctx.db.auditLog.create({ data: { entityType: "Project", entityId: project.id, action: "CREATE", changes: { after: project }, }, }); return { __action: "invalidate", scope: ["project"], success: true, message: `Created project: ${project.name} (${project.shortCode}), budget ${fmtEur(params.budgetCents)}, ${params.startDate} to ${params.endDate}, status: ${project.status}`, projectId: project.id, shortCode: project.shortCode, }; }, // ── RESOURCE MANAGEMENT ── async create_resource(params: { eid: string; displayName: string; email?: string; fte?: number; lcrCents: number; ucrCents?: number; chapter?: string; chargeabilityTarget?: number; roleName?: string; countryCode?: string; orgUnitName?: string; postalCode?: string; }, ctx: ToolContext) { assertPermission(ctx, "manageResources" as PermissionKey); const existing = await ctx.db.resource.findUnique({ where: { eid: params.eid }, select: { id: true } }); if (existing) return { error: `Resource with EID "${params.eid}" already exists.` }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const data: Record = { eid: params.eid, displayName: params.displayName, fte: params.fte ?? 1, lcrCents: params.lcrCents, ucrCents: params.ucrCents ?? Math.round(params.lcrCents * 0.7), chargeabilityTarget: params.chargeabilityTarget ?? 80, isActive: true, skills: [], }; if (params.email) data.email = params.email; if (params.chapter) data.chapter = params.chapter; if (params.postalCode) data.postalCode = params.postalCode; if (params.roleName) { const role = await ctx.db.role.findFirst({ where: { name: { contains: params.roleName, mode: "insensitive" } }, select: { id: true }, }); if (role) data.roleId = role.id; } if (params.countryCode) { const country = await ctx.db.country.findFirst({ where: { code: { equals: params.countryCode, mode: "insensitive" } }, select: { id: true }, }); if (country) data.countryId = country.id; } if (params.orgUnitName) { const ou = await ctx.db.orgUnit.findFirst({ where: { name: { contains: params.orgUnitName, mode: "insensitive" } }, select: { id: true }, }); if (ou) data.orgUnitId = ou.id; } const resource = await ctx.db.resource.create({ data: data as Parameters[0]["data"], select: { id: true, eid: true, displayName: true }, }); return { __action: "invalidate", scope: ["resource"], success: true, message: `Created resource: ${resource.displayName} (${resource.eid})`, resourceId: resource.id, }; }, async deactivate_resource(params: { identifier: string }, ctx: ToolContext) { assertPermission(ctx, "manageResources" as PermissionKey); let resource = await ctx.db.resource.findUnique({ where: { id: params.identifier }, select: { id: true, displayName: true, eid: true }, }); if (!resource) { resource = await ctx.db.resource.findUnique({ where: { eid: params.identifier }, select: { id: true, displayName: true, eid: true }, }); } if (!resource) { resource = await ctx.db.resource.findFirst({ where: { displayName: { contains: params.identifier, mode: "insensitive" } }, select: { id: true, displayName: true, eid: true }, }); } if (!resource) return { error: `Resource not found: ${params.identifier}` }; await ctx.db.resource.update({ where: { id: resource.id }, data: { isActive: false } }); return { __action: "invalidate", scope: ["resource"], success: true, message: `Deactivated resource: ${resource.displayName} (${resource.eid})`, }; }, // ── VACATION MANAGEMENT ── async create_vacation(params: { resourceId: string; type: string; startDate: string; endDate: string; isHalfDay?: boolean; halfDayPart?: string; note?: string; }, ctx: ToolContext) { assertPermission(ctx, "manageVacations" as PermissionKey); const validTypes = ["VACATION", "SICK", "PARENTAL", "SPECIAL", "PUBLIC_HOLIDAY"]; if (!validTypes.includes(params.type)) { return { error: `Invalid type: ${params.type}. Valid: ${validTypes.join(", ")}` }; } let resource = await ctx.db.resource.findUnique({ where: { id: params.resourceId }, select: { id: true, displayName: true }, }); if (!resource) { resource = await ctx.db.resource.findFirst({ where: { displayName: { contains: params.resourceId, mode: "insensitive" } }, select: { id: true, displayName: true }, }); } if (!resource) return { error: `Resource not found: ${params.resourceId}` }; // We need a requestedById — use a system/admin user as fallback const systemUser = await ctx.db.user.findFirst({ select: { id: true }, orderBy: { createdAt: "asc" } }); if (!systemUser) return { error: "No users found in system to set as requester." }; const vacation = await ctx.db.vacation.create({ data: { resourceId: resource.id, type: params.type as unknown as import("@planarchy/db").VacationType, startDate: new Date(params.startDate), endDate: new Date(params.endDate), status: "PENDING", requestedById: systemUser.id, ...(params.isHalfDay ? { isHalfDay: true } : {}), ...(params.halfDayPart ? { halfDayPart: params.halfDayPart } : {}), ...(params.note ? { note: params.note } : {}), }, select: { id: true, status: true }, }); return { __action: "invalidate", scope: ["vacation"], success: true, message: `Created ${params.type} for ${resource.displayName}: ${params.startDate} to ${params.endDate} (status: PENDING)`, vacationId: vacation.id, }; }, async approve_vacation(params: { vacationId: string }, ctx: ToolContext) { assertPermission(ctx, "manageVacations" as PermissionKey); const vacation = await ctx.db.vacation.findUnique({ where: { id: params.vacationId }, select: { id: true, status: true, resource: { select: { displayName: true } } }, }); if (!vacation) return { error: `Vacation not found: ${params.vacationId}` }; if (vacation.status !== "PENDING") return { error: `Vacation is ${vacation.status}, not PENDING` }; await ctx.db.vacation.update({ where: { id: params.vacationId }, data: { status: "APPROVED" } }); return { __action: "invalidate", scope: ["vacation"], success: true, message: `Approved vacation for ${vacation.resource.displayName}`, }; }, async reject_vacation(params: { vacationId: string; reason?: string }, ctx: ToolContext) { assertPermission(ctx, "manageVacations" as PermissionKey); const vacation = await ctx.db.vacation.findUnique({ where: { id: params.vacationId }, select: { id: true, status: true, resource: { select: { displayName: true } } }, }); if (!vacation) return { error: `Vacation not found: ${params.vacationId}` }; if (vacation.status !== "PENDING") return { error: `Vacation is ${vacation.status}, not PENDING` }; await ctx.db.vacation.update({ where: { id: params.vacationId }, data: { status: "REJECTED", ...(params.reason ? { rejectionReason: params.reason } : {}) }, }); return { __action: "invalidate", scope: ["vacation"], success: true, message: `Rejected vacation for ${vacation.resource.displayName}${params.reason ? `: ${params.reason}` : ""}`, }; }, async cancel_vacation(params: { vacationId: string }, ctx: ToolContext) { assertPermission(ctx, "manageVacations" as PermissionKey); const vacation = await ctx.db.vacation.findUnique({ where: { id: params.vacationId }, select: { id: true, status: true, resource: { select: { displayName: true } } }, }); if (!vacation) return { error: `Vacation not found: ${params.vacationId}` }; await ctx.db.vacation.update({ where: { id: params.vacationId }, data: { status: "CANCELLED" } }); return { __action: "invalidate", scope: ["vacation"], success: true, message: `Cancelled vacation for ${vacation.resource.displayName}`, }; }, async get_pending_vacation_approvals(params: { limit?: number }, ctx: ToolContext) { const limit = Math.min(params.limit ?? 20, 50); const vacations = await ctx.db.vacation.findMany({ where: { status: "PENDING" }, select: { id: true, type: true, startDate: true, endDate: true, isHalfDay: true, resource: { select: { displayName: true, eid: true, chapter: true } }, }, take: limit, orderBy: { createdAt: "asc" }, }); return vacations.map((v) => ({ id: v.id, resource: v.resource.displayName, eid: v.resource.eid, chapter: v.resource.chapter, type: v.type, start: fmtDate(v.startDate), end: fmtDate(v.endDate), isHalfDay: v.isHalfDay, })); }, async get_team_vacation_overlap(params: { resourceId: string; startDate: string; endDate: string; }, ctx: ToolContext) { const resource = await ctx.db.resource.findUnique({ where: { id: params.resourceId }, select: { id: true, displayName: true, chapter: true }, }); if (!resource) return { error: `Resource not found: ${params.resourceId}` }; const start = new Date(params.startDate); const end = new Date(params.endDate); // Find team members in same chapter const teamVacations = await ctx.db.vacation.findMany({ where: { resource: { chapter: resource.chapter, id: { not: resource.id }, isActive: true }, status: { in: ["APPROVED", "PENDING"] }, startDate: { lte: end }, endDate: { gte: start }, }, select: { type: true, startDate: true, endDate: true, status: true, resource: { select: { displayName: true } }, }, take: 20, }); return { resource: resource.displayName, chapter: resource.chapter, period: `${params.startDate} to ${params.endDate}`, overlappingVacations: teamVacations.map((v) => ({ resource: v.resource.displayName, type: v.type, status: v.status, start: fmtDate(v.startDate), end: fmtDate(v.endDate), })), overlapCount: teamVacations.length, }; }, // ── ENTITLEMENT ── async get_entitlement_summary(params: { year?: number; resourceName?: string }, ctx: ToolContext) { const year = params.year ?? new Date().getFullYear(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const where: Record = { year }; if (params.resourceName) { where.resource = { displayName: { contains: params.resourceName, mode: "insensitive" } }; } const entitlements = await ctx.db.vacationEntitlement.findMany({ where, select: { entitledDays: true, carryoverDays: true, usedDays: true, resource: { select: { displayName: true, eid: true } }, }, take: 50, orderBy: { resource: { displayName: "asc" } }, }); return entitlements.map((e) => ({ resource: e.resource.displayName, eid: e.resource.eid, year, entitled: e.entitledDays, carryover: e.carryoverDays ?? 0, used: e.usedDays, remaining: e.entitledDays + (e.carryoverDays ?? 0) - e.usedDays, })); }, async set_entitlement(params: { resourceId: string; year: number; entitledDays: number; carryoverDays?: number; }, ctx: ToolContext) { assertPermission(ctx, "manageVacations" as PermissionKey); let resource = await ctx.db.resource.findUnique({ where: { id: params.resourceId }, select: { id: true, displayName: true }, }); if (!resource) { resource = await ctx.db.resource.findFirst({ where: { displayName: { contains: params.resourceId, mode: "insensitive" } }, select: { id: true, displayName: true }, }); } if (!resource) return { error: `Resource not found: ${params.resourceId}` }; await ctx.db.vacationEntitlement.upsert({ where: { resourceId_year: { resourceId: resource.id, year: params.year } }, create: { resourceId: resource.id, year: params.year, entitledDays: params.entitledDays, carryoverDays: params.carryoverDays ?? 0, usedDays: 0, }, update: { entitledDays: params.entitledDays, ...(params.carryoverDays !== undefined ? { carryoverDays: params.carryoverDays } : {}), }, }); return { success: true, message: `Set entitlement for ${resource.displayName} (${params.year}): ${params.entitledDays} days${params.carryoverDays ? ` + ${params.carryoverDays} carryover` : ""}`, }; }, // ── DEMAND / STAFFING ── async list_demands(params: { projectId?: string; status?: string; limit?: number }, ctx: ToolContext) { const limit = Math.min(params.limit ?? 30, 50); // eslint-disable-next-line @typescript-eslint/no-explicit-any const where: Record = {}; if (params.status) where.status = params.status; if (params.projectId) { const project = await ctx.db.project.findFirst({ where: { OR: [ { id: params.projectId }, { shortCode: { contains: params.projectId, mode: "insensitive" } }, ], }, select: { id: true }, }); if (project) where.projectId = project.id; } const demands = await ctx.db.demandRequirement.findMany({ where, select: { id: true, status: true, headcount: true, hoursPerDay: true, startDate: true, endDate: true, role: true, // String? field roleEntity: { select: { name: true, color: true } }, project: { select: { name: true, shortCode: true } }, _count: { select: { assignments: true } }, }, take: limit, orderBy: { startDate: "desc" }, }); return demands.map((d) => ({ id: d.id, project: d.project.name, projectCode: d.project.shortCode, role: d.roleEntity?.name ?? d.role ?? "Unspecified", status: d.status, headcount: d.headcount, filled: d._count.assignments, remaining: d.headcount - d._count.assignments, hoursPerDay: d.hoursPerDay, start: fmtDate(d.startDate), end: fmtDate(d.endDate), })); }, async create_demand(params: { projectId: string; roleName: string; headcount?: number; hoursPerDay: number; startDate: string; endDate: string; }, ctx: ToolContext) { assertPermission(ctx, "manageAllocations" as PermissionKey); const project = await ctx.db.project.findFirst({ where: { OR: [ { id: params.projectId }, { shortCode: { contains: params.projectId, mode: "insensitive" } }, ], }, select: { id: true, name: true, shortCode: true }, }); if (!project) return { error: `Project not found: ${params.projectId}` }; const role = await ctx.db.role.findFirst({ where: { name: { contains: params.roleName, mode: "insensitive" } }, select: { id: true, name: true }, }); if (!role) return { error: `Role not found: ${params.roleName}` }; const demand = await ctx.db.demandRequirement.create({ data: { projectId: project.id, roleId: role.id, headcount: params.headcount ?? 1, hoursPerDay: params.hoursPerDay, percentage: (params.hoursPerDay / 8) * 100, startDate: new Date(params.startDate), endDate: new Date(params.endDate), status: "PROPOSED", }, select: { id: true }, }); return { __action: "invalidate", scope: ["allocation"], success: true, message: `Created demand: ${role.name} × ${params.headcount ?? 1} for ${project.name} (${project.shortCode}), ${params.hoursPerDay}h/day, ${params.startDate} to ${params.endDate}`, demandId: demand.id, }; }, async fill_demand(params: { demandId: string; resourceId: string }, ctx: ToolContext) { assertPermission(ctx, "manageAllocations" as PermissionKey); const demand = await ctx.db.demandRequirement.findUnique({ where: { id: params.demandId }, select: { id: true, status: true, headcount: true, hoursPerDay: true, startDate: true, endDate: true, roleId: true, role: true, roleEntity: { select: { name: true } }, project: { select: { id: true, name: true, shortCode: true } }, _count: { select: { assignments: true } }, }, }); if (!demand) return { error: `Demand not found: ${params.demandId}` }; const filledCount = demand._count.assignments; if (filledCount >= demand.headcount) return { error: "Demand is already fully filled." }; let resource = await ctx.db.resource.findUnique({ where: { id: params.resourceId }, select: { id: true, displayName: true, lcrCents: true }, }); if (!resource) { resource = await ctx.db.resource.findFirst({ where: { displayName: { contains: params.resourceId, mode: "insensitive" } }, select: { id: true, displayName: true, lcrCents: true }, }); } if (!resource) return { error: `Resource not found: ${params.resourceId}` }; // Check for overlapping duplicate assignments (same resource + project + overlapping dates) const existingAssignments = await ctx.db.assignment.findMany({ where: { resourceId: resource.id, status: { not: "CANCELLED" } }, select: { id: true, resourceId: true, projectId: true, startDate: true, endDate: true, status: true }, }); const dupCheck = checkDuplicateAssignment(resource.id, demand.project.id, demand.startDate, demand.endDate, existingAssignments); if (dupCheck.isDuplicate) { return { error: dupCheck.message + " Use update_allocation_status to modify the existing assignment." }; } const roleName = demand.roleEntity?.name ?? demand.role ?? null; const dailyCostCents = Math.round(resource.lcrCents * demand.hoursPerDay); const assignment = await ctx.db.assignment.create({ data: { resourceId: resource.id, projectId: demand.project.id, startDate: demand.startDate, endDate: demand.endDate, hoursPerDay: demand.hoursPerDay, percentage: (demand.hoursPerDay / 8) * 100, dailyCostCents, status: "PROPOSED", ...(roleName ? { role: roleName } : {}), demandRequirementId: demand.id, }, select: { id: true }, }); // Check if all headcount positions are now filled const newFilled = filledCount + 1; if (newFilled >= demand.headcount) { await ctx.db.demandRequirement.update({ where: { id: demand.id }, data: { status: "COMPLETED" }, }); } return { __action: "invalidate", scope: ["allocation", "timeline"], success: true, message: `Assigned ${resource.displayName} to ${roleName ?? "demand"} on ${demand.project.name} (${demand.project.shortCode})`, assignmentId: assignment.id, }; }, async check_resource_availability(params: { resourceId: string; startDate: string; endDate: string; }, ctx: ToolContext) { let resource = await ctx.db.resource.findUnique({ where: { id: params.resourceId }, select: { id: true, displayName: true, fte: true }, }); if (!resource) { resource = await ctx.db.resource.findUnique({ where: { eid: params.resourceId }, select: { id: true, displayName: true, fte: true }, }); } if (!resource) { resource = await ctx.db.resource.findFirst({ where: { displayName: { contains: params.resourceId, mode: "insensitive" } }, select: { id: true, displayName: true, fte: true }, }); } if (!resource) return { error: `Resource not found: ${params.resourceId}` }; const start = new Date(params.startDate); const end = new Date(params.endDate); const [allocations, vacations] = await Promise.all([ ctx.db.assignment.findMany({ where: { resourceId: resource.id, status: { not: "CANCELLED" }, startDate: { lte: end }, endDate: { gte: start }, }, select: { hoursPerDay: true, startDate: true, endDate: true, status: true, project: { select: { name: true, shortCode: true } }, }, }), ctx.db.vacation.findMany({ where: { resourceId: resource.id, status: { in: ["APPROVED", "PENDING"] }, startDate: { lte: end }, endDate: { gte: start }, }, select: { type: true, startDate: true, endDate: true, isHalfDay: true }, }), ]); const totalHoursBooked = allocations.reduce((sum, a) => sum + a.hoursPerDay, 0); const maxHours = resource.fte * 8; const availableHoursPerDay = Math.max(0, maxHours - totalHoursBooked); return { resource: resource.displayName, period: `${params.startDate} to ${params.endDate}`, fte: resource.fte, maxHoursPerDay: maxHours, currentBookedHoursPerDay: totalHoursBooked, availableHoursPerDay, isFullyAvailable: totalHoursBooked === 0 && vacations.length === 0, existingAllocations: allocations.map((a) => ({ project: `${a.project.name} (${a.project.shortCode})`, hoursPerDay: a.hoursPerDay, status: a.status, start: fmtDate(a.startDate), end: fmtDate(a.endDate), })), vacations: vacations.map((v) => ({ type: v.type, start: fmtDate(v.startDate), end: fmtDate(v.endDate), isHalfDay: v.isHalfDay, })), }; }, async get_staffing_suggestions(params: { projectId: string; roleName?: string; startDate?: string; endDate?: string; limit?: number; }, ctx: ToolContext) { const limit = params.limit ?? 5; const project = await ctx.db.project.findFirst({ where: { OR: [ { id: params.projectId }, { shortCode: { contains: params.projectId, mode: "insensitive" } }, ], }, select: { id: true, name: true, shortCode: true, startDate: true, endDate: true }, }); if (!project) return { error: `Project not found: ${params.projectId}` }; const start = params.startDate ? new Date(params.startDate) : project.startDate ?? new Date(); const end = params.endDate ? new Date(params.endDate) : project.endDate ?? new Date(); // Find available resources // eslint-disable-next-line @typescript-eslint/no-explicit-any const resourceWhere: Record = { isActive: true }; if (params.roleName) { resourceWhere.areaRole = { name: { contains: params.roleName, mode: "insensitive" } }; } const resources = await ctx.db.resource.findMany({ where: resourceWhere, select: { id: true, displayName: true, eid: true, fte: true, lcrCents: true, areaRole: { select: { name: true } }, chapter: true, assignments: { where: { status: { not: "CANCELLED" }, startDate: { lte: end }, endDate: { gte: start }, }, select: { hoursPerDay: true }, }, }, take: 50, }); // Score by availability const scored = resources.map((r) => { const bookedHours = r.assignments.reduce((sum, a) => sum + a.hoursPerDay, 0); const maxHours = r.fte * 8; const available = Math.max(0, maxHours - bookedHours); return { id: r.id, name: r.displayName, eid: r.eid, role: r.areaRole?.name ?? null, chapter: r.chapter, fte: r.fte, lcr: fmtEur(r.lcrCents), availableHoursPerDay: Math.round(available * 10) / 10, utilization: maxHours > 0 ? Math.round((bookedHours / maxHours) * 100) : 0, }; }) .filter((r) => r.availableHoursPerDay > 0) .sort((a, b) => b.availableHoursPerDay - a.availableHoursPerDay) .slice(0, limit); return { project: `${project.name} (${project.shortCode})`, period: `${fmtDate(start)} to ${fmtDate(end)}`, suggestions: scored, }; }, async find_capacity(params: { startDate: string; endDate: string; minHoursPerDay?: number; roleName?: string; chapter?: string; limit?: number; }, ctx: ToolContext) { const limit = params.limit ?? 20; const minHours = params.minHoursPerDay ?? 4; const start = new Date(params.startDate); const end = new Date(params.endDate); // eslint-disable-next-line @typescript-eslint/no-explicit-any const where: Record = { isActive: true }; if (params.roleName) { where.areaRole = { name: { contains: params.roleName, mode: "insensitive" } }; } if (params.chapter) { where.chapter = { contains: params.chapter, mode: "insensitive" }; } const resources = await ctx.db.resource.findMany({ where, select: { id: true, displayName: true, eid: true, fte: true, areaRole: { select: { name: true } }, chapter: true, assignments: { where: { status: { not: "CANCELLED" }, startDate: { lte: end }, endDate: { gte: start }, }, select: { hoursPerDay: true }, }, }, take: 100, }); const available = resources .map((r) => { const booked = r.assignments.reduce((sum, a) => sum + a.hoursPerDay, 0); const maxH = r.fte * 8; return { id: r.id, name: r.displayName, eid: r.eid, role: r.areaRole?.name ?? null, chapter: r.chapter, availableHoursPerDay: Math.round((maxH - booked) * 10) / 10, }; }) .filter((r) => r.availableHoursPerDay >= minHours) .sort((a, b) => b.availableHoursPerDay - a.availableHoursPerDay) .slice(0, limit); return { period: `${params.startDate} to ${params.endDate}`, minHoursFilter: minHours, results: available, totalFound: available.length, }; }, // ── BLUEPRINT ── async list_blueprints(_params: Record, ctx: ToolContext) { const blueprints = await ctx.db.blueprint.findMany({ select: { id: true, name: true, _count: { select: { projects: true } }, }, orderBy: { name: "asc" }, }); return blueprints.map((b) => ({ id: b.id, name: b.name, projectCount: b._count.projects, })); }, async get_blueprint(params: { identifier: string }, ctx: ToolContext) { let bp = await ctx.db.blueprint.findUnique({ where: { id: params.identifier }, select: { id: true, name: true, fieldDefs: true, rolePresets: true }, }); if (!bp) { bp = await ctx.db.blueprint.findFirst({ where: { name: { contains: params.identifier, mode: "insensitive" } }, select: { id: true, name: true, fieldDefs: true, rolePresets: true }, }); } if (!bp) return { error: `Blueprint not found: ${params.identifier}` }; return { id: bp.id, name: bp.name, fieldDefs: bp.fieldDefs, rolePresets: bp.rolePresets, }; }, // ── RATE CARDS ── async list_rate_cards(params: { query?: string; limit?: number }, ctx: ToolContext) { const limit = Math.min(params.limit ?? 20, 50); // eslint-disable-next-line @typescript-eslint/no-explicit-any const where: Record = { isActive: true }; if (params.query) { where.name = { contains: params.query, mode: "insensitive" }; } const cards = await ctx.db.rateCard.findMany({ where, select: { id: true, name: true, effectiveFrom: true, effectiveTo: true, _count: { select: { lines: true } }, }, take: limit, orderBy: { effectiveFrom: "desc" }, }); return cards.map((c) => ({ id: c.id, name: c.name, effectiveFrom: fmtDate(c.effectiveFrom), effectiveTo: fmtDate(c.effectiveTo), lineCount: c._count.lines, })); }, async resolve_rate(params: { resourceId?: string; roleName?: string; date?: string }, ctx: ToolContext) { const date = params.date ? new Date(params.date) : new Date(); // Find active rate card for this date const card = await ctx.db.rateCard.findFirst({ where: { isActive: true, effectiveFrom: { lte: date }, OR: [ { effectiveTo: null }, { effectiveTo: { gte: date } }, ], }, select: { id: true, name: true, lines: { select: { id: true, costRateCents: true, billRateCents: true, role: { select: { name: true } }, seniority: true, chapter: true, location: true, }, }, }, orderBy: { effectiveFrom: "desc" }, }); if (!card) return { error: "No active rate card found for the given date." }; // If resource specified, try to match their role if (params.resourceId) { let resource = await ctx.db.resource.findUnique({ where: { id: params.resourceId }, select: { displayName: true, areaRole: { select: { name: true } }, managementLevelGroup: { select: { name: true } } }, }); if (!resource) { resource = await ctx.db.resource.findFirst({ where: { displayName: { contains: params.resourceId, mode: "insensitive" } }, select: { displayName: true, areaRole: { select: { name: true } }, managementLevelGroup: { select: { name: true } } }, }); } if (resource) { const match = card.lines.find((l) => l.role?.name === resource!.areaRole?.name, ); if (match) { return { rateCard: card.name, resource: resource.displayName, rate: fmtEur(match.costRateCents), rateCents: match.costRateCents, matchedBy: match.role?.name ? `role: ${match.role.name}` : "unknown", }; } } } // Return all lines return { rateCard: card.name, lines: card.lines.map((l) => ({ role: l.role?.name ?? null, seniority: l.seniority, chapter: l.chapter, location: l.location, costRate: fmtEur(l.costRateCents), billRate: l.billRateCents != null ? fmtEur(l.billRateCents) : null, })), }; }, // ── ESTIMATES ── async get_estimate_detail(params: { estimateId: string }, ctx: ToolContext) { const estimate = await ctx.db.estimate.findUnique({ where: { id: params.estimateId }, select: { id: true, name: true, status: true, latestVersionNumber: true, project: { select: { name: true, shortCode: true } }, versions: { select: { id: true, versionNumber: true, status: true, label: true, notes: true, lockedAt: true, _count: { select: { demandLines: true } }, }, orderBy: { versionNumber: "desc" }, take: 5, }, }, }); if (!estimate) return { error: `Estimate not found: ${params.estimateId}` }; return { id: estimate.id, name: estimate.name, status: estimate.status, project: estimate.project?.name ?? null, projectCode: estimate.project?.shortCode ?? null, latestVersion: estimate.latestVersionNumber, versions: estimate.versions.map((v) => ({ id: v.id, version: v.versionNumber, status: v.status, label: v.label, demandLineCount: v._count.demandLines, lockedAt: v.lockedAt ? fmtDate(v.lockedAt) : null, })), }; }, async create_estimate(params: { name: string; projectId: string }, ctx: ToolContext) { assertPermission(ctx, "manageProjects" as PermissionKey); const project = await ctx.db.project.findFirst({ where: { OR: [ { id: params.projectId }, { shortCode: { contains: params.projectId, mode: "insensitive" } }, ], }, select: { id: true, name: true, shortCode: true }, }); if (!project) return { error: `Project not found: ${params.projectId}` }; const estimate = await ctx.db.estimate.create({ data: { name: params.name, projectId: project.id, status: "DRAFT", latestVersionNumber: 0, }, select: { id: true, name: true }, }); return { __action: "invalidate", scope: ["estimate"], success: true, message: `Created estimate "${estimate.name}" for ${project.name} (${project.shortCode})`, estimateId: estimate.id, }; }, // ── ROLES ── async create_role(params: { name: string; color?: string }, ctx: ToolContext) { assertPermission(ctx, "manageResources" as PermissionKey); const role = await ctx.db.role.create({ data: { name: params.name, color: params.color ?? "#6b7280" }, select: { id: true, name: true }, }); return { __action: "invalidate", scope: ["role"], success: true, message: `Created role: ${role.name}`, roleId: role.id }; }, async update_role(params: { id: string; name?: string; color?: string }, ctx: ToolContext) { assertPermission(ctx, "manageResources" as PermissionKey); const data: Record = {}; if (params.name !== undefined) data.name = params.name; if (params.color !== undefined) data.color = params.color; if (Object.keys(data).length === 0) return { error: "No fields to update" }; const role = await ctx.db.role.update({ where: { id: params.id }, data, select: { id: true, name: true } }); return { __action: "invalidate", scope: ["role"], success: true, message: `Updated role: ${role.name}` }; }, async delete_role(params: { id: string }, ctx: ToolContext) { assertPermission(ctx, "manageResources" as PermissionKey); const role = await ctx.db.role.findUnique({ where: { id: params.id }, select: { name: true, _count: { select: { areaResources: true } } } }); if (!role) return { error: `Role not found: ${params.id}` }; if (role._count.areaResources > 0) return { error: `Cannot delete role "${role.name}" — ${role._count.areaResources} resources are assigned to it.` }; await ctx.db.role.delete({ where: { id: params.id } }); return { __action: "invalidate", scope: ["role"], success: true, message: `Deleted role: ${role.name}` }; }, // ── CLIENTS ── async create_client(params: { name: string; code?: string }, ctx: ToolContext) { assertPermission(ctx, "manageProjects" as PermissionKey); const client = await ctx.db.client.create({ data: { name: params.name, ...(params.code ? { code: params.code } : {}) }, select: { id: true, name: true, code: true }, }); return { __action: "invalidate", scope: ["client"], success: true, message: `Created client: ${client.name}`, clientId: client.id }; }, async update_client(params: { id: string; name?: string; code?: string }, ctx: ToolContext) { assertPermission(ctx, "manageProjects" as PermissionKey); const data: Record = {}; if (params.name !== undefined) data.name = params.name; if (params.code !== undefined) data.code = params.code; if (Object.keys(data).length === 0) return { error: "No fields to update" }; const client = await ctx.db.client.update({ where: { id: params.id }, data, select: { id: true, name: true } }); return { __action: "invalidate", scope: ["client"], success: true, message: `Updated client: ${client.name}` }; }, // ── ADMIN / CONFIG ── async list_countries(_params: Record, ctx: ToolContext) { const countries = await ctx.db.country.findMany({ select: { id: true, code: true, name: true, dailyWorkingHours: true, metroCities: { select: { id: true, name: true }, orderBy: { name: "asc" as const } }, }, orderBy: { name: "asc" }, }); return countries.map((c) => ({ id: c.id, code: c.code, name: c.name, dailyWorkingHours: c.dailyWorkingHours, cities: c.metroCities.map((ci) => ci.name), })); }, async list_management_levels(_params: Record, ctx: ToolContext) { const groups = await ctx.db.managementLevelGroup.findMany({ select: { id: true, name: true, targetPercentage: true, levels: { select: { id: true, name: true }, orderBy: { name: "asc" as const } }, }, orderBy: { name: "asc" }, }); return groups.map((g) => ({ id: g.id, name: g.name, target: g.targetPercentage ? `${g.targetPercentage}%` : null, levels: g.levels.map((l: { id: string; name: string }) => ({ id: l.id, name: l.name })), })); }, async list_utilization_categories(_params: Record, ctx: ToolContext) { const cats = await ctx.db.utilizationCategory.findMany({ select: { id: true, code: true, name: true, description: true, _count: { select: { projects: true } }, }, orderBy: { code: "asc" }, }); return cats.map((c) => ({ id: c.id, code: c.code, name: c.name, description: c.description, projectCount: c._count.projects, })); }, async list_calculation_rules(_params: Record, ctx: ToolContext) { const rules = await ctx.db.calculationRule.findMany({ select: { id: true, name: true, description: true, isActive: true, triggerType: true, orderType: true, costEffect: true, costReductionPercent: true, chargeabilityEffect: true, priority: true, }, orderBy: { priority: "asc" }, }); return rules; }, async list_effort_rules(_params: Record, ctx: ToolContext) { const rules = await ctx.db.effortRule.findMany({ select: { id: true, description: true, scopeType: true, discipline: true, chapter: true, unitMode: true, hoursPerUnit: true, sortOrder: true, ruleSet: { select: { name: true, isDefault: true } }, }, orderBy: { sortOrder: "asc" }, }); return rules; }, async list_experience_multipliers(_params: Record, ctx: ToolContext) { const multipliers = await ctx.db.experienceMultiplierRule.findMany({ select: { id: true, description: true, chapter: true, location: true, level: true, costMultiplier: true, billMultiplier: true, shoringRatio: true, additionalEffortRatio: true, sortOrder: true, multiplierSet: { select: { name: true, isDefault: true } }, }, orderBy: { sortOrder: "asc" }, }); return multipliers; }, async list_users(params: { limit?: number }, ctx: ToolContext) { const limit = Math.min(params.limit ?? 50, 100); const users = await ctx.db.user.findMany({ select: { id: true, name: true, email: true, systemRole: true, resource: { select: { displayName: true, eid: true } }, }, take: limit, orderBy: { name: "asc" }, }); return users.map((u) => ({ id: u.id, name: u.name, email: u.email, role: u.systemRole, linkedResource: u.resource?.displayName ?? null, linkedEid: u.resource?.eid ?? null, })); }, async list_notifications(params: { unreadOnly?: boolean; limit?: number }, ctx: ToolContext) { const limit = Math.min(params.limit ?? 20, 50); // eslint-disable-next-line @typescript-eslint/no-explicit-any const where: Record = {}; if (params.unreadOnly) where.readAt = null; const notifications = await ctx.db.notification.findMany({ where, select: { id: true, type: true, title: true, body: true, readAt: true, createdAt: true, }, take: limit, orderBy: { createdAt: "desc" }, }); return notifications.map((n) => ({ id: n.id, type: n.type, title: n.title, message: n.body, read: n.readAt !== null, created: fmtDate(n.createdAt), })); }, async mark_notification_read(params: { notificationId: string }, ctx: ToolContext) { await ctx.db.notification.update({ where: { id: params.notificationId }, data: { readAt: new Date() }, }); return { success: true, message: "Notification marked as read" }; }, // ── DASHBOARD DETAIL ── async get_dashboard_detail(params: { section?: string }, ctx: ToolContext) { const section = params.section ?? "all"; // eslint-disable-next-line @typescript-eslint/no-explicit-any const result: Record = {}; if (section === "all" || section === "peak_times") { // Peak allocation months const allocs = await ctx.db.assignment.findMany({ where: { status: { not: "CANCELLED" } }, select: { startDate: true, endDate: true, hoursPerDay: true }, }); const monthCounts: Record = {}; for (const a of allocs) { const month = `${a.startDate.getFullYear()}-${String(a.startDate.getMonth() + 1).padStart(2, "0")}`; monthCounts[month] = (monthCounts[month] ?? 0) + a.hoursPerDay; } const sorted = Object.entries(monthCounts).sort((a, b) => b[1] - a[1]).slice(0, 6); result.peakTimes = sorted.map(([m, h]) => ({ month: m, totalHoursPerDay: Math.round(h * 10) / 10 })); } if (section === "all" || section === "top_resources") { const resources = await ctx.db.resource.findMany({ where: { isActive: true }, select: { displayName: true, eid: true, lcrCents: true, _count: { select: { assignments: true } }, }, orderBy: { lcrCents: "desc" }, take: 10, }); result.topResources = resources.map((r) => ({ name: r.displayName, eid: r.eid, lcr: fmtEur(r.lcrCents), allocations: r._count.assignments, })); } if (section === "all" || section === "demand_pipeline") { const demands = await ctx.db.demandRequirement.findMany({ where: { status: { in: ["PROPOSED", "CONFIRMED"] } }, select: { headcount: true, role: true, roleEntity: { select: { name: true } }, project: { select: { name: true, shortCode: true } }, _count: { select: { assignments: true } }, }, take: 15, orderBy: { startDate: "asc" }, }); result.demandPipeline = demands.map((d) => ({ project: `${d.project.name} (${d.project.shortCode})`, role: d.roleEntity?.name ?? d.role ?? "Unspecified", needed: d.headcount - d._count.assignments, })); } if (section === "all" || section === "chargeability_overview") { const chapters = await ctx.db.resource.groupBy({ by: ["chapter"], where: { isActive: true }, _count: true, _avg: { chargeabilityTarget: true }, }); result.chargeabilityByChapter = chapters.map((c) => ({ chapter: c.chapter ?? "Unassigned", headcount: c._count, avgTarget: c._avg.chargeabilityTarget ? `${Math.round(c._avg.chargeabilityTarget)}%` : null, })); } return result; }, // ── PROJECT MANAGEMENT ── async delete_project(params: { projectId: string }, ctx: ToolContext) { assertPermission(ctx, "manageProjects" as PermissionKey); let project = await ctx.db.project.findUnique({ where: { id: params.projectId }, select: { id: true, name: true, shortCode: true, status: true }, }); if (!project) { project = await ctx.db.project.findUnique({ where: { shortCode: params.projectId }, select: { id: true, name: true, shortCode: true, status: true }, }); } if (!project) return { error: `Project not found: ${params.projectId}` }; if (project.status !== "DRAFT") return { error: `Only DRAFT projects can be deleted. This project is ${project.status}.` }; await ctx.db.project.delete({ where: { id: project.id } }); return { __action: "invalidate", scope: ["project"], success: true, message: `Deleted project: ${project.name} (${project.shortCode})`, }; }, // ── ORG UNIT MANAGEMENT ── async create_org_unit(params: { name: string; shortName?: string; level: number; parentId?: string }, ctx: ToolContext) { assertPermission(ctx, "manageResources" as PermissionKey); const ou = await ctx.db.orgUnit.create({ data: { name: params.name, ...(params.shortName ? { shortName: params.shortName } : {}), level: params.level, ...(params.parentId ? { parentId: params.parentId } : {}), isActive: true, }, select: { id: true, name: true }, }); return { __action: "invalidate", scope: ["orgUnit"], success: true, message: `Created org unit: ${ou.name}`, orgUnitId: ou.id }; }, async update_org_unit(params: { id: string; name?: string; shortName?: string }, ctx: ToolContext) { assertPermission(ctx, "manageResources" as PermissionKey); const data: Record = {}; if (params.name !== undefined) data.name = params.name; if (params.shortName !== undefined) data.shortName = params.shortName; if (Object.keys(data).length === 0) return { error: "No fields to update" }; const ou = await ctx.db.orgUnit.update({ where: { id: params.id }, data, select: { id: true, name: true } }); return { __action: "invalidate", scope: ["orgUnit"], success: true, message: `Updated org unit: ${ou.name}` }; }, // ─── Cover Art ─────────────────────────────────────────────────────────── async generate_project_cover(params: { projectId: string; prompt?: string }, ctx: ToolContext) { assertPermission(ctx, "manageProjects" as PermissionKey); const project = await ctx.db.project.findUnique({ where: { id: params.projectId }, include: { client: { select: { name: true } } }, }); if (!project) return { error: `Project not found: ${params.projectId}` }; const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }); const imageProvider = settings?.imageProvider ?? "dalle"; const { isGeminiConfigured: isGeminiOk } = await import("../gemini-client.js"); const useGemini = imageProvider === "gemini" && isGeminiOk(settings); const useDalle = imageProvider === "dalle" && isDalleConfigured(settings); if (!useGemini && !useDalle) { return { error: "No image provider configured. Set up DALL-E or Gemini in Admin → Settings." }; } const clientName = project.client?.name ? ` for ${project.client.name}` : ""; const basePrompt = `Professional cover art for a 3D automotive visualization project: "${project.name}"${clientName}. Style: cinematic, modern, photorealistic CGI rendering, dramatic lighting, studio environment. No text or typography in the image.`; const finalPrompt = params.prompt ? `${basePrompt} Additional direction: ${params.prompt}` : basePrompt; let coverImageUrl: string; if (useGemini) { try { const { generateGeminiImage, parseGeminiError } = await import("../gemini-client.js"); coverImageUrl = await generateGeminiImage( settings!.geminiApiKey!, finalPrompt, settings!.geminiModel ?? undefined, ); } catch (err) { const { parseGeminiError: parseErr } = await import("../gemini-client.js"); return { error: `Gemini error: ${parseErr(err)}` }; } } else { const dalleClient = createDalleClient(settings!); const model = settings!.aiProvider === "azure" ? settings!.azureDalleDeployment! : "dall-e-3"; try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const response: any = await dalleClient.images.generate({ model, prompt: finalPrompt, size: "1024x1024", n: 1, response_format: "b64_json", }); const b64 = response.data?.[0]?.b64_json; if (!b64) return { error: "No image data returned from DALL-E" }; coverImageUrl = `data:image/png;base64,${b64}`; } catch (err) { return { error: `DALL-E error: ${parseAiError(err)}` }; } } await ctx.db.project.update({ where: { id: params.projectId }, data: { coverImageUrl } }); return { __action: "invalidate", scope: ["project"], success: true, message: `Generated cover art for project "${project.name}" using ${useGemini ? "Gemini" : "DALL-E"}`, coverImageUrl: coverImageUrl.slice(0, 100) + "...[truncated]", }; }, async remove_project_cover(params: { projectId: string }, ctx: ToolContext) { assertPermission(ctx, "manageProjects" as PermissionKey); const project = await ctx.db.project.findUnique({ where: { id: params.projectId }, select: { id: true, name: true }, }); if (!project) return { error: `Project not found: ${params.projectId}` }; await ctx.db.project.update({ where: { id: params.projectId }, data: { coverImageUrl: null } }); return { __action: "invalidate", scope: ["project"], success: true, message: `Removed cover art from project "${project.name}"` }; }, // ── TASK MANAGEMENT ── async list_tasks(params: { status?: string; limit?: number }, ctx: ToolContext) { const limit = Math.min(params.limit ?? 10, 50); const status = params.status ?? "OPEN"; const tasks = await ctx.db.notification.findMany({ where: { OR: [ { userId: ctx.userId }, { assigneeId: ctx.userId }, ], category: { in: ["TASK", "APPROVAL"] }, taskStatus: status as "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED", }, select: { id: true, title: true, body: true, priority: true, taskStatus: true, taskAction: true, dueDate: true, entityId: true, entityType: true, createdAt: true, }, take: limit, orderBy: [{ priority: "desc" }, { createdAt: "desc" }], }); return tasks.map((t) => ({ id: t.id, title: t.title, body: t.body, priority: t.priority, taskStatus: t.taskStatus, taskAction: t.taskAction, dueDate: fmtDate(t.dueDate), entityId: t.entityId, entityType: t.entityType, createdAt: fmtDate(t.createdAt), })); }, async get_task_detail(params: { taskId: string }, ctx: ToolContext) { const task = await ctx.db.notification.findUnique({ where: { id: params.taskId }, select: { id: true, title: true, body: true, type: true, priority: true, category: true, taskStatus: true, taskAction: true, dueDate: true, entityId: true, entityType: true, completedAt: true, completedBy: true, createdAt: true, userId: true, assigneeId: true, sender: { select: { name: true } }, }, }); if (!task) return { error: `Task not found: ${params.taskId}` }; // Verify the user has access to this task if (task.userId !== ctx.userId && task.assigneeId !== ctx.userId) { return { error: "Access denied: this task does not belong to you" }; } // eslint-disable-next-line @typescript-eslint/no-explicit-any const result: Record = { id: task.id, title: task.title, body: task.body, type: task.type, priority: task.priority, category: task.category, taskStatus: task.taskStatus, taskAction: task.taskAction, dueDate: fmtDate(task.dueDate), entityId: task.entityId, entityType: task.entityType, completedAt: fmtDate(task.completedAt), completedBy: task.completedBy, createdAt: fmtDate(task.createdAt), senderName: task.sender?.name ?? null, }; // Enrich with linked entity details if (task.entityId && task.entityType) { try { if (task.entityType === "project") { const project = await ctx.db.project.findUnique({ where: { id: task.entityId }, select: { id: true, name: true, shortCode: true, status: true }, }); if (project) result.linkedEntity = project; } else if (task.entityType === "vacation") { const vacation = await ctx.db.vacation.findUnique({ where: { id: task.entityId }, select: { id: true, type: true, status: true, startDate: true, endDate: true, resource: { select: { displayName: true } }, }, }); if (vacation) { result.linkedEntity = { id: vacation.id, type: vacation.type, status: vacation.status, startDate: fmtDate(vacation.startDate), endDate: fmtDate(vacation.endDate), resourceName: vacation.resource.displayName, }; } } else if (task.entityType === "assignment" || task.entityType === "allocation") { const assignment = await ctx.db.assignment.findUnique({ where: { id: task.entityId }, select: { id: true, status: true, startDate: true, endDate: true, resource: { select: { displayName: true } }, project: { select: { name: true } }, }, }); if (assignment) { result.linkedEntity = { id: assignment.id, status: assignment.status, startDate: fmtDate(assignment.startDate), endDate: fmtDate(assignment.endDate), resourceName: assignment.resource.displayName, projectName: assignment.project.name, }; } } } catch { // Entity may have been deleted — ignore } } return result; }, async update_task_status(params: { taskId: string; status: string }, ctx: ToolContext) { const task = await ctx.db.notification.findUnique({ where: { id: params.taskId }, select: { id: true, userId: true, assigneeId: true, taskStatus: true }, }); if (!task) return { error: `Task not found: ${params.taskId}` }; if (task.userId !== ctx.userId && task.assigneeId !== ctx.userId) { return { error: "Access denied: this task does not belong to you" }; } const newStatus = params.status as "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED"; // eslint-disable-next-line @typescript-eslint/no-explicit-any const data: Record = { taskStatus: newStatus }; if (newStatus === "DONE") { data.completedAt = new Date(); data.completedBy = "ai-assistant"; } await ctx.db.notification.update({ where: { id: params.taskId }, data, }); emitTaskStatusChanged(task.userId, task.id); if (newStatus === "DONE") { emitTaskCompleted(task.userId, task.id); } return { __action: "invalidate", scope: ["notification"], success: true, message: `Task status updated to ${newStatus}` }; }, async execute_task_action(params: { taskId: string }, ctx: ToolContext) { // 1. Fetch the notification const task = await ctx.db.notification.findUnique({ where: { id: params.taskId }, select: { id: true, userId: true, assigneeId: true, taskAction: true, taskStatus: true, }, }); if (!task) return { error: `Task not found: ${params.taskId}` }; if (task.userId !== ctx.userId && task.assigneeId !== ctx.userId) { return { error: "Access denied: this task does not belong to you" }; } if (!task.taskAction) { return { error: "This task has no executable action" }; } if (task.taskStatus === "DONE") { return { error: "This task is already completed" }; } // 2. Parse taskAction const parsed = parseTaskAction(task.taskAction); if (!parsed) { return { error: `Invalid taskAction format: ${task.taskAction}` }; } // 3. Look up handler in TASK_ACTION_REGISTRY const handler = getTaskAction(parsed.action); if (!handler) { return { error: `Unknown action: ${parsed.action}` }; } // 4. Check permission if (handler.permission && !ctx.permissions.has(handler.permission as PermissionKey)) { return { error: `Permission denied: you need "${handler.permission}" to perform this action` }; } // 5. Execute the action const actionResult = await handler.execute(parsed.entityId, ctx.db, ctx.userId); if (!actionResult.success) { return { error: actionResult.message }; } // 6. Mark the task as DONE await ctx.db.notification.update({ where: { id: params.taskId }, data: { taskStatus: "DONE", completedAt: new Date(), completedBy: "ai-assistant", }, }); emitTaskCompleted(task.userId, task.id); return { __action: "invalidate", scope: ["notification"], success: true, message: actionResult.message, action: parsed.action, entityId: parsed.entityId, }; }, async create_reminder(params: { title: string; body?: string; remindAt: string; recurrence?: string; entityId?: string; entityType?: string; }, ctx: ToolContext) { const remindAt = new Date(params.remindAt); if (isNaN(remindAt.getTime())) { return { error: "Invalid remindAt date format. Use ISO 8601 (e.g. 2026-03-20T09:00:00Z)" }; } const notification = await ctx.db.notification.create({ data: { userId: ctx.userId, type: "REMINDER", title: params.title, category: "REMINDER", remindAt, nextRemindAt: remindAt, ...(params.body !== undefined ? { body: params.body } : {}), ...(params.recurrence !== undefined ? { recurrence: params.recurrence } : {}), ...(params.entityId !== undefined ? { entityId: params.entityId } : {}), ...(params.entityType !== undefined ? { entityType: params.entityType } : {}), }, }); emitNotificationCreated(ctx.userId, notification.id); return { __action: "invalidate", scope: ["notification"], success: true, message: `Reminder "${params.title}" created for ${fmtDate(remindAt)}`, reminderId: notification.id, ...(params.recurrence !== undefined ? { recurrence: params.recurrence } : {}), }; }, async create_task_for_user(params: { userId: string; title: string; body?: string; priority?: string; dueDate?: string; taskAction?: string; entityId?: string; entityType?: string; }, ctx: ToolContext) { assertPermission(ctx, "manageProjects" as PermissionKey); // Verify target user exists const targetUser = await ctx.db.user.findUnique({ where: { id: params.userId }, select: { id: true, name: true }, }); if (!targetUser) return { error: `User not found: ${params.userId}` }; const notification = await ctx.db.notification.create({ data: { userId: params.userId, type: "TASK_ASSIGNED", title: params.title, category: "TASK", taskStatus: "OPEN", senderId: ctx.userId, priority: (params.priority ?? "NORMAL") as "LOW" | "NORMAL" | "HIGH" | "URGENT", ...(params.body !== undefined ? { body: params.body } : {}), ...(params.dueDate !== undefined ? { dueDate: new Date(params.dueDate) } : {}), ...(params.taskAction !== undefined ? { taskAction: params.taskAction } : {}), ...(params.entityId !== undefined ? { entityId: params.entityId } : {}), ...(params.entityType !== undefined ? { entityType: params.entityType } : {}), }, }); emitTaskAssigned(params.userId, notification.id); return { __action: "invalidate", scope: ["notification"], success: true, message: `Task "${params.title}" created for ${targetUser.name ?? params.userId}`, taskId: notification.id, }; }, async send_broadcast(params: { title: string; body?: string; targetType: string; targetValue?: string; priority?: string; channel?: string; link?: string; }, ctx: ToolContext) { assertPermission(ctx, "manageProjects" as PermissionKey); // Resolve recipients const recipientIds = await resolveRecipients( params.targetType, params.targetValue, ctx.db, ctx.userId, // exclude sender ); if (recipientIds.length === 0) { return { error: "No recipients found for the given target" }; } const priority = (params.priority ?? "NORMAL") as "LOW" | "NORMAL" | "HIGH" | "URGENT"; const channel = params.channel ?? "in_app"; // Create broadcast record const broadcast = await ctx.db.notificationBroadcast.create({ data: { senderId: ctx.userId, title: params.title, targetType: params.targetType, priority, channel, recipientCount: recipientIds.length, sentAt: new Date(), ...(params.body !== undefined ? { body: params.body } : {}), ...(params.targetValue !== undefined ? { targetValue: params.targetValue } : {}), ...(params.link !== undefined ? { link: params.link } : {}), }, }); // Create individual notifications for each recipient await ctx.db.notification.createMany({ data: recipientIds.map((userId) => ({ userId, type: "BROADCAST", title: params.title, category: "NOTIFICATION" as const, priority, channel, senderId: ctx.userId, sourceId: broadcast.id, ...(params.body !== undefined ? { body: params.body } : {}), ...(params.link !== undefined ? { link: params.link } : {}), })), }); // Emit SSE events for each recipient for (const userId of recipientIds) { emitNotificationCreated(userId, broadcast.id); } emitBroadcastSent(broadcast.id, recipientIds.length); return { __action: "invalidate", scope: ["notification"], success: true, message: `Broadcast "${params.title}" sent to ${recipientIds.length} recipients`, broadcastId: broadcast.id, 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), }; }, async query_change_history(params: { entityType?: string; search?: string; userId?: string; daysBack?: number; action?: string; limit?: number; }, ctx: ToolContext) { const limit = Math.min(params.limit ?? 20, 50); const daysBack = params.daysBack ?? 7; const startDate = new Date(); startDate.setDate(startDate.getDate() - daysBack); const where: Record = { createdAt: { gte: startDate }, }; if (params.entityType) where.entityType = params.entityType; if (params.action) where.action = params.action; if (params.userId) where.userId = params.userId; if (params.search) { where.OR = [ { entityName: { contains: params.search, mode: "insensitive" } }, { summary: { contains: params.search, mode: "insensitive" } }, { entityType: { contains: params.search, mode: "insensitive" } }, ]; } const entries = await ctx.db.auditLog.findMany({ where, include: { user: { select: { id: true, name: true, email: true } }, }, orderBy: { createdAt: "desc" }, take: limit, }); if (entries.length === 0) { return `No changes found in the last ${daysBack} days matching your criteria.`; } const lines = entries.map((e) => { const who = e.user?.name ?? e.user?.email ?? "System"; const when = e.createdAt.toISOString().slice(0, 16).replace("T", " "); const name = e.entityName ? ` "${e.entityName}"` : ""; const summary = e.summary ? ` — ${e.summary}` : ""; return `[${when}] ${who}: ${e.action} ${e.entityType}${name}${summary}`; }); return `Found ${entries.length} changes (last ${daysBack} days):\n\n${lines.join("\n")}`; }, async get_entity_timeline(params: { entityType: string; entityId: string; limit?: number; }, ctx: ToolContext) { const limit = Math.min(params.limit ?? 50, 200); const entries = await ctx.db.auditLog.findMany({ where: { entityType: params.entityType, entityId: params.entityId, }, include: { user: { select: { id: true, name: true, email: true } }, }, orderBy: { createdAt: "desc" }, take: limit, }); if (entries.length === 0) { return `No change history found for ${params.entityType} ${params.entityId}.`; } const entityName = entries[0]?.entityName ?? params.entityId; const lines = entries.map((e) => { const who = e.user?.name ?? e.user?.email ?? "System"; const when = e.createdAt.toISOString().slice(0, 16).replace("T", " "); const summary = e.summary ?? e.action; const source = e.source ? ` (via ${e.source})` : ""; // Include changed fields summary for UPDATE actions const changes = e.changes as Record | null; const diff = changes?.diff as Record | undefined; let diffSummary = ""; if (diff && Object.keys(diff).length > 0) { const fields = Object.entries(diff) .slice(0, 3) .map(([k, v]) => `${k}: ${JSON.stringify(v.old)} → ${JSON.stringify(v.new)}`) .join("; "); diffSummary = `\n Changed: ${fields}`; if (Object.keys(diff).length > 3) { diffSummary += ` (+${Object.keys(diff).length - 3} more)`; } } return `[${when}] ${who}${source}: ${summary}${diffSummary}`; }); return `Change history for ${params.entityType} "${entityName}" (${entries.length} entries):\n\n${lines.join("\n")}`; }, async get_shoring_ratio(params: { projectId: string }, ctx: ToolContext) { const sel = { id: true, name: true, shortCode: true, shoringThreshold: true, onshoreCountryCode: true } as const; let project = await ctx.db.project.findUnique({ where: { id: params.projectId }, select: sel }); if (!project) { project = await ctx.db.project.findUnique({ where: { shortCode: params.projectId }, select: sel }); } if (!project) return { error: `Project not found: ${params.projectId}` }; const assignments = await ctx.db.assignment.findMany({ where: { projectId: project.id, status: { not: "CANCELLED" } }, include: { resource: { include: { country: { select: { code: true } } } } }, }); if (assignments.length === 0) { return `Project "${project.name}" (${project.shortCode}): No active assignments — shoring ratio not available.`; } const { calculateShoringRatio: calcShoring } = await import("@planarchy/engine/allocation"); const mapped = assignments.map((a) => { const start = new Date(a.startDate); const end = new Date(a.endDate); const diffDays = Math.max(1, Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1); const workingDays = Math.max(1, Math.round(diffDays / 7 * 5)); return { resourceId: a.resourceId, countryCode: a.resource.country?.code ?? null, hoursPerDay: a.hoursPerDay, workingDays, }; }); const threshold = project.shoringThreshold ?? 55; const onshoreCode = project.onshoreCountryCode ?? "DE"; const result = calcShoring(mapped, threshold, onshoreCode); const countryParts = Object.entries(result.byCountry) .sort((a, b) => b[1].pct - a[1].pct) .map(([code, info]) => `${code} ${info.pct}% (${info.resourceCount} people)`) .join(", "); const warning = result.isAboveThreshold ? ` -- Above ${threshold}% offshore threshold!` : ""; return `Project "${project.name}" (${project.shortCode}): ${result.onshoreRatio}% onshore (${onshoreCode}), ${result.offshoreRatio}% offshore. Breakdown: ${countryParts}.${warning}${result.unknownCount > 0 ? ` (${result.unknownCount} resource(s) without country)` : ""}`; }, }; // ─── Executor ─────────────────────────────────────────────────────────────── export interface ToolAction { type: string; url?: string; scope?: string[]; description?: string; } export interface ToolResult { content: string; action?: ToolAction; } export async function executeTool( name: string, args: string, ctx: ToolContext, ): Promise { const executor = executors[name as keyof typeof executors]; if (!executor) return { content: JSON.stringify({ error: `Unknown tool: ${name}` }) }; try { const params = JSON.parse(args); const result = await executor(params, ctx); // Detect action payloads (e.g. navigation, invalidation) if (result && typeof result === "object" && "__action" in (result as Record)) { const actionResult = result as Record; const actionType = actionResult.__action as string; if (actionType === "navigate") { const url = actionResult.url as string; const desc = (actionResult.description as string | undefined) ?? url; return { content: JSON.stringify({ description: desc }), action: { type: "navigate", url, description: desc }, }; } if (actionType === "invalidate") { const scope = actionResult.scope as string[]; // Strip __action, scope, and large data from the result sent back to the AI const { __action: _, scope: _s, coverImageUrl: _img, ...rest } = actionResult; const content = JSON.stringify(rest); return { content: content.length > 4000 ? content.slice(0, 4000) + '..."' : content, action: { type: "invalidate", scope }, }; } } // Cap tool result size to prevent oversized OpenAI conversation payloads const content = typeof result === "string" ? result : JSON.stringify(result); return { content: content.length > 8000 ? content.slice(0, 8000) + '..."' : content }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); return { content: JSON.stringify({ error: msg }) }; } }