import type { TRPCContext } from "../../trpc.js"; import { AllocationStatus, PermissionKey, UpdateAssignmentSchema } from "@nexus/shared"; import { SystemRole } from "@nexus/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { fmtEur } from "../../lib/format-utils.js"; import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js"; type AssistantToolErrorResult = { error: string }; type ResolvedProject = { id: string; name: string; shortCode: string; }; type ResolvedResource = { id: string; displayName: string; }; type AllocationPlanningDeps = { assertPermission: (ctx: ToolContext, perm: PermissionKey) => void; createAllocationCaller: (ctx: TRPCContext) => { listView: (params: { resourceId?: string; projectId?: string; status?: AllocationStatus; }) => Promise<{ assignments: Array<{ id: string; status: string; hoursPerDay: number; dailyCostCents?: number | null; startDate: Date | string; endDate: Date | string; role?: string | null; roleEntity?: { name?: string | null } | null; resource?: { displayName?: string | null; eid?: string | null } | null; project?: { name?: string | null; shortCode?: string | null } | null; }>; }>; ensureAssignment: (params: { resourceId: string; projectId: string; startDate: Date; endDate: Date; hoursPerDay: number; role?: string; }) => Promise<{ action: "created" | "reactivated"; assignment: { id: string; status: string; }; }>; resolveAssignment: (params: { assignmentId?: string; resourceId?: string; projectId?: string; startDate?: Date; endDate?: Date; selectionMode: "WINDOW" | "EXACT_START"; excludeCancelled?: boolean; }) => Promise<{ id: string; status: string; startDate: Date; endDate: Date; resource: { displayName: string }; project: { name: string; shortCode: string }; }>; updateAssignment: (params: { id: string; data: z.input; }) => Promise; }; createTimelineCaller: (ctx: TRPCContext) => { getBudgetStatus: (params: { projectId: string }) => Promise<{ projectName: string; projectCode: string; budgetCents: number; confirmedCents: number; proposedCents: number; allocatedCents: number; remainingCents: number; utilizationPercent: number; winProbabilityWeightedCents: number; totalAllocations: number; }>; }; createScopedCallerContext: (ctx: ToolContext) => TRPCContext; resolveProjectIdentifier: ( ctx: ToolContext, identifier: string, ) => Promise; resolveResourceIdentifier: ( ctx: ToolContext, identifier: string, ) => Promise; parseIsoDate: (value: string, fieldName: string) => Date; parseOptionalIsoDate: (value: string | undefined, fieldName: string) => Date | undefined; fmtDate: (value: Date | null | undefined) => string | null; toAssistantAllocationNotFoundError: (error: unknown) => unknown; }; export const allocationPlanningReadToolDefinitions: ToolDef[] = withToolAccess( [ { 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"], }, }, }, ], { list_allocations: { requiresPlanningRead: true, }, get_budget_status: { allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], requiresCostView: true, }, }, ); export const allocationPlanningMutationToolDefinitions: ToolDef[] = withToolAccess( [ { 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_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"], }, }, }, ], { create_allocation: { requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS], }, cancel_allocation: { requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS], }, update_allocation_status: { requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS], }, }, ); export function createAllocationPlanningExecutors( deps: AllocationPlanningDeps, ): Record { return { async list_allocations( params: { resourceId?: string; projectId?: string; resourceName?: string; projectCode?: string; status?: string; limit?: number; }, ctx: ToolContext, ) { const caller = deps.createAllocationCaller(deps.createScopedCallerContext(ctx)); const status = params.status && Object.values(AllocationStatus).includes(params.status as AllocationStatus) ? (params.status as AllocationStatus) : undefined; const readModel = await caller.listView({ ...(params.resourceId ? { resourceId: params.resourceId } : {}), ...(params.projectId ? { projectId: params.projectId } : {}), ...(status ? { status } : {}), }); const resourceNameQuery = params.resourceName?.trim().toLowerCase(); const projectCodeQuery = params.projectCode?.trim().toLowerCase(); const limit = Math.min(params.limit ?? 30, 50); return readModel.assignments .filter((assignment) => { if ( resourceNameQuery && !assignment.resource?.displayName?.toLowerCase().includes(resourceNameQuery) ) { return false; } if ( projectCodeQuery && !assignment.project?.shortCode?.toLowerCase().includes(projectCodeQuery) ) { return false; } return true; }) .slice(0, limit) .map((assignment) => ({ id: assignment.id, resource: assignment.resource?.displayName ?? "Unknown", resourceEid: assignment.resource?.eid ?? null, project: assignment.project?.name ?? "Unknown", projectCode: assignment.project?.shortCode ?? null, role: assignment.role ?? assignment.roleEntity?.name ?? null, status: assignment.status, hoursPerDay: assignment.hoursPerDay, dailyCost: assignment.dailyCostCents == null ? null : fmtEur(assignment.dailyCostCents), start: deps.fmtDate(new Date(assignment.startDate)), end: deps.fmtDate(new Date(assignment.endDate)), })); }, async get_budget_status(params: { projectId: string }, ctx: ToolContext) { const project = await deps.resolveProjectIdentifier(ctx, params.projectId); if ("error" in project) { return project; } const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx)); const budgetStatus = await caller.getBudgetStatus({ projectId: project.id }); if (budgetStatus.budgetCents <= 0) { return { project: budgetStatus.projectName, code: budgetStatus.projectCode, budget: "Not set", note: "No budget defined for this project", totalAllocations: budgetStatus.totalAllocations, }; } return { project: budgetStatus.projectName, code: budgetStatus.projectCode, budget: fmtEur(budgetStatus.budgetCents), confirmed: fmtEur(budgetStatus.confirmedCents), proposed: fmtEur(budgetStatus.proposedCents), allocated: fmtEur(budgetStatus.allocatedCents), remaining: fmtEur(budgetStatus.remainingCents), utilization: `${budgetStatus.utilizationPercent.toFixed(1)}%`, winWeighted: fmtEur(budgetStatus.winProbabilityWeightedCents), }; }, async create_allocation( params: { resourceId: string; projectId: string; startDate: string; endDate: string; hoursPerDay: number; role?: string; }, ctx: ToolContext, ) { deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); const [resource, project] = await Promise.all([ deps.resolveResourceIdentifier(ctx, params.resourceId), deps.resolveProjectIdentifier(ctx, params.projectId), ]); if ("error" in resource) { return resource; } if ("error" in project) { return project; } const caller = deps.createAllocationCaller(deps.createScopedCallerContext(ctx)); try { const result = await caller.ensureAssignment({ resourceId: resource.id, projectId: project.id, startDate: deps.parseIsoDate(params.startDate, "startDate"), endDate: deps.parseIsoDate(params.endDate, "endDate"), hoursPerDay: params.hoursPerDay, ...(params.role ? { role: params.role } : {}), }); return { __action: "invalidate", scope: ["allocation", "timeline"], success: true, message: `${result.action === "reactivated" ? "Reactivated" : "Created"} allocation: ${resource.displayName} → ${project.name} (${project.shortCode}), ${params.hoursPerDay}h/day, ${params.startDate} to ${params.endDate}`, allocationId: result.assignment.id, status: result.assignment.status, }; } catch (error) { if (error instanceof TRPCError && error.code === "CONFLICT") { return { error: "Allocation already exists for this resource/project/dates. No new allocation created.", }; } throw error; } }, async cancel_allocation( params: { allocationId?: string; resourceName?: string; projectCode?: string; startDate?: string; endDate?: string; }, ctx: ToolContext, ) { deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); const caller = deps.createAllocationCaller(deps.createScopedCallerContext(ctx)); let resourceId: string | undefined; let projectId: string | undefined; if (!params.allocationId && params.resourceName && params.projectCode) { const [resource, project] = await Promise.all([ deps.resolveResourceIdentifier(ctx, params.resourceName), deps.resolveProjectIdentifier(ctx, params.projectCode), ]); if ("error" in resource) { return resource; } if ("error" in project) { return project; } resourceId = resource.id; projectId = project.id; } const startDate = deps.parseOptionalIsoDate(params.startDate, "startDate"); const endDate = deps.parseOptionalIsoDate(params.endDate, "endDate"); let assignment; try { assignment = await caller.resolveAssignment({ ...(params.allocationId ? { assignmentId: params.allocationId } : {}), ...(resourceId ? { resourceId } : {}), ...(projectId ? { projectId } : {}), ...(startDate ? { startDate } : {}), ...(endDate ? { endDate } : {}), selectionMode: "WINDOW", excludeCancelled: true, }); } catch (error) { const mapped = deps.toAssistantAllocationNotFoundError(error); if (mapped) { return mapped; } throw error; } try { await caller.updateAssignment({ id: assignment.id, data: UpdateAssignmentSchema.parse({ status: AllocationStatus.CANCELLED }), }); } catch (error) { const mapped = deps.toAssistantAllocationNotFoundError(error); if (mapped) { return mapped; } throw error; } return { __action: "invalidate", scope: ["allocation", "timeline"], success: true, message: `Cancelled allocation: ${assignment.resource.displayName} → ${assignment.project.name} (${assignment.project.shortCode}), ${deps.fmtDate(assignment.startDate)} to ${deps.fmtDate(assignment.endDate)}`, }; }, async update_allocation_status( params: { allocationId?: string; resourceName?: string; projectCode?: string; startDate?: string; newStatus: string; }, ctx: ToolContext, ) { deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); const validStatuses = ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED", "CANCELLED"]; if (!validStatuses.includes(params.newStatus)) { return { error: `Invalid status: ${params.newStatus}. Valid: ${validStatuses.join(", ")}` }; } const caller = deps.createAllocationCaller(deps.createScopedCallerContext(ctx)); let resourceId: string | undefined; let projectId: string | undefined; if (!params.allocationId && params.resourceName && params.projectCode) { const [resource, project] = await Promise.all([ deps.resolveResourceIdentifier(ctx, params.resourceName), deps.resolveProjectIdentifier(ctx, params.projectCode), ]); if ("error" in resource) { return resource; } if ("error" in project) { return project; } resourceId = resource.id; projectId = project.id; } const startDate = deps.parseOptionalIsoDate(params.startDate, "startDate"); let assignment; try { assignment = await caller.resolveAssignment({ ...(params.allocationId ? { assignmentId: params.allocationId } : {}), ...(resourceId ? { resourceId } : {}), ...(projectId ? { projectId } : {}), ...(startDate ? { startDate } : {}), selectionMode: "EXACT_START", }); } catch (error) { const mapped = deps.toAssistantAllocationNotFoundError(error); if (mapped) { return mapped; } throw error; } const oldStatus = assignment.status; try { await caller.updateAssignment({ id: assignment.id, data: UpdateAssignmentSchema.parse({ status: params.newStatus as AllocationStatus, }), }); } catch (error) { const mapped = deps.toAssistantAllocationNotFoundError(error); if (mapped) { return mapped; } throw error; } return { __action: "invalidate", scope: ["allocation", "timeline"], success: true, message: `Updated allocation status: ${assignment.resource.displayName} → ${assignment.project.name} (${assignment.project.shortCode}), ${deps.fmtDate(assignment.startDate)} to ${deps.fmtDate(assignment.endDate)}: ${oldStatus} → ${params.newStatus}`, }; }, }; }