From 92e94f43a739e9947ca3d640555c6d8c18cf47cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 23:12:47 +0200 Subject: [PATCH] feat(dashboard): enrich demand calendar locations --- .../assistant-tools-dashboard-detail.test.ts | 245 ++++++++++++++++++ .../dashboard-procedure-support.test.ts | 4 +- .../src/use-cases/dashboard/get-demand.ts | 7 + .../load-dashboard-planning-read-model.ts | 1 + 4 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 packages/api/src/__tests__/assistant-tools-dashboard-detail.test.ts diff --git a/packages/api/src/__tests__/assistant-tools-dashboard-detail.test.ts b/packages/api/src/__tests__/assistant-tools-dashboard-detail.test.ts new file mode 100644 index 0000000..91ddf1b --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-dashboard-detail.test.ts @@ -0,0 +1,245 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; + +import { + createToolContext, + executeTool, + getDashboardDemand, + getDashboardOverview, + getDashboardPeakTimes, + getDashboardTopValueResources, +} from "./assistant-tools-dashboard-test-helpers.js"; + +describe("assistant dashboard tools detail aggregation", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("routes dashboard detail reads through dashboard router callers", async () => { + vi.mocked(getDashboardOverview).mockResolvedValue({ + totalResources: 12, + activeResources: 10, + inactiveResources: 2, + totalProjects: 4, + activeProjects: 3, + inactiveProjects: 1, + totalAllocations: 8, + activeAllocations: 7, + cancelledAllocations: 1, + approvedVacations: 2, + totalEstimates: 5, + budgetSummary: { + totalBudgetCents: 500_000, + totalCostCents: 240_000, + avgUtilizationPercent: 48, + }, + budgetBasis: { + remainingBudgetCents: 260_000, + budgetedProjects: 3, + unbudgetedProjects: 1, + trackedAssignmentCount: 8, + windowStart: new Date("2026-01-01T00:00:00.000Z"), + windowEnd: new Date("2026-06-30T00:00:00.000Z"), + }, + recentActivity: [], + projectsByStatus: [], + chapterUtilization: [ + { + chapter: "Delivery", + resourceCount: 4, + avgChargeabilityTarget: 78, + }, + ], + }); + vi.mocked(getDashboardPeakTimes).mockResolvedValue([ + { + period: "2026-03", + groups: [], + totalHours: 320.4, + capacityHours: 400.2, + utilizationPct: 80, + derivation: { + periodStart: "2026-03-01", + periodEnd: "2026-03-31", + calendarContextCount: 1, + resourceCount: 4, + groupCount: 1, + calendarLocations: [ + { + countryCode: "DE", + countryName: "Germany", + federalState: "BY", + metroCityName: "Augsburg", + resourceCount: 4, + effectiveAvailableHours: 400.2, + }, + ], + bookedHours: 320.4, + capacityHours: 400.2, + remainingCapacityHours: 79.8, + overbookedHours: 0, + utilizationPct: 80, + }, + }, + ]); + vi.mocked(getDashboardTopValueResources).mockResolvedValue([ + { + id: "res_1", + eid: "pparker", + displayName: "Peter Parker", + chapter: "Delivery", + valueScore: 91, + valueScoreBreakdown: { + skillDepth: 85, + skillBreadth: 74, + costEfficiency: 93, + chargeability: 78, + experience: 88, + total: 91, + }, + valueScoreUpdatedAt: new Date("2026-03-03T00:00:00.000Z"), + lcrCents: 9_500, + countryCode: "DE", + countryName: "Germany", + federalState: "BY", + metroCityName: "Augsburg", + }, + ]); + vi.mocked(getDashboardDemand).mockResolvedValue([ + { + id: "project_1", + name: "Gelddruckmaschine", + shortCode: "GDM", + allocatedHours: 120, + requiredFTEs: 4, + resourceCount: 2, + derivation: { + periodStart: "2026-01-01", + periodEnd: "2026-06-30", + periodWorkingHoursBase: 1040, + requiredHours: 2080, + requiredFTEs: 4, + fillPct: 50, + demandSource: "DEMAND_REQUIREMENTS", + calendarLocations: [ + { + countryCode: "DE", + countryName: "Germany", + federalState: "BY", + metroCityName: "Augsburg", + resourceCount: 2, + allocatedHours: 120, + }, + ], + }, + }, + ]); + + const ctx = createToolContext( + { + systemSettings: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, + { userRole: SystemRole.CONTROLLER }, + ); + + const result = await executeTool("get_dashboard_detail", JSON.stringify({ section: "all" }), ctx); + + expect(getDashboardOverview).toHaveBeenCalledTimes(1); + expect(getDashboardPeakTimes).toHaveBeenCalledWith( + ctx.db, + expect.objectContaining({ + startDate: new Date("2026-01-01T00:00:00.000Z"), + endDate: new Date("2026-06-30T00:00:00.000Z"), + granularity: "month", + groupBy: "project", + }), + ); + expect(getDashboardTopValueResources).toHaveBeenCalledWith( + ctx.db, + expect.objectContaining({ + limit: 10, + userRole: SystemRole.CONTROLLER, + }), + ); + expect(getDashboardDemand).toHaveBeenCalledWith( + ctx.db, + expect.objectContaining({ + startDate: new Date("2026-01-01T00:00:00.000Z"), + endDate: new Date("2026-06-30T00:00:00.000Z"), + groupBy: "project", + }), + ); + expect(JSON.parse(result.content)).toEqual({ + peakTimes: [ + { + month: "2026-03", + totalHours: 320.4, + totalHoursPerDay: 320.4, + capacityHours: 400.2, + utilizationPct: 80, + calendarContextCount: 1, + calendarLocations: [ + { + countryCode: "DE", + countryName: "Germany", + federalState: "BY", + metroCityName: "Augsburg", + resourceCount: 4, + effectiveAvailableHours: 400.2, + }, + ], + }, + ], + topResources: [ + { + name: "Peter Parker", + eid: "pparker", + chapter: "Delivery", + lcr: "95,00 EUR", + valueScore: 91, + valueScoreBreakdown: { + skillDepth: 85, + skillBreadth: 74, + costEfficiency: 93, + chargeability: 78, + experience: 88, + total: 91, + }, + valueScoreUpdatedAt: "2026-03-03T00:00:00.000Z", + countryCode: "DE", + countryName: "Germany", + federalState: "BY", + metroCityName: "Augsburg", + }, + ], + demandPipeline: [ + { + project: "Gelddruckmaschine (GDM)", + needed: 2, + requiredFTEs: 4, + allocatedResources: 2, + allocatedHours: 120, + calendarLocations: [ + { + countryCode: "DE", + countryName: "Germany", + federalState: "BY", + metroCityName: "Augsburg", + resourceCount: 2, + allocatedHours: 120, + }, + ], + }, + ], + chargeabilityByChapter: [ + { + chapter: "Delivery", + headcount: 4, + avgTarget: "78%", + }, + ], + }); + }); +}); diff --git a/packages/api/src/__tests__/dashboard-procedure-support.test.ts b/packages/api/src/__tests__/dashboard-procedure-support.test.ts index d237565..c1198cd 100644 --- a/packages/api/src/__tests__/dashboard-procedure-support.test.ts +++ b/packages/api/src/__tests__/dashboard-procedure-support.test.ts @@ -202,7 +202,7 @@ describe("dashboard procedure support", () => { requiredFTEs: 3, resourceCount: 1, allocatedHours: 80, - derivation: { calendarLocations: [{ countryCode: "DE" }] }, + derivation: { calendarLocations: [{ countryCode: "DE", countryName: "Germany" }] }, }, ]); @@ -250,7 +250,7 @@ describe("dashboard procedure support", () => { requiredFTEs: 3, allocatedResources: 1, allocatedHours: 80, - calendarLocations: [{ countryCode: "DE" }], + calendarLocations: [{ countryCode: "DE", countryName: "Germany" }], }, ], chargeabilityByChapter: [ diff --git a/packages/application/src/use-cases/dashboard/get-demand.ts b/packages/application/src/use-cases/dashboard/get-demand.ts index 08e1dd1..e428275 100644 --- a/packages/application/src/use-cases/dashboard/get-demand.ts +++ b/packages/application/src/use-cases/dashboard/get-demand.ts @@ -16,6 +16,7 @@ export interface GetDashboardDemandInput { export interface DemandCalendarLocationSummary { countryCode: string | null; + countryName: string | null; federalState: string | null; metroCityName: string | null; resourceCount: number; @@ -71,11 +72,13 @@ function toIsoDate(value: Date): string { function buildLocationKey(input: { countryCode: string | null | undefined; + countryName: string | null | undefined; federalState: string | null | undefined; metroCityName: string | null | undefined; }): string { return JSON.stringify({ countryCode: input.countryCode ?? null, + countryName: input.countryName ?? null, federalState: input.federalState ?? null, metroCityName: input.metroCityName ?? null, }); @@ -118,6 +121,7 @@ function summarizeCalendarLocations( id: string; availability: WeekdayAvailability; countryCode: string | null | undefined; + countryName: string | null | undefined; federalState: string | null | undefined; metroCityName: string | null | undefined; }>, @@ -145,11 +149,13 @@ function summarizeCalendarLocations( const locationKey = buildLocationKey({ countryCode: resource.countryCode, + countryName: resource.countryName, federalState: resource.federalState, metroCityName: resource.metroCityName, }); const existing = locationMap.get(locationKey) ?? { countryCode: resource.countryCode ?? null, + countryName: resource.countryName ?? null, federalState: resource.federalState ?? null, metroCityName: resource.metroCityName ?? null, resourceCount: 0, @@ -197,6 +203,7 @@ export async function getDashboardDemand( availability: resource.availability as unknown as WeekdayAvailability, countryId: resource.countryId, countryCode: resource.country?.code, + countryName: resource.country?.name, federalState: resource.federalState, metroCityId: resource.metroCityId, metroCityName: resource.metroCity?.name, diff --git a/packages/application/src/use-cases/dashboard/load-dashboard-planning-read-model.ts b/packages/application/src/use-cases/dashboard/load-dashboard-planning-read-model.ts index b51ce8f..20aa3fe 100644 --- a/packages/application/src/use-cases/dashboard/load-dashboard-planning-read-model.ts +++ b/packages/application/src/use-cases/dashboard/load-dashboard-planning-read-model.ts @@ -25,6 +25,7 @@ export const DASHBOARD_PLANNING_ALLOCATION_INCLUDE = { country: { select: { code: true, + name: true, }, }, metroCity: {