rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)
CI / Architecture Guardrails (push) Successful in 2m38s
CI / Assistant Split Regression (push) Successful in 3m33s
CI / Typecheck (push) Successful in 3m51s
CI / Lint (push) Successful in 5m2s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled

rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)

Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
This commit was merged in pull request #61.
This commit is contained in:
2026-05-21 16:28:40 +02:00
committed by Hartmut
parent d9a7ec0338
commit b41c1d2501
943 changed files with 24548 additions and 16832 deletions
@@ -1,9 +1,9 @@
import { validateAvailability } from "@capakraken/engine";
import { validateAvailability } from "@nexus/engine";
import type {
AllocationConflictCheckResult,
AllocationStatus,
WeekdayAvailability,
} from "@capakraken/shared";
} from "@nexus/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { managerProcedure } from "../trpc.js";
@@ -8,13 +8,9 @@ import {
loadAllocationEntry,
updateAllocationEntry,
updateAssignment,
} from "@capakraken/application";
import type { PrismaClient } from "@capakraken/db";
import {
AllocationStatus,
CreateAllocationSchema,
UpdateAllocationSchema,
} from "@capakraken/shared";
} from "@nexus/application";
import type { PrismaClient } from "@nexus/db";
import { AllocationStatus, CreateAllocationSchema, UpdateAllocationSchema } from "@nexus/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../../db/helpers.js";
@@ -178,7 +174,7 @@ export async function updateAllocationWithAudit(
changes: {
before: existing.entry,
after: updatedAllocation,
} as unknown as import("@capakraken/db").Prisma.InputJsonValue,
} as unknown as import("@nexus/db").Prisma.InputJsonValue,
},
});
@@ -205,7 +201,7 @@ export async function deleteAssignmentWithAudit(db: AllocationMutationDb, id: st
entityType: "Assignment",
entityId: id,
action: "DELETE",
changes: { before: existing } as unknown as import("@capakraken/db").Prisma.InputJsonValue,
changes: { before: existing } as unknown as import("@nexus/db").Prisma.InputJsonValue,
},
});
});
@@ -226,7 +222,7 @@ export async function deleteAllocationWithAudit(db: AllocationMutationDb, id: st
action: "DELETE",
changes: {
before: existing.entry,
} as unknown as import("@capakraken/db").Prisma.InputJsonValue,
} as unknown as import("@nexus/db").Prisma.InputJsonValue,
},
});
});
@@ -253,7 +249,7 @@ export async function batchDeleteAllocationsWithAudit(db: AllocationMutationDb,
id: allocation.entry.id,
projectId: allocation.projectId,
})),
} as unknown as import("@capakraken/db").Prisma.InputJsonValue,
} as unknown as import("@nexus/db").Prisma.InputJsonValue,
},
});
});
@@ -1,4 +1,4 @@
import { createAssignment, updateAssignment } from "@capakraken/application";
import { createAssignment, updateAssignment } from "@nexus/application";
import {
AllocationStatus,
CreateAllocationSchema,
@@ -6,13 +6,13 @@ import {
PermissionKey,
UpdateAllocationSchema,
UpdateAssignmentSchema,
} from "@capakraken/shared";
} from "@nexus/shared";
import type {
CreateAllocationInput,
CreateAssignmentInput,
UpdateAllocationInput,
UpdateAssignmentInput,
} from "@capakraken/shared";
} from "@nexus/shared";
import { z } from "zod";
import { findUniqueOrThrow } from "../../db/helpers.js";
import {
@@ -1,4 +1,4 @@
import type { WeekdayAvailability } from "@capakraken/shared";
import type { WeekdayAvailability } from "@nexus/shared";
import { TRPCError } from "@trpc/server";
import {
calculateEffectiveAvailableHours,
@@ -10,7 +10,10 @@ import {
import { averagePerWorkingDay, round1, toIsoDate } from "./shared.js";
export async function buildResourceAvailabilityView(
db: Pick<import("@capakraken/db").PrismaClient, "resource" | "assignment" | "vacation" | "holidayCalendar">,
db: Pick<
import("@nexus/db").PrismaClient,
"resource" | "assignment" | "vacation" | "holidayCalendar"
>,
input: {
resourceId: string;
startDate: Date;
@@ -21,7 +24,10 @@ export async function buildResourceAvailabilityView(
const resource = await db.resource.findUnique({
where: { id: input.resourceId },
select: {
id: true, displayName: true, eid: true, fte: true,
id: true,
displayName: true,
eid: true,
fte: true,
availability: true,
countryId: true,
federalState: true,
@@ -54,7 +60,11 @@ export async function buildResourceAvailabilityView(
endDate: { gte: input.startDate },
},
select: {
id: true, startDate: true, endDate: true, hoursPerDay: true, status: true,
id: true,
startDate: true,
endDate: true,
hoursPerDay: true,
status: true,
project: { select: { name: true, shortCode: true } },
},
orderBy: { startDate: "asc" },
@@ -81,15 +91,17 @@ export async function buildResourceAvailabilityView(
const contexts = await loadResourceDailyAvailabilityContexts(
db,
[{
id: resource.id,
availability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
}],
[
{
id: resource.id,
availability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
},
],
input.startDate,
input.endDate,
);
@@ -151,9 +163,7 @@ export async function buildResourceAvailabilityView(
periodEnd: input.endDate,
context,
});
const dailyCapacity = totalWorkingDays > 0
? round1(totalPeriodCapacity / totalWorkingDays)
: 0;
const dailyCapacity = totalWorkingDays > 0 ? round1(totalPeriodCapacity / totalWorkingDays) : 0;
return {
resource: { id: resource.id, name: resource.displayName, eid: resource.eid },
@@ -164,9 +174,8 @@ export async function buildResourceAvailabilityView(
conflictDays,
totalAvailableHours: round1(totalAvailableHours),
totalRequestedHours,
coveragePercent: totalRequestedHours > 0
? Math.round((totalAvailableHours / totalRequestedHours) * 100)
: 0,
coveragePercent:
totalRequestedHours > 0 ? Math.round((totalAvailableHours / totalRequestedHours) * 100) : 0,
existingAssignments: existingAssignments.map((assignment) => ({
project: assignment.project.name,
code: assignment.project.shortCode,
@@ -191,9 +200,10 @@ export function buildResourceAvailabilitySummary(
availability: Awaited<ReturnType<typeof buildResourceAvailabilityView>>,
period: { startDate: Date; endDate: Date },
) {
const periodAvailableHours = availability.totalRequestedHours > 0
? round1(availability.dailyCapacity * availability.totalWorkingDays)
: 0;
const periodAvailableHours =
availability.totalRequestedHours > 0
? round1(availability.dailyCapacity * availability.totalWorkingDays)
: 0;
const periodRemainingHours = round1(availability.totalAvailableHours);
const periodBookedHours = round1(Math.max(0, periodAvailableHours - periodRemainingHours));
@@ -209,11 +219,16 @@ export function buildResourceAvailabilitySummary(
currentBookedHoursPerDay: round1(
Math.max(
0,
availability.dailyCapacity - availability.totalAvailableHours / Math.max(availability.totalWorkingDays, 1),
availability.dailyCapacity -
availability.totalAvailableHours / Math.max(availability.totalWorkingDays, 1),
),
),
availableHoursPerDay: averagePerWorkingDay(availability.totalAvailableHours, availability.totalWorkingDays),
isFullyAvailable: availability.existingAssignments.length === 0 && availability.vacations.length === 0,
availableHoursPerDay: averagePerWorkingDay(
availability.totalAvailableHours,
availability.totalWorkingDays,
),
isFullyAvailable:
availability.existingAssignments.length === 0 && availability.vacations.length === 0,
existingAllocations: availability.existingAssignments.map((assignment) => ({
project: `${assignment.project} (${assignment.code})`,
hoursPerDay: assignment.hoursPerDay,
+4 -4
View File
@@ -1,9 +1,9 @@
import type { Prisma } from "@capakraken/db";
import type { Prisma } from "@nexus/db";
import {
deleteDemandRequirement,
fillOpenDemand,
updateDemandRequirement,
} from "@capakraken/application";
} from "@nexus/application";
import {
BoundedJsonRecord,
CreateDemandRequirementSchema,
@@ -11,12 +11,12 @@ import {
FillOpenDemandByAllocationSchema,
PermissionKey,
UpdateDemandRequirementSchema,
} from "@capakraken/shared";
} from "@nexus/shared";
import type {
CreateDemandRequirementInput,
FillDemandRequirementInput,
FillOpenDemandByAllocationInput,
} from "@capakraken/shared";
} from "@nexus/shared";
import { z } from "zod";
import { findUniqueOrThrow } from "../../db/helpers.js";
import {
@@ -1,10 +1,7 @@
import type { PrismaClient } from "@capakraken/db";
import { createDemandRequirement, fillDemandRequirement } from "@capakraken/application";
import type {
CreateDemandRequirementSchema,
FillDemandRequirementSchema,
} from "@capakraken/shared";
import { buildTaskAction } from "@capakraken/shared";
import type { PrismaClient } from "@nexus/db";
import { createDemandRequirement, fillDemandRequirement } from "@nexus/application";
import type { CreateDemandRequirementSchema, FillDemandRequirementSchema } from "@nexus/shared";
import { buildTaskAction } from "@nexus/shared";
import type { z } from "zod";
import { checkBudgetThresholds } from "../../lib/budget-alerts.js";
import { generateAutoSuggestions } from "../../lib/auto-staffing.js";
+1 -1
View File
@@ -1,4 +1,4 @@
import { AllocationStatus } from "@capakraken/shared";
import { AllocationStatus } from "@nexus/shared";
import { z } from "zod";
import { findUniqueOrThrow } from "../../db/helpers.js";
import { anonymizeResource, getAnonymizationDirectory } from "../../lib/anonymization.js";
+7 -4
View File
@@ -1,7 +1,11 @@
import { AllocationStatus, UpdateAllocationSchema } from "@capakraken/shared";
export { toIsoDate, round1, averagePerWorkingDay } from "@capakraken/shared";
import { AllocationStatus, UpdateAllocationSchema } from "@nexus/shared";
export { toIsoDate, round1, averagePerWorkingDay } from "@nexus/shared";
import { z } from "zod";
import { PROJECT_BRIEF_SELECT, RESOURCE_BRIEF_SELECT, ROLE_BRIEF_SELECT } from "../../db/selects.js";
import {
PROJECT_BRIEF_SELECT,
RESOURCE_BRIEF_SELECT,
ROLE_BRIEF_SELECT,
} from "../../db/selects.js";
export const DEMAND_INCLUDE = {
project: { select: PROJECT_BRIEF_SELECT },
@@ -64,4 +68,3 @@ export type CreateDemandDraftInput = {
budgetCents?: number | undefined;
metadata?: Record<string, unknown> | undefined;
};
+44 -19
View File
@@ -1,10 +1,18 @@
import { buildSplitAllocationReadModel, loadAllocationEntry } from "@capakraken/application";
import { AllocationStatus, CreateDemandRequirementSchema } from "@capakraken/shared";
import { buildSplitAllocationReadModel, loadAllocationEntry } from "@nexus/application";
import { AllocationStatus, CreateDemandRequirementSchema } from "@nexus/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../../db/helpers.js";
import { anonymizeResource, getAnonymizationDirectory } from "../../lib/anonymization.js";
import { ASSIGNMENT_INCLUDE, type AllocationEntryUpdateInput, type AllocationListFilters, type AssignmentResolutionInput, type CreateDemandDraftInput, DEMAND_INCLUDE, toIsoDate } from "./shared.js";
import {
ASSIGNMENT_INCLUDE,
type AllocationEntryUpdateInput,
type AllocationListFilters,
type AssignmentResolutionInput,
type CreateDemandDraftInput,
DEMAND_INCLUDE,
toIsoDate,
} from "./shared.js";
export function toDemandRequirementUpdateInput(input: AllocationEntryUpdateInput) {
return {
@@ -38,7 +46,10 @@ export function toAssignmentUpdateInput(input: AllocationEntryUpdateInput) {
}
export async function loadAllocationReadModel(
db: Pick<import("@capakraken/db").PrismaClient, "demandRequirement" | "assignment" | "systemSettings" | "resource">,
db: Pick<
import("@nexus/db").PrismaClient,
"demandRequirement" | "assignment" | "systemSettings" | "resource"
>,
input: AllocationListFilters,
) {
const [demandRequirements, assignments] = await Promise.all([
@@ -64,12 +75,21 @@ export async function loadAllocationReadModel(
]);
const readModel = buildSplitAllocationReadModel({ demandRequirements, assignments });
const directory = await getAnonymizationDirectory(db as import("@capakraken/db").PrismaClient);
const directory = await getAnonymizationDirectory(db as import("@nexus/db").PrismaClient);
if (!directory) {
return readModel;
}
function anonymizeAllocation<T extends { resource?: { id: string; eid?: string | null; displayName?: string | null; email?: string | null } | null }>(allocation: T): T {
function anonymizeAllocation<
T extends {
resource?: {
id: string;
eid?: string | null;
displayName?: string | null;
email?: string | null;
} | null;
},
>(allocation: T): T {
if (!allocation.resource) {
return allocation;
}
@@ -85,7 +105,7 @@ export async function loadAllocationReadModel(
}
export async function findAllocationEntryOrNull(
db: Pick<import("@capakraken/db").PrismaClient, "demandRequirement" | "assignment">,
db: Pick<import("@nexus/db").PrismaClient, "demandRequirement" | "assignment">,
id: string,
) {
try {
@@ -99,7 +119,9 @@ export async function findAllocationEntryOrNull(
}
}
export function buildCreateDemandRequirementInput(input: CreateDemandDraftInput): z.infer<typeof CreateDemandRequirementSchema> {
export function buildCreateDemandRequirementInput(
input: CreateDemandDraftInput,
): z.infer<typeof CreateDemandRequirementSchema> {
return {
projectId: input.projectId,
startDate: input.startDate,
@@ -116,7 +138,7 @@ export function buildCreateDemandRequirementInput(input: CreateDemandDraftInput)
}
export async function getDemandRequirementByIdOrThrow(
db: Pick<import("@capakraken/db").PrismaClient, "demandRequirement">,
db: Pick<import("@nexus/db").PrismaClient, "demandRequirement">,
id: string,
) {
return findUniqueOrThrow(
@@ -129,7 +151,7 @@ export async function getDemandRequirementByIdOrThrow(
}
export async function resolveAssignmentBySelection(
db: Pick<import("@capakraken/db").PrismaClient, "assignment">,
db: Pick<import("@nexus/db").PrismaClient, "assignment">,
input: AssignmentResolutionInput,
) {
if (input.assignmentId) {
@@ -159,16 +181,19 @@ export async function resolveAssignmentBySelection(
orderBy: { startDate: "asc" },
});
const matchingAssignment = assignments
.filter((assignment) => {
if (input.selectionMode === "WINDOW") {
return (!input.startDate || assignment.startDate >= input.startDate)
&& (!input.endDate || assignment.endDate <= input.endDate);
}
const matchingAssignment =
assignments
.filter((assignment) => {
if (input.selectionMode === "WINDOW") {
return (
(!input.startDate || assignment.startDate >= input.startDate) &&
(!input.endDate || assignment.endDate <= input.endDate)
);
}
return !input.startDate || toIsoDate(assignment.startDate) === toIsoDate(input.startDate);
})
.sort((left, right) => right.startDate.getTime() - left.startDate.getTime())[0] ?? null;
return !input.startDate || toIsoDate(assignment.startDate) === toIsoDate(input.startDate);
})
.sort((left, right) => right.startDate.getTime() - left.startDate.getTime())[0] ?? null;
if (!matchingAssignment) {
throw new TRPCError({ code: "NOT_FOUND", message: "Assignment not found" });
+69 -64
View File
@@ -1,4 +1,4 @@
import { AssistantApprovalStatus, Prisma, type PrismaClient } from "@capakraken/db";
import { AssistantApprovalStatus, Prisma, type PrismaClient } from "@nexus/db";
import { logger } from "../lib/logger.js";
import { buildApprovalSummary } from "./assistant-confirmation.js";
@@ -96,12 +96,8 @@ function isAssistantApprovalTableMissingError(error: unknown): boolean {
const code = typeof candidate.code === "string" ? candidate.code : "";
if (code !== "P2021") return false;
const message = typeof candidate.message === "string"
? candidate.message
: "";
const metaTable = typeof candidate.meta?.table === "string"
? candidate.meta.table
: "";
const message = typeof candidate.message === "string" ? candidate.message : "";
const metaTable = typeof candidate.meta?.table === "string" ? candidate.meta.table : "";
return metaTable.includes("assistant_approvals") || message.includes("assistant_approvals");
}
@@ -141,29 +137,32 @@ export async function listPendingAssistantApprovals(
db: AssistantApprovalStore,
userId: string,
): Promise<PendingAssistantApproval[]> {
return withAssistantApprovalFallback(async () => {
await db.assistantApproval.updateMany({
where: {
userId,
status: AssistantApprovalStatus.PENDING,
expiresAt: { lte: new Date() },
},
data: {
status: AssistantApprovalStatus.EXPIRED,
},
});
return withAssistantApprovalFallback(
async () => {
await db.assistantApproval.updateMany({
where: {
userId,
status: AssistantApprovalStatus.PENDING,
expiresAt: { lte: new Date() },
},
data: {
status: AssistantApprovalStatus.EXPIRED,
},
});
const approvals = await db.assistantApproval.findMany({
where: {
userId,
status: AssistantApprovalStatus.PENDING,
expiresAt: { gt: new Date() },
},
orderBy: { createdAt: "desc" },
});
const approvals = await db.assistantApproval.findMany({
where: {
userId,
status: AssistantApprovalStatus.PENDING,
expiresAt: { gt: new Date() },
},
orderBy: { createdAt: "desc" },
});
return approvals.map(mapPendingApproval);
}, () => []);
return approvals.map(mapPendingApproval);
},
() => [],
);
}
export async function clearPendingAssistantApproval(
@@ -171,19 +170,22 @@ export async function clearPendingAssistantApproval(
userId: string,
conversationId: string,
): Promise<void> {
await withAssistantApprovalFallback(async () => {
await db.assistantApproval.updateMany({
where: {
userId,
conversationId,
status: AssistantApprovalStatus.PENDING,
},
data: {
status: AssistantApprovalStatus.CANCELLED,
cancelledAt: new Date(),
},
});
}, () => undefined);
await withAssistantApprovalFallback(
async () => {
await db.assistantApproval.updateMany({
where: {
userId,
conversationId,
status: AssistantApprovalStatus.PENDING,
},
data: {
status: AssistantApprovalStatus.CANCELLED,
cancelledAt: new Date(),
},
});
},
() => undefined,
);
}
export async function peekPendingAssistantApproval(
@@ -191,30 +193,33 @@ export async function peekPendingAssistantApproval(
userId: string,
conversationId: string,
): Promise<PendingAssistantApproval | null> {
return withAssistantApprovalFallback(async () => {
await db.assistantApproval.updateMany({
where: {
userId,
conversationId,
status: AssistantApprovalStatus.PENDING,
expiresAt: { lte: new Date() },
},
data: {
status: AssistantApprovalStatus.EXPIRED,
},
});
return withAssistantApprovalFallback(
async () => {
await db.assistantApproval.updateMany({
where: {
userId,
conversationId,
status: AssistantApprovalStatus.PENDING,
expiresAt: { lte: new Date() },
},
data: {
status: AssistantApprovalStatus.EXPIRED,
},
});
const pending = await db.assistantApproval.findFirst({
where: {
userId,
conversationId,
status: AssistantApprovalStatus.PENDING,
},
orderBy: { createdAt: "desc" },
});
if (!pending) return null;
return mapPendingApproval(pending);
}, () => null);
const pending = await db.assistantApproval.findFirst({
where: {
userId,
conversationId,
status: AssistantApprovalStatus.PENDING,
},
orderBy: { createdAt: "desc" },
});
if (!pending) return null;
return mapPendingApproval(pending);
},
() => null,
);
}
export async function consumePendingAssistantApproval(
@@ -8,10 +8,7 @@ import {
createPendingAssistantApproval,
toApprovalPayload,
} from "./assistant-approvals.js";
import {
ASSISTANT_CONFIRMATION_PREFIX,
parseToolArguments,
} from "./assistant-confirmation.js";
import { ASSISTANT_CONFIRMATION_PREFIX, parseToolArguments } from "./assistant-confirmation.js";
import {
buildAssistantChatResponse,
mergeInsights,
@@ -216,7 +213,8 @@ async function handleAssistantToolCalls(input: {
return {
response: buildAssistantChatResponse({
content: "Schreibende Assistant-Aktionen sind gerade nicht verfuegbar, weil der Bestaetigungsspeicher in der Datenbank fehlt. Bitte die CapaKraken-DB-Migration anwenden und dann erneut versuchen.",
content:
"Schreibende Assistant-Aktionen sind gerade nicht verfuegbar, weil der Bestaetigungsspeicher in der Datenbank fehlt. Bitte die Nexus-DB-Migration anwenden und dann erneut versuchen.",
insights: collectedInsights,
actions: collectedActions,
}),
@@ -4,7 +4,7 @@ import {
resolvePermissions,
type PermissionOverrides,
SystemRole,
} from "@capakraken/shared";
} from "@nexus/shared";
import { TRPCError } from "@trpc/server";
import { createHash, randomUUID } from "node:crypto";
import { z } from "zod";
@@ -1,6 +1,6 @@
import { ASSISTANT_CONFIRMATION_PREFIX } from "./assistant-confirmation.js";
export const ASSISTANT_SYSTEM_PROMPT = `Du bist der CapaKraken-Assistent — ein hilfreicher AI-Assistent für Ressourcenplanung und Projektmanagement in einer 3D-Produktionsumgebung.
export const ASSISTANT_SYSTEM_PROMPT = `Du bist der Nexus-Assistent — ein hilfreicher AI-Assistent für Ressourcenplanung und Projektmanagement in einer 3D-Produktionsumgebung.
Deine Fähigkeiten:
- Fragen über Ressourcen, Projekte, Allokationen, Budget, Urlaub, Estimates, Org-Struktur, Rollen, Blueprints, Rate Cards beantworten
@@ -1,4 +1,4 @@
import { type PermissionKey } from "@capakraken/shared";
import { type PermissionKey } from "@nexus/shared";
import { getAvailableAssistantToolsForContext } from "./assistant-tools.js";
import type { ToolDef } from "./assistant-tools/shared.js";
@@ -1,4 +1,4 @@
import type { ToolManifest } from "@capakraken/shared";
import type { ToolManifest } from "@nexus/shared";
/**
* Rich metadata registry for AI assistant tools.
@@ -67,7 +67,8 @@ export const TOOL_REGISTRY: ToolManifest[] = [
category: "staffing",
isMutation: false,
requiresAdvanced: true,
intent: "Get ranked resource suggestions for an open demand, scored by skill match, availability, and cost",
intent:
"Get ranked resource suggestions for an open demand, scored by skill match, availability, and cost",
examples: [
"Who should I assign to fill the senior designer role on Project Z?",
"Find the best available backend engineers for a 3-month engagement starting in June",
@@ -99,10 +100,7 @@ export const TOOL_REGISTRY: ToolManifest[] = [
category: "vacation",
isMutation: true,
intent: "Approve a pending vacation request",
examples: [
"Approve Alice's vacation request",
"Accept the leave request with ID abc123",
],
examples: ["Approve Alice's vacation request", "Accept the leave request with ID abc123"],
},
// ─── Reporting ─────────────────────────────────────────────────────────────
{
@@ -110,7 +108,8 @@ export const TOOL_REGISTRY: ToolManifest[] = [
category: "reporting",
isMutation: false,
requiresAdvanced: true,
intent: "Generate a chargeability report showing billable vs. non-billable utilization across resources or chapters",
intent:
"Generate a chargeability report showing billable vs. non-billable utilization across resources or chapters",
examples: [
"Show me chargeability for the engineering chapter in Q1",
"What is the billable rate for the team last month?",
+1 -1
View File
@@ -8,7 +8,7 @@
* - ./assistant-tools/access-control.ts (role/permission gating logic)
*/
import { PermissionKey } from "@capakraken/shared";
import { PermissionKey } from "@nexus/shared";
import { createReadOnlyProxy } from "../lib/read-only-prisma.js";
import { logger } from "../lib/logger.js";
@@ -1,4 +1,4 @@
import { PermissionKey, SystemRole } from "@capakraken/shared";
import { PermissionKey, SystemRole } from "@nexus/shared";
import type { ToolAccessRequirements, ToolContext, ToolDef } from "./shared.js";
import { AssistantVisibleError } from "./helpers.js";
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
import type { TRPCContext } from "../../trpc.js";
import { AllocationStatus, PermissionKey, UpdateAssignmentSchema } from "@capakraken/shared";
import { SystemRole } from "@capakraken/shared";
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";
@@ -104,132 +104,168 @@ type AllocationPlanningDeps = {
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" },
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" },
{
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"],
},
required: ["projectId"],
},
},
],
{
list_allocations: {
requiresPlanningRead: true,
},
get_budget_status: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiresCostView: true,
},
},
], {
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" },
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: "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" },
{
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" },
},
},
required: ["newStatus"],
},
},
{
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],
},
},
], {
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<string, ToolExecutor> {
return {
async list_allocations(params: {
resourceId?: string;
projectId?: string;
resourceName?: string;
projectCode?: string;
status?: string;
limit?: number;
}, ctx: ToolContext) {
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 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 } : {}),
@@ -243,14 +279,14 @@ export function createAllocationPlanningExecutors(
return readModel.assignments
.filter((assignment) => {
if (
resourceNameQuery
&& !assignment.resource?.displayName?.toLowerCase().includes(resourceNameQuery)
resourceNameQuery &&
!assignment.resource?.displayName?.toLowerCase().includes(resourceNameQuery)
) {
return false;
}
if (
projectCodeQuery
&& !assignment.project?.shortCode?.toLowerCase().includes(projectCodeQuery)
projectCodeQuery &&
!assignment.project?.shortCode?.toLowerCase().includes(projectCodeQuery)
) {
return false;
}
@@ -304,14 +340,17 @@ export function createAllocationPlanningExecutors(
};
},
async create_allocation(params: {
resourceId: string;
projectId: string;
startDate: string;
endDate: string;
hoursPerDay: number;
role?: string;
}, ctx: ToolContext) {
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),
@@ -345,19 +384,25 @@ export function createAllocationPlanningExecutors(
};
} catch (error) {
if (error instanceof TRPCError && error.code === "CONFLICT") {
return { error: "Allocation already exists for this resource/project/dates. No new allocation created." };
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) {
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));
@@ -420,13 +465,16 @@ export function createAllocationPlanningExecutors(
};
},
async update_allocation_status(params: {
allocationId?: string;
resourceName?: string;
projectCode?: string;
startDate?: string;
newStatus: string;
}, ctx: ToolContext) {
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"];
@@ -1,4 +1,4 @@
import { SystemRole } from "@capakraken/shared";
import { SystemRole } from "@nexus/shared";
import type { TRPCContext } from "../../trpc.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
@@ -45,53 +45,66 @@ type AuditHistoryDeps = {
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
};
export const auditHistoryToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
name: "query_change_history",
description: "Search the audit history for changes to projects, resources, allocations, vacations, or any entity. Reuses the real audit log list API. Controller/manager/admin roles only.",
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" },
export const auditHistoryToolDefinitions: ToolDef[] = withToolAccess(
[
{
type: "function",
function: {
name: "query_change_history",
description:
"Search the audit history for changes to projects, resources, allocations, vacations, or any entity. Reuses the real audit log list API. Controller/manager/admin roles only.",
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 audit history for a specific entity (project, resource, etc.) via the real audit API. Controller/manager/admin roles only.",
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" },
{
type: "function",
function: {
name: "get_entity_timeline",
description:
"Get the audit history for a specific entity (project, resource, etc.) via the real audit API. Controller/manager/admin roles only.",
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"],
},
required: ["entityType", "entityId"],
},
},
],
{
query_change_history: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_entity_timeline: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
},
], {
query_change_history: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_entity_timeline: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
});
);
export function createAuditHistoryExecutors(
deps: AuditHistoryDeps,
): Record<string, ToolExecutor> {
export function createAuditHistoryExecutors(deps: AuditHistoryDeps): Record<string, ToolExecutor> {
return {
async query_change_history(
params: {
@@ -1,4 +1,4 @@
import { SystemRole } from "@capakraken/shared";
import { SystemRole } from "@nexus/shared";
import type { TRPCContext } from "../../trpc.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
@@ -17,24 +17,25 @@ type ResolvedResource = {
type BlueprintsRateCardsDeps = {
createBlueprintCaller: (ctx: TRPCContext) => {
listSummaries: () => Promise<Array<{
id: string;
name: string;
_count: { projects: number };
}>>;
listSummaries: () => Promise<
Array<{
id: string;
name: string;
_count: { projects: number };
}>
>;
getByIdentifier: (params: { identifier: string }) => Promise<BlueprintRecord>;
};
createRateCardCaller: (ctx: TRPCContext) => {
list: (params: {
isActive: boolean;
search?: string;
}) => Promise<Array<{
id: string;
name: string;
effectiveFrom: Date | null;
effectiveTo: Date | null;
_count: { lines: number };
}>>;
list: (params: { isActive: boolean; search?: string }) => Promise<
Array<{
id: string;
name: string;
effectiveFrom: Date | null;
effectiveTo: Date | null;
_count: { lines: number };
}>
>;
resolveBestRate: (params: {
resourceId?: string;
roleName?: string;
@@ -50,81 +51,85 @@ type BlueprintsRateCardsDeps = {
resolve: () => Promise<T>,
notFoundMessage: string,
) => Promise<T | AssistantToolErrorResult>;
parseOptionalIsoDate: (
value: string | undefined,
fieldName: string,
) => Date | undefined;
parseOptionalIsoDate: (value: string | undefined, fieldName: string) => Date | undefined;
fmtDate: (value: Date | null | undefined) => string | null;
};
export const blueprintsRateCardsToolDefinitions: ToolDef[] = withToolAccess([
{
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"],
export const blueprintsRateCardsToolDefinitions: ToolDef[] = withToolAccess(
[
{
type: "function",
function: {
name: "list_blueprints",
description: "List available project blueprints with their field definitions.",
parameters: { type: "object", properties: {} },
},
},
},
{
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: "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"],
},
},
},
},
{
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" },
{
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",
},
},
},
},
},
],
{
list_blueprints: {
requiresPlanningRead: true,
},
get_blueprint: {
requiresPlanningRead: true,
},
list_rate_cards: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiresCostView: true,
},
resolve_rate: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiresCostView: true,
},
},
], {
list_blueprints: {
requiresPlanningRead: true,
},
get_blueprint: {
requiresPlanningRead: true,
},
list_rate_cards: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiresCostView: true,
},
resolve_rate: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiresCostView: true,
},
});
);
export function createBlueprintsRateCardsExecutors(
deps: BlueprintsRateCardsDeps,
@@ -158,10 +163,7 @@ export function createBlueprintsRateCardsExecutors(
};
},
async list_rate_cards(
params: { query?: string; limit?: number },
ctx: ToolContext,
) {
async list_rate_cards(params: { query?: string; limit?: number }, ctx: ToolContext) {
const caller = deps.createRateCardCaller(deps.createScopedCallerContext(ctx));
const cards = await caller.list({
isActive: true,
@@ -1,5 +1,5 @@
import type { TRPCContext } from "../../trpc.js";
import { PermissionKey, SystemRole } from "@capakraken/shared";
import { PermissionKey, SystemRole } from "@nexus/shared";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
type AssistantToolErrorResult = { error: string };
@@ -67,93 +67,129 @@ export type ChargeabilityComputationDeps = {
) => Promise<ResolvedProject | AssistantToolErrorResult>;
};
export const chargeabilityComputationReadToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
name: "get_chargeability_report",
description: "Get the detailed chargeability report readmodel for a month range, including group totals and per-resource month series. Requires controller/manager/admin access, viewCosts, and useAssistantAdvancedTools.",
parameters: {
type: "object",
properties: {
startMonth: { type: "string", description: "Start month in YYYY-MM format." },
endMonth: { type: "string", description: "End month in YYYY-MM format." },
orgUnitId: { type: "string", description: "Optional org unit filter." },
managementLevelGroupId: { type: "string", description: "Optional management level group filter." },
countryId: { type: "string", description: "Optional country filter." },
includeProposed: { type: "boolean", description: "Whether proposed bookings should count towards chargeability. Default: false." },
resourceQuery: { type: "string", description: "Optional resource filter by name or eid after loading the report." },
resourceLimit: { type: "integer", description: "Maximum number of resources returned. Default: 25, max 100." },
export const chargeabilityComputationReadToolDefinitions: ToolDef[] = withToolAccess(
[
{
type: "function",
function: {
name: "get_chargeability_report",
description:
"Get the detailed chargeability report readmodel for a month range, including group totals and per-resource month series. Requires controller/manager/admin access, viewCosts, and useAssistantAdvancedTools.",
parameters: {
type: "object",
properties: {
startMonth: { type: "string", description: "Start month in YYYY-MM format." },
endMonth: { type: "string", description: "End month in YYYY-MM format." },
orgUnitId: { type: "string", description: "Optional org unit filter." },
managementLevelGroupId: {
type: "string",
description: "Optional management level group filter.",
},
countryId: { type: "string", description: "Optional country filter." },
includeProposed: {
type: "boolean",
description:
"Whether proposed bookings should count towards chargeability. Default: false.",
},
resourceQuery: {
type: "string",
description: "Optional resource filter by name or eid after loading the report.",
},
resourceLimit: {
type: "integer",
description: "Maximum number of resources returned. Default: 25, max 100.",
},
},
required: ["startMonth", "endMonth"],
},
required: ["startMonth", "endMonth"],
},
},
},
{
type: "function",
function: {
name: "get_resource_computation_graph",
description: "Get the resource computation graph with transparent SAH, holiday, absence, allocation, chargeability, and budget derivation factors. Requires controller/manager/admin access, viewCosts, and useAssistantAdvancedTools.",
parameters: {
type: "object",
properties: {
resourceId: { type: "string", description: "Resource ID, eid, or display name." },
month: { type: "string", description: "Month in YYYY-MM format." },
domain: { type: "string", enum: ["INPUT", "SAH", "ALLOCATION", "RULES", "CHARGEABILITY", "BUDGET"], description: "Optional domain filter for graph nodes." },
includeLinks: { type: "boolean", description: "Include graph links for the selected nodes. Default: false." },
{
type: "function",
function: {
name: "get_resource_computation_graph",
description:
"Get the resource computation graph with transparent SAH, holiday, absence, allocation, chargeability, and budget derivation factors. Requires controller/manager/admin access, viewCosts, and useAssistantAdvancedTools.",
parameters: {
type: "object",
properties: {
resourceId: { type: "string", description: "Resource ID, eid, or display name." },
month: { type: "string", description: "Month in YYYY-MM format." },
domain: {
type: "string",
enum: ["INPUT", "SAH", "ALLOCATION", "RULES", "CHARGEABILITY", "BUDGET"],
description: "Optional domain filter for graph nodes.",
},
includeLinks: {
type: "boolean",
description: "Include graph links for the selected nodes. Default: false.",
},
},
required: ["resourceId", "month"],
},
required: ["resourceId", "month"],
},
},
},
{
type: "function",
function: {
name: "get_project_computation_graph",
description: "Get the project computation graph with estimate, commercial, effort, experience, spread, and budget derivation factors. Requires controller/manager/admin access, viewCosts, and useAssistantAdvancedTools.",
parameters: {
type: "object",
properties: {
projectId: { type: "string", description: "Project ID, short code, or project name." },
domain: { type: "string", enum: ["INPUT", "ESTIMATE", "COMMERCIAL", "EXPERIENCE", "EFFORT", "SPREAD", "BUDGET"], description: "Optional domain filter for graph nodes." },
includeLinks: { type: "boolean", description: "Include graph links for the selected nodes. Default: false." },
{
type: "function",
function: {
name: "get_project_computation_graph",
description:
"Get the project computation graph with estimate, commercial, effort, experience, spread, and budget derivation factors. Requires controller/manager/admin access, viewCosts, and useAssistantAdvancedTools.",
parameters: {
type: "object",
properties: {
projectId: { type: "string", description: "Project ID, short code, or project name." },
domain: {
type: "string",
enum: ["INPUT", "ESTIMATE", "COMMERCIAL", "EXPERIENCE", "EFFORT", "SPREAD", "BUDGET"],
description: "Optional domain filter for graph nodes.",
},
includeLinks: {
type: "boolean",
description: "Include graph links for the selected nodes. Default: false.",
},
},
required: ["projectId"],
},
required: ["projectId"],
},
},
],
{
get_chargeability_report: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiredPermissions: [PermissionKey.VIEW_COSTS],
requiresAdvancedAssistant: true,
},
get_resource_computation_graph: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiredPermissions: [PermissionKey.VIEW_COSTS],
requiresAdvancedAssistant: true,
},
get_project_computation_graph: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiredPermissions: [PermissionKey.VIEW_COSTS],
requiresAdvancedAssistant: true,
},
},
], {
get_chargeability_report: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiredPermissions: [PermissionKey.VIEW_COSTS],
requiresAdvancedAssistant: true,
},
get_resource_computation_graph: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiredPermissions: [PermissionKey.VIEW_COSTS],
requiresAdvancedAssistant: true,
},
get_project_computation_graph: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiredPermissions: [PermissionKey.VIEW_COSTS],
requiresAdvancedAssistant: true,
},
});
);
export function createChargeabilityComputationExecutors(
deps: ChargeabilityComputationDeps,
): Record<string, ToolExecutor> {
return {
async get_chargeability_report(params: {
startMonth: string;
endMonth: string;
orgUnitId?: string;
managementLevelGroupId?: string;
countryId?: string;
includeProposed?: boolean;
resourceQuery?: string;
resourceLimit?: number;
}, ctx: ToolContext) {
async get_chargeability_report(
params: {
startMonth: string;
endMonth: string;
orgUnitId?: string;
managementLevelGroupId?: string;
countryId?: string;
includeProposed?: boolean;
resourceQuery?: string;
resourceLimit?: number;
},
ctx: ToolContext,
) {
deps.assertPermission(ctx, PermissionKey.VIEW_COSTS);
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
@@ -162,7 +198,9 @@ export function createChargeabilityComputationExecutors(
startMonth: params.startMonth,
endMonth: params.endMonth,
...(params.orgUnitId ? { orgUnitId: params.orgUnitId } : {}),
...(params.managementLevelGroupId ? { managementLevelGroupId: params.managementLevelGroupId } : {}),
...(params.managementLevelGroupId
? { managementLevelGroupId: params.managementLevelGroupId }
: {}),
...(params.countryId ? { countryId: params.countryId } : {}),
includeProposed: params.includeProposed ?? false,
...(params.resourceQuery ? { resourceQuery: params.resourceQuery } : {}),
@@ -170,12 +208,15 @@ export function createChargeabilityComputationExecutors(
});
},
async get_resource_computation_graph(params: {
resourceId: string;
month: string;
domain?: ResourceComputationDomain;
includeLinks?: boolean;
}, ctx: ToolContext) {
async get_resource_computation_graph(
params: {
resourceId: string;
month: string;
domain?: ResourceComputationDomain;
includeLinks?: boolean;
},
ctx: ToolContext,
) {
deps.assertPermission(ctx, PermissionKey.VIEW_COSTS);
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
@@ -193,11 +234,14 @@ export function createChargeabilityComputationExecutors(
});
},
async get_project_computation_graph(params: {
projectId: string;
domain?: ProjectComputationDomain;
includeLinks?: boolean;
}, ctx: ToolContext) {
async get_project_computation_graph(
params: {
projectId: string;
domain?: ProjectComputationDomain;
includeLinks?: boolean;
},
ctx: ToolContext,
) {
deps.assertPermission(ctx, PermissionKey.VIEW_COSTS);
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
@@ -5,7 +5,7 @@ import {
SystemRole,
UpdateClientSchema,
UpdateOrgUnitSchema,
} from "@capakraken/shared";
} from "@nexus/shared";
import { z } from "zod";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
@@ -17,10 +17,7 @@ type ClientsOrgUnitsDeps = {
id: string;
name: string;
}>;
update: (params: {
id: string;
data: z.input<typeof UpdateClientSchema>;
}) => Promise<{
update: (params: { id: string; data: z.input<typeof UpdateClientSchema> }) => Promise<{
id: string;
name: string;
}>;
@@ -32,10 +29,7 @@ type ClientsOrgUnitsDeps = {
id: string;
name: string;
}>;
update: (params: {
id: string;
data: z.input<typeof UpdateOrgUnitSchema>;
}) => Promise<{
update: (params: { id: string; data: z.input<typeof UpdateOrgUnitSchema> }) => Promise<{
id: string;
name: string;
}>;
@@ -45,134 +39,145 @@ type ClientsOrgUnitsDeps = {
error: unknown,
action?: "create" | "update" | "delete",
) => AssistantToolErrorResult | null;
toAssistantOrgUnitMutationError: (
error: unknown,
) => AssistantToolErrorResult | null;
toAssistantOrgUnitMutationError: (error: unknown) => AssistantToolErrorResult | null;
};
export const clientMutationToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
name: "create_client",
description: "Create a new client. Requires manager or admin role. Always confirm first.",
parameters: {
type: "object",
properties: {
name: { type: "string", description: "Client name" },
code: { type: "string", description: "Client code" },
parentId: { type: "string", description: "Optional parent client ID" },
sortOrder: { type: "integer", description: "Sort order. Default: 0" },
tags: { type: "array", items: { type: "string" }, description: "Optional client tags" },
export const clientMutationToolDefinitions: ToolDef[] = withToolAccess(
[
{
type: "function",
function: {
name: "create_client",
description: "Create a new client. Requires manager or admin role. Always confirm first.",
parameters: {
type: "object",
properties: {
name: { type: "string", description: "Client name" },
code: { type: "string", description: "Client code" },
parentId: { type: "string", description: "Optional parent client ID" },
sortOrder: { type: "integer", description: "Sort order. Default: 0" },
tags: { type: "array", items: { type: "string" }, description: "Optional client tags" },
},
required: ["name"],
},
required: ["name"],
},
},
},
{
type: "function",
function: {
name: "update_client",
description: "Update a client. Requires manager or admin role. 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" },
sortOrder: { type: "integer", description: "New sort order" },
isActive: { type: "boolean", description: "Set active state" },
parentId: { type: "string", description: "Parent client ID; use null to clear" },
tags: { type: "array", items: { type: "string" }, description: "Replacement client tags" },
{
type: "function",
function: {
name: "update_client",
description: "Update a client. Requires manager or admin role. 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" },
sortOrder: { type: "integer", description: "New sort order" },
isActive: { type: "boolean", description: "Set active state" },
parentId: { type: "string", description: "Parent client ID; use null to clear" },
tags: {
type: "array",
items: { type: "string" },
description: "Replacement client tags",
},
},
required: ["id"],
},
required: ["id"],
},
},
},
{
type: "function",
function: {
name: "delete_client",
description: "Delete a client. Requires admin role. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Client ID" },
{
type: "function",
function: {
name: "delete_client",
description: "Delete a client. Requires admin role. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Client ID" },
},
required: ["id"],
},
required: ["id"],
},
},
],
{
create_client: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
update_client: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
delete_client: {
allowedSystemRoles: [SystemRole.ADMIN],
},
},
], {
create_client: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
update_client: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
delete_client: {
allowedSystemRoles: [SystemRole.ADMIN],
},
});
);
export const orgUnitMutationToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
name: "create_org_unit",
description: "Create a new organizational unit. Admin role required. 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)" },
sortOrder: { type: "integer", description: "Sort order. Default: 0" },
export const orgUnitMutationToolDefinitions: ToolDef[] = withToolAccess(
[
{
type: "function",
function: {
name: "create_org_unit",
description: "Create a new organizational unit. Admin role required. 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)" },
sortOrder: { type: "integer", description: "Sort order. Default: 0" },
},
required: ["name", "level"],
},
required: ["name", "level"],
},
},
},
{
type: "function",
function: {
name: "update_org_unit",
description: "Update an organizational unit. Admin role required. 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" },
sortOrder: { type: "integer", description: "New sort order" },
isActive: { type: "boolean", description: "Set active state" },
parentId: { type: "string", description: "Parent org unit ID; use null to clear" },
{
type: "function",
function: {
name: "update_org_unit",
description: "Update an organizational unit. Admin role required. 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" },
sortOrder: { type: "integer", description: "New sort order" },
isActive: { type: "boolean", description: "Set active state" },
parentId: { type: "string", description: "Parent org unit ID; use null to clear" },
},
required: ["id"],
},
required: ["id"],
},
},
],
{
create_org_unit: {
allowedSystemRoles: [SystemRole.ADMIN],
},
update_org_unit: {
allowedSystemRoles: [SystemRole.ADMIN],
},
},
], {
create_org_unit: {
allowedSystemRoles: [SystemRole.ADMIN],
},
update_org_unit: {
allowedSystemRoles: [SystemRole.ADMIN],
},
});
);
export function createClientsOrgUnitsExecutors(
deps: ClientsOrgUnitsDeps,
): Record<string, ToolExecutor> {
return {
async create_client(params: {
name: string;
code?: string;
parentId?: string;
sortOrder?: number;
tags?: string[];
}, ctx: ToolContext) {
async create_client(
params: {
name: string;
code?: string;
parentId?: string;
sortOrder?: number;
tags?: string[];
},
ctx: ToolContext,
) {
const caller = deps.createClientCaller(deps.createScopedCallerContext(ctx));
let client;
@@ -196,15 +201,18 @@ export function createClientsOrgUnitsExecutors(
};
},
async update_client(params: {
id: string;
name?: string;
code?: string | null;
sortOrder?: number;
isActive?: boolean;
parentId?: string | null;
tags?: string[];
}, ctx: ToolContext) {
async update_client(
params: {
id: string;
name?: string;
code?: string | null;
sortOrder?: number;
isActive?: boolean;
parentId?: string | null;
tags?: string[];
},
ctx: ToolContext,
) {
const caller = deps.createClientCaller(deps.createScopedCallerContext(ctx));
const data = UpdateClientSchema.parse({
...(params.name !== undefined ? { name: params.name } : {}),
@@ -262,13 +270,16 @@ export function createClientsOrgUnitsExecutors(
};
},
async create_org_unit(params: {
name: string;
shortName?: string;
level: number;
parentId?: string;
sortOrder?: number;
}, ctx: ToolContext) {
async create_org_unit(
params: {
name: string;
shortName?: string;
level: number;
parentId?: string;
sortOrder?: number;
},
ctx: ToolContext,
) {
const caller = deps.createOrgUnitCaller(deps.createScopedCallerContext(ctx));
let orgUnit;
@@ -292,14 +303,17 @@ export function createClientsOrgUnitsExecutors(
};
},
async update_org_unit(params: {
id: string;
name?: string;
shortName?: string | null;
sortOrder?: number;
isActive?: boolean;
parentId?: string | null;
}, ctx: ToolContext) {
async update_org_unit(
params: {
id: string;
name?: string;
shortName?: string | null;
sortOrder?: number;
isActive?: boolean;
parentId?: string | null;
},
ctx: ToolContext,
) {
const caller = deps.createOrgUnitCaller(deps.createScopedCallerContext(ctx));
const data = UpdateOrgUnitSchema.parse({
...(params.name !== undefined ? { name: params.name } : {}),
@@ -1,6 +1,9 @@
import { COMMENT_ENTITY_TYPE_VALUES, type CommentEntityType } from "@capakraken/shared";
import { COMMENT_ENTITY_TYPE_VALUES, type CommentEntityType } from "@nexus/shared";
import type { TRPCContext } from "../../trpc.js";
import { getCommentToolEntityDescription, getCommentToolScopeSentence } from "../../lib/comment-entity-registry.js";
import {
getCommentToolEntityDescription,
getCommentToolScopeSentence,
} from "../../lib/comment-entity-registry.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
type AssistantToolErrorResult = { error: string };
@@ -31,75 +34,86 @@ type CommentsDeps = {
entityId: string;
body: string;
}) => Promise<CommentRecord>;
resolve: (params: {
id: string;
resolved: boolean;
}) => Promise<CommentRecord>;
resolve: (params: { id: string; resolved: boolean }) => Promise<CommentRecord>;
};
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
toAssistantCommentCreationError: (
error: unknown,
) => AssistantToolErrorResult | null;
toAssistantCommentResolveError: (
error: unknown,
) => AssistantToolErrorResult | null;
toAssistantCommentCreationError: (error: unknown) => AssistantToolErrorResult | null;
toAssistantCommentResolveError: (error: unknown) => AssistantToolErrorResult | null;
};
export const commentReadToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
name: "list_comments",
description: `List comments (with replies) for a supported comment-enabled entity. ${getCommentToolScopeSentence()} Entity visibility is required.`,
parameters: {
type: "object",
properties: {
entityType: { type: "string", enum: [...COMMENT_ENTITY_TYPE_VALUES], description: getCommentToolEntityDescription() },
entityId: { type: "string", description: "Entity ID" },
export const commentReadToolDefinitions: ToolDef[] = withToolAccess(
[
{
type: "function",
function: {
name: "list_comments",
description: `List comments (with replies) for a supported comment-enabled entity. ${getCommentToolScopeSentence()} Entity visibility is required.`,
parameters: {
type: "object",
properties: {
entityType: {
type: "string",
enum: [...COMMENT_ENTITY_TYPE_VALUES],
description: getCommentToolEntityDescription(),
},
entityId: { type: "string", description: "Entity ID" },
},
required: ["entityType", "entityId"],
},
required: ["entityType", "entityId"],
},
},
},
], {});
],
{},
);
export const commentMutationToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
name: "create_comment",
description: `Add a comment to a supported comment-enabled entity. ${getCommentToolScopeSentence()} Entity visibility is required. Supports @mentions. Always confirm with the user first.`,
parameters: {
type: "object",
properties: {
entityType: { type: "string", enum: [...COMMENT_ENTITY_TYPE_VALUES], description: getCommentToolEntityDescription() },
entityId: { type: "string", description: "Entity ID" },
body: { type: "string", description: "Comment body text. Use @[Name](userId) for mentions." },
export const commentMutationToolDefinitions: ToolDef[] = withToolAccess(
[
{
type: "function",
function: {
name: "create_comment",
description: `Add a comment to a supported comment-enabled entity. ${getCommentToolScopeSentence()} Entity visibility is required. Supports @mentions. Always confirm with the user first.`,
parameters: {
type: "object",
properties: {
entityType: {
type: "string",
enum: [...COMMENT_ENTITY_TYPE_VALUES],
description: getCommentToolEntityDescription(),
},
entityId: { type: "string", description: "Entity ID" },
body: {
type: "string",
description: "Comment body text. Use @[Name](userId) for mentions.",
},
},
required: ["entityType", "entityId", "body"],
},
required: ["entityType", "entityId", "body"],
},
},
},
{
type: "function",
function: {
name: "resolve_comment",
description: `Mark a comment as resolved (or unresolve it) on a supported comment-enabled entity. ${getCommentToolScopeSentence()} Entity visibility is required, and only the comment author or an admin can change resolution.`,
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" },
{
type: "function",
function: {
name: "resolve_comment",
description: `Mark a comment as resolved (or unresolve it) on a supported comment-enabled entity. ${getCommentToolScopeSentence()} Entity visibility is required, and only the comment author or an admin can change resolution.`,
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"],
},
required: ["commentId"],
},
},
},
], {});
],
{},
);
export function createCommentExecutors(
deps: CommentsDeps,
): Record<string, ToolExecutor> {
export function createCommentExecutors(deps: CommentsDeps): Record<string, ToolExecutor> {
return {
async list_comments(
params: { entityType: CommentEntityType; entityId: string },
@@ -166,10 +180,7 @@ export function createCommentExecutors(
};
},
async resolve_comment(
params: { commentId: string; resolved?: boolean },
ctx: ToolContext,
) {
async resolve_comment(params: { commentId: string; resolved?: boolean }, ctx: ToolContext) {
const caller = deps.createCommentCaller(deps.createScopedCallerContext(ctx));
let updated;
try {
@@ -1,4 +1,4 @@
import { SystemRole } from "@capakraken/shared";
import { SystemRole } from "@nexus/shared";
import type { TRPCContext } from "../../trpc.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
@@ -1,12 +1,12 @@
import type { Prisma } from "@capakraken/db";
import { SystemRole } from "@capakraken/shared";
import type { Prisma } from "@nexus/db";
import { SystemRole } from "@nexus/shared";
import type { TRPCContext } from "../../trpc.js";
import {
CreateCountrySchema,
CreateMetroCitySchema,
UpdateCountrySchema,
UpdateMetroCitySchema,
} from "@capakraken/shared";
} from "@nexus/shared";
import { z } from "zod";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
@@ -45,136 +45,143 @@ type CountryMetroAdminDeps = {
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
assertAdminRole: (ctx: ToolContext) => void;
formatCountry: (country: CountryRecord) => unknown;
toAssistantCountryMutationError: (
error: unknown,
) => AssistantToolErrorResult | null;
toAssistantMetroCityMutationError: (
error: unknown,
) => AssistantToolErrorResult | null;
toAssistantCountryMutationError: (error: unknown) => AssistantToolErrorResult | null;
toAssistantMetroCityMutationError: (error: unknown) => AssistantToolErrorResult | null;
};
export const countryMetroAdminToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
name: "create_country",
description: "Create a country with daily working hours and optional schedule rules. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
code: { type: "string", description: "ISO country code such as DE or ES." },
name: { type: "string", description: "Country name." },
dailyWorkingHours: { type: "number", description: "Standard daily working hours." },
scheduleRules: {
type: "object",
description: "Optional schedule rule object such as the Spain reduced-hours configuration.",
},
},
required: ["code", "name"],
},
},
},
{
type: "function",
function: {
name: "update_country",
description: "Update a country including working hours, schedule rules, or active state. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Country ID." },
data: {
type: "object",
properties: {
code: { type: "string" },
name: { type: "string" },
dailyWorkingHours: { type: "number" },
scheduleRules: { type: ["object", "null"] },
isActive: { type: "boolean" },
export const countryMetroAdminToolDefinitions: ToolDef[] = withToolAccess(
[
{
type: "function",
function: {
name: "create_country",
description:
"Create a country with daily working hours and optional schedule rules. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
code: { type: "string", description: "ISO country code such as DE or ES." },
name: { type: "string", description: "Country name." },
dailyWorkingHours: { type: "number", description: "Standard daily working hours." },
scheduleRules: {
type: "object",
description:
"Optional schedule rule object such as the Spain reduced-hours configuration.",
},
},
required: ["code", "name"],
},
required: ["id", "data"],
},
},
},
{
type: "function",
function: {
name: "create_metro_city",
description: "Create a metro city for a country. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
countryId: { type: "string", description: "Country ID." },
name: { type: "string", description: "Metro city name." },
},
required: ["countryId", "name"],
},
},
},
{
type: "function",
function: {
name: "update_metro_city",
description: "Rename a metro city. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Metro city ID." },
data: {
type: "object",
properties: {
name: { type: "string" },
{
type: "function",
function: {
name: "update_country",
description:
"Update a country including working hours, schedule rules, or active state. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Country ID." },
data: {
type: "object",
properties: {
code: { type: "string" },
name: { type: "string" },
dailyWorkingHours: { type: "number" },
scheduleRules: { type: ["object", "null"] },
isActive: { type: "boolean" },
},
},
},
required: ["id", "data"],
},
required: ["id", "data"],
},
},
},
{
type: "function",
function: {
name: "create_metro_city",
description:
"Create a metro city for a country. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
countryId: { type: "string", description: "Country ID." },
name: { type: "string", description: "Metro city name." },
},
required: ["countryId", "name"],
},
},
},
{
type: "function",
function: {
name: "update_metro_city",
description: "Rename a metro city. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Metro city ID." },
data: {
type: "object",
properties: {
name: { type: "string" },
},
},
},
required: ["id", "data"],
},
},
},
{
type: "function",
function: {
name: "delete_metro_city",
description:
"Delete a metro city when no resource is assigned to it. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Metro city ID." },
},
required: ["id"],
},
},
},
],
{
type: "function",
function: {
name: "delete_metro_city",
description: "Delete a metro city when no resource is assigned to it. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Metro city ID." },
},
required: ["id"],
},
create_country: {
allowedSystemRoles: [SystemRole.ADMIN],
},
update_country: {
allowedSystemRoles: [SystemRole.ADMIN],
},
create_metro_city: {
allowedSystemRoles: [SystemRole.ADMIN],
},
update_metro_city: {
allowedSystemRoles: [SystemRole.ADMIN],
},
delete_metro_city: {
allowedSystemRoles: [SystemRole.ADMIN],
},
},
], {
create_country: {
allowedSystemRoles: [SystemRole.ADMIN],
},
update_country: {
allowedSystemRoles: [SystemRole.ADMIN],
},
create_metro_city: {
allowedSystemRoles: [SystemRole.ADMIN],
},
update_metro_city: {
allowedSystemRoles: [SystemRole.ADMIN],
},
delete_metro_city: {
allowedSystemRoles: [SystemRole.ADMIN],
},
});
);
export function createCountryMetroAdminExecutors(
deps: CountryMetroAdminDeps,
): Record<string, ToolExecutor> {
return {
async create_country(params: {
code: string;
name: string;
dailyWorkingHours?: number;
scheduleRules?: Prisma.JsonValue | null;
}, ctx: ToolContext) {
async create_country(
params: {
code: string;
name: string;
dailyWorkingHours?: number;
scheduleRules?: Prisma.JsonValue | null;
},
ctx: ToolContext,
) {
deps.assertAdminRole(ctx);
const caller = deps.createCountryCaller(deps.createScopedCallerContext(ctx));
@@ -198,16 +205,19 @@ export function createCountryMetroAdminExecutors(
};
},
async update_country(params: {
id: string;
data: {
code?: string;
name?: string;
dailyWorkingHours?: number;
scheduleRules?: Prisma.JsonValue | null;
isActive?: boolean;
};
}, ctx: ToolContext) {
async update_country(
params: {
id: string;
data: {
code?: string;
name?: string;
dailyWorkingHours?: number;
scheduleRules?: Prisma.JsonValue | null;
isActive?: boolean;
};
},
ctx: ToolContext,
) {
deps.assertAdminRole(ctx);
const caller = deps.createCountryCaller(deps.createScopedCallerContext(ctx));
const input = {
@@ -1,4 +1,4 @@
import type { Prisma } from "@capakraken/db";
import type { Prisma } from "@nexus/db";
import type { TRPCContext } from "../../trpc.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
@@ -22,65 +22,66 @@ type CountryReadmodelsDeps = {
};
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
formatCountry: (country: CountryRecord) => unknown;
toAssistantCountryNotFoundError: (
error: unknown,
) => AssistantToolErrorResult | null;
toAssistantCountryNotFoundError: (error: unknown) => AssistantToolErrorResult | null;
};
export const countryReadmodelToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
name: "list_countries",
description: "List countries including working hours, schedule rules, active flag, and metro cities.",
parameters: {
type: "object",
properties: {
includeInactive: { type: "boolean", description: "Include inactive countries. Default: false." },
search: { type: "string", description: "Optional country code or name search." },
export const countryReadmodelToolDefinitions: ToolDef[] = withToolAccess(
[
{
type: "function",
function: {
name: "list_countries",
description:
"List countries including working hours, schedule rules, active flag, and metro cities.",
parameters: {
type: "object",
properties: {
includeInactive: {
type: "boolean",
description: "Include inactive countries. Default: false.",
},
search: { type: "string", description: "Optional country code or name search." },
},
},
},
},
},
{
type: "function",
function: {
name: "get_country",
description: "Get one country with schedule rules, active flag, metro cities, and resource count. Accepts ID, code, or name.",
parameters: {
type: "object",
properties: {
identifier: { type: "string", description: "Country ID, code, or name." },
{
type: "function",
function: {
name: "get_country",
description:
"Get one country with schedule rules, active flag, metro cities, and resource count. Accepts ID, code, or name.",
parameters: {
type: "object",
properties: {
identifier: { type: "string", description: "Country ID, code, or name." },
},
required: ["identifier"],
},
required: ["identifier"],
},
},
],
{
get_country: {
requiresResourceOverview: true,
},
},
], {
get_country: {
requiresResourceOverview: true,
},
});
);
export function createCountryReadmodelExecutors(
deps: CountryReadmodelsDeps,
): Record<string, ToolExecutor> {
return {
async list_countries(
params: { includeInactive?: boolean; search?: string },
ctx: ToolContext,
) {
async list_countries(params: { includeInactive?: boolean; search?: string }, ctx: ToolContext) {
const caller = deps.createCountryCaller(deps.createScopedCallerContext(ctx));
const countries = await caller.list(
params.includeInactive
? undefined
: { isActive: true },
);
const countries = await caller.list(params.includeInactive ? undefined : { isActive: true });
const normalizedSearch = params.search?.trim().toLowerCase();
const filteredCountries = normalizedSearch
? countries.filter((country) =>
country.code.toLowerCase().includes(normalizedSearch)
|| country.name.toLowerCase().includes(normalizedSearch))
? countries.filter(
(country) =>
country.code.toLowerCase().includes(normalizedSearch) ||
country.name.toLowerCase().includes(normalizedSearch),
)
: countries;
return {
@@ -89,10 +90,7 @@ export function createCountryReadmodelExecutors(
};
},
async get_country(
params: { identifier: string },
ctx: ToolContext,
) {
async get_country(params: { identifier: string }, ctx: ToolContext) {
const caller = deps.createCountryCaller(deps.createScopedCallerContext(ctx));
let country;
try {
@@ -1,4 +1,4 @@
import { PermissionKey, SystemRole } from "@capakraken/shared";
import { PermissionKey, SystemRole } from "@nexus/shared";
import type { TRPCContext } from "../../trpc.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
@@ -49,141 +49,156 @@ type DashboardInsightsReportsDeps = {
const REPORT_ENTITIES: ReportEntity[] = ["resource", "project", "assignment", "resource_month"];
export const dashboardInsightsReportsToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
name: "get_dashboard_detail",
description: "Get detailed dashboard data: peak allocation times, top-value resources, demand pipeline, chargeability overview, project health risks, and skill gap staffing pressure.",
parameters: {
type: "object",
properties: {
section: {
type: "string",
description: "Which section: peak_times, top_resources, demand_pipeline, chargeability_overview, project_health, skill_gaps, or all",
},
},
},
},
},
{
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: {} },
},
},
{
type: "function",
function: {
name: "run_report",
description: "Run a dynamic report query on resources, projects, assignments, or resource-month rows with flexible column selection and filtering.",
parameters: {
type: "object",
properties: {
entity: {
type: "string",
enum: ["resource", "project", "assignment", "resource_month"],
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"],
export const dashboardInsightsReportsToolDefinitions: ToolDef[] = withToolAccess(
[
{
type: "function",
function: {
name: "get_dashboard_detail",
description:
"Get detailed dashboard data: peak allocation times, top-value resources, demand pipeline, chargeability overview, project health risks, and skill gap staffing pressure.",
parameters: {
type: "object",
properties: {
section: {
type: "string",
description:
"Which section: peak_times, top_resources, demand_pipeline, chargeability_overview, project_health, skill_gaps, or all",
},
description: "Filters to apply",
},
periodMonth: {
type: "string",
description: "Required for resource_month reports. Format: YYYY-MM",
},
groupBy: {
type: "string",
description: "Optional scalar field used to group result rows into labeled sections.",
},
sortBy: {
type: "string",
description: "Optional scalar field used to sort rows within the grouped result.",
},
sortDir: {
type: "string",
enum: ["asc", "desc"],
description: "Sort direction for sortBy. Default: asc",
},
limit: { type: "integer", description: "Max results. Default: 50" },
},
required: ["entity", "columns"],
},
},
{
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: {} },
},
},
{
type: "function",
function: {
name: "run_report",
description:
"Run a dynamic report query on resources, projects, assignments, or resource-month rows with flexible column selection and filtering.",
parameters: {
type: "object",
properties: {
entity: {
type: "string",
enum: ["resource", "project", "assignment", "resource_month"],
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",
},
periodMonth: {
type: "string",
description: "Required for resource_month reports. Format: YYYY-MM",
},
groupBy: {
type: "string",
description: "Optional scalar field used to group result rows into labeled sections.",
},
sortBy: {
type: "string",
description: "Optional scalar field used to sort rows within the grouped result.",
},
sortDir: {
type: "string",
enum: ["asc", "desc"],
description: "Sort direction for sortBy. Default: asc",
},
limit: { type: "integer", description: "Max results. Default: 50" },
},
required: ["entity", "columns"],
},
},
},
],
{
get_dashboard_detail: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
detect_anomalies: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_skill_gaps: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_project_health: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_budget_forecast: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiredPermissions: [PermissionKey.VIEW_COSTS],
},
get_insights_summary: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
run_report: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
},
], {
get_dashboard_detail: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
detect_anomalies: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_skill_gaps: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_project_health: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_budget_forecast: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiredPermissions: [PermissionKey.VIEW_COSTS],
},
get_insights_summary: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
run_report: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
});
);
export function createDashboardInsightsReportsExecutors(
deps: DashboardInsightsReportsDeps,
@@ -220,21 +235,23 @@ export function createDashboardInsightsReportsExecutors(
return caller.getInsightsSummary();
},
async run_report(params: {
entity: string;
columns: string[];
filters?: ReportFilter[];
periodMonth?: string;
groupBy?: string;
sortBy?: string;
sortDir?: "asc" | "desc";
limit?: number;
}, ctx: ToolContext) {
async run_report(
params: {
entity: string;
columns: string[];
filters?: ReportFilter[];
periodMonth?: string;
groupBy?: string;
sortBy?: string;
sortDir?: "asc" | "desc";
limit?: number;
},
ctx: ToolContext,
) {
const entity = params.entity as ReportEntity;
if (!REPORT_ENTITIES.includes(entity)) {
return {
error:
`Unknown entity: ${params.entity}. Use resource, project, assignment, or resource_month.`,
error: `Unknown entity: ${params.entity}. Use resource, project, assignment, or resource_month.`,
};
}
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
import { Prisma, VacationType } from "@capakraken/db";
import { AllocationStatus, PermissionKey, SystemRole, toIsoDateOrNull } from "@capakraken/shared";
import type { WeekdayAvailability } from "@capakraken/shared";
import { Prisma, VacationType } from "@nexus/db";
import { AllocationStatus, PermissionKey, SystemRole, toIsoDateOrNull } from "@nexus/shared";
import type { WeekdayAvailability } from "@nexus/shared";
import { TRPCError } from "@trpc/server";
import { ZodError } from "zod";
export { fmtEur } from "../../lib/format-utils.js";
@@ -1,5 +1,5 @@
import { DispoStagedRecordType, ImportBatchStatus, StagedRecordStatus } from "@capakraken/db";
import { PermissionKey, SystemRole } from "@capakraken/shared";
import { DispoStagedRecordType, ImportBatchStatus, StagedRecordStatus } from "@nexus/db";
import { PermissionKey, SystemRole } from "@nexus/shared";
import type { TRPCContext } from "../../trpc.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
@@ -86,305 +86,383 @@ type ImportExportDispoDeps = {
toAssistantDispoImportBatchNotFoundError: (error: unknown) => { error: string } | null;
};
const CONTROLLER_ASSISTANT_ROLES = [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER] as const;
const CONTROLLER_ASSISTANT_ROLES = [
SystemRole.ADMIN,
SystemRole.MANAGER,
SystemRole.CONTROLLER,
] as const;
const MANAGER_ASSISTANT_ROLES = [SystemRole.ADMIN, SystemRole.MANAGER] as const;
const ADMIN_ASSISTANT_ROLES = [SystemRole.ADMIN] as const;
export const importExportDispoToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
name: "export_resources_csv",
description: "Export the current active resource list as CSV via the real import/export router. Controller/manager/admin roles only.",
parameters: {
type: "object",
properties: {},
export const importExportDispoToolDefinitions: ToolDef[] = withToolAccess(
[
{
type: "function",
function: {
name: "export_resources_csv",
description:
"Export the current active resource list as CSV via the real import/export router. Controller/manager/admin roles only.",
parameters: {
type: "object",
properties: {},
},
},
},
},
{
type: "function",
function: {
name: "export_projects_csv",
description: "Export the current project list as CSV via the real import/export router. Controller/manager/admin roles only.",
parameters: {
type: "object",
properties: {},
{
type: "function",
function: {
name: "export_projects_csv",
description:
"Export the current project list as CSV via the real import/export router. Controller/manager/admin roles only.",
parameters: {
type: "object",
properties: {},
},
},
},
},
{
type: "function",
function: {
name: "import_csv_data",
description: "Import CSV-style row data for resources, projects, or allocations via the real import/export router. Requires manager/admin, importData permission, and defaults to dry-run.",
parameters: {
type: "object",
properties: {
entityType: { type: "string", enum: ["resources", "projects", "allocations"], description: "Import target entity type." },
rows: {
type: "array",
description: "CSV rows already parsed to key/value objects.",
items: {
type: "object",
additionalProperties: { type: "string" },
{
type: "function",
function: {
name: "import_csv_data",
description:
"Import CSV-style row data for resources, projects, or allocations via the real import/export router. Requires manager/admin, importData permission, and defaults to dry-run.",
parameters: {
type: "object",
properties: {
entityType: {
type: "string",
enum: ["resources", "projects", "allocations"],
description: "Import target entity type.",
},
rows: {
type: "array",
description: "CSV rows already parsed to key/value objects.",
items: {
type: "object",
additionalProperties: { type: "string" },
},
},
dryRun: {
type: "boolean",
description: "Validate only without persisting changes. Default: true.",
},
},
dryRun: { type: "boolean", description: "Validate only without persisting changes. Default: true." },
required: ["entityType", "rows"],
},
required: ["entityType", "rows"],
},
},
},
{
type: "function",
function: {
name: "list_dispo_import_batches",
description:
"List Dispo import batches with pagination and optional status filter via the real dispo router. Admin role required.",
parameters: {
type: "object",
properties: {
status: { type: "string", description: "Optional batch status filter." },
limit: { type: "integer", description: "Max results. Default: 50, max: 200." },
cursor: { type: "string", description: "Optional pagination cursor." },
},
},
},
},
{
type: "function",
function: {
name: "get_dispo_import_batch",
description:
"Get one Dispo import batch including staged record counters via the real dispo router. Admin role required.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Import batch ID." },
},
required: ["id"],
},
},
},
{
type: "function",
function: {
name: "stage_dispo_import_batch",
description:
"Stage a Dispo import batch via the real dispo router. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
planningWorkbookPath: {
type: "string",
description: "Filesystem path to the planning workbook.",
},
referenceWorkbookPath: {
type: "string",
description: "Filesystem path to the reference workbook.",
},
chargeabilityWorkbookPath: {
type: "string",
description: "Filesystem path to the chargeability workbook.",
},
costWorkbookPath: {
type: "string",
description: "Optional filesystem path to the cost workbook.",
},
rosterWorkbookPath: {
type: "string",
description: "Optional filesystem path to the roster workbook.",
},
notes: { type: "string", description: "Optional import notes." },
},
required: ["planningWorkbookPath", "referenceWorkbookPath", "chargeabilityWorkbookPath"],
},
},
},
{
type: "function",
function: {
name: "validate_dispo_import_batch",
description:
"Validate a Dispo import batch readiness check via the real dispo router without committing anything. Admin role required.",
parameters: {
type: "object",
properties: {
planningWorkbookPath: {
type: "string",
description: "Filesystem path to the planning workbook.",
},
referenceWorkbookPath: {
type: "string",
description: "Filesystem path to the reference workbook.",
},
chargeabilityWorkbookPath: {
type: "string",
description: "Filesystem path to the chargeability workbook.",
},
costWorkbookPath: {
type: "string",
description: "Optional filesystem path to the cost workbook.",
},
rosterWorkbookPath: {
type: "string",
description: "Optional filesystem path to the roster workbook.",
},
importBatchId: {
type: "string",
description: "Optional existing staged import batch ID.",
},
notes: { type: "string", description: "Optional import notes." },
},
required: ["planningWorkbookPath", "referenceWorkbookPath", "chargeabilityWorkbookPath"],
},
},
},
{
type: "function",
function: {
name: "cancel_dispo_import_batch",
description:
"Cancel a staged Dispo import batch via the real dispo router. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Import batch ID." },
},
required: ["id"],
},
},
},
{
type: "function",
function: {
name: "list_dispo_staged_resources",
description:
"List staged Dispo resources for one import batch via the real dispo router. Admin role required.",
parameters: {
type: "object",
properties: {
importBatchId: { type: "string", description: "Import batch ID." },
status: { type: "string", description: "Optional staged record status filter." },
limit: { type: "integer", description: "Max results. Default: 50, max: 200." },
cursor: { type: "string", description: "Optional pagination cursor." },
},
required: ["importBatchId"],
},
},
},
{
type: "function",
function: {
name: "list_dispo_staged_projects",
description:
"List staged Dispo projects for one import batch via the real dispo router. Admin role required.",
parameters: {
type: "object",
properties: {
importBatchId: { type: "string", description: "Import batch ID." },
status: { type: "string", description: "Optional staged record status filter." },
isTbd: { type: "boolean", description: "Optional TBD-project filter." },
limit: { type: "integer", description: "Max results. Default: 50, max: 200." },
cursor: { type: "string", description: "Optional pagination cursor." },
},
required: ["importBatchId"],
},
},
},
{
type: "function",
function: {
name: "list_dispo_staged_assignments",
description:
"List staged Dispo assignments for one import batch via the real dispo router. Admin role required.",
parameters: {
type: "object",
properties: {
importBatchId: { type: "string", description: "Import batch ID." },
status: { type: "string", description: "Optional staged record status filter." },
resourceExternalId: {
type: "string",
description: "Optional resource external ID filter.",
},
limit: { type: "integer", description: "Max results. Default: 50, max: 200." },
cursor: { type: "string", description: "Optional pagination cursor." },
},
required: ["importBatchId"],
},
},
},
{
type: "function",
function: {
name: "list_dispo_staged_vacations",
description:
"List staged Dispo vacations for one import batch via the real dispo router. Admin role required.",
parameters: {
type: "object",
properties: {
importBatchId: { type: "string", description: "Import batch ID." },
resourceExternalId: {
type: "string",
description: "Optional resource external ID filter.",
},
limit: { type: "integer", description: "Max results. Default: 50, max: 200." },
cursor: { type: "string", description: "Optional pagination cursor." },
},
required: ["importBatchId"],
},
},
},
{
type: "function",
function: {
name: "list_dispo_staged_unresolved_records",
description:
"List staged unresolved Dispo records for one import batch via the real dispo router. Admin role required.",
parameters: {
type: "object",
properties: {
importBatchId: { type: "string", description: "Import batch ID." },
recordType: { type: "string", description: "Optional unresolved record type filter." },
limit: { type: "integer", description: "Max results. Default: 50, max: 200." },
cursor: { type: "string", description: "Optional pagination cursor." },
},
required: ["importBatchId"],
},
},
},
{
type: "function",
function: {
name: "resolve_dispo_staged_record",
description:
"Resolve one staged Dispo record via the real dispo router. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Staged record ID." },
recordType: { type: "string", description: "Staged record type." },
action: {
type: "string",
enum: ["APPROVE", "REJECT", "SKIP"],
description: "Resolution action.",
},
},
required: ["id", "recordType", "action"],
},
},
},
{
type: "function",
function: {
name: "commit_dispo_import_batch",
description:
"Commit a staged Dispo import batch via the real dispo router. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
importBatchId: { type: "string", description: "Import batch ID." },
allowTbdUnresolved: {
type: "boolean",
description: "Allow unresolved TBD projects during commit.",
},
importTbdProjects: {
type: "boolean",
description: "Whether TBD projects should be imported.",
},
},
required: ["importBatchId"],
},
},
},
],
{
type: "function",
function: {
name: "list_dispo_import_batches",
description: "List Dispo import batches with pagination and optional status filter via the real dispo router. Admin role required.",
parameters: {
type: "object",
properties: {
status: { type: "string", description: "Optional batch status filter." },
limit: { type: "integer", description: "Max results. Default: 50, max: 200." },
cursor: { type: "string", description: "Optional pagination cursor." },
},
},
export_resources_csv: {
allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES],
},
export_projects_csv: {
allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES],
},
import_csv_data: {
requiredPermissions: [PermissionKey.IMPORT_DATA],
allowedSystemRoles: [...MANAGER_ASSISTANT_ROLES],
},
list_dispo_import_batches: {
allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES],
},
get_dispo_import_batch: {
allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES],
},
stage_dispo_import_batch: {
allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES],
},
validate_dispo_import_batch: {
allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES],
},
cancel_dispo_import_batch: {
allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES],
},
list_dispo_staged_resources: {
allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES],
},
list_dispo_staged_projects: {
allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES],
},
list_dispo_staged_assignments: {
allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES],
},
list_dispo_staged_vacations: {
allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES],
},
list_dispo_staged_unresolved_records: {
allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES],
},
resolve_dispo_staged_record: {
allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES],
},
commit_dispo_import_batch: {
allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES],
},
},
{
type: "function",
function: {
name: "get_dispo_import_batch",
description: "Get one Dispo import batch including staged record counters via the real dispo router. Admin role required.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Import batch ID." },
},
required: ["id"],
},
},
},
{
type: "function",
function: {
name: "stage_dispo_import_batch",
description: "Stage a Dispo import batch via the real dispo router. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
planningWorkbookPath: { type: "string", description: "Filesystem path to the planning workbook." },
referenceWorkbookPath: { type: "string", description: "Filesystem path to the reference workbook." },
chargeabilityWorkbookPath: { type: "string", description: "Filesystem path to the chargeability workbook." },
costWorkbookPath: { type: "string", description: "Optional filesystem path to the cost workbook." },
rosterWorkbookPath: { type: "string", description: "Optional filesystem path to the roster workbook." },
notes: { type: "string", description: "Optional import notes." },
},
required: ["planningWorkbookPath", "referenceWorkbookPath", "chargeabilityWorkbookPath"],
},
},
},
{
type: "function",
function: {
name: "validate_dispo_import_batch",
description: "Validate a Dispo import batch readiness check via the real dispo router without committing anything. Admin role required.",
parameters: {
type: "object",
properties: {
planningWorkbookPath: { type: "string", description: "Filesystem path to the planning workbook." },
referenceWorkbookPath: { type: "string", description: "Filesystem path to the reference workbook." },
chargeabilityWorkbookPath: { type: "string", description: "Filesystem path to the chargeability workbook." },
costWorkbookPath: { type: "string", description: "Optional filesystem path to the cost workbook." },
rosterWorkbookPath: { type: "string", description: "Optional filesystem path to the roster workbook." },
importBatchId: { type: "string", description: "Optional existing staged import batch ID." },
notes: { type: "string", description: "Optional import notes." },
},
required: ["planningWorkbookPath", "referenceWorkbookPath", "chargeabilityWorkbookPath"],
},
},
},
{
type: "function",
function: {
name: "cancel_dispo_import_batch",
description: "Cancel a staged Dispo import batch via the real dispo router. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Import batch ID." },
},
required: ["id"],
},
},
},
{
type: "function",
function: {
name: "list_dispo_staged_resources",
description: "List staged Dispo resources for one import batch via the real dispo router. Admin role required.",
parameters: {
type: "object",
properties: {
importBatchId: { type: "string", description: "Import batch ID." },
status: { type: "string", description: "Optional staged record status filter." },
limit: { type: "integer", description: "Max results. Default: 50, max: 200." },
cursor: { type: "string", description: "Optional pagination cursor." },
},
required: ["importBatchId"],
},
},
},
{
type: "function",
function: {
name: "list_dispo_staged_projects",
description: "List staged Dispo projects for one import batch via the real dispo router. Admin role required.",
parameters: {
type: "object",
properties: {
importBatchId: { type: "string", description: "Import batch ID." },
status: { type: "string", description: "Optional staged record status filter." },
isTbd: { type: "boolean", description: "Optional TBD-project filter." },
limit: { type: "integer", description: "Max results. Default: 50, max: 200." },
cursor: { type: "string", description: "Optional pagination cursor." },
},
required: ["importBatchId"],
},
},
},
{
type: "function",
function: {
name: "list_dispo_staged_assignments",
description: "List staged Dispo assignments for one import batch via the real dispo router. Admin role required.",
parameters: {
type: "object",
properties: {
importBatchId: { type: "string", description: "Import batch ID." },
status: { type: "string", description: "Optional staged record status filter." },
resourceExternalId: { type: "string", description: "Optional resource external ID filter." },
limit: { type: "integer", description: "Max results. Default: 50, max: 200." },
cursor: { type: "string", description: "Optional pagination cursor." },
},
required: ["importBatchId"],
},
},
},
{
type: "function",
function: {
name: "list_dispo_staged_vacations",
description: "List staged Dispo vacations for one import batch via the real dispo router. Admin role required.",
parameters: {
type: "object",
properties: {
importBatchId: { type: "string", description: "Import batch ID." },
resourceExternalId: { type: "string", description: "Optional resource external ID filter." },
limit: { type: "integer", description: "Max results. Default: 50, max: 200." },
cursor: { type: "string", description: "Optional pagination cursor." },
},
required: ["importBatchId"],
},
},
},
{
type: "function",
function: {
name: "list_dispo_staged_unresolved_records",
description: "List staged unresolved Dispo records for one import batch via the real dispo router. Admin role required.",
parameters: {
type: "object",
properties: {
importBatchId: { type: "string", description: "Import batch ID." },
recordType: { type: "string", description: "Optional unresolved record type filter." },
limit: { type: "integer", description: "Max results. Default: 50, max: 200." },
cursor: { type: "string", description: "Optional pagination cursor." },
},
required: ["importBatchId"],
},
},
},
{
type: "function",
function: {
name: "resolve_dispo_staged_record",
description: "Resolve one staged Dispo record via the real dispo router. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Staged record ID." },
recordType: { type: "string", description: "Staged record type." },
action: { type: "string", enum: ["APPROVE", "REJECT", "SKIP"], description: "Resolution action." },
},
required: ["id", "recordType", "action"],
},
},
},
{
type: "function",
function: {
name: "commit_dispo_import_batch",
description: "Commit a staged Dispo import batch via the real dispo router. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
importBatchId: { type: "string", description: "Import batch ID." },
allowTbdUnresolved: { type: "boolean", description: "Allow unresolved TBD projects during commit." },
importTbdProjects: { type: "boolean", description: "Whether TBD projects should be imported." },
},
required: ["importBatchId"],
},
},
},
], {
export_resources_csv: {
allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES],
},
export_projects_csv: {
allowedSystemRoles: [...CONTROLLER_ASSISTANT_ROLES],
},
import_csv_data: {
requiredPermissions: [PermissionKey.IMPORT_DATA],
allowedSystemRoles: [...MANAGER_ASSISTANT_ROLES],
},
list_dispo_import_batches: {
allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES],
},
get_dispo_import_batch: {
allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES],
},
stage_dispo_import_batch: {
allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES],
},
validate_dispo_import_batch: {
allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES],
},
cancel_dispo_import_batch: {
allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES],
},
list_dispo_staged_resources: {
allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES],
},
list_dispo_staged_projects: {
allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES],
},
list_dispo_staged_assignments: {
allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES],
},
list_dispo_staged_vacations: {
allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES],
},
list_dispo_staged_unresolved_records: {
allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES],
},
resolve_dispo_staged_record: {
allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES],
},
commit_dispo_import_batch: {
allowedSystemRoles: [...ADMIN_ASSISTANT_ROLES],
},
});
);
function clampLimit(limit: number | undefined): number | undefined {
return limit !== undefined ? Math.min(Math.max(limit, 1), 200) : undefined;
@@ -444,10 +522,7 @@ export function createImportExportDispoExecutors(
});
},
async get_dispo_import_batch(
params: { id: string },
ctx: ToolContext,
) {
async get_dispo_import_batch(params: { id: string }, ctx: ToolContext) {
const caller = deps.createDispoCaller(deps.createScopedCallerContext(ctx));
try {
return await caller.getImportBatch({ id: params.id });
@@ -476,9 +551,13 @@ export function createImportExportDispoExecutors(
chargeabilityWorkbookPath: params.chargeabilityWorkbookPath,
planningWorkbookPath: params.planningWorkbookPath,
referenceWorkbookPath: params.referenceWorkbookPath,
...(params.costWorkbookPath !== undefined ? { costWorkbookPath: params.costWorkbookPath } : {}),
...(params.costWorkbookPath !== undefined
? { costWorkbookPath: params.costWorkbookPath }
: {}),
...(params.notes !== undefined ? { notes: params.notes } : {}),
...(params.rosterWorkbookPath !== undefined ? { rosterWorkbookPath: params.rosterWorkbookPath } : {}),
...(params.rosterWorkbookPath !== undefined
? { rosterWorkbookPath: params.rosterWorkbookPath }
: {}),
});
},
@@ -499,23 +578,29 @@ export function createImportExportDispoExecutors(
chargeabilityWorkbookPath: params.chargeabilityWorkbookPath,
planningWorkbookPath: params.planningWorkbookPath,
referenceWorkbookPath: params.referenceWorkbookPath,
...(params.costWorkbookPath !== undefined ? { costWorkbookPath: params.costWorkbookPath } : {}),
...(params.costWorkbookPath !== undefined
? { costWorkbookPath: params.costWorkbookPath }
: {}),
...(params.importBatchId !== undefined ? { importBatchId: params.importBatchId } : {}),
...(params.notes !== undefined ? { notes: params.notes } : {}),
...(params.rosterWorkbookPath !== undefined ? { rosterWorkbookPath: params.rosterWorkbookPath } : {}),
...(params.rosterWorkbookPath !== undefined
? { rosterWorkbookPath: params.rosterWorkbookPath }
: {}),
});
},
async cancel_dispo_import_batch(
params: { id: string },
ctx: ToolContext,
) {
async cancel_dispo_import_batch(params: { id: string }, ctx: ToolContext) {
const caller = deps.createDispoCaller(deps.createScopedCallerContext(ctx));
return caller.cancelImportBatch({ id: params.id });
},
async list_dispo_staged_resources(
params: { importBatchId: string; status?: StagedRecordStatus; limit?: number; cursor?: string },
params: {
importBatchId: string;
status?: StagedRecordStatus;
limit?: number;
cursor?: string;
},
ctx: ToolContext,
) {
const limit = clampLimit(params.limit);
@@ -529,7 +614,13 @@ export function createImportExportDispoExecutors(
},
async list_dispo_staged_projects(
params: { importBatchId: string; status?: StagedRecordStatus; isTbd?: boolean; limit?: number; cursor?: string },
params: {
importBatchId: string;
status?: StagedRecordStatus;
isTbd?: boolean;
limit?: number;
cursor?: string;
},
ctx: ToolContext,
) {
const limit = clampLimit(params.limit);
@@ -544,7 +635,13 @@ export function createImportExportDispoExecutors(
},
async list_dispo_staged_assignments(
params: { importBatchId: string; status?: StagedRecordStatus; resourceExternalId?: string; limit?: number; cursor?: string },
params: {
importBatchId: string;
status?: StagedRecordStatus;
resourceExternalId?: string;
limit?: number;
cursor?: string;
},
ctx: ToolContext,
) {
const limit = clampLimit(params.limit);
@@ -552,28 +649,42 @@ export function createImportExportDispoExecutors(
return caller.listStagedAssignments({
importBatchId: params.importBatchId,
...(params.status !== undefined ? { status: params.status } : {}),
...(params.resourceExternalId !== undefined ? { resourceExternalId: params.resourceExternalId } : {}),
...(params.resourceExternalId !== undefined
? { resourceExternalId: params.resourceExternalId }
: {}),
...(params.cursor ? { cursor: params.cursor } : {}),
...(limit !== undefined ? { limit } : {}),
});
},
async list_dispo_staged_vacations(
params: { importBatchId: string; resourceExternalId?: string; limit?: number; cursor?: string },
params: {
importBatchId: string;
resourceExternalId?: string;
limit?: number;
cursor?: string;
},
ctx: ToolContext,
) {
const limit = clampLimit(params.limit);
const caller = deps.createDispoCaller(deps.createScopedCallerContext(ctx));
return caller.listStagedVacations({
importBatchId: params.importBatchId,
...(params.resourceExternalId !== undefined ? { resourceExternalId: params.resourceExternalId } : {}),
...(params.resourceExternalId !== undefined
? { resourceExternalId: params.resourceExternalId }
: {}),
...(params.cursor ? { cursor: params.cursor } : {}),
...(limit !== undefined ? { limit } : {}),
});
},
async list_dispo_staged_unresolved_records(
params: { importBatchId: string; recordType?: DispoStagedRecordType; limit?: number; cursor?: string },
params: {
importBatchId: string;
recordType?: DispoStagedRecordType;
limit?: number;
cursor?: string;
},
ctx: ToolContext,
) {
const limit = clampLimit(params.limit);
@@ -587,7 +698,11 @@ export function createImportExportDispoExecutors(
},
async resolve_dispo_staged_record(
params: { action: "APPROVE" | "REJECT" | "SKIP"; id: string; recordType: DispoStagedRecordType },
params: {
action: "APPROVE" | "REJECT" | "SKIP";
id: string;
recordType: DispoStagedRecordType;
},
ctx: ToolContext,
) {
const caller = deps.createDispoCaller(deps.createScopedCallerContext(ctx));
@@ -605,8 +720,12 @@ export function createImportExportDispoExecutors(
const caller = deps.createDispoCaller(deps.createScopedCallerContext(ctx));
return caller.commitImportBatch({
importBatchId: params.importBatchId,
...(params.allowTbdUnresolved !== undefined ? { allowTbdUnresolved: params.allowTbdUnresolved } : {}),
...(params.importTbdProjects !== undefined ? { importTbdProjects: params.importTbdProjects } : {}),
...(params.allowTbdUnresolved !== undefined
? { allowTbdUnresolved: params.allowTbdUnresolved }
: {}),
...(params.importTbdProjects !== undefined
? { importTbdProjects: params.importTbdProjects }
: {}),
});
},
};
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
import { EstimateStatus, SystemRole } from "@capakraken/shared";
import { EstimateStatus, SystemRole } from "@nexus/shared";
import type { TRPCContext } from "../../trpc.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
@@ -17,21 +17,17 @@ type PlanningNavigationDeps = {
}) => Promise<unknown>;
};
createClientCaller: (ctx: TRPCContext) => {
list: (params: {
isActive: boolean;
search?: string;
}) => Promise<Array<{
id: string;
name: string;
code: string | null;
_count: { projects: number };
}>>;
list: (params: { isActive: boolean; search?: string }) => Promise<
Array<{
id: string;
name: string;
code: string | null;
_count: { projects: number };
}>
>;
};
createOrgUnitCaller: (ctx: TRPCContext) => {
list: (params: {
isActive: boolean;
level?: number;
}) => Promise<Array<{ id: string }>>;
list: (params: { isActive: boolean; level?: number }) => Promise<Array<{ id: string }>>;
getById: (params: { id: string }) => Promise<{
id: string;
name: string;
@@ -71,127 +67,204 @@ type PlanningNavigationDeps = {
parseIsoDate: (value: string, fieldName: string) => Date;
};
export const planningNavigationToolDefinitions: ToolDef[] = withToolAccess([
{
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, IN_REVIEW, APPROVED, ARCHIVED" },
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)" },
},
},
},
},
{
type: "function",
function: {
name: "get_my_timeline_entries_view",
description: "Get the caller's own self-service timeline entries view for a date range using the real timeline self-service endpoint. Returns only data for the caller's linked resource.",
parameters: {
type: "object",
properties: {
startDate: { type: "string", description: "Start date in YYYY-MM-DD." },
endDate: { type: "string", description: "End date in YYYY-MM-DD." },
resourceIds: { type: "array", items: { type: "string" }, description: "Optional filters are accepted but will be scoped to the caller's own linked resource." },
projectIds: { type: "array", items: { type: "string" }, description: "Optional project IDs to narrow the caller's own timeline view." },
clientIds: { type: "array", items: { type: "string" }, description: "Optional client IDs to narrow the caller's own timeline view." },
chapters: { type: "array", items: { type: "string" }, description: "Optional chapter filters. Self-service scoping still applies." },
eids: { type: "array", items: { type: "string" }, description: "Optional employee IDs. Self-service scoping still applies." },
countryCodes: { type: "array", items: { type: "string" }, description: "Optional country codes. Self-service scoping still applies." },
},
required: ["startDate", "endDate"],
},
},
},
{
type: "function",
function: {
name: "get_my_timeline_holiday_overlays",
description: "Get the caller's own self-service holiday overlays for a date range using the real timeline self-service endpoint. Returns only holiday overlays for the caller's linked resource.",
parameters: {
type: "object",
properties: {
startDate: { type: "string", description: "Start date in YYYY-MM-DD." },
endDate: { type: "string", description: "End date in YYYY-MM-DD." },
resourceIds: { type: "array", items: { type: "string" }, description: "Optional filters are accepted but will be scoped to the caller's own linked resource." },
projectIds: { type: "array", items: { type: "string" }, description: "Optional project IDs to narrow the caller's own holiday overlay view." },
clientIds: { type: "array", items: { type: "string" }, description: "Optional client IDs to narrow the caller's own holiday overlay view." },
chapters: { type: "array", items: { type: "string" }, description: "Optional chapter filters. Self-service scoping still applies." },
eids: { type: "array", items: { type: "string" }, description: "Optional employee IDs. Self-service scoping still applies." },
countryCodes: { type: "array", items: { type: "string" }, description: "Optional country codes. Self-service scoping still applies." },
},
required: ["startDate", "endDate"],
},
},
},
{
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",
export const planningNavigationToolDefinitions: ToolDef[] = withToolAccess(
[
{
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, IN_REVIEW, APPROVED, ARCHIVED",
},
limit: { type: "integer", description: "Max results. Default: 20" },
},
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"],
},
},
{
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)" },
},
},
},
},
{
type: "function",
function: {
name: "get_my_timeline_entries_view",
description:
"Get the caller's own self-service timeline entries view for a date range using the real timeline self-service endpoint. Returns only data for the caller's linked resource.",
parameters: {
type: "object",
properties: {
startDate: { type: "string", description: "Start date in YYYY-MM-DD." },
endDate: { type: "string", description: "End date in YYYY-MM-DD." },
resourceIds: {
type: "array",
items: { type: "string" },
description:
"Optional filters are accepted but will be scoped to the caller's own linked resource.",
},
projectIds: {
type: "array",
items: { type: "string" },
description: "Optional project IDs to narrow the caller's own timeline view.",
},
clientIds: {
type: "array",
items: { type: "string" },
description: "Optional client IDs to narrow the caller's own timeline view.",
},
chapters: {
type: "array",
items: { type: "string" },
description: "Optional chapter filters. Self-service scoping still applies.",
},
eids: {
type: "array",
items: { type: "string" },
description: "Optional employee IDs. Self-service scoping still applies.",
},
countryCodes: {
type: "array",
items: { type: "string" },
description: "Optional country codes. Self-service scoping still applies.",
},
},
required: ["startDate", "endDate"],
},
},
},
{
type: "function",
function: {
name: "get_my_timeline_holiday_overlays",
description:
"Get the caller's own self-service holiday overlays for a date range using the real timeline self-service endpoint. Returns only holiday overlays for the caller's linked resource.",
parameters: {
type: "object",
properties: {
startDate: { type: "string", description: "Start date in YYYY-MM-DD." },
endDate: { type: "string", description: "End date in YYYY-MM-DD." },
resourceIds: {
type: "array",
items: { type: "string" },
description:
"Optional filters are accepted but will be scoped to the caller's own linked resource.",
},
projectIds: {
type: "array",
items: { type: "string" },
description: "Optional project IDs to narrow the caller's own holiday overlay view.",
},
clientIds: {
type: "array",
items: { type: "string" },
description: "Optional client IDs to narrow the caller's own holiday overlay view.",
},
chapters: {
type: "array",
items: { type: "string" },
description: "Optional chapter filters. Self-service scoping still applies.",
},
eids: {
type: "array",
items: { type: "string" },
description: "Optional employee IDs. Self-service scoping still applies.",
},
countryCodes: {
type: "array",
items: { type: "string" },
description: "Optional country codes. Self-service scoping still applies.",
},
},
required: ["startDate", "endDate"],
},
},
},
{
type: "function",
function: {
name: "navigate_to_page",
description:
"Navigate the user to a specific page in Nexus, 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"],
},
},
},
],
{
search_estimates: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
list_clients: {
requiresPlanningRead: true,
},
list_org_units: {
requiresResourceOverview: true,
},
},
], {
search_estimates: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
list_clients: {
requiresPlanningRead: true,
},
list_org_units: {
requiresResourceOverview: true,
},
});
);
export function createPlanningNavigationExecutors(
deps: PlanningNavigationDeps,
@@ -223,10 +296,7 @@ export function createPlanningNavigationExecutors(
});
},
async list_clients(
params: { query?: string; limit?: number },
ctx: ToolContext,
) {
async list_clients(params: { query?: string; limit?: number }, ctx: ToolContext) {
const limit = Math.min(params.limit ?? 20, 50);
const caller = deps.createClientCaller(deps.createScopedCallerContext(ctx));
const clients = await caller.list({
@@ -242,10 +312,7 @@ export function createPlanningNavigationExecutors(
}));
},
async list_org_units(
params: { level?: number },
ctx: ToolContext,
) {
async list_org_units(params: { level?: number }, ctx: ToolContext) {
const caller = deps.createOrgUnitCaller(deps.createScopedCallerContext(ctx));
const units = await caller.list({
isActive: true,
@@ -345,7 +412,9 @@ export function createPlanningNavigationExecutors(
};
const path = pageMap[params.page];
if (!path) {
return { error: `Unknown page: ${params.page}. Available: ${Object.keys(pageMap).join(", ")}` };
return {
error: `Unknown page: ${params.page}. Available: ${Object.keys(pageMap).join(", ")}`,
};
}
const queryParts: string[] = [];
@@ -353,7 +422,8 @@ export function createPlanningNavigationExecutors(
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.countryCodes)
queryParts.push(`countryCodes=${encodeURIComponent(params.countryCodes)}`);
if (params.startDate) queryParts.push(`startDate=${encodeURIComponent(params.startDate)}`);
if (params.days) queryParts.push(`days=${params.days}`);
@@ -1,4 +1,9 @@
import { CreateProjectSchema, PermissionKey, type ProjectStatus, UpdateProjectSchema } from "@capakraken/shared";
import {
CreateProjectSchema,
PermissionKey,
type ProjectStatus,
UpdateProjectSchema,
} from "@nexus/shared";
import { fmtEur } from "../../lib/format-utils.js";
import type { TRPCContext } from "../../trpc.js";
import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js";
@@ -50,14 +55,10 @@ type ProjectToolsDeps = {
removeCover: (params: { projectId: string }) => Promise<unknown>;
};
createBlueprintCaller: (ctx: TRPCContext) => {
resolveByIdentifier: (params: {
identifier: string;
}) => Promise<ResolvedReference>;
resolveByIdentifier: (params: { identifier: string }) => Promise<ResolvedReference>;
};
createClientCaller: (ctx: TRPCContext) => {
resolveByIdentifier: (params: {
identifier: string;
}) => Promise<ResolvedReference>;
resolveByIdentifier: (params: { identifier: string }) => Promise<ResolvedReference>;
};
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
resolveProjectIdentifier: (
@@ -72,13 +73,8 @@ type ProjectToolsDeps = {
resolve: () => Promise<T>,
notFoundMessage: string,
) => Promise<T | AssistantToolErrorResult>;
toAssistantNotFoundError: (
error: unknown,
message: string,
) => AssistantToolErrorResult | null;
toAssistantProjectMutationError: (
error: unknown,
) => AssistantToolErrorResult | null;
toAssistantNotFoundError: (error: unknown, message: string) => AssistantToolErrorResult | null;
toAssistantProjectMutationError: (error: unknown) => AssistantToolErrorResult | null;
toAssistantProjectCreationError: (
error: unknown,
shortCode: string,
@@ -99,10 +95,7 @@ async function resolveOptionalReferenceId(
return undefined;
}
const resolved = await deps.resolveEntityOrAssistantError(
resolveReference,
notFoundMessage,
);
const resolved = await deps.resolveEntityOrAssistantError(resolveReference, notFoundMessage);
if ("error" in resolved) {
return resolved;
}
@@ -119,7 +112,10 @@ export const projectReadToolDefinitions: ToolDef[] = [
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" },
status: {
type: "string",
description: "Filter by status: DRAFT, ACTIVE, ON_HOLD, COMPLETED, CANCELLED",
},
limit: { type: "integer", description: "Max results. Default: 20" },
},
},
@@ -129,7 +125,8 @@ export const projectReadToolDefinitions: ToolDef[] = [
type: "function",
function: {
name: "get_project",
description: "Get detailed information about a single project by ID or short code, including top allocations.",
description:
"Get detailed information about a single project by ID or short code, including top allocations.",
parameters: {
type: "object",
properties: {
@@ -146,16 +143,27 @@ export const projectMutationToolDefinitions: ToolDef[] = [
type: "function",
function: {
name: "update_project",
description: "Update a project's details. Requires manageProjects permission. Always confirm with the user before calling this.",
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, short code, or project name" },
name: { type: "string", description: "New project name" },
budgetCents: { type: "integer", description: "New budget in cents (e.g. 10000000 = 100,000 EUR)" },
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)." },
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"],
},
@@ -165,25 +173,60 @@ export const projectMutationToolDefinitions: ToolDef[] = [
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.",
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')" },
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)" },
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)." },
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)" },
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", "responsiblePerson"],
required: [
"shortCode",
"name",
"orderType",
"budgetCents",
"startDate",
"endDate",
"responsiblePerson",
],
},
},
},
@@ -191,7 +234,8 @@ export const projectMutationToolDefinitions: ToolDef[] = [
type: "function",
function: {
name: "delete_project",
description: "Delete a project. Only DRAFT projects can be deleted. Requires manageProjects permission. Always confirm first.",
description:
"Delete a project. Only DRAFT projects can be deleted. Requires manageProjects permission. Always confirm first.",
parameters: {
type: "object",
properties: {
@@ -205,12 +249,17 @@ export const projectMutationToolDefinitions: ToolDef[] = [
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.",
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." },
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"],
},
@@ -232,9 +281,7 @@ export const projectMutationToolDefinitions: ToolDef[] = [
},
];
export function createProjectExecutors(
deps: ProjectToolsDeps,
): Record<string, ToolExecutor> {
export function createProjectExecutors(deps: ProjectToolsDeps): Record<string, ToolExecutor> {
return {
async search_projects(
params: { query?: string; status?: string; limit?: number },
@@ -440,10 +487,7 @@ export function createProjectExecutors(
};
},
async generate_project_cover(
params: { projectId: string; prompt?: string },
ctx: ToolContext,
) {
async generate_project_cover(params: { projectId: string; prompt?: string }, ctx: ToolContext) {
deps.assertPermission(ctx, PermissionKey.MANAGE_PROJECTS);
const caller = deps.createProjectCaller(deps.createScopedCallerContext(ctx));
const project = await deps.resolveProjectIdentifier(ctx, params.projectId);
@@ -1,4 +1,4 @@
import { CreateResourceSchema, PermissionKey, UpdateResourceSchema } from "@capakraken/shared";
import { CreateResourceSchema, PermissionKey, UpdateResourceSchema } from "@nexus/shared";
import type { TRPCContext } from "../../trpc.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
@@ -37,10 +37,7 @@ type ResourceToolsDeps = {
}) => Promise<unknown>;
getByIdentifierDetail: (params: { identifier: string }) => Promise<unknown>;
create: (params: ParsedCreateResourceInput) => Promise<ResourceRecord>;
update: (params: {
id: string;
data: ParsedUpdateResourceInput;
}) => Promise<ResourceRecord>;
update: (params: { id: string; data: ParsedUpdateResourceInput }) => Promise<ResourceRecord>;
deactivate: (params: { id: string }) => Promise<unknown>;
};
createRoleCaller: (ctx: TRPCContext) => {
@@ -61,128 +58,167 @@ type ResourceToolsDeps = {
resolve: () => Promise<T>,
notFoundMessage: string,
) => Promise<T | AssistantToolErrorResult>;
toAssistantResourceMutationError: (
error: unknown,
) => AssistantToolErrorResult | null;
toAssistantResourceCreationError: (
error: unknown,
) => AssistantToolErrorResult | null;
toAssistantResourceMutationError: (error: unknown) => AssistantToolErrorResult | null;
toAssistantResourceCreationError: (error: unknown) => AssistantToolErrorResult | null;
};
export const resourceReadToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
name: "search_resources",
description: "Search for resources (employees) by name, employee ID, chapter, country, metro city, org unit, or role. Resource overview access required. 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" },
export const resourceReadToolDefinitions: ToolDef[] = withToolAccess(
[
{
type: "function",
function: {
name: "search_resources",
description:
"Search for resources (employees) by name, employee ID, chapter, country, metro city, org unit, or role. Resource overview access required. 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" },
{
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"],
},
required: ["identifier"],
},
},
],
{
search_resources: {
requiresResourceOverview: true,
},
},
], {
search_resources: {
requiresResourceOverview: true,
},
});
);
export const resourceMutationToolDefinitions: ToolDef[] = withToolAccess([
{
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, EID, or display name" },
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)" },
export const resourceMutationToolDefinitions: ToolDef[] = withToolAccess(
[
{
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, EID, or display name" },
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"],
},
required: ["id"],
},
},
},
{
type: "function",
function: {
name: "create_resource",
description: "Create a new resource/employee. Requires manageResources permission. Always confirm with the user before calling this.",
parameters: {
type: "object",
properties: {
eid: { type: "string", description: "Employee ID (e.g. EMP-001)" },
displayName: { type: "string", description: "Full name" },
email: { type: "string", description: "Email address" },
fte: { type: "number", description: "Full-time equivalent 0.0-1.0. Default: 1.0" },
lcrCents: { type: "integer", description: "Labor cost rate in cents/hour (e.g. 8500 = 85.00 EUR/h)" },
ucrCents: { type: "integer", description: "Utilization cost rate in cents/hour (optional; defaults to 70% of LCR)" },
chapter: { type: "string", description: "Chapter/department" },
chargeabilityTarget: { type: "number", description: "Target utilization percentage 0-100. Default: 80" },
roleName: { type: "string", description: "Role name to assign (e.g. 'Designer')" },
countryCode: { type: "string", description: "Country code or name (e.g. 'DE', 'Germany')" },
orgUnitName: { type: "string", description: "Organizational unit name" },
postalCode: { type: "string", description: "Postal code. If provided without federalState, state may be inferred." },
{
type: "function",
function: {
name: "create_resource",
description:
"Create a new resource/employee. Requires manageResources permission. Always confirm with the user before calling this.",
parameters: {
type: "object",
properties: {
eid: { type: "string", description: "Employee ID (e.g. EMP-001)" },
displayName: { type: "string", description: "Full name" },
email: { type: "string", description: "Email address" },
fte: { type: "number", description: "Full-time equivalent 0.0-1.0. Default: 1.0" },
lcrCents: {
type: "integer",
description: "Labor cost rate in cents/hour (e.g. 8500 = 85.00 EUR/h)",
},
ucrCents: {
type: "integer",
description: "Utilization cost rate in cents/hour (optional; defaults to 70% of LCR)",
},
chapter: { type: "string", description: "Chapter/department" },
chargeabilityTarget: {
type: "number",
description: "Target utilization percentage 0-100. Default: 80",
},
roleName: { type: "string", description: "Role name to assign (e.g. 'Designer')" },
countryCode: {
type: "string",
description: "Country code or name (e.g. 'DE', 'Germany')",
},
orgUnitName: { type: "string", description: "Organizational unit name" },
postalCode: {
type: "string",
description: "Postal code. If provided without federalState, state may be inferred.",
},
},
required: ["eid", "displayName", "lcrCents"],
},
required: ["eid", "displayName", "lcrCents"],
},
},
},
{
type: "function",
function: {
name: "deactivate_resource",
description: "Deactivate a resource (soft delete). Requires manageResources permission. Always confirm with the user before calling this.",
parameters: {
type: "object",
properties: {
identifier: { type: "string", description: "Resource ID, EID, or display name" },
{
type: "function",
function: {
name: "deactivate_resource",
description:
"Deactivate a resource (soft delete). Requires manageResources permission. Always confirm with the user before calling this.",
parameters: {
type: "object",
properties: {
identifier: { type: "string", description: "Resource ID, EID, or display name" },
},
required: ["identifier"],
},
required: ["identifier"],
},
},
],
{
update_resource: {
requiredPermissions: [PermissionKey.MANAGE_RESOURCES],
},
create_resource: {
requiredPermissions: [PermissionKey.MANAGE_RESOURCES],
},
deactivate_resource: {
requiredPermissions: [PermissionKey.MANAGE_RESOURCES],
},
},
], {
update_resource: {
requiredPermissions: [PermissionKey.MANAGE_RESOURCES],
},
create_resource: {
requiredPermissions: [PermissionKey.MANAGE_RESOURCES],
},
deactivate_resource: {
requiredPermissions: [PermissionKey.MANAGE_RESOURCES],
},
});
);
export function createResourceExecutors(
deps: ResourceToolsDeps,
): Record<string, ToolExecutor> {
export function createResourceExecutors(deps: ResourceToolsDeps): Record<string, ToolExecutor> {
return {
async search_resources(
params: {
@@ -373,10 +409,7 @@ export function createResourceExecutors(
};
},
async deactivate_resource(
params: { identifier: string },
ctx: ToolContext,
) {
async deactivate_resource(params: { identifier: string }, ctx: ToolContext) {
deps.assertPermission(ctx, PermissionKey.MANAGE_RESOURCES);
const resource = await deps.resolveResourceIdentifier(ctx, params.identifier);
if ("error" in resource) {
@@ -1,5 +1,5 @@
import type { TRPCContext } from "../../trpc.js";
import { CreateRoleSchema, PermissionKey, SystemRole, UpdateRoleSchema } from "@capakraken/shared";
import { CreateRoleSchema, PermissionKey, SystemRole, UpdateRoleSchema } from "@nexus/shared";
import { z } from "zod";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
@@ -11,19 +11,18 @@ type ResolvedResource = {
type RolesAnalyticsDeps = {
createRoleCaller: (ctx: TRPCContext) => {
list: (params: Record<string, never>) => Promise<Array<{
id: string;
name: string;
color?: string | null;
}>>;
list: (params: Record<string, never>) => Promise<
Array<{
id: string;
name: string;
color?: string | null;
}>
>;
create: (params: z.input<typeof CreateRoleSchema>) => Promise<{
id: string;
name: string;
}>;
update: (params: {
id: string;
data: z.input<typeof UpdateRoleSchema>;
}) => Promise<{
update: (params: { id: string; data: z.input<typeof UpdateRoleSchema> }) => Promise<{
id: string;
name: string;
}>;
@@ -34,20 +33,19 @@ type RolesAnalyticsDeps = {
searchBySkills: (params: {
rules: Array<{ skill: string; minProficiency: number }>;
operator: "OR";
}) => Promise<Array<{
id: string;
eid: string;
displayName: string;
chapter?: string | null;
matchedSkills: Array<{
skill: string;
proficiency: number;
}>;
}>>;
getChargeabilitySummary: (params: {
resourceId: string;
month: string;
}) => Promise<unknown>;
}) => Promise<
Array<{
id: string;
eid: string;
displayName: string;
chapter?: string | null;
matchedSkills: Array<{
skill: string;
proficiency: number;
}>;
}>
>;
getChargeabilitySummary: (params: { resourceId: string; month: string }) => Promise<unknown>;
};
createDashboardCaller: (ctx: TRPCContext) => {
getStatisticsDetail: () => Promise<unknown>;
@@ -63,130 +61,145 @@ type RolesAnalyticsDeps = {
) => AssistantToolErrorResult | null;
};
export const rolesAnalyticsReadToolDefinitions: ToolDef[] = withToolAccess([
{
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. Controller/manager/admin access required.",
parameters: {
type: "object",
properties: {
skill: { type: "string", description: "Skill name to search for" },
},
required: ["skill"],
export const rolesAnalyticsReadToolDefinitions: ToolDef[] = withToolAccess(
[
{
type: "function",
function: {
name: "list_roles",
description: "List all available roles with their colors.",
parameters: { type: "object", properties: {} },
},
},
},
{
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" },
{
type: "function",
function: {
name: "search_by_skill",
description:
"Find resources that have a specific skill. Controller/manager/admin access required.",
parameters: {
type: "object",
properties: {
skill: { type: "string", description: "Skill name to search for" },
},
required: ["skill"],
},
required: ["resourceId"],
},
},
{
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"],
},
},
},
],
{
list_roles: {
requiresPlanningRead: true,
},
search_by_skill: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_statistics: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_chargeability: {
requiresCostView: true,
},
},
], {
list_roles: {
requiresPlanningRead: true,
},
search_by_skill: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_statistics: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
get_chargeability: {
requiresCostView: true,
},
});
);
export const rolesAnalyticsMutationToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
name: "create_role",
description: "Create a new role. Requires manager or admin role plus manageRoles permission. Always confirm first.",
parameters: {
type: "object",
properties: {
name: { type: "string", description: "Role name" },
description: { type: "string", description: "Optional role description" },
color: { type: "string", description: "Hex color (e.g. #3b82f6). Default: #6b7280" },
export const rolesAnalyticsMutationToolDefinitions: ToolDef[] = withToolAccess(
[
{
type: "function",
function: {
name: "create_role",
description:
"Create a new role. Requires manager or admin role plus manageRoles permission. Always confirm first.",
parameters: {
type: "object",
properties: {
name: { type: "string", description: "Role name" },
description: { type: "string", description: "Optional role description" },
color: { type: "string", description: "Hex color (e.g. #3b82f6). Default: #6b7280" },
},
required: ["name"],
},
required: ["name"],
},
},
},
{
type: "function",
function: {
name: "update_role",
description: "Update a role. Requires manager or admin role plus manageRoles permission. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Role ID" },
name: { type: "string", description: "New name" },
description: { type: "string", description: "New description" },
color: { type: "string", description: "New hex color" },
isActive: { type: "boolean", description: "Set active state" },
{
type: "function",
function: {
name: "update_role",
description:
"Update a role. Requires manager or admin role plus manageRoles permission. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Role ID" },
name: { type: "string", description: "New name" },
description: { type: "string", description: "New description" },
color: { type: "string", description: "New hex color" },
isActive: { type: "boolean", description: "Set active state" },
},
required: ["id"],
},
required: ["id"],
},
},
},
{
type: "function",
function: {
name: "delete_role",
description: "Delete a role. Requires manager or admin role plus manageRoles permission. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Role ID" },
{
type: "function",
function: {
name: "delete_role",
description:
"Delete a role. Requires manager or admin role plus manageRoles permission. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Role ID" },
},
required: ["id"],
},
required: ["id"],
},
},
],
{
create_role: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_ROLES],
},
update_role: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_ROLES],
},
delete_role: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_ROLES],
},
},
], {
create_role: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_ROLES],
},
update_role: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_ROLES],
},
delete_role: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
requiredPermissions: [PermissionKey.MANAGE_ROLES],
},
});
);
export function createRolesAnalyticsExecutors(
deps: RolesAnalyticsDeps,
@@ -226,7 +239,8 @@ export function createRolesAnalyticsExecutors(
async get_chargeability(params: { resourceId: string; month?: string }, ctx: ToolContext) {
const now = new Date();
const month = params.month ?? `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
const month =
params.month ?? `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
const resource = await deps.resolveResourceIdentifier(ctx, params.resourceId);
if ("error" in resource) {
return resource;
@@ -239,11 +253,14 @@ export function createRolesAnalyticsExecutors(
});
},
async create_role(params: {
name: string;
description?: string;
color?: string;
}, ctx: ToolContext) {
async create_role(
params: {
name: string;
description?: string;
color?: string;
},
ctx: ToolContext,
) {
const caller = deps.createRoleCaller(deps.createScopedCallerContext(ctx));
let role;
@@ -267,13 +284,16 @@ export function createRolesAnalyticsExecutors(
};
},
async update_role(params: {
id: string;
name?: string;
description?: string;
color?: string;
isActive?: boolean;
}, ctx: ToolContext) {
async update_role(
params: {
id: string;
name?: string;
description?: string;
color?: string;
isActive?: boolean;
},
ctx: ToolContext,
) {
const caller = deps.createRoleCaller(deps.createScopedCallerContext(ctx));
const data = UpdateRoleSchema.parse({
...(params.name !== undefined ? { name: params.name } : {}),
@@ -1,4 +1,4 @@
import { PermissionKey, SystemRole } from "@capakraken/shared";
import { PermissionKey, SystemRole } from "@nexus/shared";
import type { TRPCContext } from "../../trpc.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
@@ -76,81 +76,93 @@ type ScenarioRateAnalysisDeps = {
fmtEur: (value: number) => string;
};
export const scenarioRateAnalysisToolDefinitions: ToolDef[] = withToolAccess([
{
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" },
},
},
},
},
{
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",
export const scenarioRateAnalysisToolDefinitions: ToolDef[] = withToolAccess(
[
{
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" },
},
},
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" },
{
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"],
},
required: ["projectId"],
},
},
{
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"],
},
},
},
],
{
lookup_rate: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiredPermissions: [PermissionKey.VIEW_COSTS],
},
simulate_scenario: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
generate_project_narrative: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
},
], {
lookup_rate: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
requiredPermissions: [PermissionKey.VIEW_COSTS],
},
simulate_scenario: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
generate_project_narrative: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
},
});
);
export function createScenarioRateAnalysisExecutors(
deps: ScenarioRateAnalysisDeps,
@@ -176,7 +188,9 @@ export function createScenarioRateAnalysisExecutors(
? {
...result.bestMatch,
costRate: deps.fmtEur(result.bestMatch.costRateCents),
billRate: result.bestMatch.billRateCents ? deps.fmtEur(result.bestMatch.billRateCents) : null,
billRate: result.bestMatch.billRateCents
? deps.fmtEur(result.bestMatch.billRateCents)
: null,
}
: null,
alternatives: result.alternatives.map((alternative) => ({
@@ -232,10 +246,7 @@ export function createScenarioRateAnalysisExecutors(
};
},
async generate_project_narrative(
params: { projectId: string },
ctx: ToolContext,
) {
async generate_project_narrative(params: { projectId: string }, ctx: ToolContext) {
const caller = deps.createInsightsCaller(deps.createScopedCallerContext(ctx));
return caller.generateProjectNarrative({ projectId: params.projectId });
},
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
import type { prisma } from "@capakraken/db";
import type { PermissionKey, SystemRole } from "@capakraken/shared";
import type { prisma } from "@nexus/db";
import type { PermissionKey, SystemRole } from "@nexus/shared";
import type { z } from "zod";
import type { TRPCContext } from "../../trpc.js";
@@ -1,4 +1,4 @@
import { AllocationStatus, PermissionKey, SystemRole } from "@capakraken/shared";
import { AllocationStatus, PermissionKey, SystemRole } from "@nexus/shared";
import type { TRPCContext } from "../../trpc.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
@@ -112,144 +112,157 @@ type StaffingDemandDeps = {
parseIsoDate: (value: string, fieldName: string) => Date;
parseOptionalIsoDate: (value: string | undefined, fieldName: string) => Date | undefined;
fmtDate: (value: Date | null | undefined) => string | null;
toAssistantDemandCreationError: (
error: unknown,
) => AssistantToolErrorResult | null;
toAssistantDemandFillError: (
error: unknown,
) => AssistantToolErrorResult | null;
toAssistantDemandCreationError: (error: unknown) => AssistantToolErrorResult | null;
toAssistantDemandFillError: (error: unknown) => AssistantToolErrorResult | null;
};
export const staffingDemandReadToolDefinitions: ToolDef[] = withToolAccess([
{
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" },
export const staffingDemandReadToolDefinitions: ToolDef[] = withToolAccess(
[
{
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: "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" },
{
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"],
},
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" },
{
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"],
},
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" },
{
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"],
},
required: ["startDate", "endDate"],
},
},
],
{
list_demands: {
requiresPlanningRead: true,
},
check_resource_availability: {
requiresPlanningRead: true,
},
get_staffing_suggestions: {
requiresPlanningRead: true,
requiresCostView: true,
},
find_capacity: {
requiresPlanningRead: true,
},
},
], {
list_demands: {
requiresPlanningRead: true,
},
check_resource_availability: {
requiresPlanningRead: true,
},
get_staffing_suggestions: {
requiresPlanningRead: true,
requiresCostView: true,
},
find_capacity: {
requiresPlanningRead: true,
},
});
);
export const staffingDemandMutationToolDefinitions: ToolDef[] = withToolAccess([
{
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" },
export const staffingDemandMutationToolDefinitions: ToolDef[] = withToolAccess(
[
{
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"],
},
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" },
{
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"],
},
required: ["demandId", "resourceId"],
},
},
],
{
create_demand: {
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
fill_demand: {
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
},
], {
create_demand: {
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
fill_demand: {
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
});
);
export function createStaffingDemandExecutors(
deps: StaffingDemandDeps,
@@ -273,19 +286,21 @@ export function createStaffingDemandExecutors(
...(params.status ? { status: params.status as AllocationStatus } : {}),
});
return demands.map((demand) => ({
id: demand.id,
project: demand.project.name,
projectCode: demand.project.shortCode,
role: demand.roleEntity?.name ?? demand.role ?? "Unspecified",
status: demand.status,
headcount: demand.headcount,
filled: demand.assignments.length,
remaining: demand.headcount - demand.assignments.length,
hoursPerDay: demand.hoursPerDay,
start: deps.fmtDate(demand.startDate),
end: deps.fmtDate(demand.endDate),
})).slice(0, limit);
return demands
.map((demand) => ({
id: demand.id,
project: demand.project.name,
projectCode: demand.project.shortCode,
role: demand.roleEntity?.name ?? demand.role ?? "Unspecified",
status: demand.status,
headcount: demand.headcount,
filled: demand.assignments.length,
remaining: demand.headcount - demand.assignments.length,
hoursPerDay: demand.hoursPerDay,
start: deps.fmtDate(demand.startDate),
end: deps.fmtDate(demand.endDate),
}))
.slice(0, limit);
},
async create_demand(
@@ -346,10 +361,7 @@ export function createStaffingDemandExecutors(
};
},
async fill_demand(
params: { demandId: string; resourceId: string },
ctx: ToolContext,
) {
async fill_demand(params: { demandId: string; resourceId: string }, ctx: ToolContext) {
deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const allocationCaller = deps.createAllocationCaller(deps.createScopedCallerContext(ctx));
const resource = await deps.resolveResourceIdentifier(ctx, params.resourceId);
@@ -371,9 +383,8 @@ export function createStaffingDemandExecutors(
throw error;
}
const roleName = result.demandRequirement.roleEntity?.name
?? result.demandRequirement.role
?? null;
const roleName =
result.demandRequirement.roleEntity?.name ?? result.demandRequirement.role ?? null;
return {
__action: "invalidate",
@@ -1,4 +1,4 @@
import { SystemRole } from "@capakraken/shared";
import { SystemRole } from "@nexus/shared";
import type { TRPCContext } from "../../trpc.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
@@ -22,9 +22,10 @@ type UserAdminDeps = {
setPassword: (params: { userId: string; password: string }) => Promise<Record<string, unknown>>;
updateRole: (params: { id: string; systemRole: SystemRole }) => Promise<UserRecord>;
updateName: (params: { id: string; name: string }) => Promise<UserRecord>;
linkResource: (
params: { userId: string; resourceId: string | null },
) => Promise<Record<string, unknown>>;
linkResource: (params: {
userId: string;
resourceId: string | null;
}) => Promise<Record<string, unknown>>;
autoLinkAllByEmail: () => Promise<Record<string, unknown> & { linked: number }>;
setPermissions: (params: {
userId: string;
@@ -43,214 +44,228 @@ type UserAdminDeps = {
error: unknown,
mode?: "create" | "password",
) => AssistantToolErrorResult | null;
toAssistantUserResourceLinkError: (
error: unknown,
) => AssistantToolErrorResult | null;
toAssistantUserResourceLinkError: (error: unknown) => AssistantToolErrorResult | null;
};
export const userAdminToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
name: "list_users",
description: "List all system users via the admin user router, including role and MFA state. Admin role required.",
parameters: {
type: "object",
properties: {
limit: { type: "integer", description: "Max results. Default: 50" },
},
},
},
},
{
type: "function",
function: {
name: "create_user",
description: "Create a new system user and auto-link a matching resource by email when possible. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
email: { type: "string", description: "User email address." },
name: { type: "string", description: "Display name." },
systemRole: { type: "string", enum: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"], description: "Initial system role." },
password: { type: "string", description: "Initial password, minimum 8 characters." },
},
required: ["email", "name", "password"],
},
},
},
{
type: "function",
function: {
name: "set_user_password",
description: "Reset a user's password. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
userId: { type: "string", description: "User ID." },
password: { type: "string", description: "New password, minimum 8 characters." },
},
required: ["userId", "password"],
},
},
},
{
type: "function",
function: {
name: "update_user_role",
description: "Change a user's system role. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "User ID." },
systemRole: { type: "string", enum: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
},
required: ["id", "systemRole"],
},
},
},
{
type: "function",
function: {
name: "update_user_name",
description: "Rename a user. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "User ID." },
name: { type: "string", description: "New display name." },
},
required: ["id", "name"],
},
},
},
{
type: "function",
function: {
name: "link_user_resource",
description: "Link or unlink a user to a resource. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
userId: { type: "string", description: "User ID." },
resourceId: { type: ["string", "null"], description: "Resource ID or null to unlink." },
},
required: ["userId"],
},
},
},
{
type: "function",
function: {
name: "auto_link_users_by_email",
description: "Auto-link all users without a resource to matching resources by email. Admin role required. Always confirm first.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "set_user_permissions",
description: "Set explicit permission overrides for a user. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
userId: { type: "string", description: "User ID." },
overrides: {
type: ["object", "null"],
properties: {
granted: { type: "array", items: { type: "string" } },
denied: { type: "array", items: { type: "string" } },
chapterIds: { type: "array", items: { type: "string" } },
},
description: "Permission override object or null to clear.",
export const userAdminToolDefinitions: ToolDef[] = withToolAccess(
[
{
type: "function",
function: {
name: "list_users",
description:
"List all system users via the admin user router, including role and MFA state. Admin role required.",
parameters: {
type: "object",
properties: {
limit: { type: "integer", description: "Max results. Default: 50" },
},
},
required: ["userId"],
},
},
},
{
type: "function",
function: {
name: "reset_user_permissions",
description: "Reset a user's permission overrides back to role defaults. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
userId: { type: "string", description: "User ID." },
{
type: "function",
function: {
name: "create_user",
description:
"Create a new system user and auto-link a matching resource by email when possible. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
email: { type: "string", description: "User email address." },
name: { type: "string", description: "Display name." },
systemRole: {
type: "string",
enum: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"],
description: "Initial system role.",
},
password: { type: "string", description: "Initial password, minimum 8 characters." },
},
required: ["email", "name", "password"],
},
required: ["userId"],
},
},
},
{
type: "function",
function: {
name: "get_effective_user_permissions",
description: "Get a user's resolved permissions, role, and explicit overrides. Admin role required.",
parameters: {
type: "object",
properties: {
userId: { type: "string", description: "User ID." },
{
type: "function",
function: {
name: "set_user_password",
description: "Reset a user's password. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
userId: { type: "string", description: "User ID." },
password: { type: "string", description: "New password, minimum 8 characters." },
},
required: ["userId", "password"],
},
required: ["userId"],
},
},
},
{
type: "function",
function: {
name: "disable_user_totp",
description: "Disable MFA TOTP for a user as an admin override. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
userId: { type: "string", description: "User ID." },
{
type: "function",
function: {
name: "update_user_role",
description: "Change a user's system role. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "User ID." },
systemRole: {
type: "string",
enum: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"],
},
},
required: ["id", "systemRole"],
},
required: ["userId"],
},
},
{
type: "function",
function: {
name: "update_user_name",
description: "Rename a user. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "User ID." },
name: { type: "string", description: "New display name." },
},
required: ["id", "name"],
},
},
},
{
type: "function",
function: {
name: "link_user_resource",
description:
"Link or unlink a user to a resource. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
userId: { type: "string", description: "User ID." },
resourceId: { type: ["string", "null"], description: "Resource ID or null to unlink." },
},
required: ["userId"],
},
},
},
{
type: "function",
function: {
name: "auto_link_users_by_email",
description:
"Auto-link all users without a resource to matching resources by email. Admin role required. Always confirm first.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "set_user_permissions",
description:
"Set explicit permission overrides for a user. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
userId: { type: "string", description: "User ID." },
overrides: {
type: ["object", "null"],
properties: {
granted: { type: "array", items: { type: "string" } },
denied: { type: "array", items: { type: "string" } },
chapterIds: { type: "array", items: { type: "string" } },
},
description: "Permission override object or null to clear.",
},
},
required: ["userId"],
},
},
},
{
type: "function",
function: {
name: "reset_user_permissions",
description:
"Reset a user's permission overrides back to role defaults. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
userId: { type: "string", description: "User ID." },
},
required: ["userId"],
},
},
},
{
type: "function",
function: {
name: "get_effective_user_permissions",
description:
"Get a user's resolved permissions, role, and explicit overrides. Admin role required.",
parameters: {
type: "object",
properties: {
userId: { type: "string", description: "User ID." },
},
required: ["userId"],
},
},
},
{
type: "function",
function: {
name: "disable_user_totp",
description:
"Disable MFA TOTP for a user as an admin override. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
userId: { type: "string", description: "User ID." },
},
required: ["userId"],
},
},
},
],
{
list_users: {
allowedSystemRoles: [SystemRole.ADMIN],
},
create_user: {
allowedSystemRoles: [SystemRole.ADMIN],
},
set_user_password: {
allowedSystemRoles: [SystemRole.ADMIN],
},
update_user_role: {
allowedSystemRoles: [SystemRole.ADMIN],
},
update_user_name: {
allowedSystemRoles: [SystemRole.ADMIN],
},
link_user_resource: {
allowedSystemRoles: [SystemRole.ADMIN],
},
auto_link_users_by_email: {
allowedSystemRoles: [SystemRole.ADMIN],
},
set_user_permissions: {
allowedSystemRoles: [SystemRole.ADMIN],
},
reset_user_permissions: {
allowedSystemRoles: [SystemRole.ADMIN],
},
get_effective_user_permissions: {
allowedSystemRoles: [SystemRole.ADMIN],
},
disable_user_totp: {
allowedSystemRoles: [SystemRole.ADMIN],
},
},
], {
list_users: {
allowedSystemRoles: [SystemRole.ADMIN],
},
create_user: {
allowedSystemRoles: [SystemRole.ADMIN],
},
set_user_password: {
allowedSystemRoles: [SystemRole.ADMIN],
},
update_user_role: {
allowedSystemRoles: [SystemRole.ADMIN],
},
update_user_name: {
allowedSystemRoles: [SystemRole.ADMIN],
},
link_user_resource: {
allowedSystemRoles: [SystemRole.ADMIN],
},
auto_link_users_by_email: {
allowedSystemRoles: [SystemRole.ADMIN],
},
set_user_permissions: {
allowedSystemRoles: [SystemRole.ADMIN],
},
reset_user_permissions: {
allowedSystemRoles: [SystemRole.ADMIN],
},
get_effective_user_permissions: {
allowedSystemRoles: [SystemRole.ADMIN],
},
disable_user_totp: {
allowedSystemRoles: [SystemRole.ADMIN],
},
});
);
export function createUserAdminExecutors(
deps: UserAdminDeps,
): Record<string, ToolExecutor> {
export function createUserAdminExecutors(deps: UserAdminDeps): Record<string, ToolExecutor> {
return {
async list_users(params: { limit?: number }, ctx: ToolContext) {
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
@@ -258,12 +273,15 @@ export function createUserAdminExecutors(
return users.slice(0, Math.min(params.limit ?? 50, 100));
},
async create_user(params: {
email: string;
name: string;
systemRole?: SystemRole;
password: string;
}, ctx: ToolContext) {
async create_user(
params: {
email: string;
name: string;
systemRole?: SystemRole;
password: string;
},
ctx: ToolContext,
) {
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
let user;
try {
@@ -354,7 +372,10 @@ export function createUserAdminExecutors(
};
},
async link_user_resource(params: { userId: string; resourceId?: string | null }, ctx: ToolContext) {
async link_user_resource(
params: { userId: string; resourceId?: string | null },
ctx: ToolContext,
) {
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
let result;
try {
@@ -389,14 +410,17 @@ export function createUserAdminExecutors(
};
},
async set_user_permissions(params: {
userId: string;
overrides?: {
granted?: string[];
denied?: string[];
chapterIds?: string[];
} | null;
}, ctx: ToolContext) {
async set_user_permissions(
params: {
userId: string;
overrides?: {
granted?: string[];
denied?: string[];
chapterIds?: string[];
} | null;
},
ctx: ToolContext,
) {
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
let user;
try {
@@ -417,7 +441,9 @@ export function createUserAdminExecutors(
success: true,
user,
userId: user.id,
message: params.overrides ? "Updated user permission overrides." : "Cleared user permission overrides.",
message: params.overrides
? "Updated user permission overrides."
: "Cleared user permission overrides.",
};
},
@@ -1,4 +1,4 @@
import { SystemRole } from "@capakraken/shared";
import { SystemRole } from "@nexus/shared";
import type { TRPCContext } from "../../trpc.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
@@ -20,9 +20,9 @@ type UserSelfServiceDeps = {
getDashboardLayout: () => Promise<unknown>;
saveDashboardLayout: (params: { layout: unknown[] }) => Promise<Record<string, unknown>>;
getFavoriteProjectIds: () => Promise<unknown>;
toggleFavoriteProject: (
params: { projectId: string },
) => Promise<Record<string, unknown> & { added: boolean }>;
toggleFavoriteProject: (params: {
projectId: string;
}) => Promise<Record<string, unknown> & { added: boolean }>;
getColumnPreferences: () => Promise<unknown>;
setColumnPreferences: (params: {
view: ColumnPreferenceView;
@@ -36,169 +36,184 @@ type UserSelfServiceDeps = {
activeCount: () => Promise<unknown>;
};
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
toAssistantCurrentUserError: (
error: unknown,
) => AssistantToolErrorResult | null;
toAssistantTotpEnableError: (
error: unknown,
) => AssistantToolErrorResult | null;
toAssistantCurrentUserError: (error: unknown) => AssistantToolErrorResult | null;
toAssistantTotpEnableError: (error: unknown) => AssistantToolErrorResult | null;
};
export const userSelfServiceToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
name: "list_assignable_users",
description: "List lightweight users available for assignment workflows. Manager or admin role required.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "get_current_user",
description: "Get the authenticated user's own profile, role, and permission overrides.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "get_dashboard_layout",
description: "Get the authenticated user's saved dashboard widget layout and last update timestamp.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "save_dashboard_layout",
description: "Save the authenticated user's dashboard layout. Always confirm first.",
parameters: {
type: "object",
properties: {
layout: {
type: "array",
description: "Dashboard layout items as stored by the user router.",
items: { type: "object" },
},
},
required: ["layout"],
export const userSelfServiceToolDefinitions: ToolDef[] = withToolAccess(
[
{
type: "function",
function: {
name: "list_assignable_users",
description:
"List lightweight users available for assignment workflows. Manager or admin role required.",
parameters: { type: "object", properties: {} },
},
},
},
{
type: "function",
function: {
name: "get_favorite_project_ids",
description: "Get the authenticated user's favorite project IDs.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "toggle_favorite_project",
description: "Add or remove a project from the authenticated user's favorites. Always confirm first.",
parameters: {
type: "object",
properties: {
projectId: { type: "string", description: "Project ID." },
},
required: ["projectId"],
{
type: "function",
function: {
name: "get_current_user",
description: "Get the authenticated user's own profile, role, and permission overrides.",
parameters: { type: "object", properties: {} },
},
},
},
{
type: "function",
function: {
name: "get_column_preferences",
description: "Get the authenticated user's saved table column preferences for all supported views.",
parameters: { type: "object", properties: {} },
{
type: "function",
function: {
name: "get_dashboard_layout",
description:
"Get the authenticated user's saved dashboard widget layout and last update timestamp.",
parameters: { type: "object", properties: {} },
},
},
},
{
type: "function",
function: {
name: "set_column_preferences",
description: "Update the authenticated user's table column preferences for one view. Always confirm first.",
parameters: {
type: "object",
properties: {
view: {
type: "string",
enum: ["resources", "projects", "allocations", "vacations", "roles", "users", "blueprints"],
description: "View key to update.",
},
visible: {
type: "array",
items: { type: "string" },
description: "Visible column IDs.",
},
sort: {
type: ["object", "null"],
properties: {
field: { type: "string" },
dir: { type: "string", enum: ["asc", "desc"] },
{
type: "function",
function: {
name: "save_dashboard_layout",
description: "Save the authenticated user's dashboard layout. Always confirm first.",
parameters: {
type: "object",
properties: {
layout: {
type: "array",
description: "Dashboard layout items as stored by the user router.",
items: { type: "object" },
},
description: "Sort state. Use null to clear it.",
},
rowOrder: {
type: ["array", "null"],
items: { type: "string" },
description: "Optional row order. Use null to clear it.",
},
required: ["layout"],
},
required: ["view"],
},
},
},
{
type: "function",
function: {
name: "generate_totp_secret",
description: "Generate a new MFA TOTP secret and provisioning URI for the authenticated user. Always confirm first. The secret is sensitive.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "verify_and_enable_totp",
description: "Verify a 6-digit MFA TOTP token and enable MFA for the authenticated user. Always confirm first.",
parameters: {
type: "object",
properties: {
token: { type: "string", description: "6-digit TOTP token." },
},
required: ["token"],
{
type: "function",
function: {
name: "get_favorite_project_ids",
description: "Get the authenticated user's favorite project IDs.",
parameters: { type: "object", properties: {} },
},
},
},
{
type: "function",
function: {
name: "toggle_favorite_project",
description:
"Add or remove a project from the authenticated user's favorites. Always confirm first.",
parameters: {
type: "object",
properties: {
projectId: { type: "string", description: "Project ID." },
},
required: ["projectId"],
},
},
},
{
type: "function",
function: {
name: "get_column_preferences",
description:
"Get the authenticated user's saved table column preferences for all supported views.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "set_column_preferences",
description:
"Update the authenticated user's table column preferences for one view. Always confirm first.",
parameters: {
type: "object",
properties: {
view: {
type: "string",
enum: [
"resources",
"projects",
"allocations",
"vacations",
"roles",
"users",
"blueprints",
],
description: "View key to update.",
},
visible: {
type: "array",
items: { type: "string" },
description: "Visible column IDs.",
},
sort: {
type: ["object", "null"],
properties: {
field: { type: "string" },
dir: { type: "string", enum: ["asc", "desc"] },
},
description: "Sort state. Use null to clear it.",
},
rowOrder: {
type: ["array", "null"],
items: { type: "string" },
description: "Optional row order. Use null to clear it.",
},
},
required: ["view"],
},
},
},
{
type: "function",
function: {
name: "generate_totp_secret",
description:
"Generate a new MFA TOTP secret and provisioning URI for the authenticated user. Always confirm first. The secret is sensitive.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "verify_and_enable_totp",
description:
"Verify a 6-digit MFA TOTP token and enable MFA for the authenticated user. Always confirm first.",
parameters: {
type: "object",
properties: {
token: { type: "string", description: "6-digit TOTP token." },
},
required: ["token"],
},
},
},
{
type: "function",
function: {
name: "get_mfa_status",
description: "Get the authenticated user's MFA status.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "get_active_user_count",
description:
"Get the number of users active in the last five minutes. Admin role required.",
parameters: { type: "object", properties: {} },
},
},
],
{
type: "function",
function: {
name: "get_mfa_status",
description: "Get the authenticated user's MFA status.",
parameters: { type: "object", properties: {} },
list_assignable_users: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
get_active_user_count: {
allowedSystemRoles: [SystemRole.ADMIN],
},
},
{
type: "function",
function: {
name: "get_active_user_count",
description: "Get the number of users active in the last five minutes. Admin role required.",
parameters: { type: "object", properties: {} },
},
},
], {
list_assignable_users: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
get_active_user_count: {
allowedSystemRoles: [SystemRole.ADMIN],
},
});
);
export function createUserSelfServiceExecutors(
deps: UserSelfServiceDeps,
@@ -233,8 +248,8 @@ export function createUserSelfServiceExecutors(
async save_dashboard_layout(params: { layout: unknown[] }, ctx: ToolContext) {
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
const result = await withCurrentUserErrorMapping(
() => caller.saveDashboardLayout({ layout: params.layout }),
const result = await withCurrentUserErrorMapping(() =>
caller.saveDashboardLayout({ layout: params.layout }),
);
if ("error" in result) {
return result;
@@ -255,8 +270,8 @@ export function createUserSelfServiceExecutors(
async toggle_favorite_project(params: { projectId: string }, ctx: ToolContext) {
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
const result = await withCurrentUserErrorMapping(
() => caller.toggleFavoriteProject({ projectId: params.projectId }),
const result = await withCurrentUserErrorMapping(() =>
caller.toggleFavoriteProject({ projectId: params.projectId }),
);
if ("error" in result) {
return result;
@@ -275,19 +290,24 @@ export function createUserSelfServiceExecutors(
return withCurrentUserErrorMapping(() => caller.getColumnPreferences());
},
async set_column_preferences(params: {
view: ColumnPreferenceView;
visible?: string[];
sort?: { field: string; dir: "asc" | "desc" } | null;
rowOrder?: string[] | null;
}, ctx: ToolContext) {
async set_column_preferences(
params: {
view: ColumnPreferenceView;
visible?: string[];
sort?: { field: string; dir: "asc" | "desc" } | null;
rowOrder?: string[] | null;
},
ctx: ToolContext,
) {
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
const result = await withCurrentUserErrorMapping(() => caller.setColumnPreferences({
view: params.view,
...(params.visible !== undefined ? { visible: params.visible } : {}),
...(params.sort !== undefined ? { sort: params.sort } : {}),
...(params.rowOrder !== undefined ? { rowOrder: params.rowOrder } : {}),
}));
const result = await withCurrentUserErrorMapping(() =>
caller.setColumnPreferences({
view: params.view,
...(params.visible !== undefined ? { visible: params.visible } : {}),
...(params.sort !== undefined ? { sort: params.sort } : {}),
...(params.rowOrder !== undefined ? { rowOrder: params.rowOrder } : {}),
}),
);
if ("error" in result) {
return result;
}
@@ -1,5 +1,5 @@
import { VacationType } from "@capakraken/db";
import { SystemRole } from "@capakraken/shared";
import { VacationType } from "@nexus/db";
import { SystemRole } from "@nexus/shared";
import type { TRPCContext } from "../../trpc.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
@@ -56,15 +56,8 @@ type VacationEntitlementDeps = {
}) => Promise<unknown>;
};
createEntitlementCaller: (ctx: TRPCContext) => {
getYearSummaryDetail: (params: {
year: number;
resourceName?: string;
}) => Promise<unknown>;
set: (params: {
resourceId: string;
year: number;
entitledDays: number;
}) => Promise<unknown>;
getYearSummaryDetail: (params: { year: number; resourceName?: string }) => Promise<unknown>;
set: (params: { resourceId: string; year: number; entitledDays: number }) => Promise<unknown>;
};
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
resolveResourceIdentifier: (
@@ -74,162 +67,168 @@ type VacationEntitlementDeps = {
parseIsoDate: (value: string, fieldName: string) => Date;
fmtDate: (value: Date | null | undefined) => string | null;
parseAssistantVacationRequestType: (input: string) => VacationType;
toAssistantVacationCreationError: (
error: unknown,
) => AssistantToolErrorResult | null;
toAssistantVacationCreationError: (error: unknown) => AssistantToolErrorResult | null;
toAssistantVacationMutationError: (
error: unknown,
action: "approve" | "reject" | "cancel",
) => AssistantToolErrorResult | null;
toAssistantEntitlementMutationError: (
error: unknown,
) => AssistantToolErrorResult | null;
toAssistantEntitlementMutationError: (error: unknown) => AssistantToolErrorResult | null;
};
export const vacationEntitlementToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
name: "create_vacation",
description: "Create a vacation/leave request through the real vacation workflow. Any authenticated user can request leave for their own resource; manager/admin can create requests for others. Always confirm with the user.",
parameters: {
type: "object",
properties: {
resourceId: { type: "string", description: "Resource ID, EID, or display name" },
type: {
type: "string",
enum: ["ANNUAL", "SICK", "OTHER"],
description: "Vacation type. PUBLIC_HOLIDAY requests are managed through holiday calendars, not manual vacation requests.",
export const vacationEntitlementToolDefinitions: ToolDef[] = withToolAccess(
[
{
type: "function",
function: {
name: "create_vacation",
description:
"Create a vacation/leave request through the real vacation workflow. Any authenticated user can request leave for their own resource; manager/admin can create requests for others. Always confirm with the user.",
parameters: {
type: "object",
properties: {
resourceId: { type: "string", description: "Resource ID, EID, or display name" },
type: {
type: "string",
enum: ["ANNUAL", "SICK", "OTHER"],
description:
"Vacation type. PUBLIC_HOLIDAY requests are managed through holiday calendars, not manual vacation requests.",
},
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" },
},
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"],
},
required: ["resourceId", "type", "startDate", "endDate"],
},
},
},
{
type: "function",
function: {
name: "approve_vacation",
description:
"Approve a vacation request through the real vacation workflow. Manager or admin role required. 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 through the real vacation workflow. Manager or admin role required. 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 request through the real vacation workflow. Users can cancel their own requests; manager/admin can cancel any request. 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"],
},
},
},
{
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 the annual vacation entitlement for a resource/year through the real entitlement workflow. Manager or admin role required. Carryover is computed automatically. Always confirm first.",
parameters: {
type: "object",
properties: {
resourceId: { type: "string", description: "Resource ID, EID, or display name" },
year: { type: "integer", description: "Year" },
entitledDays: { type: "number", description: "Number of entitled vacation days" },
},
required: ["resourceId", "year", "entitledDays"],
},
},
},
],
{
type: "function",
function: {
name: "approve_vacation",
description: "Approve a vacation request through the real vacation workflow. Manager or admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
vacationId: { type: "string", description: "Vacation ID" },
},
required: ["vacationId"],
},
approve_vacation: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
reject_vacation: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
get_pending_vacation_approvals: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
get_entitlement_summary: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
set_entitlement: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
},
{
type: "function",
function: {
name: "reject_vacation",
description: "Reject a pending vacation request through the real vacation workflow. Manager or admin role required. 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 request through the real vacation workflow. Users can cancel their own requests; manager/admin can cancel any request. 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"],
},
},
},
{
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 the annual vacation entitlement for a resource/year through the real entitlement workflow. Manager or admin role required. Carryover is computed automatically. Always confirm first.",
parameters: {
type: "object",
properties: {
resourceId: { type: "string", description: "Resource ID, EID, or display name" },
year: { type: "integer", description: "Year" },
entitledDays: { type: "number", description: "Number of entitled vacation days" },
},
required: ["resourceId", "year", "entitledDays"],
},
},
},
], {
approve_vacation: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
reject_vacation: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
get_pending_vacation_approvals: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
get_entitlement_summary: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
set_entitlement: {
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
},
});
);
export function createVacationEntitlementExecutors(
deps: VacationEntitlementDeps,
@@ -262,7 +261,9 @@ export function createVacationEntitlementExecutors(
startDate: deps.parseIsoDate(params.startDate, "startDate"),
endDate: deps.parseIsoDate(params.endDate, "endDate"),
...(params.isHalfDay !== undefined ? { isHalfDay: params.isHalfDay } : {}),
...(params.halfDayPart !== undefined ? { halfDayPart: params.halfDayPart as "MORNING" | "AFTERNOON" } : {}),
...(params.halfDayPart !== undefined
? { halfDayPart: params.halfDayPart as "MORNING" | "AFTERNOON" }
: {}),
...(params.note !== undefined ? { note: params.note } : {}),
});
} catch (error) {
@@ -273,9 +274,8 @@ export function createVacationEntitlementExecutors(
throw error;
}
const effectiveDays = typeof vacation.effectiveDays === "number"
? vacation.effectiveDays
: null;
const effectiveDays =
typeof vacation.effectiveDays === "number" ? vacation.effectiveDays : null;
return {
__action: "invalidate",
@@ -287,10 +287,7 @@ export function createVacationEntitlementExecutors(
};
},
async approve_vacation(
params: { vacationId: string },
ctx: ToolContext,
) {
async approve_vacation(params: { vacationId: string }, ctx: ToolContext) {
const caller = deps.createVacationCaller(deps.createScopedCallerContext(ctx));
let existing;
try {
@@ -324,10 +321,7 @@ export function createVacationEntitlementExecutors(
};
},
async reject_vacation(
params: { vacationId: string; reason?: string },
ctx: ToolContext,
) {
async reject_vacation(params: { vacationId: string; reason?: string }, ctx: ToolContext) {
const caller = deps.createVacationCaller(deps.createScopedCallerContext(ctx));
let existing;
try {
@@ -363,10 +357,7 @@ export function createVacationEntitlementExecutors(
};
},
async cancel_vacation(
params: { vacationId: string },
ctx: ToolContext,
) {
async cancel_vacation(params: { vacationId: string }, ctx: ToolContext) {
const caller = deps.createVacationCaller(deps.createScopedCallerContext(ctx));
let existing;
try {
@@ -399,24 +390,23 @@ export function createVacationEntitlementExecutors(
};
},
async get_pending_vacation_approvals(
params: { limit?: number },
ctx: ToolContext,
) {
async get_pending_vacation_approvals(params: { limit?: number }, ctx: ToolContext) {
const limit = Math.min(params.limit ?? 20, 50);
const caller = deps.createVacationCaller(deps.createScopedCallerContext(ctx));
const vacations = await caller.getPendingApprovals();
return vacations.map((vacation) => ({
id: vacation.id,
resource: vacation.resource.displayName,
eid: vacation.resource.eid,
chapter: vacation.resource.chapter,
type: vacation.type,
start: deps.fmtDate(vacation.startDate),
end: deps.fmtDate(vacation.endDate),
isHalfDay: vacation.isHalfDay,
})).slice(0, limit);
return vacations
.map((vacation) => ({
id: vacation.id,
resource: vacation.resource.displayName,
eid: vacation.resource.eid,
chapter: vacation.resource.chapter,
type: vacation.type,
start: deps.fmtDate(vacation.startDate),
end: deps.fmtDate(vacation.endDate),
isHalfDay: vacation.isHalfDay,
}))
.slice(0, limit);
},
async get_team_vacation_overlap(
@@ -459,7 +449,8 @@ export function createVacationEntitlementExecutors(
) {
if (params.carryoverDays !== undefined) {
return {
error: "Manual carryoverDays is not supported here. Carryover is computed automatically from prior-year balances.",
error:
"Manual carryoverDays is not supported here. Carryover is computed automatically from prior-year balances.",
};
}
@@ -6,7 +6,7 @@ import {
SystemRole,
UpdateHolidayCalendarEntrySchema,
UpdateHolidayCalendarSchema,
} from "@capakraken/shared";
} from "@nexus/shared";
import { z } from "zod";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
@@ -47,18 +47,20 @@ type VacationHolidayDeps = {
startDate: Date;
endDate: Date;
limit: number;
}) => Promise<Array<{
type: string;
startDate: Date;
endDate: Date;
isHalfDay?: boolean | null;
halfDayPart?: string | null;
resource: {
displayName: string;
eid: string;
chapter?: string | null;
};
}>>;
}) => Promise<
Array<{
type: string;
startDate: Date;
endDate: Date;
isHalfDay?: boolean | null;
halfDayPart?: string | null;
resource: {
displayName: string;
eid: string;
chapter?: string | null;
};
}>
>;
};
createHolidayCalendarCaller: (ctx: TRPCContext) => {
resolveHolidaysDetail: (params: {
@@ -120,11 +122,11 @@ type VacationHolidayDeps = {
ctx: ToolContext,
identifier: string,
) => Promise<ResolvedResource | AssistantToolErrorResult>;
resolveHolidayPeriod: (input: {
year?: number;
periodStart?: string;
periodEnd?: string;
}) => { year: number | null; periodStart: Date; periodEnd: Date };
resolveHolidayPeriod: (input: { year?: number; periodStart?: string; periodEnd?: string }) => {
year: number | null;
periodStart: Date;
periodEnd: Date;
};
resolveEntityOrAssistantError: <T>(
resolve: () => Promise<T>,
notFoundMessage: string,
@@ -133,279 +135,334 @@ type VacationHolidayDeps = {
fmtDate: (value: Date | null | undefined) => string | null;
formatHolidayCalendar: (calendar: HolidayCalendarRecord) => unknown;
formatHolidayCalendarEntry: (entry: HolidayCalendarEntryRecord) => unknown;
toAssistantHolidayCalendarMutationError: (
error: unknown,
) => AssistantToolErrorResult | null;
toAssistantHolidayCalendarNotFoundError: (
error: unknown,
) => AssistantToolErrorResult | null;
toAssistantHolidayEntryMutationError: (
error: unknown,
) => AssistantToolErrorResult | null;
toAssistantHolidayEntryNotFoundError: (
error: unknown,
) => AssistantToolErrorResult | null;
toAssistantHolidayCalendarMutationError: (error: unknown) => AssistantToolErrorResult | null;
toAssistantHolidayCalendarNotFoundError: (error: unknown) => AssistantToolErrorResult | null;
toAssistantHolidayEntryMutationError: (error: unknown) => AssistantToolErrorResult | null;
toAssistantHolidayEntryNotFoundError: (error: unknown) => AssistantToolErrorResult | null;
};
export const vacationHolidayReadToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
name: "get_vacation_balance",
description: "Get the holiday-aware vacation balance for a resource via the real entitlement workflow. Authenticated users can read their own balance; manager/admin/controller can read broader balances.",
parameters: {
type: "object",
properties: {
resourceId: { type: "string", description: "Resource ID, EID, or display 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" },
export const vacationHolidayReadToolDefinitions: ToolDef[] = withToolAccess(
[
{
type: "function",
function: {
name: "get_vacation_balance",
description:
"Get the holiday-aware vacation balance for a resource via the real entitlement workflow. Authenticated users can read their own balance; manager/admin/controller can read broader balances.",
parameters: {
type: "object",
properties: {
resourceId: { type: "string", description: "Resource ID, EID, or display name" },
year: { type: "integer", description: "Year. Default: current year" },
},
required: ["resourceId"],
},
},
},
},
{
type: "function",
function: {
name: "list_holidays_by_region",
description: "List resolved public holidays for a country, federal state, and optionally a city in a given year or date range. Use this to compare regions such as Bayern vs Hamburg.",
parameters: {
type: "object",
properties: {
countryCode: { type: "string", description: "Country code such as DE, ES, US, IN." },
federalState: { type: "string", description: "Federal state / region code, e.g. BY, HH, NRW." },
metroCity: { type: "string", description: "Optional city name for local city-specific holidays, e.g. Augsburg." },
year: { type: "integer", description: "Full year, e.g. 2026. Default: current year." },
periodStart: { type: "string", description: "Optional start date in YYYY-MM-DD. Requires periodEnd." },
periodEnd: { type: "string", description: "Optional end date in YYYY-MM-DD. Requires periodStart." },
},
required: ["countryCode"],
},
},
},
{
type: "function",
function: {
name: "get_resource_holidays",
description: "List resolved public holidays for a specific resource based on that person's country, federal state, and city context.",
parameters: {
type: "object",
properties: {
identifier: { type: "string", description: "Resource ID, EID, or display name." },
year: { type: "integer", description: "Full year, e.g. 2026. Default: current year." },
periodStart: { type: "string", description: "Optional start date in YYYY-MM-DD. Requires periodEnd." },
periodEnd: { type: "string", description: "Optional end date in YYYY-MM-DD. Requires periodStart." },
},
required: ["identifier"],
},
},
},
{
type: "function",
function: {
name: "list_holiday_calendars",
description: "List holiday calendars including scope, assignment, active flag, priority, and entry count. Useful to inspect the calendar-editor configuration context.",
parameters: {
type: "object",
properties: {
includeInactive: { type: "boolean", description: "Include inactive calendars. Default: false." },
countryCode: { type: "string", description: "Optional country code filter such as DE or ES." },
scopeType: { type: "string", description: "Optional scope filter: COUNTRY, STATE, CITY." },
stateCode: { type: "string", description: "Optional state/region code filter such as BY or NRW." },
metroCity: { type: "string", description: "Optional city-name filter." },
{
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: "get_holiday_calendar",
description: "Get a single holiday calendar including all entries. Accepts either the calendar ID or its name.",
parameters: {
type: "object",
properties: {
identifier: { type: "string", description: "Holiday calendar ID or name." },
{
type: "function",
function: {
name: "list_holidays_by_region",
description:
"List resolved public holidays for a country, federal state, and optionally a city in a given year or date range. Use this to compare regions such as Bayern vs Hamburg.",
parameters: {
type: "object",
properties: {
countryCode: { type: "string", description: "Country code such as DE, ES, US, IN." },
federalState: {
type: "string",
description: "Federal state / region code, e.g. BY, HH, NRW.",
},
metroCity: {
type: "string",
description: "Optional city name for local city-specific holidays, e.g. Augsburg.",
},
year: { type: "integer", description: "Full year, e.g. 2026. Default: current year." },
periodStart: {
type: "string",
description: "Optional start date in YYYY-MM-DD. Requires periodEnd.",
},
periodEnd: {
type: "string",
description: "Optional end date in YYYY-MM-DD. Requires periodStart.",
},
},
required: ["countryCode"],
},
required: ["identifier"],
},
},
},
{
type: "function",
function: {
name: "preview_resolved_holiday_calendar",
description: "Preview the resolved holiday result for a country/state/city scope and year, including which calendar each holiday comes from.",
parameters: {
type: "object",
properties: {
countryId: { type: "string", description: "Country ID." },
stateCode: { type: "string", description: "Optional state/region code." },
metroCityId: { type: "string", description: "Optional metro city ID for city-specific preview." },
year: { type: "integer", description: "Full year, e.g. 2026." },
{
type: "function",
function: {
name: "get_resource_holidays",
description:
"List resolved public holidays for a specific resource based on that person's country, federal state, and city context.",
parameters: {
type: "object",
properties: {
identifier: { type: "string", description: "Resource ID, EID, or display name." },
year: { type: "integer", description: "Full year, e.g. 2026. Default: current year." },
periodStart: {
type: "string",
description: "Optional start date in YYYY-MM-DD. Requires periodEnd.",
},
periodEnd: {
type: "string",
description: "Optional end date in YYYY-MM-DD. Requires periodStart.",
},
},
required: ["identifier"],
},
required: ["countryId", "year"],
},
},
{
type: "function",
function: {
name: "list_holiday_calendars",
description:
"List holiday calendars including scope, assignment, active flag, priority, and entry count. Useful to inspect the calendar-editor configuration context.",
parameters: {
type: "object",
properties: {
includeInactive: {
type: "boolean",
description: "Include inactive calendars. Default: false.",
},
countryCode: {
type: "string",
description: "Optional country code filter such as DE or ES.",
},
scopeType: {
type: "string",
description: "Optional scope filter: COUNTRY, STATE, CITY.",
},
stateCode: {
type: "string",
description: "Optional state/region code filter such as BY or NRW.",
},
metroCity: { type: "string", description: "Optional city-name filter." },
},
},
},
},
{
type: "function",
function: {
name: "get_holiday_calendar",
description:
"Get a single holiday calendar including all entries. Accepts either the calendar ID or its name.",
parameters: {
type: "object",
properties: {
identifier: { type: "string", description: "Holiday calendar ID or name." },
},
required: ["identifier"],
},
},
},
{
type: "function",
function: {
name: "preview_resolved_holiday_calendar",
description:
"Preview the resolved holiday result for a country/state/city scope and year, including which calendar each holiday comes from.",
parameters: {
type: "object",
properties: {
countryId: { type: "string", description: "Country ID." },
stateCode: { type: "string", description: "Optional state/region code." },
metroCityId: {
type: "string",
description: "Optional metro city ID for city-specific preview.",
},
year: { type: "integer", description: "Full year, e.g. 2026." },
},
required: ["countryId", "year"],
},
},
},
],
{
list_holiday_calendars: {
allowedSystemRoles: [SystemRole.ADMIN],
},
get_holiday_calendar: {
allowedSystemRoles: [SystemRole.ADMIN],
},
},
], {
list_holiday_calendars: {
allowedSystemRoles: [SystemRole.ADMIN],
},
get_holiday_calendar: {
allowedSystemRoles: [SystemRole.ADMIN],
},
});
);
export const vacationHolidayMutationToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
name: "create_holiday_calendar",
description: "Create a holiday calendar for a country, state, or city scope. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
name: { type: "string", description: "Calendar name." },
scopeType: { type: "string", description: "COUNTRY, STATE, or CITY." },
countryId: { type: "string", description: "Country ID." },
stateCode: { type: "string", description: "Required for STATE calendars." },
metroCityId: { type: "string", description: "Required for CITY calendars." },
isActive: { type: "boolean", description: "Whether the calendar is active. Default: true." },
priority: { type: "integer", description: "Priority used during calendar resolution. Default: 0." },
},
required: ["name", "scopeType", "countryId"],
},
},
},
{
type: "function",
function: {
name: "update_holiday_calendar",
description: "Update an existing holiday calendar. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Holiday calendar ID." },
data: {
type: "object",
properties: {
name: { type: "string" },
stateCode: { type: "string" },
metroCityId: { type: "string" },
isActive: { type: "boolean" },
priority: { type: "integer" },
export const vacationHolidayMutationToolDefinitions: ToolDef[] = withToolAccess(
[
{
type: "function",
function: {
name: "create_holiday_calendar",
description:
"Create a holiday calendar for a country, state, or city scope. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
name: { type: "string", description: "Calendar name." },
scopeType: { type: "string", description: "COUNTRY, STATE, or CITY." },
countryId: { type: "string", description: "Country ID." },
stateCode: { type: "string", description: "Required for STATE calendars." },
metroCityId: { type: "string", description: "Required for CITY calendars." },
isActive: {
type: "boolean",
description: "Whether the calendar is active. Default: true.",
},
priority: {
type: "integer",
description: "Priority used during calendar resolution. Default: 0.",
},
},
required: ["name", "scopeType", "countryId"],
},
required: ["id", "data"],
},
},
},
{
type: "function",
function: {
name: "delete_holiday_calendar",
description: "Delete a holiday calendar and all of its entries. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Holiday calendar ID." },
},
required: ["id"],
},
},
},
{
type: "function",
function: {
name: "create_holiday_calendar_entry",
description: "Create a holiday entry in an existing calendar. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
holidayCalendarId: { type: "string", description: "Holiday calendar ID." },
date: { type: "string", description: "Date in YYYY-MM-DD format." },
name: { type: "string", description: "Holiday name." },
isRecurringAnnual: { type: "boolean", description: "Whether the holiday repeats every year." },
source: { type: "string", description: "Optional source or legal basis." },
},
required: ["holidayCalendarId", "date", "name"],
},
},
},
{
type: "function",
function: {
name: "update_holiday_calendar_entry",
description: "Update an existing holiday calendar entry. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Holiday calendar entry ID." },
data: {
type: "object",
properties: {
date: { type: "string", description: "Date in YYYY-MM-DD format." },
name: { type: "string" },
isRecurringAnnual: { type: "boolean" },
source: { type: "string" },
{
type: "function",
function: {
name: "update_holiday_calendar",
description:
"Update an existing holiday calendar. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Holiday calendar ID." },
data: {
type: "object",
properties: {
name: { type: "string" },
stateCode: { type: "string" },
metroCityId: { type: "string" },
isActive: { type: "boolean" },
priority: { type: "integer" },
},
},
},
required: ["id", "data"],
},
required: ["id", "data"],
},
},
},
{
type: "function",
function: {
name: "delete_holiday_calendar",
description:
"Delete a holiday calendar and all of its entries. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Holiday calendar ID." },
},
required: ["id"],
},
},
},
{
type: "function",
function: {
name: "create_holiday_calendar_entry",
description:
"Create a holiday entry in an existing calendar. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
holidayCalendarId: { type: "string", description: "Holiday calendar ID." },
date: { type: "string", description: "Date in YYYY-MM-DD format." },
name: { type: "string", description: "Holiday name." },
isRecurringAnnual: {
type: "boolean",
description: "Whether the holiday repeats every year.",
},
source: { type: "string", description: "Optional source or legal basis." },
},
required: ["holidayCalendarId", "date", "name"],
},
},
},
{
type: "function",
function: {
name: "update_holiday_calendar_entry",
description:
"Update an existing holiday calendar entry. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Holiday calendar entry ID." },
data: {
type: "object",
properties: {
date: { type: "string", description: "Date in YYYY-MM-DD format." },
name: { type: "string" },
isRecurringAnnual: { type: "boolean" },
source: { type: "string" },
},
},
},
required: ["id", "data"],
},
},
},
{
type: "function",
function: {
name: "delete_holiday_calendar_entry",
description: "Delete a holiday calendar entry. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Holiday calendar entry ID." },
},
required: ["id"],
},
},
},
],
{
type: "function",
function: {
name: "delete_holiday_calendar_entry",
description: "Delete a holiday calendar entry. Admin role required. Always confirm first.",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Holiday calendar entry ID." },
},
required: ["id"],
},
create_holiday_calendar: {
allowedSystemRoles: [SystemRole.ADMIN],
},
update_holiday_calendar: {
allowedSystemRoles: [SystemRole.ADMIN],
},
delete_holiday_calendar: {
allowedSystemRoles: [SystemRole.ADMIN],
},
create_holiday_calendar_entry: {
allowedSystemRoles: [SystemRole.ADMIN],
},
update_holiday_calendar_entry: {
allowedSystemRoles: [SystemRole.ADMIN],
},
delete_holiday_calendar_entry: {
allowedSystemRoles: [SystemRole.ADMIN],
},
},
], {
create_holiday_calendar: {
allowedSystemRoles: [SystemRole.ADMIN],
},
update_holiday_calendar: {
allowedSystemRoles: [SystemRole.ADMIN],
},
delete_holiday_calendar: {
allowedSystemRoles: [SystemRole.ADMIN],
},
create_holiday_calendar_entry: {
allowedSystemRoles: [SystemRole.ADMIN],
},
update_holiday_calendar_entry: {
allowedSystemRoles: [SystemRole.ADMIN],
},
delete_holiday_calendar_entry: {
allowedSystemRoles: [SystemRole.ADMIN],
},
});
);
export function createVacationHolidayExecutors(
deps: VacationHolidayDeps,
@@ -422,12 +479,15 @@ export function createVacationHolidayExecutors(
return caller.getBalanceDetail({ resourceId: resource.id, year });
},
async list_vacations_upcoming(params: {
resourceName?: string;
chapter?: string;
daysAhead?: number;
limit?: number;
}, ctx: ToolContext) {
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 caller = deps.createVacationCaller(deps.createScopedCallerContext(ctx));
@@ -470,14 +530,17 @@ export function createVacationHolidayExecutors(
}));
},
async list_holidays_by_region(params: {
countryCode: string;
federalState?: string;
metroCity?: string;
year?: number;
periodStart?: string;
periodEnd?: string;
}, ctx: ToolContext) {
async list_holidays_by_region(
params: {
countryCode: string;
federalState?: string;
metroCity?: string;
year?: number;
periodStart?: string;
periodEnd?: string;
},
ctx: ToolContext,
) {
const { year, periodStart, periodEnd } = deps.resolveHolidayPeriod(params);
const caller = deps.createHolidayCalendarCaller(deps.createScopedCallerContext(ctx));
const resolved = await caller.resolveHolidaysDetail({
@@ -499,12 +562,15 @@ export function createVacationHolidayExecutors(
};
},
async get_resource_holidays(params: {
identifier: string;
year?: number;
periodStart?: string;
periodEnd?: string;
}, ctx: ToolContext) {
async get_resource_holidays(
params: {
identifier: string;
year?: number;
periodStart?: string;
periodEnd?: string;
},
ctx: ToolContext,
) {
const resource = await deps.resolveResourceIdentifier(ctx, params.identifier);
if ("error" in resource) {
return resource;
@@ -529,13 +595,16 @@ export function createVacationHolidayExecutors(
};
},
async list_holiday_calendars(params: {
includeInactive?: boolean;
countryCode?: string;
scopeType?: "COUNTRY" | "STATE" | "CITY";
stateCode?: string;
metroCity?: string;
}, ctx: ToolContext) {
async list_holiday_calendars(
params: {
includeInactive?: boolean;
countryCode?: string;
scopeType?: "COUNTRY" | "STATE" | "CITY";
stateCode?: string;
metroCity?: string;
},
ctx: ToolContext,
) {
const caller = deps.createHolidayCalendarCaller(deps.createScopedCallerContext(ctx));
return caller.listCalendarsDetail(params);
},
@@ -549,12 +618,15 @@ export function createVacationHolidayExecutors(
);
},
async preview_resolved_holiday_calendar(params: {
countryId: string;
stateCode?: string;
metroCityId?: string;
year: number;
}, ctx: ToolContext) {
async preview_resolved_holiday_calendar(
params: {
countryId: string;
stateCode?: string;
metroCityId?: string;
year: number;
},
ctx: ToolContext,
) {
const input = PreviewResolvedHolidaysSchema.parse(params);
const caller = deps.createHolidayCalendarCaller(deps.createScopedCallerContext(ctx));
return deps.resolveEntityOrAssistantError(
@@ -563,15 +635,18 @@ export function createVacationHolidayExecutors(
);
},
async create_holiday_calendar(params: {
name: string;
scopeType: "COUNTRY" | "STATE" | "CITY";
countryId: string;
stateCode?: string;
metroCityId?: string;
isActive?: boolean;
priority?: number;
}, ctx: ToolContext) {
async create_holiday_calendar(
params: {
name: string;
scopeType: "COUNTRY" | "STATE" | "CITY";
countryId: string;
stateCode?: string;
metroCityId?: string;
isActive?: boolean;
priority?: number;
},
ctx: ToolContext,
) {
deps.assertAdminRole(ctx);
const caller = deps.createHolidayCalendarCaller(deps.createScopedCallerContext(ctx));
@@ -595,16 +670,19 @@ export function createVacationHolidayExecutors(
};
},
async update_holiday_calendar(params: {
id: string;
data: {
name?: string;
stateCode?: string | null;
metroCityId?: string | null;
isActive?: boolean;
priority?: number;
};
}, ctx: ToolContext) {
async update_holiday_calendar(
params: {
id: string;
data: {
name?: string;
stateCode?: string | null;
metroCityId?: string | null;
isActive?: boolean;
priority?: number;
};
},
ctx: ToolContext,
) {
deps.assertAdminRole(ctx);
const caller = deps.createHolidayCalendarCaller(deps.createScopedCallerContext(ctx));
const input = {
@@ -655,13 +733,16 @@ export function createVacationHolidayExecutors(
};
},
async create_holiday_calendar_entry(params: {
holidayCalendarId: string;
date: string;
name: string;
isRecurringAnnual?: boolean;
source?: string;
}, ctx: ToolContext) {
async create_holiday_calendar_entry(
params: {
holidayCalendarId: string;
date: string;
name: string;
isRecurringAnnual?: boolean;
source?: string;
},
ctx: ToolContext,
) {
deps.assertAdminRole(ctx);
const caller = deps.createHolidayCalendarCaller(deps.createScopedCallerContext(ctx));
@@ -685,15 +766,18 @@ export function createVacationHolidayExecutors(
};
},
async update_holiday_calendar_entry(params: {
id: string;
data: {
date?: string;
name?: string;
isRecurringAnnual?: boolean;
source?: string | null;
};
}, ctx: ToolContext) {
async update_holiday_calendar_entry(
params: {
id: string;
data: {
date?: string;
name?: string;
isRecurringAnnual?: boolean;
source?: string | null;
};
},
ctx: ToolContext,
) {
deps.assertAdminRole(ctx);
const caller = deps.createHolidayCalendarCaller(deps.createScopedCallerContext(ctx));
const input = {
+3 -6
View File
@@ -1,12 +1,10 @@
/**
* AI Assistant router — provides a chat endpoint that uses OpenAI Function Calling
* to answer questions about CapaKraken data and modify resources/projects.
* to answer questions about Nexus data and modify resources/projects.
*/
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
import {
TOOL_DEFINITIONS,
} from "./assistant-tools.js";
import { TOOL_DEFINITIONS } from "./assistant-tools.js";
import { type ChatMessage } from "./assistant-confirmation.js";
import {
assistantChatInputSchema,
@@ -41,8 +39,7 @@ export { getAvailableAssistantTools } from "./assistant-tool-policy.js";
export { selectAssistantToolsForRequest } from "./assistant-tool-selection.js";
export const assistantRouter = createTRPCRouter({
listPendingApprovals: protectedProcedure
.query(({ ctx }) => listPendingApprovalPayloads(ctx)),
listPendingApprovals: protectedProcedure.query(({ ctx }) => listPendingApprovalPayloads(ctx)),
chat: protectedProcedure
.input(assistantChatInputSchema)
.mutation(({ ctx, input }) => runAssistantChat(ctx, input)),
+3 -7
View File
@@ -1,9 +1,5 @@
import { randomBytes } from "node:crypto";
import {
PASSWORD_MAX_LENGTH,
PASSWORD_MIN_LENGTH,
PASSWORD_POLICY_MESSAGE,
} from "@capakraken/shared";
import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@nexus/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "../trpc.js";
@@ -15,7 +11,7 @@ const RESET_TTL_MS = 60 * 60 * 1000; // 1 hour
function resetEmailHtml(resetUrl: string): string {
return `
<p>You requested a password reset for your CapaKraken account.</p>
<p>You requested a password reset for your Nexus account.</p>
<p>Click the link below to set a new password:</p>
<p><a href="${resetUrl}">${resetUrl}</a></p>
<p>This link expires in 1 hour and can only be used once.</p>
@@ -70,7 +66,7 @@ export const authRouter = createTRPCRouter({
void sendEmail({
to: input.email,
subject: "CapaKraken — reset your password",
subject: "Nexus — reset your password",
text: `You requested a password reset.\n\nReset your password: ${resetUrl}\n\nThis link expires in 1 hour. If you did not request this, ignore this email.`,
html: resetEmailHtml(resetUrl),
});
@@ -3,7 +3,7 @@ import {
CreateBlueprintSchema,
RolePresetsSchema,
UpdateBlueprintSchema,
} from "@capakraken/shared";
} from "@nexus/shared";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { makeAuditLogger } from "../lib/audit-helpers.js";
+2 -2
View File
@@ -1,9 +1,9 @@
import type { Prisma, PrismaClient } from "@capakraken/db";
import type { Prisma, PrismaClient } from "@nexus/db";
import {
CreateBlueprintSchema,
UpdateBlueprintSchema,
type BlueprintFieldDefinition,
} from "@capakraken/shared";
} from "@nexus/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
@@ -1,5 +1,5 @@
import { validateCustomFields } from "@capakraken/engine";
import { BlueprintTarget, type BlueprintFieldDefinition } from "@capakraken/shared";
import { validateCustomFields } from "@nexus/engine";
import { BlueprintTarget, type BlueprintFieldDefinition } from "@nexus/shared";
import { TRPCError } from "@trpc/server";
import { findUniqueOrThrow } from "../db/helpers.js";
+1 -1
View File
@@ -26,7 +26,7 @@ import {
updateBlueprint,
updateBlueprintRolePresets,
} from "./blueprint-procedure-support.js";
import { CreateBlueprintSchema } from "@capakraken/shared";
import { CreateBlueprintSchema } from "@nexus/shared";
export const blueprintRouter = createTRPCRouter({
listSummaries: planningReadProcedure.query(({ ctx }) => listBlueprintSummaries(ctx)),
@@ -1,7 +1,4 @@
import {
CreateCalculationRuleSchema,
UpdateCalculationRuleSchema,
} from "@capakraken/shared";
import { CreateCalculationRuleSchema, UpdateCalculationRuleSchema } from "@nexus/shared";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { PROJECT_BRIEF_SELECT } from "../db/selects.js";
@@ -1,8 +1,5 @@
import type { Prisma } from "@capakraken/db";
import {
CreateCalculationRuleSchema,
UpdateCalculationRuleSchema,
} from "@capakraken/shared";
import type { Prisma } from "@nexus/db";
import { CreateCalculationRuleSchema, UpdateCalculationRuleSchema } from "@nexus/shared";
import { z } from "zod";
type CreateCalculationRuleInput = z.infer<typeof CreateCalculationRuleSchema>;
@@ -19,7 +16,9 @@ export function buildCalculationRuleCreateData(
...(input.description !== undefined ? { description: input.description } : {}),
...(input.projectId !== undefined ? { projectId: input.projectId } : {}),
...(input.orderType !== undefined ? { orderType: input.orderType as never } : {}),
...(input.costReductionPercent !== undefined ? { costReductionPercent: input.costReductionPercent } : {}),
...(input.costReductionPercent !== undefined
? { costReductionPercent: input.costReductionPercent }
: {}),
priority: input.priority,
isActive: input.isActive,
};
@@ -35,8 +34,12 @@ export function buildCalculationRuleUpdateData(
...(input.projectId !== undefined ? { projectId: input.projectId } : {}),
...(input.orderType !== undefined ? { orderType: input.orderType as never } : {}),
...(input.costEffect !== undefined ? { costEffect: input.costEffect } : {}),
...(input.costReductionPercent !== undefined ? { costReductionPercent: input.costReductionPercent } : {}),
...(input.chargeabilityEffect !== undefined ? { chargeabilityEffect: input.chargeabilityEffect } : {}),
...(input.costReductionPercent !== undefined
? { costReductionPercent: input.costReductionPercent }
: {}),
...(input.chargeabilityEffect !== undefined
? { chargeabilityEffect: input.chargeabilityEffect }
: {}),
...(input.priority !== undefined ? { priority: input.priority } : {}),
...(input.isActive !== undefined ? { isActive: input.isActive } : {}),
};
@@ -6,11 +6,11 @@ import {
getMonthRange,
getMonthKeys,
type AssignmentSlice,
} from "@capakraken/engine";
import type { PrismaClient } from "@capakraken/db";
import type { WeekdayAvailability, PermissionKey } from "@capakraken/shared";
import { PermissionKey as PermissionKeys, round1 } from "@capakraken/shared";
import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application";
} from "@nexus/engine";
import type { PrismaClient } from "@nexus/db";
import type { WeekdayAvailability, PermissionKey } from "@nexus/shared";
import { PermissionKey as PermissionKeys, round1 } from "@nexus/shared";
import { isChargeabilityActualBooking, listAssignmentBookings } from "@nexus/application";
import { z } from "zod";
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
import {
@@ -32,19 +32,33 @@ function isIsoDateInMonth(isoDate: string, monthKey: string): boolean {
function getMonthCapacityDerivation(input: {
monthKey: string;
availability: WeekdayAvailability;
context: (Awaited<ReturnType<typeof loadResourceDailyAvailabilityContexts>> extends Map<string, infer T> ? T : never) | undefined;
context:
| (Awaited<ReturnType<typeof loadResourceDailyAvailabilityContexts>> extends Map<
string,
infer T
>
? T
: never)
| undefined;
baseAvailableHours: number;
effectiveAvailableHours: number;
}) {
const holidayDates = [...(input.context?.holidayDates ?? new Set<string>())]
.filter((isoDate) => isIsoDateInMonth(isoDate, input.monthKey))
.sort();
const publicHolidayWorkdayCount = holidayDates.reduce((count, isoDate) => (
count + (getAvailabilityHoursForDate(input.availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0)
), 0);
const publicHolidayHoursDeduction = holidayDates.reduce((sum, isoDate) => (
sum + getAvailabilityHoursForDate(input.availability, new Date(`${isoDate}T00:00:00.000Z`))
), 0);
const publicHolidayWorkdayCount = holidayDates.reduce(
(count, isoDate) =>
count +
(getAvailabilityHoursForDate(input.availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0
? 1
: 0),
0,
);
const publicHolidayHoursDeduction = holidayDates.reduce(
(sum, isoDate) =>
sum + getAvailabilityHoursForDate(input.availability, new Date(`${isoDate}T00:00:00.000Z`)),
0,
);
let absenceDayEquivalent = 0;
let absenceHoursDeduction = 0;
@@ -52,7 +66,10 @@ function getMonthCapacityDerivation(input: {
if (!isIsoDateInMonth(isoDate, input.monthKey) || input.context?.holidayDates.has(isoDate)) {
continue;
}
const dayHours = getAvailabilityHoursForDate(input.availability, new Date(`${isoDate}T00:00:00.000Z`));
const dayHours = getAvailabilityHoursForDate(
input.availability,
new Date(`${isoDate}T00:00:00.000Z`),
);
if (dayHours <= 0) {
continue;
}
@@ -238,7 +255,9 @@ async function queryChargeabilityReport(
departed: false,
rolledOff: false,
...(input.orgUnitId ? { orgUnitId: input.orgUnitId } : {}),
...(input.managementLevelGroupId ? { managementLevelGroupId: input.managementLevelGroupId } : {}),
...(input.managementLevelGroupId
? { managementLevelGroupId: input.managementLevelGroupId }
: {}),
...(input.countryId ? { countryId: input.countryId } : {}),
};
@@ -268,12 +287,14 @@ async function queryChargeabilityReport(
return {
monthKeys,
resources: [],
groupTotals: monthKeys.map((key) => buildGroupTotal({
monthKey: key,
totalFte: 0,
chargeabilityRatio: 0,
targetRatio: 0,
})),
groupTotals: monthKeys.map((key) =>
buildGroupTotal({
monthKey: key,
totalFte: 0,
chargeabilityRatio: 0,
targetRatio: 0,
}),
),
explainability: buildChargeabilityExplainability(input),
};
}
@@ -300,12 +321,13 @@ async function queryChargeabilityReport(
);
const projectIds = [...new Set(allBookings.map((booking) => booking.projectId))];
const projectUtilCats = projectIds.length > 0
? await db.project.findMany({
where: { id: { in: projectIds } },
select: { id: true, utilizationCategory: { select: { code: true } } },
})
: [];
const projectUtilCats =
projectIds.length > 0
? await db.project.findMany({
where: { id: { in: projectIds } },
select: { id: true, utilizationCategory: { select: { code: true } } },
})
: [];
const projectUtilCatMap = new Map(
projectUtilCats.map((project) => [project.id, project.utilizationCategory?.code ?? null]),
);
@@ -324,90 +346,97 @@ async function queryChargeabilityReport(
},
}));
const resourceRows = await Promise.all(resources.map(async (resource) => {
const resourceAssignments = assignments.filter((assignment) => assignment.resourceId === resource.id);
const targetPct = resource.managementLevelGroup?.targetPercentage
?? (resource.chargeabilityTarget / 100);
const availability = resource.availability as unknown as WeekdayAvailability;
const context = availabilityContexts.get(resource.id);
const resourceRows = await Promise.all(
resources.map(async (resource) => {
const resourceAssignments = assignments.filter(
(assignment) => assignment.resourceId === resource.id,
);
const targetPct =
resource.managementLevelGroup?.targetPercentage ?? resource.chargeabilityTarget / 100;
const availability = resource.availability as unknown as WeekdayAvailability;
const context = availabilityContexts.get(resource.id);
const months = await Promise.all(monthKeys.map(async (key) => {
const [year, month] = key.split("-").map(Number) as [number, number];
const { start: monthStart, end: monthEnd } = getMonthRange(year, month);
const baseAvailableHours = calculateEffectiveAvailableHours({
availability,
periodStart: monthStart,
periodEnd: monthEnd,
context: undefined,
});
const availableHours = calculateEffectiveAvailableHours({
availability,
periodStart: monthStart,
periodEnd: monthEnd,
context,
});
const slices: AssignmentSlice[] = resourceAssignments.flatMap((assignment) => {
const totalChargeableHours = calculateEffectiveBookedHours({
availability,
startDate: assignment.startDate,
endDate: assignment.endDate,
hoursPerDay: assignment.hoursPerDay,
periodStart: monthStart,
periodEnd: monthEnd,
context,
});
if (totalChargeableHours <= 0) {
return [];
}
const months = await Promise.all(
monthKeys.map(async (key) => {
const [year, month] = key.split("-").map(Number) as [number, number];
const { start: monthStart, end: monthEnd } = getMonthRange(year, month);
const baseAvailableHours = calculateEffectiveAvailableHours({
availability,
periodStart: monthStart,
periodEnd: monthEnd,
context: undefined,
});
const availableHours = calculateEffectiveAvailableHours({
availability,
periodStart: monthStart,
periodEnd: monthEnd,
context,
});
const slices: AssignmentSlice[] = resourceAssignments.flatMap((assignment) => {
const totalChargeableHours = calculateEffectiveBookedHours({
availability,
startDate: assignment.startDate,
endDate: assignment.endDate,
hoursPerDay: assignment.hoursPerDay,
periodStart: monthStart,
periodEnd: monthEnd,
context,
});
if (totalChargeableHours <= 0) {
return [];
}
const categoryCode = assignment.project.utilizationCategory?.code;
const categoryCode = assignment.project.utilizationCategory?.code;
return {
hoursPerDay: assignment.hoursPerDay,
workingDays: 0,
categoryCode: typeof categoryCode === "string" && categoryCode.length > 0 ? categoryCode : "Chg",
totalChargeableHours,
};
});
return {
hoursPerDay: assignment.hoursPerDay,
workingDays: 0,
categoryCode:
typeof categoryCode === "string" && categoryCode.length > 0 ? categoryCode : "Chg",
totalChargeableHours,
};
});
const forecast = deriveResourceForecast({
const forecast = deriveResourceForecast({
fte: resource.fte,
targetPercentage: targetPct,
assignments: slices,
sah: availableHours,
});
const derivation = getMonthCapacityDerivation({
monthKey: key,
availability,
context,
baseAvailableHours,
effectiveAvailableHours: availableHours,
});
return buildReportMonth({
monthKey: key,
sahHours: availableHours,
targetRatio: targetPct,
forecast,
derivation,
});
}),
);
return {
id: resource.id,
eid: resource.eid,
displayName: resource.displayName,
fte: resource.fte,
targetPercentage: targetPct,
assignments: slices,
sah: availableHours,
});
const derivation = getMonthCapacityDerivation({
monthKey: key,
availability,
context,
baseAvailableHours,
effectiveAvailableHours: availableHours,
});
return buildReportMonth({
monthKey: key,
sahHours: availableHours,
targetRatio: targetPct,
forecast,
derivation,
});
}));
return {
id: resource.id,
eid: resource.eid,
displayName: resource.displayName,
fte: resource.fte,
country: resource.country?.code ?? null,
federalState: resource.federalState ?? null,
city: resource.metroCity?.name ?? null,
orgUnit: resource.orgUnit?.name ?? null,
mgmtGroup: resource.managementLevelGroup?.name ?? null,
mgmtLevel: resource.managementLevel?.name ?? null,
targetPct,
months,
};
}));
country: resource.country?.code ?? null,
federalState: resource.federalState ?? null,
city: resource.metroCity?.name ?? null,
orgUnit: resource.orgUnit?.name ?? null,
mgmtGroup: resource.managementLevelGroup?.name ?? null,
mgmtLevel: resource.managementLevel?.name ?? null,
targetPct,
months,
};
}),
);
const groupTotals = monthKeys.map((key, monthIdx) => {
const groupInputs = resourceRows.map((resource) => ({
@@ -446,10 +475,11 @@ export function buildChargeabilityReportDetail(
) {
const resourceQuery = input.resourceQuery?.trim().toLowerCase();
const matchingResources = resourceQuery
? report.resources.filter((resource) => (
resource.displayName.toLowerCase().includes(resourceQuery)
|| resource.eid.toLowerCase().includes(resourceQuery)
))
? report.resources.filter(
(resource) =>
resource.displayName.toLowerCase().includes(resourceQuery) ||
resource.eid.toLowerCase().includes(resourceQuery),
)
: report.resources;
const resourceLimit = Math.min(Math.max(input.resourceLimit ?? 25, 1), 100);
const resources = matchingResources.slice(0, resourceLimit).map((resource) => ({
@@ -1,4 +1,4 @@
import { CreateClientSchema, UpdateClientSchema } from "@capakraken/shared";
import { CreateClientSchema, UpdateClientSchema } from "@nexus/shared";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { createAuditEntry } from "../lib/audit.js";
+12 -16
View File
@@ -1,5 +1,5 @@
import type { Prisma, PrismaClient } from "@capakraken/db";
import { CreateClientSchema, UpdateClientSchema, type ClientTree } from "@capakraken/shared";
import type { Prisma, PrismaClient } from "@nexus/db";
import { CreateClientSchema, UpdateClientSchema, type ClientTree } from "@nexus/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
@@ -68,27 +68,27 @@ export async function findClientByIdentifier<TClient>(
): Promise<TClient> {
const normalizedIdentifier = identifier.trim();
let client = await db.client.findUnique({
let client = (await db.client.findUnique({
where: { id: normalizedIdentifier },
...extraArgs,
}) as TClient | null;
})) as TClient | null;
if (!client) {
client = await db.client.findUnique({
client = (await db.client.findUnique({
where: { code: normalizedIdentifier },
...extraArgs,
}) as TClient | null;
})) as TClient | null;
}
if (!client) {
client = await db.client.findFirst({
client = (await db.client.findFirst({
where: { name: { equals: normalizedIdentifier, mode: "insensitive" } },
...extraArgs,
}) as TClient | null;
})) as TClient | null;
}
if (!client) {
client = await db.client.findFirst({
client = (await db.client.findFirst({
where: {
OR: [
{ name: { contains: normalizedIdentifier, mode: "insensitive" } },
@@ -96,7 +96,7 @@ export async function findClientByIdentifier<TClient>(
],
},
...extraArgs,
}) as TClient | null;
})) as TClient | null;
}
if (!client) {
@@ -109,9 +109,7 @@ export async function findClientByIdentifier<TClient>(
return client;
}
export function buildClientCreateData(
input: CreateClientInput,
): Prisma.ClientUncheckedCreateInput {
export function buildClientCreateData(input: CreateClientInput): Prisma.ClientUncheckedCreateInput {
return {
name: input.name,
...(input.code ? { code: input.code } : {}),
@@ -121,9 +119,7 @@ export function buildClientCreateData(
};
}
export function buildClientUpdateData(
input: UpdateClientInput,
): Prisma.ClientUncheckedUpdateInput {
export function buildClientUpdateData(input: UpdateClientInput): Prisma.ClientUncheckedUpdateInput {
return {
...(input.name !== undefined ? { name: input.name } : {}),
...(input.code !== undefined ? { code: input.code } : {}),
+1 -1
View File
@@ -1,4 +1,4 @@
import { CreateClientSchema } from "@capakraken/shared";
import { CreateClientSchema } from "@nexus/shared";
import {
adminProcedure,
createTRPCRouter,
@@ -2,7 +2,7 @@ import {
computeEvenSpread,
distributeHoursToWeeks,
summarizeEstimateDemandLines,
} from "@capakraken/engine";
} from "@nexus/engine";
import { fmtEur } from "../lib/format-utils.js";
import type { ProjectGraphSnapshot } from "./computation-graph-project-snapshot.js";
import { type GraphLink, type GraphNode, fmtNum, l, n } from "./computation-graph-shared.js";
@@ -33,7 +33,13 @@ export function buildProjectEstimateGraph(input: ProjectEstimateGraphInput): {
const lines = latestVersion.demandLines;
const summary = summarizeEstimateDemandLines(lines);
const { totalHours, totalCostCents, totalPriceCents, marginCents, marginPercent: marginPct } = summary;
const {
totalHours,
totalCostCents,
totalPriceCents,
marginCents,
marginPercent: marginPct,
} = summary;
const avgCostRate = totalHours > 0 ? Math.round(totalCostCents / totalHours) : 0;
const avgBillRate = totalHours > 0 ? Math.round(totalPriceCents / totalHours) : 0;
@@ -47,20 +53,109 @@ export function buildProjectEstimateGraph(input: ProjectEstimateGraphInput): {
const snapshotCount = latestVersion.resourceSnapshots?.length ?? 0;
nodes.push(
n("input.estLines", "Demand Lines", `${lines.length}`, "count", "INPUT", "Estimate demand line count", 0),
n("input.avgCostRate", "Avg Cost Rate", fmtEur(avgCostRate), "cents/h", "INPUT", "Average cost rate across demand lines", 0),
n("input.avgBillRate", "Avg Bill Rate", fmtEur(avgBillRate), "cents/h", "INPUT", "Average bill rate across demand lines", 0),
...(snapshotCount > 0 ? [
n("input.resourceSnapshots", "Res. Snapshots", `${snapshotCount}`, "count", "INPUT", "Resource rate snapshots frozen in estimate version", 0),
] : []),
n("est.totalHours", "Est. Hours", fmtNum(totalHours), "hours", "ESTIMATE", "Total estimated hours", 2, "Σ(line.hours)"),
n("est.totalCostCents", "Est. Cost", fmtEur(totalCostCents), "EUR", "ESTIMATE", "Total estimated cost", 2, "Σ(hours × costRate)"),
n("est.totalPriceCents", "Est. Price", fmtEur(totalPriceCents), "EUR", "ESTIMATE", "Total estimated price", 2, "Σ(hours × billRate)"),
n("est.marginCents", "Margin", fmtEur(marginCents), "EUR", "ESTIMATE", "Price minus cost", 3, "price - cost"),
n("est.marginPercent", "Margin %", `${marginPct.toFixed(1)}%`, "%", "ESTIMATE", "Margin as percentage of price", 3, "margin / price × 100"),
...(chapterCount > 1 ? [
n("est.chapters", "Chapters", `${chapterCount}`, "count", "ESTIMATE", `Demand lines grouped by ${chapterCount} chapters`, 1),
] : []),
n(
"input.estLines",
"Demand Lines",
`${lines.length}`,
"count",
"INPUT",
"Estimate demand line count",
0,
),
n(
"input.avgCostRate",
"Avg Cost Rate",
fmtEur(avgCostRate),
"cents/h",
"INPUT",
"Average cost rate across demand lines",
0,
),
n(
"input.avgBillRate",
"Avg Bill Rate",
fmtEur(avgBillRate),
"cents/h",
"INPUT",
"Average bill rate across demand lines",
0,
),
...(snapshotCount > 0
? [
n(
"input.resourceSnapshots",
"Res. Snapshots",
`${snapshotCount}`,
"count",
"INPUT",
"Resource rate snapshots frozen in estimate version",
0,
),
]
: []),
n(
"est.totalHours",
"Est. Hours",
fmtNum(totalHours),
"hours",
"ESTIMATE",
"Total estimated hours",
2,
"Σ(line.hours)",
),
n(
"est.totalCostCents",
"Est. Cost",
fmtEur(totalCostCents),
"EUR",
"ESTIMATE",
"Total estimated cost",
2,
"Σ(hours × costRate)",
),
n(
"est.totalPriceCents",
"Est. Price",
fmtEur(totalPriceCents),
"EUR",
"ESTIMATE",
"Total estimated price",
2,
"Σ(hours × billRate)",
),
n(
"est.marginCents",
"Margin",
fmtEur(marginCents),
"EUR",
"ESTIMATE",
"Price minus cost",
3,
"price - cost",
),
n(
"est.marginPercent",
"Margin %",
`${marginPct.toFixed(1)}%`,
"%",
"ESTIMATE",
"Margin as percentage of price",
3,
"margin / price × 100",
),
...(chapterCount > 1
? [
n(
"est.chapters",
"Chapters",
`${chapterCount}`,
"count",
"ESTIMATE",
`Demand lines grouped by ${chapterCount} chapters`,
1,
),
]
: []),
);
links.push(
@@ -73,56 +168,159 @@ export function buildProjectEstimateGraph(input: ProjectEstimateGraphInput): {
l("est.totalCostCents", "est.marginCents", "", 2),
l("est.marginCents", "est.marginPercent", "÷ price × 100", 2),
l("est.totalPriceCents", "est.marginPercent", "÷", 1),
...(snapshotCount > 0 ? [
l("input.resourceSnapshots", "input.avgCostRate", "LCR snapshot", 1),
l("input.resourceSnapshots", "input.avgBillRate", "UCR snapshot", 1),
] : []),
...(chapterCount > 1 ? [
l("input.estLines", "est.chapters", "group by", 1),
l("est.chapters", "est.totalHours", "Σ per chapter", 1),
] : []),
...(snapshotCount > 0
? [
l("input.resourceSnapshots", "input.avgCostRate", "LCR snapshot", 1),
l("input.resourceSnapshots", "input.avgBillRate", "UCR snapshot", 1),
]
: []),
...(chapterCount > 1
? [
l("input.estLines", "est.chapters", "group by", 1),
l("est.chapters", "est.totalHours", "Σ per chapter", 1),
]
: []),
);
const scopeItems = latestVersion.scopeItems ?? [];
if (scopeItems.length > 0) {
const totalFrameCount = scopeItems.reduce((sum, scopeItem) => sum + (scopeItem.frameCount ?? 0), 0);
const totalItemCount = scopeItems.reduce((sum, scopeItem) => sum + (scopeItem.itemCount ?? 0), 0);
const totalFrameCount = scopeItems.reduce(
(sum, scopeItem) => sum + (scopeItem.frameCount ?? 0),
0,
);
const totalItemCount = scopeItems.reduce(
(sum, scopeItem) => sum + (scopeItem.itemCount ?? 0),
0,
);
const scopeTypes = new Set(scopeItems.map((scopeItem) => scopeItem.scopeType));
nodes.push(
n("effort.scopeItems", "Scope Items", `${scopeItems.length}`, "count", "EFFORT", `${scopeItems.length} scope items across ${scopeTypes.size} type(s)`, 0),
...(totalFrameCount > 0 ? [
n("effort.totalFrames", "Total Frames", `${totalFrameCount}`, "frames", "EFFORT", "Sum of frame counts across scope items", 1),
] : []),
...(totalItemCount > 0 ? [
n("effort.totalItems", "Total Items", fmtNum(totalItemCount), "items", "EFFORT", "Sum of item counts across scope items", 1),
] : []),
n("effort.effortRules", "Effort Rules", `${effortRuleCount}`, "count", "EFFORT", "Configured effort expansion rules (scopeType → discipline)", 0),
n("effort.expandedHours", "Expanded Hours", fmtNum(totalHours), "hours", "EFFORT", "Total hours from scope-to-effort expansion (unitCount × hoursPerUnit)", 2, "Σ(unitCount × hoursPerUnit)"),
n(
"effort.scopeItems",
"Scope Items",
`${scopeItems.length}`,
"count",
"EFFORT",
`${scopeItems.length} scope items across ${scopeTypes.size} type(s)`,
0,
),
...(totalFrameCount > 0
? [
n(
"effort.totalFrames",
"Total Frames",
`${totalFrameCount}`,
"frames",
"EFFORT",
"Sum of frame counts across scope items",
1,
),
]
: []),
...(totalItemCount > 0
? [
n(
"effort.totalItems",
"Total Items",
fmtNum(totalItemCount),
"items",
"EFFORT",
"Sum of item counts across scope items",
1,
),
]
: []),
n(
"effort.effortRules",
"Effort Rules",
`${effortRuleCount}`,
"count",
"EFFORT",
"Configured effort expansion rules (scopeType → discipline)",
0,
),
n(
"effort.expandedHours",
"Expanded Hours",
fmtNum(totalHours),
"hours",
"EFFORT",
"Total hours from scope-to-effort expansion (unitCount × hoursPerUnit)",
2,
"Σ(unitCount × hoursPerUnit)",
),
);
links.push(
l("effort.scopeItems", "effort.expandedHours", "expand", 2),
l("effort.effortRules", "effort.expandedHours", "× hoursPerUnit", 2),
...(totalFrameCount > 0 ? [
l("effort.scopeItems", "effort.totalFrames", "Σ frames", 1),
l("effort.totalFrames", "effort.expandedHours", "per_frame", 1),
] : []),
...(totalItemCount > 0 ? [
l("effort.scopeItems", "effort.totalItems", "Σ items", 1),
l("effort.totalItems", "effort.expandedHours", "per_item", 1),
] : []),
...(totalFrameCount > 0
? [
l("effort.scopeItems", "effort.totalFrames", "Σ frames", 1),
l("effort.totalFrames", "effort.expandedHours", "per_frame", 1),
]
: []),
...(totalItemCount > 0
? [
l("effort.scopeItems", "effort.totalItems", "Σ items", 1),
l("effort.totalItems", "effort.expandedHours", "per_item", 1),
]
: []),
l("effort.expandedHours", "est.totalHours", "→ demand lines", 2),
);
}
if (experienceRuleCount > 0) {
nodes.push(
n("exp.ruleCount", "Exp. Rules", `${experienceRuleCount}`, "count", "EXPERIENCE", "Experience multiplier rules (chapter/location/level → rate adjustments)", 0),
n("exp.costMultiplier", "Cost Multiplier", "per rule", "×", "EXPERIENCE", "Multiplier applied to cost rate (costRateCents × multiplier)", 1, "costRate × costMultiplier"),
n("exp.billMultiplier", "Bill Multiplier", "per rule", "×", "EXPERIENCE", "Multiplier applied to bill rate (billRateCents × multiplier)", 1, "billRate × billMultiplier"),
n("exp.shoringRatio", "Shoring Ratio", "per rule", "ratio", "EXPERIENCE", "Offshore/nearshore effort factor (onsiteHours + offshoreHours × (1 + additionalEffort))", 2, "onsite + offshore × (1 + addlEffort)"),
n("exp.adjustedRates", "Adjusted Rates", "applied", "—", "EXPERIENCE", "Final cost and bill rates after experience multipliers", 2, "rate × multiplier"),
n(
"exp.ruleCount",
"Exp. Rules",
`${experienceRuleCount}`,
"count",
"EXPERIENCE",
"Experience multiplier rules (chapter/location/level → rate adjustments)",
0,
),
n(
"exp.costMultiplier",
"Cost Multiplier",
"per rule",
"×",
"EXPERIENCE",
"Multiplier applied to cost rate (costRateCents × multiplier)",
1,
"costRate × costMultiplier",
),
n(
"exp.billMultiplier",
"Bill Multiplier",
"per rule",
"×",
"EXPERIENCE",
"Multiplier applied to bill rate (billRateCents × multiplier)",
1,
"billRate × billMultiplier",
),
n(
"exp.shoringRatio",
"Shoring Ratio",
"per rule",
"ratio",
"EXPERIENCE",
"Offshore/nearshore effort factor (onsiteHours + offshoreHours × (1 + additionalEffort))",
2,
"onsite + offshore × (1 + addlEffort)",
),
n(
"exp.adjustedRates",
"Adjusted Rates",
"applied",
"—",
"EXPERIENCE",
"Final cost and bill rates after experience multipliers",
2,
"rate × multiplier",
),
);
links.push(
@@ -147,7 +345,8 @@ export function buildProjectEstimateGraph(input: ProjectEstimateGraphInput): {
} | null;
const hasCommercialAdjustments = terms && (terms.contingencyPercent || terms.discountPercent);
const hasCommercialMeta = terms && (terms.pricingModel || terms.paymentTermDays || terms.warrantyMonths);
const hasCommercialMeta =
terms && (terms.pricingModel || terms.paymentTermDays || terms.warrantyMonths);
if (hasCommercialAdjustments) {
const contingencyPct = terms!.contingencyPercent ?? 0;
@@ -160,14 +359,84 @@ export function buildProjectEstimateGraph(input: ProjectEstimateGraphInput): {
const adjustedMarginPct = adjustedPrice > 0 ? (adjustedMargin / adjustedPrice) * 100 : 0;
nodes.push(
n("input.contingencyPct", "Contingency %", `${contingencyPct}%`, "%", "INPUT", "Contingency percentage (risk buffer on cost)", 0),
n("input.discountPct", "Discount %", `${discountPct}%`, "%", "INPUT", "Discount percentage (reduction on sell side)", 0),
n("comm.contingencyCents", "Contingency", fmtEur(contingencyCents), "EUR", "COMMERCIAL", "Contingency surcharge", 2, "baseCost × contingency%"),
n("comm.discountCents", "Discount", fmtEur(discountCents), "EUR", "COMMERCIAL", "Discount deduction", 2, "basePrice × discount%"),
n("comm.adjustedCost", "Adj. Cost", fmtEur(adjustedCost), "EUR", "COMMERCIAL", "Cost plus contingency", 3, "baseCost + contingency"),
n("comm.adjustedPrice", "Adj. Price", fmtEur(adjustedPrice), "EUR", "COMMERCIAL", "Price minus discount", 3, "basePrice - discount"),
n("comm.adjustedMargin", "Adj. Margin", fmtEur(adjustedMargin), "EUR", "COMMERCIAL", "Adjusted margin", 3, "adjPrice - adjCost"),
n("comm.adjustedMarginPct", "Adj. Margin %", `${adjustedMarginPct.toFixed(1)}%`, "%", "COMMERCIAL", "Adjusted margin percentage", 3, "adjMargin / adjPrice × 100"),
n(
"input.contingencyPct",
"Contingency %",
`${contingencyPct}%`,
"%",
"INPUT",
"Contingency percentage (risk buffer on cost)",
0,
),
n(
"input.discountPct",
"Discount %",
`${discountPct}%`,
"%",
"INPUT",
"Discount percentage (reduction on sell side)",
0,
),
n(
"comm.contingencyCents",
"Contingency",
fmtEur(contingencyCents),
"EUR",
"COMMERCIAL",
"Contingency surcharge",
2,
"baseCost × contingency%",
),
n(
"comm.discountCents",
"Discount",
fmtEur(discountCents),
"EUR",
"COMMERCIAL",
"Discount deduction",
2,
"basePrice × discount%",
),
n(
"comm.adjustedCost",
"Adj. Cost",
fmtEur(adjustedCost),
"EUR",
"COMMERCIAL",
"Cost plus contingency",
3,
"baseCost + contingency",
),
n(
"comm.adjustedPrice",
"Adj. Price",
fmtEur(adjustedPrice),
"EUR",
"COMMERCIAL",
"Price minus discount",
3,
"basePrice - discount",
),
n(
"comm.adjustedMargin",
"Adj. Margin",
fmtEur(adjustedMargin),
"EUR",
"COMMERCIAL",
"Adjusted margin",
3,
"adjPrice - adjCost",
),
n(
"comm.adjustedMarginPct",
"Adj. Margin %",
`${adjustedMarginPct.toFixed(1)}%`,
"%",
"COMMERCIAL",
"Adjusted margin percentage",
3,
"adjMargin / adjPrice × 100",
),
);
links.push(
@@ -189,27 +458,73 @@ export function buildProjectEstimateGraph(input: ProjectEstimateGraphInput): {
if (hasCommercialMeta || (terms?.paymentMilestones && terms.paymentMilestones.length > 0)) {
if (terms!.pricingModel) {
nodes.push(
n("comm.pricingModel", "Pricing Model", terms!.pricingModel.replace(/_/g, " "), "—", "COMMERCIAL", `Pricing model: ${terms!.pricingModel}`, 0),
n(
"comm.pricingModel",
"Pricing Model",
terms!.pricingModel.replace(/_/g, " "),
"—",
"COMMERCIAL",
`Pricing model: ${terms!.pricingModel}`,
0,
),
);
}
if (terms!.paymentTermDays) {
nodes.push(
n("comm.paymentTermDays", "Payment Terms", `${terms!.paymentTermDays} days`, "days", "COMMERCIAL", `Net payment terms: ${terms!.paymentTermDays} days`, 0),
n(
"comm.paymentTermDays",
"Payment Terms",
`${terms!.paymentTermDays} days`,
"days",
"COMMERCIAL",
`Net payment terms: ${terms!.paymentTermDays} days`,
0,
),
);
}
if (terms!.warrantyMonths) {
nodes.push(
n("comm.warrantyMonths", "Warranty", `${terms!.warrantyMonths} mo`, "months", "COMMERCIAL", `Warranty period: ${terms!.warrantyMonths} months`, 0),
n(
"comm.warrantyMonths",
"Warranty",
`${terms!.warrantyMonths} mo`,
"months",
"COMMERCIAL",
`Warranty period: ${terms!.warrantyMonths} months`,
0,
),
);
}
const milestones = terms!.paymentMilestones ?? [];
if (milestones.length > 0) {
nodes.push(
n("comm.milestones", "Milestones", `${milestones.length}`, "count", "COMMERCIAL", `${milestones.length} payment milestones (${milestones.map((milestone) => `${milestone.label}: ${milestone.percent}%`).join(", ")})`, 2),
n("comm.milestoneTotalPct", "Milestone Sum", `${milestones.reduce((sum, milestone) => sum + milestone.percent, 0).toFixed(0)}%`, "%", "COMMERCIAL", "Sum of milestone percentages (should be 100%)", 2, "Σ(milestone.percent)"),
n(
"comm.milestones",
"Milestones",
`${milestones.length}`,
"count",
"COMMERCIAL",
`${milestones.length} payment milestones (${milestones.map((milestone) => `${milestone.label}: ${milestone.percent}%`).join(", ")})`,
2,
),
n(
"comm.milestoneTotalPct",
"Milestone Sum",
`${milestones.reduce((sum, milestone) => sum + milestone.percent, 0).toFixed(0)}%`,
"%",
"COMMERCIAL",
"Sum of milestone percentages (should be 100%)",
2,
"Σ(milestone.percent)",
),
);
links.push(
l(hasCommercialAdjustments ? "comm.adjustedPrice" : "est.totalPriceCents", "comm.milestones", "× %", 1),
l(
hasCommercialAdjustments ? "comm.adjustedPrice" : "est.totalPriceCents",
"comm.milestones",
"× %",
1,
),
);
}
}
@@ -232,11 +547,54 @@ export function buildProjectEstimateGraph(input: ProjectEstimateGraphInput): {
});
nodes.push(
n("spread.monthCount", "Months", `${spreadResult.months.length}`, "count", "SPREAD", `${spreadResult.months.length} months in project date range`, 1),
n("spread.weekCount", "Weeks", `${weeklyResult.weeks.length}`, "count", "SPREAD", `${weeklyResult.weeks.length} ISO weeks in project date range`, 1),
n("spread.monthlySpread", "Monthly Spread", hasManualSpreads ? "manual + even" : "even", "—", "SPREAD", "Hours distributed across months weighted by working days", 2, "hours × (monthWorkDays / totalWorkDays)"),
n("spread.weeklyPhasing", "Weekly Phasing", "even", "—", "SPREAD", "Hours distributed across ISO weeks (even/front/back-loaded)", 2, "totalHours / weekCount"),
n("spread.totalDistributed", "Distributed Hours", fmtNum(weeklyResult.totalDistributedHours), "hours", "SPREAD", "Total hours after weekly distribution (should match estimate)", 3, "Σ(weeklyHours)"),
n(
"spread.monthCount",
"Months",
`${spreadResult.months.length}`,
"count",
"SPREAD",
`${spreadResult.months.length} months in project date range`,
1,
),
n(
"spread.weekCount",
"Weeks",
`${weeklyResult.weeks.length}`,
"count",
"SPREAD",
`${weeklyResult.weeks.length} ISO weeks in project date range`,
1,
),
n(
"spread.monthlySpread",
"Monthly Spread",
hasManualSpreads ? "manual + even" : "even",
"—",
"SPREAD",
"Hours distributed across months weighted by working days",
2,
"hours × (monthWorkDays / totalWorkDays)",
),
n(
"spread.weeklyPhasing",
"Weekly Phasing",
"even",
"—",
"SPREAD",
"Hours distributed across ISO weeks (even/front/back-loaded)",
2,
"totalHours / weekCount",
),
n(
"spread.totalDistributed",
"Distributed Hours",
fmtNum(weeklyResult.totalDistributedHours),
"hours",
"SPREAD",
"Total hours after weekly distribution (should match estimate)",
3,
"Σ(weeklyHours)",
),
);
links.push(
@@ -1,20 +1,18 @@
import { computeBudgetStatus } from "@capakraken/engine";
import { computeBudgetStatus } from "@nexus/engine";
import { fmtEur } from "../lib/format-utils.js";
import { buildProjectEstimateGraph } from "./computation-graph-project-estimate.js";
import { loadProjectGraphSnapshot, type ProjectGraphInput } from "./computation-graph-project-snapshot.js";
import {
loadProjectGraphSnapshot,
type ProjectGraphInput,
} from "./computation-graph-project-snapshot.js";
import { type GraphLink, type GraphNode, l, n } from "./computation-graph-shared.js";
export async function readProjectGraphSnapshot(
ctx: Parameters<typeof loadProjectGraphSnapshot>[0],
input: ProjectGraphInput,
) {
const {
project,
latestVersion,
effortRuleCount,
experienceRuleCount,
projectAllocations,
} = await loadProjectGraphSnapshot(ctx, input);
const { project, latestVersion, effortRuleCount, experienceRuleCount, projectAllocations } =
await loadProjectGraphSnapshot(ctx, input);
const nodes: GraphNode[] = [];
const links: GraphLink[] = [];
@@ -22,12 +20,46 @@ export async function readProjectGraphSnapshot(
const hasBudget = project.budgetCents > 0;
const hasDateRange = !!(project.startDate && project.endDate);
nodes.push(
n("input.budgetCents", "Project Budget", hasBudget ? fmtEur(project.budgetCents) : "Not set", hasBudget ? "EUR" : "—", "INPUT", hasBudget ? `Budget for ${project.name}` : `No budget defined for ${project.name}`, 0),
n("input.winProbability", "Win Probability", `${project.winProbability}%`, "%", "INPUT", "Project win probability", 0),
...(hasDateRange ? [
n("input.projectStart", "Project Start", project.startDate!.toISOString().slice(0, 10), "date", "INPUT", "Project start date", 0),
n("input.projectEnd", "Project End", project.endDate!.toISOString().slice(0, 10), "date", "INPUT", "Project end date", 0),
] : []),
n(
"input.budgetCents",
"Project Budget",
hasBudget ? fmtEur(project.budgetCents) : "Not set",
hasBudget ? "EUR" : "—",
"INPUT",
hasBudget ? `Budget for ${project.name}` : `No budget defined for ${project.name}`,
0,
),
n(
"input.winProbability",
"Win Probability",
`${project.winProbability}%`,
"%",
"INPUT",
"Project win probability",
0,
),
...(hasDateRange
? [
n(
"input.projectStart",
"Project Start",
project.startDate!.toISOString().slice(0, 10),
"date",
"INPUT",
"Project start date",
0,
),
n(
"input.projectEnd",
"Project End",
project.endDate!.toISOString().slice(0, 10),
"date",
"INPUT",
"Project end date",
0,
),
]
: []),
);
const estimateGraph = buildProjectEstimateGraph({
@@ -55,13 +87,75 @@ export async function readProjectGraphSnapshot(
);
nodes.push(
n("budget.confirmedCents", "Confirmed", fmtEur(budgetStatus.confirmedCents), "EUR", "BUDGET", "Confirmed allocation costs", 2, "Σ(CONFIRMED allocs)"),
n("budget.proposedCents", "Proposed", fmtEur(budgetStatus.proposedCents), "EUR", "BUDGET", "Proposed allocation costs", 2, "Σ(PROPOSED allocs)"),
n("budget.allocatedCents", "Allocated", fmtEur(budgetStatus.allocatedCents), "EUR", "BUDGET", "Total allocated", 2, "confirmed + proposed"),
n("budget.remainingCents", "Remaining", hasBudget ? fmtEur(budgetStatus.remainingCents) : "N/A", hasBudget ? "EUR" : "—", "BUDGET", hasBudget ? "Remaining budget" : "Cannot compute — no budget set", 3, hasBudget ? "budget - allocated" : "needs budget"),
n("budget.utilizationPct", "Utilization", hasBudget ? `${budgetStatus.utilizationPercent.toFixed(1)}%` : "N/A", hasBudget ? "%" : "—", "BUDGET", hasBudget ? "Budget utilization" : "Cannot compute — no budget set", 3, hasBudget ? "allocated / budget × 100" : "needs budget"),
n("budget.weightedCents", "Win-Weighted", fmtEur(budgetStatus.winProbabilityWeightedCents), "EUR", "BUDGET", "Win-weighted cost", 3, "allocated × winProb / 100"),
n("budget.allocCount", "Allocations", `${projectAllocations.length}`, "count", "BUDGET", `${projectAllocations.length} resource allocations on project`, 1),
n(
"budget.confirmedCents",
"Confirmed",
fmtEur(budgetStatus.confirmedCents),
"EUR",
"BUDGET",
"Confirmed allocation costs",
2,
"Σ(CONFIRMED allocs)",
),
n(
"budget.proposedCents",
"Proposed",
fmtEur(budgetStatus.proposedCents),
"EUR",
"BUDGET",
"Proposed allocation costs",
2,
"Σ(PROPOSED allocs)",
),
n(
"budget.allocatedCents",
"Allocated",
fmtEur(budgetStatus.allocatedCents),
"EUR",
"BUDGET",
"Total allocated",
2,
"confirmed + proposed",
),
n(
"budget.remainingCents",
"Remaining",
hasBudget ? fmtEur(budgetStatus.remainingCents) : "N/A",
hasBudget ? "EUR" : "—",
"BUDGET",
hasBudget ? "Remaining budget" : "Cannot compute — no budget set",
3,
hasBudget ? "budget - allocated" : "needs budget",
),
n(
"budget.utilizationPct",
"Utilization",
hasBudget ? `${budgetStatus.utilizationPercent.toFixed(1)}%` : "N/A",
hasBudget ? "%" : "—",
"BUDGET",
hasBudget ? "Budget utilization" : "Cannot compute — no budget set",
3,
hasBudget ? "allocated / budget × 100" : "needs budget",
),
n(
"budget.weightedCents",
"Win-Weighted",
fmtEur(budgetStatus.winProbabilityWeightedCents),
"EUR",
"BUDGET",
"Win-weighted cost",
3,
"allocated × winProb / 100",
),
n(
"budget.allocCount",
"Allocations",
`${projectAllocations.length}`,
"count",
"BUDGET",
`${projectAllocations.length} resource allocations on project`,
1,
),
);
links.push(
@@ -81,13 +175,20 @@ export async function readProjectGraphSnapshot(
const estimatedCost = estimateGraph.estimatedCostCents;
const gapCents = budgetStatus.allocatedCents - estimatedCost;
nodes.push(
n("budget.estVsActualGap", "Est. vs Actual", fmtEur(Math.abs(gapCents)), "EUR", "BUDGET",
n(
"budget.estVsActualGap",
"Est. vs Actual",
fmtEur(Math.abs(gapCents)),
"EUR",
"BUDGET",
gapCents > 0
? `Actual allocations exceed estimate by ${fmtEur(gapCents)}`
: gapCents < 0
? `Actual allocations under estimate by ${fmtEur(Math.abs(gapCents))}`
: "Actual allocations match estimate",
3, "allocated - estCost"),
3,
"allocated - estCost",
),
);
links.push(
l("budget.allocatedCents", "budget.estVsActualGap", "", 1),
@@ -1,9 +1,5 @@
import {
calculateAllocation,
deriveResourceForecast,
type AssignmentSlice,
} from "@capakraken/engine";
import type { CalculationRule, WeekdayAvailability } from "@capakraken/shared";
import { calculateAllocation, deriveResourceForecast, type AssignmentSlice } from "@nexus/engine";
import type { CalculationRule, WeekdayAvailability } from "@nexus/shared";
import type { loadResourceGraphAvailability } from "./computation-graph-resource-availability.js";
type ResourceGraphAssignment = {
@@ -55,7 +51,9 @@ export function buildResourceAllocationSummary(input: ResourceGraphAllocationInp
let hasRulesEffect = false;
for (const assignment of input.assignments) {
const overlapStart = new Date(Math.max(input.monthStart.getTime(), assignment.startDate.getTime()));
const overlapStart = new Date(
Math.max(input.monthStart.getTime(), assignment.startDate.getTime()),
);
const overlapEnd = new Date(Math.min(input.monthEnd.getTime(), assignment.endDate.getTime()));
const categoryCode = assignment.project.utilizationCategory?.code ?? "Chg";
@@ -110,15 +108,17 @@ export function buildResourceAllocationSummary(input: ResourceGraphAllocationInp
sah: input.effectiveAvailableHours,
});
const dailyCostCents = input.assignments.length > 0
? Math.round(input.assignments[0]!.hoursPerDay * input.resourceLcrCents)
: 0;
const avgHoursPerDay = input.assignments.length > 0
? input.assignments.reduce((sum, assignment) => sum + assignment.hoursPerDay, 0) / input.assignments.length
: 0;
const utilizationPct = input.effectiveAvailableHours > 0
? (totalAllocHours / input.effectiveAvailableHours) * 100
: 0;
const dailyCostCents =
input.assignments.length > 0
? Math.round(input.assignments[0]!.hoursPerDay * input.resourceLcrCents)
: 0;
const avgHoursPerDay =
input.assignments.length > 0
? input.assignments.reduce((sum, assignment) => sum + assignment.hoursPerDay, 0) /
input.assignments.length
: 0;
const utilizationPct =
input.effectiveAvailableHours > 0 ? (totalAllocHours / input.effectiveAvailableHours) * 100 : 0;
const chargeableHours = forecast.chg * input.effectiveAvailableHours;
return {
@@ -1,5 +1,5 @@
import { VacationStatus } from "@capakraken/db";
import type { WeekdayAvailability } from "@capakraken/shared";
import { VacationStatus } from "@nexus/db";
import type { WeekdayAvailability } from "@nexus/shared";
import {
asHolidayResolverDb,
collectHolidayAvailability,
@@ -29,18 +29,14 @@ type ResourceAvailabilityInput = {
monthEnd: Date;
};
function getAvailabilityHoursForDate(
availability: WeekdayAvailability,
date: Date,
): number {
const dayKey = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"][date.getUTCDay()] as keyof WeekdayAvailability;
function getAvailabilityHoursForDate(availability: WeekdayAvailability, date: Date): number {
const dayKey = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"][
date.getUTCDay()
] as keyof WeekdayAvailability;
return availability[dayKey] ?? 0;
}
function sumAvailabilityHoursForDates(
availability: WeekdayAvailability,
dates: Date[],
): number {
function sumAvailabilityHoursForDates(availability: WeekdayAvailability, dates: Date[]): number {
return dates.reduce((sum, date) => sum + getAvailabilityHoursForDate(availability, date), 0);
}
@@ -90,15 +86,17 @@ export async function loadResourceGraphAvailability(input: ResourceAvailabilityI
const contexts = await loadResourceDailyAvailabilityContexts(
db,
[{
id: resource.id,
availability: weeklyAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
}],
[
{
id: resource.id,
availability: weeklyAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
},
],
monthStart,
monthEnd,
);
@@ -128,10 +126,13 @@ export async function loadResourceGraphAvailability(input: ResourceAvailabilityI
periodEnd: monthEnd,
context: availabilityContext,
});
const publicHolidayDates = resolvedHolidays.map((holiday) => new Date(`${holiday.date}T00:00:00.000Z`));
const publicHolidayWorkdayCount = publicHolidayDates.reduce((count, date) => (
count + (getAvailabilityHoursForDate(weeklyAvailability, date) > 0 ? 1 : 0)
), 0);
const publicHolidayDates = resolvedHolidays.map(
(holiday) => new Date(`${holiday.date}T00:00:00.000Z`),
);
const publicHolidayWorkdayCount = publicHolidayDates.reduce(
(count, date) => count + (getAvailabilityHoursForDate(weeklyAvailability, date) > 0 ? 1 : 0),
0,
);
const publicHolidayHoursDeduction = sumAvailabilityHoursForDates(
weeklyAvailability,
publicHolidayDates,
@@ -143,21 +144,27 @@ export async function loadResourceGraphAvailability(input: ResourceAvailabilityI
const baseHours = getAvailabilityHoursForDate(weeklyAvailability, absence.date);
return sum + baseHours * (absence.isHalfDay ? 0.5 : 1);
}, 0);
const effectiveHoursPerWorkingDay = effectiveWorkingDays > 0
? effectiveAvailableHours / effectiveWorkingDays
: 0;
const effectiveHoursPerWorkingDay =
effectiveWorkingDays > 0 ? effectiveAvailableHours / effectiveWorkingDays : 0;
const holidayScopeSummary = [
resource.country?.code ?? "—",
resource.federalState ?? "—",
resource.metroCity?.name ?? "—",
].join(" / ");
const holidayExamples = resolvedHolidays.length > 0
? resolvedHolidays.slice(0, 4).map((holiday) => `${holiday.date} ${holiday.name}`).join(", ")
: "none";
const holidayScopeBreakdown = resolvedHolidays.reduce<Record<string, number>>((counts, holiday) => {
counts[holiday.scope] = (counts[holiday.scope] ?? 0) + 1;
return counts;
}, {});
const holidayExamples =
resolvedHolidays.length > 0
? resolvedHolidays
.slice(0, 4)
.map((holiday) => `${holiday.date} ${holiday.name}`)
.join(", ")
: "none";
const holidayScopeBreakdown = resolvedHolidays.reduce<Record<string, number>>(
(counts, holiday) => {
counts[holiday.scope] = (counts[holiday.scope] ?? 0) + 1;
return counts;
},
{},
);
return {
absenceDateStrings,
@@ -1,4 +1,4 @@
import { computeBudgetStatus } from "@capakraken/engine";
import { computeBudgetStatus } from "@nexus/engine";
import type { TRPCContext } from "../trpc.js";
import { fmtEur } from "../lib/format-utils.js";
import { type GraphLink, type GraphNode, l, n } from "./computation-graph-shared.js";
@@ -25,9 +25,9 @@ export async function readResourceBudgetGraph(
monthStart: Date,
monthEnd: Date,
): Promise<ResourceBudgetGraph> {
const budgetProject = assignments.find((assignment) => (
assignment.project.budgetCents != null && assignment.project.budgetCents > 0
))?.project;
const budgetProject = assignments.find(
(assignment) => assignment.project.budgetCents != null && assignment.project.budgetCents > 0,
)?.project;
if (!budgetProject?.budgetCents) {
return { nodes: [], links: [] };
@@ -59,14 +59,84 @@ export async function readResourceBudgetGraph(
return {
nodes: [
n("input.budgetCents", "Project Budget", fmtEur(budgetProject.budgetCents), "EUR", "INPUT", `Budget for ${budgetProject.name}`, 0),
n("input.winProbability", "Win Probability", `${budgetProject.winProbability}%`, "%", "INPUT", "Project win probability", 0),
n("budget.confirmedCents", "Confirmed", fmtEur(budgetStatus.confirmedCents), "EUR", "BUDGET", "Sum of CONFIRMED/ACTIVE allocation costs", 2, "Σ(confirmed allocs)"),
n("budget.proposedCents", "Proposed", fmtEur(budgetStatus.proposedCents), "EUR", "BUDGET", "Sum of PROPOSED allocation costs", 2, "Σ(proposed allocs)"),
n("budget.allocatedCents", "Allocated", fmtEur(budgetStatus.allocatedCents), "EUR", "BUDGET", "Total allocated budget", 2, "confirmed + proposed"),
n("budget.remainingCents", "Remaining", fmtEur(budgetStatus.remainingCents), "EUR", "BUDGET", "Remaining budget", 3, "budget - allocated"),
n("budget.utilizationPct", "Utilization", `${budgetStatus.utilizationPercent.toFixed(1)}%`, "%", "BUDGET", "Budget utilization percentage", 3, "allocated / budget × 100"),
n("budget.weightedCents", "Win-Weighted", fmtEur(budgetStatus.winProbabilityWeightedCents), "EUR", "BUDGET", "Win-probability-weighted cost", 3, "allocated × winProb / 100"),
n(
"input.budgetCents",
"Project Budget",
fmtEur(budgetProject.budgetCents),
"EUR",
"INPUT",
`Budget for ${budgetProject.name}`,
0,
),
n(
"input.winProbability",
"Win Probability",
`${budgetProject.winProbability}%`,
"%",
"INPUT",
"Project win probability",
0,
),
n(
"budget.confirmedCents",
"Confirmed",
fmtEur(budgetStatus.confirmedCents),
"EUR",
"BUDGET",
"Sum of CONFIRMED/ACTIVE allocation costs",
2,
"Σ(confirmed allocs)",
),
n(
"budget.proposedCents",
"Proposed",
fmtEur(budgetStatus.proposedCents),
"EUR",
"BUDGET",
"Sum of PROPOSED allocation costs",
2,
"Σ(proposed allocs)",
),
n(
"budget.allocatedCents",
"Allocated",
fmtEur(budgetStatus.allocatedCents),
"EUR",
"BUDGET",
"Total allocated budget",
2,
"confirmed + proposed",
),
n(
"budget.remainingCents",
"Remaining",
fmtEur(budgetStatus.remainingCents),
"EUR",
"BUDGET",
"Remaining budget",
3,
"budget - allocated",
),
n(
"budget.utilizationPct",
"Utilization",
`${budgetStatus.utilizationPercent.toFixed(1)}%`,
"%",
"BUDGET",
"Budget utilization percentage",
3,
"allocated / budget × 100",
),
n(
"budget.weightedCents",
"Win-Weighted",
fmtEur(budgetStatus.winProbabilityWeightedCents),
"EUR",
"BUDGET",
"Win-probability-weighted cost",
3,
"allocated × winProb / 100",
),
],
links: [
l("alloc.totalCostCents", "budget.confirmedCents", "per assignment", 1),
@@ -1,6 +1,13 @@
import type { WeekdayAvailability } from "@capakraken/shared";
import type { WeekdayAvailability } from "@nexus/shared";
import { fmtEur } from "../lib/format-utils.js";
import { type GraphLink, type GraphNode, fmtPct, fmtNum, l, n } from "./computation-graph-shared.js";
import {
type GraphLink,
type GraphNode,
fmtPct,
fmtNum,
l,
n,
} from "./computation-graph-shared.js";
type ResourceGraphResolvedHoliday = {
date: string;
@@ -116,84 +123,510 @@ function describeWeeklyAvailability(availability: WeekdayAvailability): {
}
export function buildResourceGraphSnapshot(input: ResourceGraphPresentationInput) {
const { totalHours: weeklyTotalHours, label: availabilityLabel } = describeWeeklyAvailability(input.weeklyAvailability);
const { totalHours: weeklyTotalHours, label: availabilityLabel } = describeWeeklyAvailability(
input.weeklyAvailability,
);
const hasScheduleRules = !!input.scheduleRules;
const nodes: GraphNode[] = [
n("input.fte", "FTE", fmtNum(input.resource.fte, 2), "ratio", "INPUT", "Resource FTE factor", 0),
n("input.country", "Country", input.resource.country?.name ?? input.resource.country?.code ?? "—", "text", "INPUT", "Country used for base working-time and national holiday rules", 0),
n("input.state", "State", input.resource.federalState ?? "—", "text", "INPUT", "Federal state / region used for regional holidays", 0),
n("input.city", "City", input.resource.metroCity?.name ?? "—", "text", "INPUT", "City / metro used for local holidays", 0),
n("input.holidayContext", "Holiday Context", input.holidayScopeSummary, "text", "INPUT", "Resolved holiday scope chain: country / state / city", 0),
n("input.holidayExamples", "Holiday Dates", input.holidayExamples, "text", "INPUT", `Resolved holidays in ${input.month}; scopes: COUNTRY ${input.holidayScopeBreakdown.COUNTRY ?? 0}, STATE ${input.holidayScopeBreakdown.STATE ?? 0}, CITY ${input.holidayScopeBreakdown.CITY ?? 0}`, 0),
n("input.dailyHours", "Country Hours", `${input.dailyHours} h`, "hours", "INPUT", `Base daily working hours (${input.resource.country?.code ?? "?"})`, 0),
n(
"input.fte",
"FTE",
fmtNum(input.resource.fte, 2),
"ratio",
"INPUT",
"Resource FTE factor",
0,
),
n(
"input.country",
"Country",
input.resource.country?.name ?? input.resource.country?.code ?? "—",
"text",
"INPUT",
"Country used for base working-time and national holiday rules",
0,
),
n(
"input.state",
"State",
input.resource.federalState ?? "—",
"text",
"INPUT",
"Federal state / region used for regional holidays",
0,
),
n(
"input.city",
"City",
input.resource.metroCity?.name ?? "—",
"text",
"INPUT",
"City / metro used for local holidays",
0,
),
n(
"input.holidayContext",
"Holiday Context",
input.holidayScopeSummary,
"text",
"INPUT",
"Resolved holiday scope chain: country / state / city",
0,
),
n(
"input.holidayExamples",
"Holiday Dates",
input.holidayExamples,
"text",
"INPUT",
`Resolved holidays in ${input.month}; scopes: COUNTRY ${input.holidayScopeBreakdown.COUNTRY ?? 0}, STATE ${input.holidayScopeBreakdown.STATE ?? 0}, CITY ${input.holidayScopeBreakdown.CITY ?? 0}`,
0,
),
n(
"input.dailyHours",
"Country Hours",
`${input.dailyHours} h`,
"hours",
"INPUT",
`Base daily working hours (${input.resource.country?.code ?? "?"})`,
0,
),
...(hasScheduleRules
? [n("input.scheduleRules", "Schedule Rules", "Spain", "—", "INPUT", "Variable daily hours (regular/friday/summer)", 0)]
? [
n(
"input.scheduleRules",
"Schedule Rules",
"Spain",
"—",
"INPUT",
"Variable daily hours (regular/friday/summer)",
0,
),
]
: []),
n("input.weeklyAvail", "Weekly Avail.", `${weeklyTotalHours}h`, "h/week", "INPUT", `Resource availability: ${availabilityLabel}`, 0),
n("input.lcrCents", "LCR", fmtEur(input.resource.lcrCents), "cents/h", "INPUT", "Loaded Cost Rate per hour", 0),
n("input.hoursPerDay", "Hours/Day", fmtNum(input.avgHoursPerDay), "hours", "INPUT", "Average hours/day across assignments", 0),
n("input.absences", "Absences", `${input.absenceCount}`, "count", "INPUT", `Absence days in ${input.month} (${input.vacationDayCount} vacation, ${input.sickDayCount} sick${input.halfDayCount > 0 ? `, ${input.halfDayCount} half-day` : ""})`, 0),
n("input.publicHolidays", "Public Holidays", `${input.publicHolidayCount}`, "count", "INPUT", `Resolved holidays in ${input.month}; ${input.publicHolidayWorkdayCount} hit configured working days`, 0),
n("input.calcRules", "Active Rules", `${input.calcRulesCount}`, "count", "INPUT", "Active calculation rules", 0),
n("input.targetPct", "Target", fmtPct(input.targetPct), "%", "INPUT", `Chargeability target (${input.resource.managementLevelGroup?.name ?? "legacy"})`, 0),
n("input.assignmentCount", "Assignments", `${input.assignmentCount}`, "count", "INPUT", `Active assignments in ${input.month}`, 0),
n(
"input.weeklyAvail",
"Weekly Avail.",
`${weeklyTotalHours}h`,
"h/week",
"INPUT",
`Resource availability: ${availabilityLabel}`,
0,
),
n(
"input.lcrCents",
"LCR",
fmtEur(input.resource.lcrCents),
"cents/h",
"INPUT",
"Loaded Cost Rate per hour",
0,
),
n(
"input.hoursPerDay",
"Hours/Day",
fmtNum(input.avgHoursPerDay),
"hours",
"INPUT",
"Average hours/day across assignments",
0,
),
n(
"input.absences",
"Absences",
`${input.absenceCount}`,
"count",
"INPUT",
`Absence days in ${input.month} (${input.vacationDayCount} vacation, ${input.sickDayCount} sick${input.halfDayCount > 0 ? `, ${input.halfDayCount} half-day` : ""})`,
0,
),
n(
"input.publicHolidays",
"Public Holidays",
`${input.publicHolidayCount}`,
"count",
"INPUT",
`Resolved holidays in ${input.month}; ${input.publicHolidayWorkdayCount} hit configured working days`,
0,
),
n(
"input.calcRules",
"Active Rules",
`${input.calcRulesCount}`,
"count",
"INPUT",
"Active calculation rules",
0,
),
n(
"input.targetPct",
"Target",
fmtPct(input.targetPct),
"%",
"INPUT",
`Chargeability target (${input.resource.managementLevelGroup?.name ?? "legacy"})`,
0,
),
n(
"input.assignmentCount",
"Assignments",
`${input.assignmentCount}`,
"count",
"INPUT",
`Active assignments in ${input.month}`,
0,
),
n("sah.calendarDays", "Calendar Days", `${input.sahCalendarDays}`, "days", "SAH", "Total calendar days in period", 1),
n("sah.weekendDays", "Weekend Days", `${input.sahWeekendDays}`, "days", "SAH", "Saturday + Sunday count", 1),
n("sah.grossWorkingDays", "Gross Work Days", `${input.baseWorkingDays}`, "days", "SAH", "Working days from the resource-specific weekly availability before holidays/absences", 1, "count(availability > 0)"),
n("sah.baseHours", "Base Hours", fmtNum(input.baseAvailableHours), "hours", "SAH", "Available hours from weekly availability before holiday/absence deductions", 1, "Σ(daily availability)"),
n("sah.publicHolidayDays", "Holiday Ded.", `${input.publicHolidayWorkdayCount}`, "days", "SAH", "Holiday workdays deducted after applying country/state/city scope and weekday availability", 1),
n("sah.publicHolidayHours", "Holiday Hrs Ded.", fmtNum(input.publicHolidayHoursDeduction), "hours", "SAH", "Hours removed by resolved public holidays", 1, "Σ(availability on holiday dates)"),
n("sah.absenceDays", "Absence Ded.", `${input.absenceCount}`, "days", "SAH", "Vacation/sick days that hit working days and are not already public holidays", 1),
n("sah.absenceHours", "Absence Hrs Ded.", fmtNum(input.absenceHoursDeduction), "hours", "SAH", "Hours removed by vacation/sick absences", 1, "Σ(availability × absence fraction)"),
n("sah.netWorkingDays", "Net Work Days", `${input.effectiveWorkingDays}`, "days", "SAH", "Remaining working days after holiday and absence deductions", 2, "gross - holidays - absences"),
n("sah.effectiveHoursPerDay", "Eff. Hrs/Day", fmtNum(input.effectiveHoursPerWorkingDay), "hours", "SAH", "Average effective hours per remaining working day", 2, "SAH / net work days"),
n("sah.sah", "SAH", fmtNum(input.effectiveAvailableHours), "hours", "SAH", "Effective available hours after weekly availability, local holidays and absences", 2, "base hours - holiday hours - absence hours"),
n(
"sah.calendarDays",
"Calendar Days",
`${input.sahCalendarDays}`,
"days",
"SAH",
"Total calendar days in period",
1,
),
n(
"sah.weekendDays",
"Weekend Days",
`${input.sahWeekendDays}`,
"days",
"SAH",
"Saturday + Sunday count",
1,
),
n(
"sah.grossWorkingDays",
"Gross Work Days",
`${input.baseWorkingDays}`,
"days",
"SAH",
"Working days from the resource-specific weekly availability before holidays/absences",
1,
"count(availability > 0)",
),
n(
"sah.baseHours",
"Base Hours",
fmtNum(input.baseAvailableHours),
"hours",
"SAH",
"Available hours from weekly availability before holiday/absence deductions",
1,
"Σ(daily availability)",
),
n(
"sah.publicHolidayDays",
"Holiday Ded.",
`${input.publicHolidayWorkdayCount}`,
"days",
"SAH",
"Holiday workdays deducted after applying country/state/city scope and weekday availability",
1,
),
n(
"sah.publicHolidayHours",
"Holiday Hrs Ded.",
fmtNum(input.publicHolidayHoursDeduction),
"hours",
"SAH",
"Hours removed by resolved public holidays",
1,
"Σ(availability on holiday dates)",
),
n(
"sah.absenceDays",
"Absence Ded.",
`${input.absenceCount}`,
"days",
"SAH",
"Vacation/sick days that hit working days and are not already public holidays",
1,
),
n(
"sah.absenceHours",
"Absence Hrs Ded.",
fmtNum(input.absenceHoursDeduction),
"hours",
"SAH",
"Hours removed by vacation/sick absences",
1,
"Σ(availability × absence fraction)",
),
n(
"sah.netWorkingDays",
"Net Work Days",
`${input.effectiveWorkingDays}`,
"days",
"SAH",
"Remaining working days after holiday and absence deductions",
2,
"gross - holidays - absences",
),
n(
"sah.effectiveHoursPerDay",
"Eff. Hrs/Day",
fmtNum(input.effectiveHoursPerWorkingDay),
"hours",
"SAH",
"Average effective hours per remaining working day",
2,
"SAH / net work days",
),
n(
"sah.sah",
"SAH",
fmtNum(input.effectiveAvailableHours),
"hours",
"SAH",
"Effective available hours after weekly availability, local holidays and absences",
2,
"base hours - holiday hours - absence hours",
),
n("alloc.workingDays", "Work Days", `${input.totalWorkingDaysInMonth}`, "days", "ALLOCATION", "Working days covered by assignments in period", 1, "Σ(overlap workdays)"),
n("alloc.totalHours", "Total Hours", fmtNum(input.totalAllocHours), "hours", "ALLOCATION", "Sum of effective hours across assignments", 2, "Σ(min(h/day, avail) × workdays)"),
n("alloc.dailyCostCents", "Daily Cost", fmtEur(input.dailyCostCents), "EUR", "ALLOCATION", "Cost per working day", 1, "hoursPerDay × LCR"),
n("alloc.totalCostCents", "Total Cost", fmtEur(input.totalAllocCostCents), "EUR", "ALLOCATION", "Sum of daily costs", 2, "Σ(dailyCost × workdays)"),
n("alloc.utilizationPct", "Utilization", `${input.utilizationPct.toFixed(1)}%`, "%", "ALLOCATION", "Allocation utilization: allocated hours / SAH", 3, "totalHours / SAH × 100"),
n(
"alloc.workingDays",
"Work Days",
`${input.totalWorkingDaysInMonth}`,
"days",
"ALLOCATION",
"Working days covered by assignments in period",
1,
"Σ(overlap workdays)",
),
n(
"alloc.totalHours",
"Total Hours",
fmtNum(input.totalAllocHours),
"hours",
"ALLOCATION",
"Sum of effective hours across assignments",
2,
"Σ(min(h/day, avail) × workdays)",
),
n(
"alloc.dailyCostCents",
"Daily Cost",
fmtEur(input.dailyCostCents),
"EUR",
"ALLOCATION",
"Cost per working day",
1,
"hoursPerDay × LCR",
),
n(
"alloc.totalCostCents",
"Total Cost",
fmtEur(input.totalAllocCostCents),
"EUR",
"ALLOCATION",
"Sum of daily costs",
2,
"Σ(dailyCost × workdays)",
),
n(
"alloc.utilizationPct",
"Utilization",
`${input.utilizationPct.toFixed(1)}%`,
"%",
"ALLOCATION",
"Allocation utilization: allocated hours / SAH",
3,
"totalHours / SAH × 100",
),
...(input.hasRulesEffect
? [
n("alloc.chargeableHours", "Chargeable Hrs", fmtNum(input.totalChargeableHours), "hours", "ALLOCATION", "Rules-adjusted chargeable hours", 2, "rules-adjusted"),
n("alloc.projectCostCents", "Project Cost", fmtEur(input.totalProjectCostCents), "EUR", "ALLOCATION", "Rules-adjusted project cost", 2, "rules-adjusted"),
]
n(
"alloc.chargeableHours",
"Chargeable Hrs",
fmtNum(input.totalChargeableHours),
"hours",
"ALLOCATION",
"Rules-adjusted chargeable hours",
2,
"rules-adjusted",
),
n(
"alloc.projectCostCents",
"Project Cost",
fmtEur(input.totalProjectCostCents),
"EUR",
"ALLOCATION",
"Rules-adjusted project cost",
2,
"rules-adjusted",
),
]
: []),
...(input.absenceCount > 0
? [
n("rules.activeRules", "Matched Rules", `${input.calcRulesCount} rules`, "—", "RULES", "Rules evaluated for absence days", 1),
n("rules.costEffect", "Cost Effect", input.hasRulesEffect ? "ZERO" : "—", "—", "RULES", "How absent days affect project cost", 1, "CHARGE / ZERO / REDUCE"),
n("rules.chgEffect", "Chg Effect", input.hasRulesEffect ? "COUNT" : "—", "—", "RULES", "How absent days affect chargeability", 1, "COUNT / SKIP"),
...(input.hasRulesEffect
? [n("rules.costReduction", "Cost Reduction", "per rule", "—", "RULES", "Cost reduction percentage applied to absent hours", 2, "normalCost × (100 - reductionPct) / 100")]
: []),
]
n(
"rules.activeRules",
"Matched Rules",
`${input.calcRulesCount} rules`,
"—",
"RULES",
"Rules evaluated for absence days",
1,
),
n(
"rules.costEffect",
"Cost Effect",
input.hasRulesEffect ? "ZERO" : "—",
"—",
"RULES",
"How absent days affect project cost",
1,
"CHARGE / ZERO / REDUCE",
),
n(
"rules.chgEffect",
"Chg Effect",
input.hasRulesEffect ? "COUNT" : "—",
"—",
"RULES",
"How absent days affect chargeability",
1,
"COUNT / SKIP",
),
...(input.hasRulesEffect
? [
n(
"rules.costReduction",
"Cost Reduction",
"per rule",
"—",
"RULES",
"Cost reduction percentage applied to absent hours",
2,
"normalCost × (100 - reductionPct) / 100",
),
]
: []),
]
: []),
n("chg.chgHours", "Chg Hours", fmtNum(input.chargeableHours), "hours", "CHARGEABILITY", "Total chargeable hours against effective SAH", 2, "chargeability × SAH"),
n("chg.chg", "Chargeability", fmtPct(input.forecast.chg), "%", "CHARGEABILITY", "Chargeability ratio", 3, "chgHours / SAH"),
n(
"chg.chgHours",
"Chg Hours",
fmtNum(input.chargeableHours),
"hours",
"CHARGEABILITY",
"Total chargeable hours against effective SAH",
2,
"chargeability × SAH",
),
n(
"chg.chg",
"Chargeability",
fmtPct(input.forecast.chg),
"%",
"CHARGEABILITY",
"Chargeability ratio",
3,
"chgHours / SAH",
),
...(input.forecast.bd > 0
? [n("chg.bd", "BD Ratio", fmtPct(input.forecast.bd), "%", "CHARGEABILITY", `Business development: ${fmtNum(input.forecast.bd * input.effectiveAvailableHours)}h`, 3, "bdHours / SAH")]
? [
n(
"chg.bd",
"BD Ratio",
fmtPct(input.forecast.bd),
"%",
"CHARGEABILITY",
`Business development: ${fmtNum(input.forecast.bd * input.effectiveAvailableHours)}h`,
3,
"bdHours / SAH",
),
]
: []),
...(input.forecast.mdi > 0
? [n("chg.mdi", "MD&I Ratio", fmtPct(input.forecast.mdi), "%", "CHARGEABILITY", `MD&I hours: ${fmtNum(input.forecast.mdi * input.effectiveAvailableHours)}h`, 3, "mdiHours / SAH")]
? [
n(
"chg.mdi",
"MD&I Ratio",
fmtPct(input.forecast.mdi),
"%",
"CHARGEABILITY",
`MD&I hours: ${fmtNum(input.forecast.mdi * input.effectiveAvailableHours)}h`,
3,
"mdiHours / SAH",
),
]
: []),
...(input.forecast.mo > 0
? [n("chg.mo", "M&O Ratio", fmtPct(input.forecast.mo), "%", "CHARGEABILITY", `M&O hours: ${fmtNum(input.forecast.mo * input.effectiveAvailableHours)}h`, 3, "moHours / SAH")]
? [
n(
"chg.mo",
"M&O Ratio",
fmtPct(input.forecast.mo),
"%",
"CHARGEABILITY",
`M&O hours: ${fmtNum(input.forecast.mo * input.effectiveAvailableHours)}h`,
3,
"moHours / SAH",
),
]
: []),
...(input.forecast.pdr > 0
? [n("chg.pdr", "PD&R Ratio", fmtPct(input.forecast.pdr), "%", "CHARGEABILITY", `PD&R hours: ${fmtNum(input.forecast.pdr * input.effectiveAvailableHours)}h`, 3, "pdrHours / SAH")]
? [
n(
"chg.pdr",
"PD&R Ratio",
fmtPct(input.forecast.pdr),
"%",
"CHARGEABILITY",
`PD&R hours: ${fmtNum(input.forecast.pdr * input.effectiveAvailableHours)}h`,
3,
"pdrHours / SAH",
),
]
: []),
...(input.forecast.absence > 0
? [n("chg.absence", "Absence Ratio", fmtPct(input.forecast.absence), "%", "CHARGEABILITY", `Absence hours: ${fmtNum(input.forecast.absence * input.effectiveAvailableHours)}h`, 3, "absenceHours / SAH")]
? [
n(
"chg.absence",
"Absence Ratio",
fmtPct(input.forecast.absence),
"%",
"CHARGEABILITY",
`Absence hours: ${fmtNum(input.forecast.absence * input.effectiveAvailableHours)}h`,
3,
"absenceHours / SAH",
),
]
: []),
n("chg.unassigned", "Unassigned", fmtPct(input.forecast.unassigned), "%", "CHARGEABILITY", `${fmtNum(input.forecast.unassigned * input.effectiveAvailableHours)}h of ${fmtNum(input.effectiveAvailableHours)}h SAH not assigned`, 3, "max(0, SAH - assigned) / SAH"),
n("chg.target", "Target", fmtPct(input.targetPct), "%", "CHARGEABILITY", "Chargeability target from management level", 3),
n("chg.gap", "Gap to Target", `${input.forecast.chg - input.targetPct >= 0 ? "+" : ""}${((input.forecast.chg - input.targetPct) * 100).toFixed(1)} pp`, "pp", "CHARGEABILITY", `Chargeability (${fmtPct(input.forecast.chg)}) vs. target (${fmtPct(input.targetPct)})`, 3, "chargeability target"),
n(
"chg.unassigned",
"Unassigned",
fmtPct(input.forecast.unassigned),
"%",
"CHARGEABILITY",
`${fmtNum(input.forecast.unassigned * input.effectiveAvailableHours)}h of ${fmtNum(input.effectiveAvailableHours)}h SAH not assigned`,
3,
"max(0, SAH - assigned) / SAH",
),
n(
"chg.target",
"Target",
fmtPct(input.targetPct),
"%",
"CHARGEABILITY",
"Chargeability target from management level",
3,
),
n(
"chg.gap",
"Gap to Target",
`${input.forecast.chg - input.targetPct >= 0 ? "+" : ""}${((input.forecast.chg - input.targetPct) * 100).toFixed(1)} pp`,
"pp",
"CHARGEABILITY",
`Chargeability (${fmtPct(input.forecast.chg)}) vs. target (${fmtPct(input.targetPct)})`,
3,
"chargeability target",
),
...input.budgetNodes,
];
@@ -209,7 +642,9 @@ export function buildResourceGraphSnapshot(input: ResourceGraphPresentationInput
l("input.holidayExamples", "sah.publicHolidayDays", "resolved dates", 2),
l("input.holidayExamples", "sah.publicHolidayHours", "remove matching day hours", 2),
l("input.absences", "sah.absenceHours", "remove absence fractions", 1),
...(hasScheduleRules ? [l("input.scheduleRules", "sah.effectiveHoursPerDay", "variable h/day", 1)] : []),
...(hasScheduleRules
? [l("input.scheduleRules", "sah.effectiveHoursPerDay", "variable h/day", 1)]
: []),
l("sah.calendarDays", "sah.grossWorkingDays", " weekends", 2),
l("sah.weekendDays", "sah.grossWorkingDays", "", 1),
l("input.publicHolidays", "sah.publicHolidayDays", "∩ workdays", 1),
@@ -237,24 +672,31 @@ export function buildResourceGraphSnapshot(input: ResourceGraphPresentationInput
...(input.absenceCount > 0
? [
l("input.calcRules", "rules.activeRules", "filter active", 1),
l("input.absences", "rules.activeRules", "match trigger", 1),
l("rules.activeRules", "rules.costEffect", "→ effect", 1),
l("rules.activeRules", "rules.chgEffect", "→ effect", 1),
]
l("input.calcRules", "rules.activeRules", "filter active", 1),
l("input.absences", "rules.activeRules", "match trigger", 1),
l("rules.activeRules", "rules.costEffect", "→ effect", 1),
l("rules.activeRules", "rules.chgEffect", "→ effect", 1),
]
: []),
...(input.hasRulesEffect
? [
l("rules.costEffect", "alloc.projectCostCents", "apply", 2),
l("alloc.totalCostCents", "alloc.projectCostCents", "adjust", 1),
l("rules.chgEffect", "alloc.chargeableHours", "apply", 2),
l("alloc.totalHours", "alloc.chargeableHours", "adjust", 1),
...(input.absenceCount > 0 ? [l("rules.costEffect", "rules.costReduction", "reduce %", 1)] : []),
]
l("rules.costEffect", "alloc.projectCostCents", "apply", 2),
l("alloc.totalCostCents", "alloc.projectCostCents", "adjust", 1),
l("rules.chgEffect", "alloc.chargeableHours", "apply", 2),
l("alloc.totalHours", "alloc.chargeableHours", "adjust", 1),
...(input.absenceCount > 0
? [l("rules.costEffect", "rules.costReduction", "reduce %", 1)]
: []),
]
: []),
l(input.hasRulesEffect ? "alloc.chargeableHours" : "alloc.totalHours", "chg.chgHours", "Σ Chg", 2),
l(
input.hasRulesEffect ? "alloc.chargeableHours" : "alloc.totalHours",
"chg.chgHours",
"Σ Chg",
2,
),
l("chg.chgHours", "chg.chg", "÷ SAH", 2),
l("sah.sah", "chg.chg", "÷", 2),
...(input.forecast.bd > 0 ? [l("sah.sah", "chg.bd", "÷", 1)] : []),
@@ -1,9 +1,5 @@
import {
calculateSAH,
getMonthRange,
DEFAULT_CALCULATION_RULES,
} from "@capakraken/engine";
import type { CalculationRule, SpainScheduleRule, WeekdayAvailability } from "@capakraken/shared";
import { calculateSAH, getMonthRange, DEFAULT_CALCULATION_RULES } from "@nexus/engine";
import type { CalculationRule, SpainScheduleRule, WeekdayAvailability } from "@nexus/shared";
import type { TRPCContext } from "../trpc.js";
import { loadResourceGraphAvailability } from "./computation-graph-resource-availability.js";
@@ -32,7 +28,9 @@ export async function loadResourceGraphSnapshot(
federalState: true,
metroCityId: true,
availability: true,
country: { select: { id: true, code: true, name: true, dailyWorkingHours: true, scheduleRules: true } },
country: {
select: { id: true, code: true, name: true, dailyWorkingHours: true, scheduleRules: true },
},
metroCity: { select: { id: true, name: true } },
managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } },
},
@@ -40,7 +38,8 @@ export async function loadResourceGraphSnapshot(
const dailyHours = resource.country?.dailyWorkingHours ?? 8;
const scheduleRules = resource.country?.scheduleRules as SpainScheduleRule | null;
const targetPct = resource.managementLevelGroup?.targetPercentage ?? (resource.chargeabilityTarget / 100);
const targetPct =
resource.managementLevelGroup?.targetPercentage ?? resource.chargeabilityTarget / 100;
const availability = resource.availability as WeekdayAvailability | null;
const weeklyAvailability: WeekdayAvailability = availability ?? {
@@ -3,8 +3,8 @@ import {
CreateMetroCitySchema,
UpdateCountrySchema,
UpdateMetroCitySchema,
} from "@capakraken/shared";
import type { Prisma } from "@capakraken/db";
} from "@nexus/shared";
import type { Prisma } from "@nexus/db";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { createAuditEntry } from "../lib/audit.js";
@@ -40,9 +40,11 @@ function withAuditUser(userId: string | undefined) {
return userId ? { userId } : {};
}
export const countryListInputSchema = z.object({
isActive: z.boolean().optional(),
}).optional();
export const countryListInputSchema = z
.object({
isActive: z.boolean().optional(),
})
.optional();
export const countryIdentifierInputSchema = z.object({
identifier: z.string().trim().min(1),
@@ -123,10 +125,7 @@ export async function getCountryById(ctx: CountryProcedureContext, input: Countr
);
}
export async function getMetroCityById(
ctx: CountryProcedureContext,
input: MetroCityIdInput,
) {
export async function getMetroCityById(ctx: CountryProcedureContext, input: MetroCityIdInput) {
return findUniqueOrThrow(
ctx.db.metroCity.findUnique({
where: { id: input.id },
@@ -192,10 +191,7 @@ export async function updateCountry(ctx: CountryProcedureContext, input: UpdateC
}
export async function createMetroCity(ctx: CountryProcedureContext, input: CreateMetroCityInput) {
await findUniqueOrThrow(
ctx.db.country.findUnique({ where: { id: input.countryId } }),
"Country",
);
await findUniqueOrThrow(ctx.db.country.findUnique({ where: { id: input.countryId } }), "Country");
const created = await ctx.db.metroCity.create({
data: buildMetroCityCreateData(input),
@@ -215,10 +211,7 @@ export async function createMetroCity(ctx: CountryProcedureContext, input: Creat
return created;
}
export async function updateMetroCity(
ctx: CountryProcedureContext,
input: UpdateMetroCityInput,
) {
export async function updateMetroCity(ctx: CountryProcedureContext, input: UpdateMetroCityInput) {
const existing = await findUniqueOrThrow(
ctx.db.metroCity.findUnique({ where: { id: input.id } }),
"Metro city",
@@ -245,10 +238,7 @@ export async function updateMetroCity(
return updated;
}
export async function deleteMetroCity(
ctx: CountryProcedureContext,
input: MetroCityIdInput,
) {
export async function deleteMetroCity(ctx: CountryProcedureContext, input: MetroCityIdInput) {
const city = await findUniqueOrThrow(
ctx.db.metroCity.findUnique({
where: { id: input.id },
+18 -18
View File
@@ -1,10 +1,10 @@
import { Prisma, type PrismaClient } from "@capakraken/db";
import { Prisma, type PrismaClient } from "@nexus/db";
import {
CreateCountrySchema,
CreateMetroCitySchema,
UpdateCountrySchema,
UpdateMetroCitySchema,
} from "@capakraken/shared";
} from "@nexus/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
@@ -31,9 +31,7 @@ export function jsonOrNull(val: unknown): Prisma.InputJsonValue | typeof Prisma.
return val as Prisma.InputJsonValue;
}
export function buildCountryListWhere(
input: CountryListInput,
): Prisma.CountryWhereInput {
export function buildCountryListWhere(input: CountryListInput): Prisma.CountryWhereInput {
return {
...(input.isActive !== undefined ? { isActive: input.isActive } : {}),
};
@@ -47,30 +45,30 @@ export async function findCountryByIdentifier<TCountry>(
const normalizedIdentifier = identifier.trim();
const upperIdentifier = normalizedIdentifier.toUpperCase();
let country = await db.country.findUnique({
let country = (await db.country.findUnique({
where: { id: normalizedIdentifier },
...extraArgs,
}) as TCountry | null;
})) as TCountry | null;
if (!country) {
country = await db.country.findFirst({
country = (await db.country.findFirst({
where: { code: { equals: upperIdentifier, mode: "insensitive" } },
...extraArgs,
}) as TCountry | null;
})) as TCountry | null;
}
if (!country) {
country = await db.country.findFirst({
country = (await db.country.findFirst({
where: { name: { equals: normalizedIdentifier, mode: "insensitive" } },
...extraArgs,
}) as TCountry | null;
})) as TCountry | null;
}
if (!country) {
country = await db.country.findFirst({
country = (await db.country.findFirst({
where: { name: { contains: normalizedIdentifier, mode: "insensitive" } },
...extraArgs,
}) as TCountry | null;
})) as TCountry | null;
}
if (!country) {
@@ -116,8 +114,12 @@ export function buildCountryUpdateData(
return {
...(input.code !== undefined ? { code: input.code } : {}),
...(input.name !== undefined ? { name: input.name } : {}),
...(input.dailyWorkingHours !== undefined ? { dailyWorkingHours: input.dailyWorkingHours } : {}),
...(input.scheduleRules !== undefined ? { scheduleRules: jsonOrNull(input.scheduleRules) } : {}),
...(input.dailyWorkingHours !== undefined
? { dailyWorkingHours: input.dailyWorkingHours }
: {}),
...(input.scheduleRules !== undefined
? { scheduleRules: jsonOrNull(input.scheduleRules) }
: {}),
...(input.isActive !== undefined ? { isActive: input.isActive } : {}),
};
}
@@ -139,9 +141,7 @@ export function buildMetroCityUpdateData(
};
}
export function assertMetroCityDeletable(
city: MetroCityDeleteRecord,
): void {
export function assertMetroCityDeletable(city: MetroCityDeleteRecord): void {
if (city._count.resources > 0) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
+2 -2
View File
@@ -22,8 +22,8 @@ import {
updateCountry,
updateMetroCity,
} from "./country-procedure-support.js";
import { CreateCountrySchema, CreateMetroCitySchema } from "@capakraken/shared";
import type { CreateCountryInput } from "@capakraken/shared";
import { CreateCountrySchema, CreateMetroCitySchema } from "@nexus/shared";
import type { CreateCountryInput } from "@nexus/shared";
import type { z } from "zod";
export const countryRouter = createTRPCRouter({
@@ -1,4 +1,4 @@
import { FieldType } from "@capakraken/shared";
import { FieldType } from "@nexus/shared";
export interface CustomFieldFilterInput {
key: string;
@@ -1,4 +1,4 @@
import { type BudgetForecastRow, getDashboardProjectHealth } from "@capakraken/application";
import { type BudgetForecastRow, getDashboardProjectHealth } from "@nexus/application";
import { fmtEur } from "../lib/format-utils.js";
type DashboardBudgetForecastCalendarLocation = {
@@ -121,11 +121,8 @@ export function mapProjectHealthDetailRows(
const projects: DashboardProjectHealthDetail["projects"] = rows
.map((project): DashboardProjectHealthDetail["projects"][number] => {
const overall = project.compositeScore;
const rating: DashboardProjectHealthDetail["projects"][number]["rating"] = overall >= 80
? "healthy"
: overall >= 50
? "at_risk"
: "critical";
const rating: DashboardProjectHealthDetail["projects"][number]["rating"] =
overall >= 80 ? "healthy" : overall >= 50 ? "at_risk" : "critical";
return {
projectId: project.id,
@@ -188,14 +185,16 @@ export function mapBudgetForecastDetailRows(
budgetCents: forecast.budgetCents,
spent: fmtEur(forecast.spentCents),
spentCents: forecast.spentCents,
remaining: fmtEur(forecast.remainingCents ?? (forecast.budgetCents - forecast.spentCents)),
remainingCents: forecast.remainingCents ?? (forecast.budgetCents - forecast.spentCents),
projected: forecast.burnRate > 0
? fmtEur(forecast.spentCents + Math.max(0, forecast.budgetCents - forecast.spentCents))
: fmtEur(forecast.spentCents),
projectedCents: forecast.burnRate > 0
? Math.max(forecast.spentCents, forecast.budgetCents)
: forecast.spentCents,
remaining: fmtEur(forecast.remainingCents ?? forecast.budgetCents - forecast.spentCents),
remainingCents: forecast.remainingCents ?? forecast.budgetCents - forecast.spentCents,
projected:
forecast.burnRate > 0
? fmtEur(forecast.spentCents + Math.max(0, forecast.budgetCents - forecast.spentCents))
: fmtEur(forecast.spentCents),
projectedCents:
forecast.burnRate > 0
? Math.max(forecast.spentCents, forecast.budgetCents)
: forecast.spentCents,
burnRate: fmtEur(forecast.burnRate),
burnRateCents: forecast.burnRate,
utilization: `${forecast.pctUsed}%`,
@@ -203,11 +202,8 @@ export function mapBudgetForecastDetailRows(
activeAssignmentCount: forecast.activeAssignmentCount ?? null,
calendarLocations: forecast.calendarLocations ?? [],
derivation: forecast.derivation ?? null,
burnStatus: forecast.pctUsed >= 100
? "ahead"
: forecast.burnRate > 0
? "on_track"
: "not_started",
burnStatus:
forecast.pctUsed >= 100 ? "ahead" : forecast.burnRate > 0 ? "on_track" : "not_started",
})),
};
}
@@ -9,8 +9,8 @@ import {
getDashboardSkillGaps,
getDashboardSkillGapSummary,
getDashboardTopValueResources,
} from "@capakraken/application";
import { round1 } from "@capakraken/shared";
} from "@nexus/application";
import { round1 } from "@nexus/shared";
import { z } from "zod";
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
import { cacheGet, cacheSet } from "../lib/cache.js";
@@ -32,7 +32,7 @@ type TopValueResourceRow = {
displayName: string;
chapter: string | null;
valueScore: number | null;
valueScoreBreakdown: import("@capakraken/shared").ValueScoreBreakdown | null;
valueScoreBreakdown: import("@nexus/shared").ValueScoreBreakdown | null;
valueScoreUpdatedAt: Date | null;
lcrCents: number;
countryCode: string | null;
@@ -74,8 +74,12 @@ type DashboardPeakTimesInput = z.infer<typeof dashboardPeakTimesInputSchema>;
type DashboardTopValueResourcesInput = z.infer<typeof dashboardTopValueResourcesInputSchema>;
type DashboardDemandInput = z.infer<typeof dashboardDemandInputSchema>;
type DashboardDetailInput = z.infer<typeof dashboardDetailInputSchema>;
type DashboardChargeabilityOverviewInput = z.infer<typeof dashboardChargeabilityOverviewInputSchema>;
type DashboardChargeabilityOverviewRead = Awaited<ReturnType<typeof getDashboardChargeabilityOverview>>;
type DashboardChargeabilityOverviewInput = z.infer<
typeof dashboardChargeabilityOverviewInputSchema
>;
type DashboardChargeabilityOverviewRead = Awaited<
ReturnType<typeof getDashboardChargeabilityOverview>
>;
function formatPct(value: number): string {
return `${Math.round(value)}%`;
@@ -89,9 +93,10 @@ function mapStatisticsDetail(overview: Awaited<ReturnType<typeof getDashboardOve
totalAllocations: overview.totalAllocations,
approvedVacations: overview.approvedVacations,
totalEstimates: overview.totalEstimates,
totalBudget: overview.budgetSummary.totalBudgetCents > 0
? fmtEur(overview.budgetSummary.totalBudgetCents)
: "N/A",
totalBudget:
overview.budgetSummary.totalBudgetCents > 0
? fmtEur(overview.budgetSummary.totalBudgetCents)
: "N/A",
projectsByStatus: Object.fromEntries(
overview.projectsByStatus.map((entry) => [entry.status, entry.count]),
),
@@ -190,31 +195,34 @@ async function getTopValueResourcesCached(
}
function getUserRole(ctx: DashboardProcedureContext) {
return (ctx.session?.user as { role?: string } | undefined)?.role
?? ctx.dbUser?.systemRole
?? "USER";
return (
(ctx.session?.user as { role?: string } | undefined)?.role ?? ctx.dbUser?.systemRole ?? "USER"
);
}
function mapChargeabilityByChapter(
rows: DashboardChargeabilityOverviewRead["rows"],
month: string,
) {
const chapterMap = new Map<string, {
headcount: number;
avgTargetSum: number;
avgActualSum: number;
avgExpectedSum: number;
derivedHeadcount: number;
baseAvailableHours: number;
effectiveAvailableHours: number;
actualBookedHours: number;
expectedBookedHours: number;
targetBookedHours: number;
publicHolidayHoursDeduction: number;
absenceDayEquivalent: number;
absenceHoursDeduction: number;
unassignedHours: number;
}>();
const chapterMap = new Map<
string,
{
headcount: number;
avgTargetSum: number;
avgActualSum: number;
avgExpectedSum: number;
derivedHeadcount: number;
baseAvailableHours: number;
effectiveAvailableHours: number;
actualBookedHours: number;
expectedBookedHours: number;
targetBookedHours: number;
publicHolidayHoursDeduction: number;
absenceDayEquivalent: number;
absenceHoursDeduction: number;
unassignedHours: number;
}
>();
for (const row of rows) {
const chapter = row.chapter ?? "Unassigned";
@@ -267,27 +275,28 @@ function mapChargeabilityByChapter(
avgTarget: formatPct(summary.avgTargetSum / summary.headcount),
avgActual: formatPct(summary.avgActualSum / summary.headcount),
avgExpected: formatPct(summary.avgExpectedSum / summary.headcount),
explainability: summary.derivedHeadcount > 0
? {
month,
resourceCount: summary.headcount,
derivedHeadcount: summary.derivedHeadcount,
baseAvailableHours: round1(summary.baseAvailableHours),
effectiveAvailableHours: round1(summary.effectiveAvailableHours),
actualBookedHours: round1(summary.actualBookedHours),
expectedBookedHours: round1(summary.expectedBookedHours),
targetBookedHours: round1(summary.targetBookedHours),
publicHolidayHoursDeduction: round1(summary.publicHolidayHoursDeduction),
absenceDayEquivalent: round1(summary.absenceDayEquivalent),
absenceHoursDeduction: round1(summary.absenceHoursDeduction),
unassignedHours: round1(summary.unassignedHours),
}
: null,
explainability:
summary.derivedHeadcount > 0
? {
month,
resourceCount: summary.headcount,
derivedHeadcount: summary.derivedHeadcount,
baseAvailableHours: round1(summary.baseAvailableHours),
effectiveAvailableHours: round1(summary.effectiveAvailableHours),
actualBookedHours: round1(summary.actualBookedHours),
expectedBookedHours: round1(summary.expectedBookedHours),
targetBookedHours: round1(summary.targetBookedHours),
publicHolidayHoursDeduction: round1(summary.publicHolidayHoursDeduction),
absenceDayEquivalent: round1(summary.absenceDayEquivalent),
absenceHoursDeduction: round1(summary.absenceHoursDeduction),
unassignedHours: round1(summary.unassignedHours),
}
: null,
}))
.sort((left, right) => (
right.headcount - left.headcount
|| left.chapter.localeCompare(right.chapter)
));
.sort(
(left, right) =>
right.headcount - left.headcount || left.chapter.localeCompare(right.chapter),
);
}
function getProjectHealthRating(overall: number): "healthy" | "at_risk" | "critical" {
@@ -326,14 +335,14 @@ export async function getDashboardDemandRead(
return getDemandCached(ctx.db, input);
}
export async function getDashboardDetail(ctx: DashboardProcedureContext, input: DashboardDetailInput) {
export async function getDashboardDetail(
ctx: DashboardProcedureContext,
input: DashboardDetailInput,
) {
const section = input.section;
const result: Record<string, unknown> = {};
const needsOverview = (
section === "all"
|| section === "peak_times"
|| section === "demand_pipeline"
);
const needsOverview =
section === "all" || section === "peak_times" || section === "demand_pipeline";
const overview = needsOverview ? await getOverviewCached(ctx.db) : null;
const now = new Date();
const rangeStart = overview?.budgetBasis.windowStart
@@ -467,8 +476,7 @@ export async function getDashboardDetail(ctx: DashboardProcedureContext, input:
remainingBudgetCents: project.remainingBudgetCents ?? null,
calendarContextCount: project.derivation?.calendarContextCount ?? 0,
holidayAwareAssignmentCount: project.derivation?.holidayAwareAssignmentCount ?? 0,
publicHolidayCostDeductionCents:
project.derivation?.publicHolidayCostDeductionCents ?? 0,
publicHolidayCostDeductionCents: project.derivation?.publicHolidayCostDeductionCents ?? 0,
absenceCostDeductionCents: project.derivation?.absenceCostDeductionCents ?? 0,
},
}));
@@ -478,15 +486,13 @@ export async function getDashboardDetail(ctx: DashboardProcedureContext, input:
const skillGapSummary = await getDashboardSkillGapSummaryRead(ctx);
result.skillGaps = {
totalOpenPositions: skillGapSummary.totalOpenPositions,
roleGaps: skillGapSummary.roleGaps
.slice(0, 10)
.map((gap) => ({
role: gap.role,
gap: gap.gap,
needed: gap.needed,
filled: gap.filled,
fillRate: gap.fillRate,
})),
roleGaps: skillGapSummary.roleGaps.slice(0, 10).map((gap) => ({
role: gap.role,
gap: gap.gap,
needed: gap.needed,
filled: gap.filled,
fillRate: gap.fillRate,
})),
topSkillsInSupply: skillGapSummary.skillSupplyTop10.slice(0, 5),
resourcesByRole: skillGapSummary.resourcesByRole.slice(0, 5),
};
@@ -1,8 +1,4 @@
import {
DispoStagedRecordType,
ImportBatchStatus,
StagedRecordStatus,
} from "@capakraken/db";
import { DispoStagedRecordType, ImportBatchStatus, StagedRecordStatus } from "@nexus/db";
import { TRPCError } from "@trpc/server";
type StagedRecordAction = "APPROVE" | "REJECT" | "SKIP";
@@ -24,9 +20,7 @@ export function assertImportBatchCancelable(input: {
}
}
export function resolveDispoStagedRecordStatus(
action: StagedRecordAction,
): StagedRecordStatus {
export function resolveDispoStagedRecordStatus(action: StagedRecordAction): StagedRecordStatus {
switch (action) {
case "APPROVE":
return StagedRecordStatus.APPROVED;
@@ -76,14 +70,10 @@ export function buildCancelledImportBatchUpdateData() {
return { status: ImportBatchStatus.CANCELLED } as const;
}
export function buildResolvedStagedRecordUpdateData(
action: StagedRecordAction,
) {
export function buildResolvedStagedRecordUpdateData(action: StagedRecordAction) {
return { status: resolveDispoStagedRecordStatus(action) } as const;
}
export function buildDispoImportCommitAuditSummary(
counts: Record<string, unknown>,
): string {
export function buildDispoImportCommitAuditSummary(counts: Record<string, unknown>): string {
return `Committed import batch (${JSON.stringify(counts)})`;
}
+8 -6
View File
@@ -1,7 +1,5 @@
import {
DispoStagedRecordType,
} from "@capakraken/db";
import { commitDispoImportBatch } from "@capakraken/application";
import { DispoStagedRecordType } from "@nexus/db";
import { commitDispoImportBatch } from "@nexus/application";
import { TRPCError } from "@trpc/server";
import { createAuditEntry } from "../lib/audit.js";
import {
@@ -73,8 +71,12 @@ export async function commitImportBatch(
) {
const result = await commitDispoImportBatch(db, {
importBatchId: input.importBatchId,
...(input.allowTbdUnresolved !== undefined ? { allowTbdUnresolved: input.allowTbdUnresolved } : {}),
...(input.importTbdProjects !== undefined ? { importTbdProjects: input.importTbdProjects } : {}),
...(input.allowTbdUnresolved !== undefined
? { allowTbdUnresolved: input.allowTbdUnresolved }
: {}),
...(input.importTbdProjects !== undefined
? { importTbdProjects: input.importTbdProjects }
: {}),
});
const counts = result as unknown as Record<string, unknown>;
@@ -1,9 +1,9 @@
import path from "node:path";
import { DispoStagedRecordType, ImportBatchStatus, StagedRecordStatus } from "@capakraken/db";
import { DispoStagedRecordType, ImportBatchStatus, StagedRecordStatus } from "@nexus/db";
import {
assessDispoImportReadiness,
stageDispoImportBatch as stageDispoImportBatchApplication,
} from "@capakraken/application";
} from "@nexus/application";
import { z } from "zod";
import type { TRPCContext } from "../trpc.js";
import {
+7 -6
View File
@@ -1,7 +1,4 @@
import {
ImportBatchStatus,
StagedRecordStatus,
} from "@capakraken/db";
import { ImportBatchStatus, StagedRecordStatus } from "@nexus/db";
import { TRPCError } from "@trpc/server";
type PaginationInput = {
@@ -146,7 +143,9 @@ export async function listStagedAssignments(
where: {
importBatchId: input.importBatchId,
...(input.status !== undefined ? { status: input.status } : {}),
...(input.resourceExternalId !== undefined ? { resourceExternalId: input.resourceExternalId } : {}),
...(input.resourceExternalId !== undefined
? { resourceExternalId: input.resourceExternalId }
: {}),
},
orderBy: { createdAt: "asc" },
...toCursorPagination(input),
@@ -165,7 +164,9 @@ export async function listStagedVacations(
const items = await db.stagedVacation.findMany({
where: {
importBatchId: input.importBatchId,
...(input.resourceExternalId !== undefined ? { resourceExternalId: input.resourceExternalId } : {}),
...(input.resourceExternalId !== undefined
? { resourceExternalId: input.resourceExternalId }
: {}),
},
orderBy: { startDate: "asc" },
...toCursorPagination(input),
@@ -1,12 +1,9 @@
import {
aggregateByDiscipline,
expandScopeToEffort,
} from "@capakraken/engine";
import { aggregateByDiscipline, expandScopeToEffort } from "@nexus/engine";
import {
ApplyEffortRulesSchema,
CreateEffortRuleSetSchema,
UpdateEffortRuleSetSchema,
} from "@capakraken/shared";
} from "@nexus/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
@@ -41,10 +38,7 @@ type EffortRuleUpdateInput = z.infer<typeof UpdateEffortRuleSetSchema>;
type EffortRulePreviewInput = z.infer<typeof effortRulePreviewInputSchema>;
type EffortRuleApplyInput = z.infer<typeof ApplyEffortRulesSchema>;
async function getEffortRuleSetOrThrow(
ctx: EffortRuleProcedureContext,
id: string,
) {
async function getEffortRuleSetOrThrow(ctx: EffortRuleProcedureContext, id: string) {
return findUniqueOrThrow(
ctx.db.effortRuleSet.findUnique({
where: { id },
@@ -77,9 +71,7 @@ async function getEstimateWithLatestVersionOrThrow(
);
}
export async function listEffortRuleSets(
ctx: EffortRuleProcedureContext,
) {
export async function listEffortRuleSets(ctx: EffortRuleProcedureContext) {
return ctx.db.effortRuleSet.findMany({
include: effortRuleInclude,
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
+15 -22
View File
@@ -1,12 +1,6 @@
import type { Prisma } from "@capakraken/db";
import type {
EffortRuleInput,
ScopeItemInput,
} from "@capakraken/engine";
import {
CreateEffortRuleSetSchema,
UpdateEffortRuleSetSchema,
} from "@capakraken/shared";
import type { Prisma } from "@nexus/db";
import type { EffortRuleInput, ScopeItemInput } from "@nexus/engine";
import { CreateEffortRuleSetSchema, UpdateEffortRuleSetSchema } from "@nexus/shared";
import { z } from "zod";
type CreateEffortRuleSetInput = z.infer<typeof CreateEffortRuleSetSchema>;
@@ -104,21 +98,20 @@ export function buildEffortRuleSetUpdateData(
};
}
export function toScopeItemInputs(
scopeItems: ScopeItemRecord[],
): ScopeItemInput[] {
return scopeItems.map((scopeItem) => ({
name: scopeItem.name,
scopeType: scopeItem.scopeType as ScopeItemInput["scopeType"],
frameCount: scopeItem.frameCount,
itemCount: scopeItem.itemCount,
unitMode: (scopeItem.unitMode ?? null) as Exclude<ScopeItemInput["unitMode"], undefined>,
}) as ScopeItemInput);
export function toScopeItemInputs(scopeItems: ScopeItemRecord[]): ScopeItemInput[] {
return scopeItems.map(
(scopeItem) =>
({
name: scopeItem.name,
scopeType: scopeItem.scopeType as ScopeItemInput["scopeType"],
frameCount: scopeItem.frameCount,
itemCount: scopeItem.itemCount,
unitMode: (scopeItem.unitMode ?? null) as Exclude<ScopeItemInput["unitMode"], undefined>,
}) as ScopeItemInput,
);
}
export function toEffortRuleEngineInputs(
rules: EffortRuleRecord[],
): EffortRuleInput[] {
export function toEffortRuleEngineInputs(rules: EffortRuleRecord[]): EffortRuleInput[] {
return rules.map((rule) => ({
scopeType: rule.scopeType as EffortRuleInput["scopeType"],
discipline: rule.discipline,
+1 -1
View File
@@ -2,7 +2,7 @@ import {
ApplyEffortRulesSchema,
CreateEffortRuleSetSchema,
UpdateEffortRuleSetSchema,
} from "@capakraken/shared";
} from "@nexus/shared";
import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js";
import {
applyEffortRules,
@@ -9,10 +9,13 @@ import {
setEntitlement as setEntitlementUseCase,
bulkSetEntitlements as bulkSetEntitlementsUseCase,
type ReadEntitlementBalanceDeps,
} from "@capakraken/application";
} from "@nexus/application";
import { createAuditEntry } from "../lib/audit.js";
import { loadResourceHolidayContext } from "../lib/resource-holiday-context.js";
import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js";
import {
countCalendarDaysInPeriod,
countVacationChargeableDays,
} from "../lib/vacation-day-count.js";
import {
countVacationChargeableDaysFromSnapshot,
parseVacationSnapshotDateList,
@@ -168,7 +171,11 @@ export async function bulkSetEntitlements(
entityName: `Bulk Entitlement ${input.year}`,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
after: { year: input.year, entitledDays: input.entitledDays, resourceCount: result.updated } as unknown as Record<string, unknown>,
after: {
year: input.year,
entitledDays: input.entitledDays,
resourceCount: result.updated,
} as unknown as Record<string, unknown>,
source: "ui",
summary: `Bulk set entitlement to ${input.entitledDays} days for ${result.updated} resources (${input.year})`,
});
@@ -1,5 +1,5 @@
import type { Prisma } from "@capakraken/db";
import { CommercialTermsSchema, PermissionKey, UpdateCommercialTermsSchema } from "@capakraken/shared";
import type { Prisma } from "@nexus/db";
import { CommercialTermsSchema, PermissionKey, UpdateCommercialTermsSchema } from "@nexus/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { controllerProcedure, managerProcedure, requirePermission } from "../trpc.js";
@@ -26,9 +26,7 @@ export const estimateCommercialProcedures = {
const version = estimate.versions[0]!;
const raw = version.commercialTerms;
const terms = raw
? CommercialTermsSchema.parse(raw)
: CommercialTermsSchema.parse({});
const terms = raw ? CommercialTermsSchema.parse(raw) : CommercialTermsSchema.parse({});
return { versionId: version.id, terms };
}),
@@ -1,5 +1,5 @@
import { normalizeEstimateDemandLine, summarizeEstimateDemandLines } from "@capakraken/engine";
import { CreateEstimateSchema } from "@capakraken/shared";
import { normalizeEstimateDemandLine, summarizeEstimateDemandLines } from "@nexus/engine";
import { CreateEstimateSchema } from "@nexus/shared";
import { z } from "zod";
import { lookupRatesBatch } from "../lib/rate-card-lookup.js";
+6 -12
View File
@@ -1,20 +1,16 @@
import type { Prisma } from "@capakraken/db";
import type { Prisma } from "@nexus/db";
import {
aggregateWeeklyByChapter,
aggregateWeeklyToMonthly,
distributeHoursToWeeks,
generateWeekRange,
} from "@capakraken/engine";
import { PermissionKey, GenerateWeeklyPhasingSchema } from "@capakraken/shared";
} from "@nexus/engine";
import { PermissionKey, GenerateWeeklyPhasingSchema } from "@nexus/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import {
controllerProcedure,
managerProcedure,
requirePermission,
} from "../trpc.js";
import { getEstimateById } from "@capakraken/application";
import { controllerProcedure, managerProcedure, requirePermission } from "../trpc.js";
import { getEstimateById } from "@nexus/application";
type WeeklyPhasingMeta = {
startDate: string;
@@ -38,9 +34,7 @@ export const estimatePhasingProcedures = {
"Estimate",
);
const workingVersion = estimate.versions.find(
(version) => version.status === "WORKING",
);
const workingVersion = estimate.versions.find((version) => version.status === "WORKING");
if (!workingVersion) {
throw new TRPCError({
@@ -4,25 +4,22 @@ import {
createEstimateExport,
createEstimatePlanningHandoff,
updateEstimateDraft,
} from "@capakraken/application";
import type { Prisma } from "@capakraken/db";
} from "@nexus/application";
import type { Prisma } from "@nexus/db";
import {
CloneEstimateSchema,
CreateEstimateExportSchema,
CreateEstimatePlanningHandoffSchema,
CreateEstimateSchema,
UpdateEstimateDraftSchema,
} from "@capakraken/shared";
} from "@nexus/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { lookupRate } from "../lib/rate-card-lookup.js";
import { emitAllocationCreated } from "../sse/event-bus.js";
import type { TRPCContext } from "../trpc.js";
import {
autoFillDemandLineRates,
withComputedMetrics,
} from "./estimate-demand-lines.js";
import { autoFillDemandLineRates, withComputedMetrics } from "./estimate-demand-lines.js";
type EstimateProcedureContext = Pick<TRPCContext, "db" | "dbUser">;
@@ -77,9 +74,7 @@ type CreateEstimateInput = z.infer<typeof CreateEstimateSchema>;
type CloneEstimateInput = z.infer<typeof CloneEstimateSchema>;
type UpdateEstimateDraftInput = z.infer<typeof UpdateEstimateDraftSchema>;
type CreateEstimateExportInput = z.infer<typeof CreateEstimateExportSchema>;
type CreateEstimatePlanningHandoffInput = z.infer<
typeof CreateEstimatePlanningHandoffSchema
>;
type CreateEstimatePlanningHandoffInput = z.infer<typeof CreateEstimatePlanningHandoffSchema>;
type LookupDemandLineRateInput = z.infer<typeof lookupDemandLineRateInputSchema>;
export async function createEstimateRecord(
@@ -96,8 +91,11 @@ export async function createEstimateRecord(
);
}
const { demandLines: enrichedLines, autoFilledIndices } =
await autoFillDemandLineRates(ctx.db, input.demandLines, input.projectId);
const { demandLines: enrichedLines, autoFilledIndices } = await autoFillDemandLineRates(
ctx.db,
input.demandLines,
input.projectId,
);
const enrichedInput = { ...input, demandLines: enrichedLines };
const estimate = await ctx.db.$transaction(async (tx) => {
@@ -196,8 +194,11 @@ export async function updateEstimateDraftRecord(
effectiveProjectId = existing?.projectId ?? undefined;
}
const { demandLines: enrichedLines, autoFilledIndices } =
await autoFillDemandLineRates(ctx.db, input.demandLines, effectiveProjectId);
const { demandLines: enrichedLines, autoFilledIndices } = await autoFillDemandLineRates(
ctx.db,
input.demandLines,
effectiveProjectId,
);
const enrichedInput = { ...input, demandLines: enrichedLines };
let estimate;
@@ -220,9 +221,8 @@ export async function updateEstimateDraftRecord(
name: updated.name,
status: updated.status,
latestVersionNumber: updated.latestVersionNumber,
workingVersionId: updated.versions.find(
(version) => version.status === "WORKING",
)?.id,
workingVersionId: updated.versions.find((version) => version.status === "WORKING")
?.id,
autoFilledRateCardLines: autoFilledIndices.length,
},
} as Prisma.InputJsonValue,
@@ -338,11 +338,7 @@ export async function createEstimatePlanningHandoffRecord(
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: [
"Estimate not found",
"Estimate version not found",
"Linked project not found",
],
messages: ["Estimate not found", "Estimate version not found", "Linked project not found"],
},
{
code: "PRECONDITION_FAILED",
@@ -354,8 +350,7 @@ export async function createEstimatePlanningHandoffRecord(
"Linked project has an invalid date range",
],
predicates: [
(message) =>
message.startsWith("Project window has no working days for demand line"),
(message) => message.startsWith("Project window has no working days for demand line"),
],
},
]);
+35 -28
View File
@@ -1,6 +1,6 @@
import { getEstimateById, listEstimates } from "@capakraken/application";
import { summarizeEstimateDemandLines } from "@capakraken/engine";
import { EstimateListFiltersSchema } from "@capakraken/shared";
import { getEstimateById, listEstimates } from "@nexus/application";
import { summarizeEstimateDemandLines } from "@nexus/engine";
import { EstimateListFiltersSchema } from "@nexus/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
@@ -79,13 +79,18 @@ async function readEstimateVersionSnapshot(
const version = estimate.versions[0]!;
const demandSummary = summarizeEstimateDemandLines(version.demandLines);
const chapterTotals = version.demandLines.reduce<Record<string, {
lineCount: number;
hours: number;
costTotalCents: number;
priceTotalCents: number;
currency: string;
}>>((acc, line) => {
const chapterTotals = version.demandLines.reduce<
Record<
string,
{
lineCount: number;
hours: number;
costTotalCents: number;
priceTotalCents: number;
currency: string;
}
>
>((acc, line) => {
const key = line.chapter ?? "Unassigned";
const current = acc[key] ?? {
lineCount: 0,
@@ -107,10 +112,13 @@ async function readEstimateVersionSnapshot(
return acc;
}, {});
const assumptionCategoryTotals = version.assumptions.reduce<Record<string, number>>((acc, assumption) => {
acc[assumption.category] = (acc[assumption.category] ?? 0) + 1;
return acc;
}, {});
const assumptionCategoryTotals = version.assumptions.reduce<Record<string, number>>(
(acc, assumption) => {
acc[assumption.category] = (acc[assumption.category] ?? 0) + 1;
return acc;
},
{},
);
return {
estimate: {
@@ -163,21 +171,17 @@ export const estimateReadProcedures = {
list: controllerProcedure
.input(EstimateListFiltersSchema.default({}))
.query(async ({ ctx, input }) =>
listEstimates(
ctx.db as unknown as Parameters<typeof listEstimates>[0],
input,
)),
listEstimates(ctx.db as unknown as Parameters<typeof listEstimates>[0], input),
),
getById: controllerProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) =>
findUniqueOrThrow(
getEstimateById(
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
input.id,
),
getEstimateById(ctx.db as unknown as Parameters<typeof getEstimateById>[0], input.id),
"Estimate",
)),
),
),
listVersions: controllerProcedure
.input(z.object({ estimateId: z.string() }))
@@ -215,12 +219,15 @@ export const estimateReadProcedures = {
},
}),
"Estimate",
)),
),
),
getVersionSnapshot: controllerProcedure
.input(z.object({ estimateId: z.string(), versionId: z.string().optional() }))
.query(async ({ ctx, input }) => readEstimateVersionSnapshot(
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
input,
)),
.query(async ({ ctx, input }) =>
readEstimateVersionSnapshot(
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
input,
),
),
};
@@ -2,14 +2,14 @@ import {
approveEstimateVersion,
createEstimateRevision,
submitEstimateVersion,
} from "@capakraken/application";
import type { Prisma } from "@capakraken/db";
} from "@nexus/application";
import type { Prisma } from "@nexus/db";
import {
ApproveEstimateVersionSchema,
CreateEstimateRevisionSchema,
PermissionKey,
SubmitEstimateVersionSchema,
} from "@capakraken/shared";
} from "@nexus/shared";
import { managerProcedure, requirePermission } from "../trpc.js";
import { rethrowEstimateRouterError } from "./estimate-procedure-support.js";
@@ -55,10 +55,7 @@ export const estimateVersionWorkflowProcedures = {
},
{
code: "PRECONDITION_FAILED",
messages: [
"Estimate has no working version",
"Only working versions can be submitted",
],
messages: ["Estimate has no working version", "Only working versions can be submitted"],
},
]);
}
+9 -13
View File
@@ -5,7 +5,7 @@ import {
CreateEstimateSchema,
PermissionKey,
UpdateEstimateDraftSchema,
} from "@capakraken/shared";
} from "@nexus/shared";
import {
cloneEstimateRecord,
createEstimateExportRecord,
@@ -32,19 +32,15 @@ export const estimateRouter = createTRPCRouter({
...estimatePhasingProcedures,
...estimateVersionWorkflowProcedures,
create: managerProcedure
.input(CreateEstimateSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
return createEstimateRecord(ctx, input);
}),
create: managerProcedure.input(CreateEstimateSchema).mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
return createEstimateRecord(ctx, input);
}),
clone: managerProcedure
.input(CloneEstimateSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
return cloneEstimateRecord(ctx, input);
}),
clone: managerProcedure.input(CloneEstimateSchema).mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
return cloneEstimateRecord(ctx, input);
}),
updateDraft: managerProcedure
.input(UpdateEstimateDraftSchema)
@@ -1,12 +1,9 @@
import {
applyExperienceMultipliers,
applyExperienceMultipliersBatch,
} from "@capakraken/engine";
import { applyExperienceMultipliers, applyExperienceMultipliersBatch } from "@nexus/engine";
import {
ApplyExperienceMultipliersSchema,
CreateExperienceMultiplierSetSchema,
UpdateExperienceMultiplierSetSchema,
} from "@capakraken/shared";
} from "@nexus/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
@@ -68,9 +65,7 @@ async function getEstimateWithLatestVersionOrThrow(
orderBy: { versionNumber: "desc" },
take: 1,
include: {
demandLines: orderedDemandLines
? { orderBy: { createdAt: "asc" } }
: true,
demandLines: orderedDemandLines ? { orderBy: { createdAt: "asc" } } : true,
},
},
},
@@ -79,9 +74,7 @@ async function getEstimateWithLatestVersionOrThrow(
);
}
export async function listExperienceMultiplierSets(
ctx: ExperienceMultiplierProcedureContext,
) {
export async function listExperienceMultiplierSets(ctx: ExperienceMultiplierProcedureContext) {
return ctx.db.experienceMultiplierSet.findMany({
include: experienceMultiplierRuleInclude,
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
@@ -209,10 +202,7 @@ export async function previewExperienceMultipliers(
const demandLines = version.demandLines;
const previews = demandLines.map((line) => {
const result = applyExperienceMultipliers(
buildExperienceMultiplierInput(line),
engineRules,
);
const result = applyExperienceMultipliers(buildExperienceMultiplierInput(line), engineRules);
return {
demandLineId: line.id,
@@ -1,9 +1,9 @@
import type { Prisma } from "@capakraken/db";
import type { ExperienceMultiplierRule as EngineRule } from "@capakraken/engine";
import type { Prisma } from "@nexus/db";
import type { ExperienceMultiplierRule as EngineRule } from "@nexus/engine";
import {
CreateExperienceMultiplierSetSchema,
UpdateExperienceMultiplierSetSchema,
} from "@capakraken/shared";
} from "@nexus/shared";
import { z } from "zod";
type CreateExperienceMultiplierSetInput = z.infer<typeof CreateExperienceMultiplierSetSchema>;
@@ -50,10 +50,7 @@ export const experienceMultiplierRuleInclude = {
rules: { orderBy: { sortOrder: "asc" as const } },
} as const;
function buildExperienceMultiplierRuleRow(
input: ExperienceMultiplierRuleRowInput,
index: number,
) {
function buildExperienceMultiplierRuleRow(input: ExperienceMultiplierRuleRowInput, index: number) {
return {
...(input.chapter ? { chapter: input.chapter } : {}),
...(input.location ? { location: input.location } : {}),
@@ -61,7 +58,9 @@ function buildExperienceMultiplierRuleRow(
costMultiplier: input.costMultiplier,
billMultiplier: input.billMultiplier,
...(input.shoringRatio !== undefined ? { shoringRatio: input.shoringRatio } : {}),
...(input.additionalEffortRatio !== undefined ? { additionalEffortRatio: input.additionalEffortRatio } : {}),
...(input.additionalEffortRatio !== undefined
? { additionalEffortRatio: input.additionalEffortRatio }
: {}),
...(input.description ? { description: input.description } : {}),
sortOrder: input.sortOrder ?? index,
};
@@ -118,23 +117,27 @@ export function toExperienceMultiplierEngineRules(
costMultiplier: rule.costMultiplier,
billMultiplier: rule.billMultiplier,
...(rule.shoringRatio != null ? { shoringRatio: rule.shoringRatio } : {}),
...(rule.additionalEffortRatio != null ? { additionalEffortRatio: rule.additionalEffortRatio } : {}),
...(rule.additionalEffortRatio != null
? { additionalEffortRatio: rule.additionalEffortRatio }
: {}),
...(rule.description != null ? { description: rule.description } : {}),
}));
}
export function buildExperienceMultiplierInput(
line: DemandLineRecord,
) {
export function buildExperienceMultiplierInput(line: DemandLineRecord) {
return {
costRateCents: line.costRateCents,
billRateCents: line.billRateCents,
hours: line.hours,
...(line.chapter != null ? { chapter: line.chapter } : {}),
...(line.metadata != null && typeof line.metadata === "object" && "location" in (line.metadata as Record<string, unknown>)
...(line.metadata != null &&
typeof line.metadata === "object" &&
"location" in (line.metadata as Record<string, unknown>)
? { location: (line.metadata as Record<string, unknown>).location as string }
: {}),
...(line.staffingAttributes != null && typeof line.staffingAttributes === "object" && "level" in (line.staffingAttributes as Record<string, unknown>)
...(line.staffingAttributes != null &&
typeof line.staffingAttributes === "object" &&
"level" in (line.staffingAttributes as Record<string, unknown>)
? { level: (line.staffingAttributes as Record<string, unknown>).level as string }
: {}),
};
@@ -167,7 +170,7 @@ export function buildExperienceMultiplierDemandLineUpdateData(input: {
priceTotalCents: newPriceTotal,
metadata: {
...(typeof input.line.metadata === "object" && input.line.metadata !== null
? input.line.metadata as Record<string, unknown>
? (input.line.metadata as Record<string, unknown>)
: {}),
experienceMultiplier: {
setId: input.multiplierSet.id,
@@ -2,7 +2,7 @@ import {
ApplyExperienceMultipliersSchema,
CreateExperienceMultiplierSetSchema,
UpdateExperienceMultiplierSetSchema,
} from "@capakraken/shared";
} from "@nexus/shared";
import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js";
import {
applyExperienceMultiplierRules,
@@ -3,7 +3,7 @@ import {
CreateHolidayCalendarSchema,
UpdateHolidayCalendarEntrySchema,
UpdateHolidayCalendarSchema,
} from "@capakraken/shared";
} from "@nexus/shared";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { makeAuditLogger } from "../lib/audit-helpers.js";
@@ -107,12 +107,16 @@ export async function updateHolidayCalendar(
data: input.data,
});
await assertHolidayCalendarScopeConsistency(db, {
scopeType: existing.scopeType,
countryId: existing.countryId,
stateCode,
metroCityId,
}, existing.id);
await assertHolidayCalendarScopeConsistency(
db,
{
scopeType: existing.scopeType,
countryId: existing.countryId,
stateCode,
metroCityId,
},
existing.id,
);
const updated = await db.holidayCalendar.update({
where: { id: input.id },
@@ -213,14 +217,17 @@ export async function updateHolidayCalendarEntry(
db.holidayCalendarEntry.findUnique({ where: { id: input.id } }),
"Holiday calendar entry",
);
const nextDate = input.data.date !== undefined
? clampHolidayCalendarDate(input.data.date)
: existing.date;
const nextDate =
input.data.date !== undefined ? clampHolidayCalendarDate(input.data.date) : existing.date;
await assertHolidayCalendarEntryDateAvailable(db, {
holidayCalendarId: existing.holidayCalendarId,
date: nextDate,
}, existing.id);
await assertHolidayCalendarEntryDateAvailable(
db,
{
holidayCalendarId: existing.holidayCalendarId,
date: nextDate,
},
existing.id,
);
const updated = await db.holidayCalendarEntry.update({
where: { id: input.id },
@@ -1,4 +1,4 @@
import { PreviewResolvedHolidaysSchema } from "@capakraken/shared";
import { PreviewResolvedHolidaysSchema } from "@nexus/shared";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
@@ -33,13 +33,15 @@ function formatResolvedHolidayDetail(holiday: {
};
}
function summarizeResolvedHolidaysDetail(holidays: Array<{
date: string;
name: string;
scope: string;
calendarName: string;
sourceType: string;
}>) {
function summarizeResolvedHolidaysDetail(
holidays: Array<{
date: string;
name: string;
scope: string;
calendarName: string;
sourceType: string;
}>,
) {
const byScope = new Map<string, number>();
const bySourceType = new Map<string, number>();
const byCalendar = new Map<string, number>();
@@ -63,44 +65,48 @@ function summarizeResolvedHolidaysDetail(holidays: Array<{
};
}
const ResolveHolidaysInputSchema = z.object({
periodStart: z.coerce.date(),
periodEnd: z.coerce.date(),
countryId: z.string().optional(),
countryCode: z.string().trim().min(1).optional(),
stateCode: z.string().trim().min(1).optional(),
metroCityId: z.string().optional(),
metroCityName: z.string().trim().min(1).optional(),
}).superRefine((input, issueCtx) => {
if (!input.countryId && !input.countryCode) {
issueCtx.addIssue({
code: z.ZodIssueCode.custom,
message: "Either countryId or countryCode is required.",
path: ["countryId"],
});
}
if (input.periodEnd < input.periodStart) {
issueCtx.addIssue({
code: z.ZodIssueCode.custom,
message: "periodEnd must be on or after periodStart.",
path: ["periodEnd"],
});
}
});
const ResolveHolidaysInputSchema = z
.object({
periodStart: z.coerce.date(),
periodEnd: z.coerce.date(),
countryId: z.string().optional(),
countryCode: z.string().trim().min(1).optional(),
stateCode: z.string().trim().min(1).optional(),
metroCityId: z.string().optional(),
metroCityName: z.string().trim().min(1).optional(),
})
.superRefine((input, issueCtx) => {
if (!input.countryId && !input.countryCode) {
issueCtx.addIssue({
code: z.ZodIssueCode.custom,
message: "Either countryId or countryCode is required.",
path: ["countryId"],
});
}
if (input.periodEnd < input.periodStart) {
issueCtx.addIssue({
code: z.ZodIssueCode.custom,
message: "periodEnd must be on or after periodStart.",
path: ["periodEnd"],
});
}
});
const ResolveResourceHolidaysInputSchema = z.object({
resourceId: z.string(),
periodStart: z.coerce.date(),
periodEnd: z.coerce.date(),
}).superRefine((input, issueCtx) => {
if (input.periodEnd < input.periodStart) {
issueCtx.addIssue({
code: z.ZodIssueCode.custom,
message: "periodEnd must be on or after periodStart.",
path: ["periodEnd"],
});
}
});
const ResolveResourceHolidaysInputSchema = z
.object({
resourceId: z.string(),
periodStart: z.coerce.date(),
periodEnd: z.coerce.date(),
})
.superRefine((input, issueCtx) => {
if (input.periodEnd < input.periodStart) {
issueCtx.addIssue({
code: z.ZodIssueCode.custom,
message: "periodEnd must be on or after periodStart.",
path: ["periodEnd"],
});
}
});
async function readPreviewResolvedHolidaysSnapshot(
ctx: HolidayReadContext,
@@ -168,11 +174,13 @@ async function readResolvedHolidaysSnapshot(
}
const metroCityName = input.metroCityId
? (await ctx.db.metroCity.findUnique({
where: { id: input.metroCityId },
select: { name: true },
}))?.name ?? null
: input.metroCityName?.trim() ?? null;
? ((
await ctx.db.metroCity.findUnique({
where: { id: input.metroCityId },
select: { name: true },
})
)?.name ?? null)
: (input.metroCityName?.trim() ?? null);
const resolved = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
periodStart: input.periodStart,
@@ -262,7 +270,9 @@ async function readResolvedResourceHolidaysSnapshot(
export const holidayCalendarResolutionReadProcedures = {
previewResolvedHolidays: protectedProcedure
.input(PreviewResolvedHolidaysSchema)
.query(async ({ ctx, input }) => (await readPreviewResolvedHolidaysSnapshot(ctx, input)).holidays),
.query(
async ({ ctx, input }) => (await readPreviewResolvedHolidaysSnapshot(ctx, input)).holidays,
),
previewResolvedHolidaysDetail: protectedProcedure
.input(PreviewResolvedHolidaysSchema)
@@ -1,10 +1,10 @@
import type { Prisma } from "@capakraken/db";
import type { Prisma } from "@nexus/db";
import {
CreateHolidayCalendarEntrySchema,
CreateHolidayCalendarSchema,
UpdateHolidayCalendarEntrySchema,
UpdateHolidayCalendarSchema,
} from "@capakraken/shared";
} from "@nexus/shared";
import { z } from "zod";
type CreateHolidayCalendarInput = z.infer<typeof CreateHolidayCalendarSchema>;
@@ -12,10 +12,7 @@ type UpdateHolidayCalendarInput = z.infer<typeof UpdateHolidayCalendarSchema>;
type CreateHolidayCalendarEntryInput = z.infer<typeof CreateHolidayCalendarEntrySchema>;
type UpdateHolidayCalendarEntryInput = z.infer<typeof UpdateHolidayCalendarEntrySchema>;
export const holidayCalendarEntryOrderBy = [
{ date: "asc" },
{ name: "asc" },
] as const;
export const holidayCalendarEntryOrderBy = [{ date: "asc" }, { name: "asc" }] as const;
export const holidayCalendarDetailInclude = {
country: { select: { id: true, code: true, name: true } },
@@ -41,7 +38,9 @@ export function buildHolidayCalendarCreateData(
scopeType: input.scopeType,
countryId: input.countryId,
...(input.normalizedScope.stateCode ? { stateCode: input.normalizedScope.stateCode } : {}),
...(input.normalizedScope.metroCityId ? { metroCityId: input.normalizedScope.metroCityId } : {}),
...(input.normalizedScope.metroCityId
? { metroCityId: input.normalizedScope.metroCityId }
: {}),
isActive: input.isActive ?? true,
priority: input.priority ?? 0,
};
@@ -57,7 +56,9 @@ export function buildHolidayCalendarUpdateData(input: {
return {
...(input.data.name !== undefined ? { name: input.data.name } : {}),
...(input.data.stateCode !== undefined ? { stateCode: input.resolvedScope.stateCode } : {}),
...(input.data.metroCityId !== undefined ? { metroCityId: input.resolvedScope.metroCityId } : {}),
...(input.data.metroCityId !== undefined
? { metroCityId: input.resolvedScope.metroCityId }
: {}),
...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}),
...(input.data.priority !== undefined ? { priority: input.data.priority } : {}),
};
@@ -83,7 +84,9 @@ export function buildHolidayCalendarEntryUpdateData(input: {
return {
...(input.data.date !== undefined ? { date: input.date } : {}),
...(input.data.name !== undefined ? { name: input.data.name } : {}),
...(input.data.isRecurringAnnual !== undefined ? { isRecurringAnnual: input.data.isRecurringAnnual } : {}),
...(input.data.isRecurringAnnual !== undefined
? { isRecurringAnnual: input.data.isRecurringAnnual }
: {}),
...(input.data.source !== undefined ? { source: input.data.source ?? null } : {}),
};
}
@@ -1,9 +1,7 @@
import { TRPCError } from "@trpc/server";
import { findUniqueOrThrow } from "../db/helpers.js";
import {
type HolidayCalendarDb,
} from "./holiday-calendar-shared.js";
import type { HolidayCalendarScopeInput } from "@capakraken/shared";
import { type HolidayCalendarDb } from "./holiday-calendar-shared.js";
import type { HolidayCalendarScopeInput } from "@nexus/shared";
type HolidayCalendarScope = HolidayCalendarScopeInput;
@@ -46,12 +44,14 @@ export function resolveHolidayCalendarUpdateScope(input: {
};
}) {
return {
stateCode: input.data.stateCode === undefined
? input.existing.stateCode
: normalizeHolidayCalendarStateCode(input.data.stateCode),
metroCityId: input.data.metroCityId === undefined
? input.existing.metroCityId
: input.data.metroCityId ?? null,
stateCode:
input.data.stateCode === undefined
? input.existing.stateCode
: normalizeHolidayCalendarStateCode(input.data.stateCode),
metroCityId:
input.data.metroCityId === undefined
? input.existing.metroCityId
: (input.data.metroCityId ?? null),
};
}
+1 -5
View File
@@ -12,10 +12,7 @@ import {
updateHolidayCalendarEntry,
} from "./holiday-calendar-procedure-support.js";
import { holidayCalendarResolutionReadProcedures } from "./holiday-calendar-resolution-read.js";
import {
CreateHolidayCalendarEntrySchema,
CreateHolidayCalendarSchema,
} from "@capakraken/shared";
import { CreateHolidayCalendarEntrySchema, CreateHolidayCalendarSchema } from "@nexus/shared";
export const holidayCalendarRouter = createTRPCRouter({
...holidayCalendarCatalogReadProcedures,
@@ -44,5 +41,4 @@ export const holidayCalendarRouter = createTRPCRouter({
deleteEntry: adminProcedure
.input(holidayCalendarIdInputSchema)
.mutation(({ ctx, input }) => deleteHolidayCalendarEntry(ctx, input)),
});
@@ -1,5 +1,5 @@
import { BlueprintTarget, PermissionKey } from "@capakraken/shared";
import type { BlueprintFieldDefinition } from "@capakraken/shared";
import { BlueprintTarget, PermissionKey } from "@nexus/shared";
import type { BlueprintFieldDefinition } from "@nexus/shared";
import { z } from "zod";
import type { TRPCContext } from "../trpc.js";
import { requirePermission } from "../trpc.js";
@@ -1,4 +1,4 @@
import { DEFAULT_OPENAI_MODEL } from "@capakraken/shared";
import { DEFAULT_OPENAI_MODEL } from "@nexus/shared";
import { TRPCError } from "@trpc/server";
import type {
ChatCompletion,
@@ -95,14 +95,18 @@ export async function generateProjectNarrative(
);
const progressPercent = totalDays > 0 ? Math.round((elapsedDays / totalDays) * 100) : 0;
const totalDemandHeadcount = project.demandRequirements.reduce((sum, demand) => sum + demand.headcount, 0);
const totalDemandHeadcount = project.demandRequirements.reduce(
(sum, demand) => sum + demand.headcount,
0,
);
const filledDemandHeadcount = project.demandRequirements.reduce(
(sum, demand) => sum + Math.min(demand._count.assignments, demand.headcount),
0,
);
const staffingPercent = totalDemandHeadcount > 0
? Math.round((filledDemandHeadcount / totalDemandHeadcount) * 100)
: 100;
const staffingPercent =
totalDemandHeadcount > 0
? Math.round((filledDemandHeadcount / totalDemandHeadcount) * 100)
: 100;
const totalCostCents = project.assignments.reduce((sum, assignment) => {
const days = countBusinessDays(assignment.startDate, assignment.endDate);
@@ -144,7 +148,8 @@ ${dataContext}`;
messages: [
{
role: "system",
content: "You are a project management analyst providing brief executive summaries. Be factual and action-oriented.",
content:
"You are a project management analyst providing brief executive summaries. Be factual and action-oriented.",
},
{ role: "user", content: prompt },
],
+5 -9
View File
@@ -1,12 +1,8 @@
import { randomBytes } from "node:crypto";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { SystemRole } from "@capakraken/db";
import {
PASSWORD_MAX_LENGTH,
PASSWORD_MIN_LENGTH,
PASSWORD_POLICY_MESSAGE,
} from "@capakraken/shared";
import { SystemRole } from "@nexus/db";
import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@nexus/shared";
import { createTRPCRouter, adminProcedure, publicProcedure } from "../trpc.js";
import { getAppBaseUrl } from "../lib/app-base-url.js";
import { sendEmail } from "../lib/email.js";
@@ -16,7 +12,7 @@ const INVITE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours
function inviteEmailHtml(inviteUrl: string, role: SystemRole): string {
return `
<p>You have been invited to join CapaKraken as <strong>${role}</strong>.</p>
<p>You have been invited to join Nexus as <strong>${role}</strong>.</p>
<p>Click the link below to accept the invitation and set your password:</p>
<p><a href="${inviteUrl}">${inviteUrl}</a></p>
<p>This link expires in 72 hours and can only be used once.</p>
@@ -61,8 +57,8 @@ export const inviteRouter = createTRPCRouter({
void sendEmail({
to: input.email,
subject: "You have been invited to CapaKraken",
text: `You have been invited to join CapaKraken as ${input.role}.\n\nAccept your invitation: ${inviteUrl}\n\nThis link expires in 72 hours.`,
subject: "You have been invited to Nexus",
text: `You have been invited to join Nexus as ${input.role}.\n\nAccept your invitation: ${inviteUrl}\n\nThis link expires in 72 hours.`,
html: inviteEmailHtml(inviteUrl, input.role),
});
@@ -3,7 +3,7 @@ import {
CreateManagementLevelSchema,
UpdateManagementLevelGroupSchema,
UpdateManagementLevelSchema,
} from "@capakraken/shared";
} from "@nexus/shared";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { createAuditEntry } from "../lib/audit.js";
@@ -45,9 +45,7 @@ type ManagementLevelCreateInput = z.infer<typeof CreateManagementLevelSchema>;
type ManagementLevelUpdateInput = z.infer<typeof managementLevelUpdateInputSchema>;
type ManagementLevelIdInput = z.infer<typeof managementLevelIdInputSchema>;
export async function listManagementLevelGroups(
ctx: ManagementLevelProcedureContext,
) {
export async function listManagementLevelGroups(ctx: ManagementLevelProcedureContext) {
return ctx.db.managementLevelGroup.findMany({
include: { levels: { orderBy: { name: "asc" } } },
orderBy: { sortOrder: "asc" },
@@ -1,10 +1,10 @@
import type { Prisma, PrismaClient } from "@capakraken/db";
import type { Prisma, PrismaClient } from "@nexus/db";
import {
CreateManagementLevelGroupSchema,
CreateManagementLevelSchema,
UpdateManagementLevelGroupSchema,
UpdateManagementLevelSchema,
} from "@capakraken/shared";
} from "@nexus/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
@@ -88,9 +88,7 @@ export function buildManagementLevelUpdateData(
};
}
export function assertManagementLevelDeletable(
level: ManagementLevelDeleteRecord,
): void {
export function assertManagementLevelDeletable(level: ManagementLevelDeleteRecord): void {
if (level._count.resources > 0) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
+1 -4
View File
@@ -1,7 +1,4 @@
import {
CreateManagementLevelGroupSchema,
CreateManagementLevelSchema,
} from "@capakraken/shared";
import { CreateManagementLevelGroupSchema, CreateManagementLevelSchema } from "@nexus/shared";
import { adminProcedure, createTRPCRouter, planningReadProcedure } from "../trpc.js";
import {
createManagementLevel,
@@ -1,14 +1,10 @@
import { PermissionKey, parseTaskAction, resolvePermissions } from "@capakraken/shared";
import { PermissionKey, parseTaskAction, resolvePermissions } from "@nexus/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { createNotification } from "../lib/create-notification.js";
import { getTaskAction } from "../lib/task-actions.js";
import {
emitTaskAssigned,
emitTaskCompleted,
emitTaskStatusChanged,
} from "../sse/event-bus.js";
import { emitTaskAssigned, emitTaskCompleted, emitTaskStatusChanged } from "../sse/event-bus.js";
import {
AssignTaskInputSchema,
CreateTaskInputSchema,
@@ -246,8 +242,8 @@ export async function executeNotificationTaskAction(
const currentUser = requireNotificationDbUser(ctx);
const permissions = resolvePermissions(
currentUser.systemRole as import("@capakraken/shared").SystemRole,
currentUser.permissionOverrides as import("@capakraken/shared").PermissionOverrides | null,
currentUser.systemRole as import("@nexus/shared").SystemRole,
currentUser.permissionOverrides as import("@nexus/shared").PermissionOverrides | null,
ctx.roleDefaults ?? undefined,
);
if (handler.permission && !permissions.has(handler.permission as PermissionKey)) {
@@ -1,4 +1,4 @@
import { CreateOrgUnitSchema, UpdateOrgUnitSchema } from "@capakraken/shared";
import { CreateOrgUnitSchema, UpdateOrgUnitSchema } from "@nexus/shared";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { createAuditEntry } from "../lib/audit.js";
@@ -34,15 +34,19 @@ function withAuditUser(userId: string | undefined) {
return userId ? { userId } : {};
}
export const orgUnitListInputSchema = z.object({
level: z.number().int().min(5).max(7).optional(),
parentId: z.string().optional(),
isActive: z.boolean().optional(),
}).optional();
export const orgUnitListInputSchema = z
.object({
level: z.number().int().min(5).max(7).optional(),
parentId: z.string().optional(),
isActive: z.boolean().optional(),
})
.optional();
export const orgUnitTreeInputSchema = z.object({
isActive: z.boolean().optional(),
}).optional();
export const orgUnitTreeInputSchema = z
.object({
isActive: z.boolean().optional(),
})
.optional();
export const orgUnitIdInputSchema = z.object({
id: z.string(),

Some files were not shown because too many files have changed in this diff Show More