feat(api): include project health in dashboard detail
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
|||||||
getDashboardDemand,
|
getDashboardDemand,
|
||||||
getDashboardOverview,
|
getDashboardOverview,
|
||||||
getDashboardPeakTimes,
|
getDashboardPeakTimes,
|
||||||
|
getDashboardProjectHealth,
|
||||||
getDashboardTopValueResources,
|
getDashboardTopValueResources,
|
||||||
} from "./assistant-tools-dashboard-test-helpers.js";
|
} from "./assistant-tools-dashboard-test-helpers.js";
|
||||||
|
|
||||||
@@ -172,6 +173,42 @@ describe("assistant dashboard tools detail aggregation", () => {
|
|||||||
watchlist: [],
|
watchlist: [],
|
||||||
month: "2026-03",
|
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(
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -257,6 +257,42 @@ describe("dashboard procedure support", () => {
|
|||||||
watchlist: [],
|
watchlist: [],
|
||||||
month: "2026-03",
|
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 {
|
try {
|
||||||
const result = await getDashboardDetail(createContext(), { section: "all" });
|
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(
|
expect(getDashboardChargeabilityOverview).toHaveBeenCalledWith(
|
||||||
|
|||||||
@@ -870,6 +870,42 @@ describe("dashboard router", () => {
|
|||||||
watchlist: [],
|
watchlist: [],
|
||||||
month: "2026-03",
|
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 caller = createControllerCaller({});
|
||||||
const result = await caller.getDetail({ section: "all" });
|
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(getDashboardPeakTimes).toHaveBeenCalledWith(
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
@@ -1003,6 +1066,7 @@ describe("dashboard router", () => {
|
|||||||
watchlistThreshold: 15,
|
watchlistThreshold: 15,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
expect(getDashboardProjectHealth).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type ReportQueryResult = {
|
|||||||
totalCount: number;
|
totalCount: number;
|
||||||
columns: unknown;
|
columns: unknown;
|
||||||
groups: unknown;
|
groups: unknown;
|
||||||
|
explainability?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DashboardInsightsReportsDeps = {
|
type DashboardInsightsReportsDeps = {
|
||||||
@@ -53,13 +54,13 @@ export const dashboardInsightsReportsToolDefinitions: ToolDef[] = withToolAccess
|
|||||||
type: "function",
|
type: "function",
|
||||||
function: {
|
function: {
|
||||||
name: "get_dashboard_detail",
|
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: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
section: {
|
section: {
|
||||||
type: "string",
|
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: {
|
get_budget_forecast: {
|
||||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||||
|
requiredPermissions: [PermissionKey.VIEW_COSTS],
|
||||||
},
|
},
|
||||||
get_insights_summary: {
|
get_insights_summary: {
|
||||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||||
@@ -255,6 +257,7 @@ export function createDashboardInsightsReportsExecutors(
|
|||||||
totalCount: result.totalCount,
|
totalCount: result.totalCount,
|
||||||
columns: result.columns,
|
columns: result.columns,
|
||||||
groups: result.groups,
|
groups: result.groups,
|
||||||
|
...(result.explainability ? { explainability: result.explainability } : {}),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) {
|
export async function getDashboardOverviewRead(ctx: DashboardProcedureContext) {
|
||||||
return getOverviewCached(ctx.db);
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user