92a982b151
Engine (packages/engine): - calculateShoringRatio() pure function: onshore/offshore hours, country breakdown, threshold check, weighted by hours not headcount - 12 unit tests: empty, 100% onshore/offshore, mixed ratios, custom threshold, case-insensitive, unknown country, FTE weighting Schema: - Project.shoringThreshold (default 55%) — per-project configurable - Project.onshoreCountryCode (default "DE") — configurable onshore country API (project router): - getShoringRatio query: loads assignments with resource.country, computes ratio, returns full breakdown - update mutation: accepts shoringThreshold + onshoreCountryCode UI: - ShoringIndicator: stacked horizontal bar with country segments, severity badge (green/yellow/red), hover tooltip, dark theme - ShoringBadge: mini colored dot + % for project list column - ProjectModal: "Max Offshore %" number input - Project detail: indicator after budget status card - Project list: "Shoring" column (default hidden, toggleable) AI Assistant: - get_shoring_ratio tool: human-readable breakdown with threshold alert Colors: green (<threshold-10), yellow (threshold-10 to threshold), red (>=threshold) Default: 55% offshore threshold, "DE" as onshore country Co-Authored-By: claude-flow <ruv@ruv.net>
5645 lines
205 KiB
TypeScript
5645 lines
205 KiB
TypeScript
/**
|
||
* 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<PermissionKey>;
|
||
};
|
||
|
||
interface ToolDef {
|
||
type: "function";
|
||
function: {
|
||
name: string;
|
||
description: string;
|
||
parameters: Record<string, unknown>;
|
||
};
|
||
}
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
type ToolExecutor = (params: any, ctx: ToolContext) => Promise<unknown>;
|
||
|
||
// ─── 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<string, any> = {};
|
||
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<string, any> = {};
|
||
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<string, unknown> = { 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<string, any> = {};
|
||
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<string, any> = {
|
||
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<string, never>, 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<string, never>, 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<string, number> = {};
|
||
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<string, any> = {};
|
||
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<string, any> = { 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<string, any> = { 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<string, string> = {
|
||
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<string, any> = {
|
||
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<string, any> = {
|
||
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<string, unknown> = {};
|
||
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<string, unknown> = {};
|
||
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<typeof ctx.db.project.create>[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<string, any> = {
|
||
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<typeof ctx.db.resource.create>[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<string, any> = { 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<string, any> = {};
|
||
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<string, any> = { 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<string, any> = { 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<string, never>, 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<string, any> = { 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<string, unknown> = {};
|
||
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<string, unknown> = {};
|
||
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<string, never>, 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<string, never>, 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<string, never>, 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<string, never>, 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<string, never>, 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<string, never>, 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<string, any> = {};
|
||
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<string, any> = {};
|
||
|
||
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<string, number> = {};
|
||
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<string, unknown> = {};
|
||
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<string, any> = {
|
||
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<string, any> = { 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<string, never>, 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<string, number>();
|
||
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<string, number> | 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<string, never>, 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<string, { needed: number; filled: number }>();
|
||
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<string, number>();
|
||
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<string, number>();
|
||
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<string, never>, 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<string, never>, 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<string, never>, 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<string, number>();
|
||
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<string, number> | 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<string, Array<{ key: string; prismaPath?: string }>> = {
|
||
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<string, any> = { 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<string, Set<string>> = {
|
||
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<string, any> = {};
|
||
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<string, unknown>) => {
|
||
const flat: Record<string, unknown> = {};
|
||
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<string, unknown>)) {
|
||
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<string, any> = { 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<string>();
|
||
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<string, unknown>) ?? {};
|
||
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<string, unknown> = {
|
||
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<string, unknown> | null;
|
||
const diff = changes?.diff as Record<string, { old: unknown; new: unknown }> | 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<ToolResult> {
|
||
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<string, unknown>)) {
|
||
const actionResult = result as Record<string, unknown>;
|
||
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 }) };
|
||
}
|
||
}
|