diff --git a/packages/api/src/__tests__/assistant-tools-dashboard-detail.test.ts b/packages/api/src/__tests__/assistant-tools-dashboard-detail.test.ts index f4246c2..e5aca4b 100644 --- a/packages/api/src/__tests__/assistant-tools-dashboard-detail.test.ts +++ b/packages/api/src/__tests__/assistant-tools-dashboard-detail.test.ts @@ -8,6 +8,7 @@ import { getDashboardDemand, getDashboardOverview, getDashboardPeakTimes, + getDashboardProjectHealth, getDashboardTopValueResources, } from "./assistant-tools-dashboard-test-helpers.js"; @@ -172,6 +173,42 @@ describe("assistant dashboard tools detail aggregation", () => { watchlist: [], month: "2026-03", }); + vi.mocked(getDashboardProjectHealth).mockResolvedValue([ + { + id: "project_1", + projectName: "Gelddruckmaschine", + shortCode: "GDM", + status: "ACTIVE", + clientId: "client_1", + clientName: "Acme", + budgetHealth: 58, + staffingHealth: 46, + timelineHealth: 61, + compositeScore: 55, + budgetUtilizationPercent: 73, + remainingBudgetCents: 88_000, + demandHeadcountTotal: 4, + demandHeadcountFilled: 2, + demandHeadcountOpen: 2, + demandRequirementCount: 2, + plannedEndDate: new Date("2026-09-30T00:00:00.000Z"), + daysUntilEndDate: 183, + timelineStatus: "DUE_SOON", + derivation: { + periodStart: "2026-01-01", + periodEnd: "2026-06-30", + calendarContextCount: 1, + holidayAwareAssignmentCount: 2, + fallbackAssignmentCount: 0, + baseSpentCents: 140_000, + adjustedSpentCents: 132_000, + publicHolidayDayEquivalent: 1, + publicHolidayCostDeductionCents: 5_000, + absenceDayEquivalent: 0.5, + absenceCostDeductionCents: 3_000, + }, + }, + ]); const ctx = createToolContext( { @@ -320,6 +357,33 @@ describe("assistant dashboard tools detail aggregation", () => { }, }, ], + projectHealth: [ + { + project: "Gelddruckmaschine (GDM)", + status: "ACTIVE", + overall: 55, + rating: "at_risk", + budget: 58, + staffing: 46, + timeline: 61, + timelineStatus: "DUE_SOON", + daysUntilEndDate: 183, + demandHeadcountOpen: 2, + explainability: { + demandHeadcountTotal: 4, + demandHeadcountFilled: 2, + demandHeadcountOpen: 2, + demandRequirementCount: 2, + plannedEndDate: "2026-09-30T00:00:00.000Z", + budgetUtilizationPercent: 73, + remainingBudgetCents: 88_000, + calendarContextCount: 1, + holidayAwareAssignmentCount: 2, + publicHolidayCostDeductionCents: 5_000, + absenceCostDeductionCents: 3_000, + }, + }, + ], }); }); }); diff --git a/packages/api/src/__tests__/dashboard-procedure-support.test.ts b/packages/api/src/__tests__/dashboard-procedure-support.test.ts index 12f9fce..611e54f 100644 --- a/packages/api/src/__tests__/dashboard-procedure-support.test.ts +++ b/packages/api/src/__tests__/dashboard-procedure-support.test.ts @@ -257,6 +257,42 @@ describe("dashboard procedure support", () => { watchlist: [], month: "2026-03", }); + vi.mocked(getDashboardProjectHealth).mockResolvedValue([ + { + id: "project_1", + projectName: "Apollo", + shortCode: "APO", + status: "ACTIVE", + clientId: "client_1", + clientName: "Acme", + budgetHealth: 62, + staffingHealth: 55, + timelineHealth: 48, + compositeScore: 54, + budgetUtilizationPercent: 81, + remainingBudgetCents: 45_000, + demandHeadcountTotal: 5, + demandHeadcountFilled: 3, + demandHeadcountOpen: 2, + demandRequirementCount: 2, + plannedEndDate: new Date("2026-07-31T00:00:00.000Z"), + daysUntilEndDate: 138, + timelineStatus: "DUE_SOON", + derivation: { + periodStart: "2026-03-01", + periodEnd: "2026-06-30", + calendarContextCount: 1, + holidayAwareAssignmentCount: 3, + fallbackAssignmentCount: 0, + baseSpentCents: 170_000, + adjustedSpentCents: 165_000, + publicHolidayDayEquivalent: 1, + publicHolidayCostDeductionCents: 5_000, + absenceDayEquivalent: 0.5, + absenceCostDeductionCents: 3_000, + }, + }, + ]); try { const result = await getDashboardDetail(createContext(), { section: "all" }); @@ -354,6 +390,33 @@ describe("dashboard procedure support", () => { }, }, ], + projectHealth: [ + { + project: "Apollo (APO)", + status: "ACTIVE", + overall: 54, + rating: "at_risk", + budget: 62, + staffing: 55, + timeline: 48, + timelineStatus: "DUE_SOON", + daysUntilEndDate: 138, + demandHeadcountOpen: 2, + explainability: { + demandHeadcountTotal: 5, + demandHeadcountFilled: 3, + demandHeadcountOpen: 2, + demandRequirementCount: 2, + plannedEndDate: "2026-07-31T00:00:00.000Z", + budgetUtilizationPercent: 81, + remainingBudgetCents: 45_000, + calendarContextCount: 1, + holidayAwareAssignmentCount: 3, + publicHolidayCostDeductionCents: 5_000, + absenceCostDeductionCents: 3_000, + }, + }, + ], }); expect(getDashboardChargeabilityOverview).toHaveBeenCalledWith( diff --git a/packages/api/src/__tests__/dashboard-router.test.ts b/packages/api/src/__tests__/dashboard-router.test.ts index 8579853..879ed10 100644 --- a/packages/api/src/__tests__/dashboard-router.test.ts +++ b/packages/api/src/__tests__/dashboard-router.test.ts @@ -870,6 +870,42 @@ describe("dashboard router", () => { watchlist: [], month: "2026-03", }); + vi.mocked(getDashboardProjectHealth).mockResolvedValue([ + { + id: "project_1", + projectName: "Gelddruckmaschine", + shortCode: "GDM", + status: "ACTIVE", + clientId: "client_1", + clientName: "Acme", + budgetHealth: 58, + staffingHealth: 46, + timelineHealth: 61, + compositeScore: 55, + budgetUtilizationPercent: 73, + remainingBudgetCents: 88_000, + demandHeadcountTotal: 4, + demandHeadcountFilled: 2, + demandHeadcountOpen: 2, + demandRequirementCount: 2, + plannedEndDate: new Date("2026-09-30T00:00:00.000Z"), + daysUntilEndDate: 183, + timelineStatus: "DUE_SOON", + derivation: { + periodStart: "2026-01-01", + periodEnd: "2026-06-30", + calendarContextCount: 1, + holidayAwareAssignmentCount: 2, + fallbackAssignmentCount: 0, + baseSpentCents: 140_000, + adjustedSpentCents: 132_000, + publicHolidayDayEquivalent: 1, + publicHolidayCostDeductionCents: 5_000, + absenceDayEquivalent: 0.5, + absenceCostDeductionCents: 3_000, + }, + }, + ]); const caller = createControllerCaller({}); const result = await caller.getDetail({ section: "all" }); @@ -985,6 +1021,33 @@ describe("dashboard router", () => { }, }, ], + projectHealth: [ + { + project: "Gelddruckmaschine (GDM)", + status: "ACTIVE", + overall: 55, + rating: "at_risk", + budget: 58, + staffing: 46, + timeline: 61, + timelineStatus: "DUE_SOON", + daysUntilEndDate: 183, + demandHeadcountOpen: 2, + explainability: { + demandHeadcountTotal: 4, + demandHeadcountFilled: 2, + demandHeadcountOpen: 2, + demandRequirementCount: 2, + plannedEndDate: "2026-09-30T00:00:00.000Z", + budgetUtilizationPercent: 73, + remainingBudgetCents: 88_000, + calendarContextCount: 1, + holidayAwareAssignmentCount: 2, + publicHolidayCostDeductionCents: 5_000, + absenceCostDeductionCents: 3_000, + }, + }, + ], }); expect(getDashboardPeakTimes).toHaveBeenCalledWith( expect.anything(), @@ -1003,6 +1066,7 @@ describe("dashboard router", () => { watchlistThreshold: 15, }), ); + expect(getDashboardProjectHealth).toHaveBeenCalledTimes(1); }); }); }); diff --git a/packages/api/src/router/assistant-tools/dashboard-insights-reports.ts b/packages/api/src/router/assistant-tools/dashboard-insights-reports.ts index e39a5c7..2c5fa9a 100644 --- a/packages/api/src/router/assistant-tools/dashboard-insights-reports.ts +++ b/packages/api/src/router/assistant-tools/dashboard-insights-reports.ts @@ -16,6 +16,7 @@ type ReportQueryResult = { totalCount: number; columns: unknown; groups: unknown; + explainability?: unknown; }; type DashboardInsightsReportsDeps = { @@ -53,13 +54,13 @@ 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.", + description: "Get detailed dashboard data: peak allocation times, top-value resources, demand pipeline, chargeability overview, and project health risks.", parameters: { type: "object", properties: { section: { type: "string", - description: "Which section: peak_times, top_resources, demand_pipeline, chargeability_overview, or all", + description: "Which section: peak_times, top_resources, demand_pipeline, chargeability_overview, project_health, or all", }, }, }, @@ -174,6 +175,7 @@ export const dashboardInsightsReportsToolDefinitions: ToolDef[] = withToolAccess }, get_budget_forecast: { allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], + requiredPermissions: [PermissionKey.VIEW_COSTS], }, get_insights_summary: { allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], @@ -255,6 +257,7 @@ export function createDashboardInsightsReportsExecutors( totalCount: result.totalCount, columns: result.columns, groups: result.groups, + ...(result.explainability ? { explainability: result.explainability } : {}), }; }, }; diff --git a/packages/api/src/router/dashboard-procedure-support.ts b/packages/api/src/router/dashboard-procedure-support.ts index d47fa1e..a19d2b6 100644 --- a/packages/api/src/router/dashboard-procedure-support.ts +++ b/packages/api/src/router/dashboard-procedure-support.ts @@ -293,6 +293,12 @@ function mapChargeabilityByChapter( )); } +function getProjectHealthRating(overall: number): "healthy" | "at_risk" | "critical" { + if (overall >= 80) return "healthy"; + if (overall >= 50) return "at_risk"; + return "critical"; +} + export async function getDashboardOverviewRead(ctx: DashboardProcedureContext) { return getOverviewCached(ctx.db); } @@ -438,6 +444,39 @@ export async function getDashboardDetail(ctx: DashboardProcedureContext, input: ); } + if (section === "all" || section === "project_health") { + const projectHealth = await getDashboardProjectHealthRead(ctx); + result.projectHealth = [...projectHealth] + .sort((left, right) => left.compositeScore - right.compositeScore) + .slice(0, 10) + .map((project) => ({ + project: `${project.projectName} (${project.shortCode})`, + status: project.status, + overall: project.compositeScore, + rating: getProjectHealthRating(project.compositeScore), + budget: project.budgetHealth, + staffing: project.staffingHealth, + timeline: project.timelineHealth, + timelineStatus: project.timelineStatus ?? "UNSCHEDULED", + daysUntilEndDate: project.daysUntilEndDate ?? null, + demandHeadcountOpen: project.demandHeadcountOpen ?? 0, + explainability: { + demandHeadcountTotal: project.demandHeadcountTotal ?? 0, + demandHeadcountFilled: project.demandHeadcountFilled ?? 0, + demandHeadcountOpen: project.demandHeadcountOpen ?? 0, + demandRequirementCount: project.demandRequirementCount ?? 0, + plannedEndDate: project.plannedEndDate?.toISOString() ?? null, + budgetUtilizationPercent: project.budgetUtilizationPercent ?? null, + remainingBudgetCents: project.remainingBudgetCents ?? null, + calendarContextCount: project.derivation?.calendarContextCount ?? 0, + holidayAwareAssignmentCount: project.derivation?.holidayAwareAssignmentCount ?? 0, + publicHolidayCostDeductionCents: + project.derivation?.publicHolidayCostDeductionCents ?? 0, + absenceCostDeductionCents: project.derivation?.absenceCostDeductionCents ?? 0, + }, + })); + } + return result; }