import { SystemRole } from "@capakraken/shared"; import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@capakraken/application", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getDashboardOverview: vi.fn(), getDashboardPeakTimes: vi.fn(), getDashboardDemand: vi.fn(), getDashboardTopValueResources: vi.fn(), getDashboardChargeabilityOverview: vi.fn(), getDashboardBudgetForecast: vi.fn(), getDashboardSkillGaps: vi.fn(), getDashboardSkillGapSummary: vi.fn(), getDashboardProjectHealth: vi.fn(), }; }); vi.mock("../lib/cache.js", () => ({ cacheGet: vi.fn().mockResolvedValue(null), cacheSet: vi.fn().mockResolvedValue(undefined), })); vi.mock("../lib/anonymization.js", () => ({ anonymizeResources: vi.fn((resources: unknown[]) => resources.map((resource) => ({ ...resource, anonymized: true }))), getAnonymizationDirectory: vi.fn().mockResolvedValue({}), })); import { getDashboardBudgetForecast, getDashboardChargeabilityOverview, getDashboardDemand, getDashboardOverview, getDashboardPeakTimes, getDashboardProjectHealth, getDashboardSkillGapSummary, getDashboardTopValueResources, } from "@capakraken/application"; import { cacheGet } from "../lib/cache.js"; import { anonymizeResources } from "../lib/anonymization.js"; import { getDashboardBudgetForecastDetail, getDashboardChargeabilityOverviewRead, getDashboardDetail, getDashboardProjectHealthDetail, getDashboardStatisticsDetail, } from "../router/dashboard-procedure-support.js"; function createContext() { return { db: {} as never, session: { user: { email: "controller@example.com", name: "Controller", image: null }, expires: "2099-01-01T00:00:00.000Z", }, dbUser: { id: "user_1", systemRole: SystemRole.CONTROLLER, permissionOverrides: null, }, }; } describe("dashboard procedure support", () => { beforeEach(() => { vi.clearAllMocks(); }); it("derives statistics detail from the canonical overview", async () => { vi.mocked(getDashboardOverview).mockResolvedValue({ totalResources: 12, activeResources: 10, inactiveResources: 2, totalProjects: 7, activeProjects: 4, inactiveProjects: 3, totalAllocations: 21, activeAllocations: 18, cancelledAllocations: 3, approvedVacations: 6, totalEstimates: 9, budgetSummary: { totalBudgetCents: 1_234_56, totalCostCents: 654_32, avgUtilizationPercent: 53, }, budgetBasis: { remainingBudgetCents: 58_024, budgetedProjects: 5, unbudgetedProjects: 2, trackedAssignmentCount: 18, windowStart: null, windowEnd: null, }, projectsByStatus: [ { status: "ACTIVE", count: 4 }, { status: "DRAFT", count: 2 }, { status: "DONE", count: 1 }, ], chapterUtilization: [ { chapter: "CGI", resourceCount: 5, avgChargeabilityTarget: 78 }, { chapter: "Compositing", resourceCount: 3, avgChargeabilityTarget: 74 }, ], recentActivity: [], }); const result = await getDashboardStatisticsDetail(createContext()); expect(result).toEqual({ activeResources: 10, totalProjects: 7, activeProjects: 4, totalAllocations: 21, approvedVacations: 6, totalEstimates: 9, totalBudget: "1.234,56 EUR", projectsByStatus: { ACTIVE: 4, DRAFT: 2, DONE: 1, }, topChapters: [ { chapter: "CGI", count: 5 }, { chapter: "Compositing", count: 3 }, ], }); }); it("builds the isolated skill gap detail section without overview reads", async () => { vi.mocked(getDashboardSkillGapSummary).mockResolvedValue({ roleGaps: [ { role: "Pipeline TD", needed: 4, filled: 1, gap: 3, fillRate: 25 }, ], totalOpenPositions: 3, skillSupplyTop10: [{ skill: "houdini", resourceCount: 5 }], resourcesByRole: [{ role: "Pipeline TD", count: 2 }], }); const result = await getDashboardDetail(createContext(), { section: "skill_gaps" }); expect(result).toEqual({ skillGaps: { totalOpenPositions: 3, roleGaps: [ { role: "Pipeline TD", gap: 3, needed: 4, filled: 1, fillRate: 25 }, ], topSkillsInSupply: [{ skill: "houdini", resourceCount: 5 }], resourcesByRole: [{ role: "Pipeline TD", count: 2 }], }, }); expect(getDashboardSkillGapSummary).toHaveBeenCalledTimes(1); expect(getDashboardOverview).not.toHaveBeenCalled(); expect(getDashboardPeakTimes).not.toHaveBeenCalled(); expect(getDashboardDemand).not.toHaveBeenCalled(); expect(getDashboardProjectHealth).not.toHaveBeenCalled(); }); it("builds the assistant-facing dashboard detail payload", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-15T00:00:00.000Z")); vi.mocked(getDashboardOverview).mockResolvedValue({ totalResources: 12, activeResources: 10, inactiveResources: 2, totalProjects: 7, activeProjects: 4, inactiveProjects: 3, totalAllocations: 21, activeAllocations: 18, cancelledAllocations: 3, approvedVacations: 6, totalEstimates: 9, budgetSummary: { totalBudgetCents: 1_234_56, totalCostCents: 654_32, avgUtilizationPercent: 53, }, budgetBasis: { remainingBudgetCents: 58_024, budgetedProjects: 5, unbudgetedProjects: 2, trackedAssignmentCount: 18, windowStart: "2026-03-01T00:00:00.000Z", windowEnd: "2026-06-30T00:00:00.000Z", }, projectsByStatus: [], chapterUtilization: [ { chapter: "CGI", resourceCount: 5, avgChargeabilityTarget: 78 }, ], recentActivity: [], }); vi.mocked(getDashboardPeakTimes).mockResolvedValue([ { period: "2026-03", totalHours: 160.34, capacityHours: 200.12, utilizationPct: 80, derivation: { periodStart: "2026-03-01", periodEnd: "2026-03-31", calendarContextCount: 1, resourceCount: 5, groupCount: 2, baseAvailableHours: 216, effectiveAvailableHours: 200.12, publicHolidayHoursDeduction: 8, absenceDayEquivalent: 1, absenceHoursDeduction: 7.88, calendarLocations: [{ countryCode: "DE", federalState: "BY", metroCityName: "Munich" }], remainingCapacityHours: 39.78, overbookedHours: 0, }, }, ]); vi.mocked(getDashboardTopValueResources).mockResolvedValue([ { id: "res_1", eid: "E-001", displayName: "Ada Lovelace", chapter: "CGI", lcrCents: 12345, valueScore: 98, valueScoreBreakdown: { skillDepth: 94, skillBreadth: 86, costEfficiency: 82, chargeability: 88, experience: 96, total: 98, }, valueScoreUpdatedAt: new Date("2026-03-05T00:00:00.000Z"), countryCode: "DE", countryName: "Germany", federalState: "BY", metroCityName: "Munich", }, ]); vi.mocked(getDashboardDemand).mockResolvedValue([ { name: "Apollo", shortCode: "APO", requiredFTEs: 3, resourceCount: 1, allocatedHours: 80, derivation: { periodStart: "2026-03-01", periodEnd: "2026-06-30", periodWorkingHoursBase: 672, requiredHours: 2016, requiredFTEs: 3, fillPct: 40, demandSource: "DEMAND_REQUIREMENTS", calendarLocations: [{ countryCode: "DE", countryName: "Germany" }], }, }, ]); vi.mocked(getDashboardChargeabilityOverview).mockResolvedValue({ rows: [ { id: "res_1", eid: "E-001", displayName: "Ada Lovelace", chapter: "CGI", chargeabilityTarget: 78, actualChargeability: 64, expectedChargeability: 72, derivation: { weeklyAvailabilityHours: 40, baseWorkingDays: 23, effectiveWorkingDayEquivalent: 21, baseAvailableHours: 184, effectiveAvailableHours: 168, publicHolidayCount: 1, publicHolidayWorkdayCount: 1, publicHolidayHoursDeduction: 8, absenceDayEquivalent: 1, absenceHoursDeduction: 8, actualBookedHours: 107.5, expectedBookedHours: 121, targetBookedHours: 131, unassignedHours: 47, }, }, ], top: [], 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, }, }, ]); vi.mocked(getDashboardSkillGapSummary).mockResolvedValue({ roleGaps: [ { role: "Pipeline TD", needed: 4, filled: 1, gap: 3, fillRate: 25 }, { role: "Lighting TD", needed: 2, filled: 1, gap: 1, fillRate: 50 }, ], totalOpenPositions: 4, skillSupplyTop10: [ { skill: "houdini", resourceCount: 5 }, { skill: "nuke", resourceCount: 4 }, ], resourcesByRole: [ { role: "Compositor", count: 6 }, { role: "Pipeline TD", count: 2 }, ], }); try { const result = await getDashboardDetail(createContext(), { section: "all" }); expect(result).toEqual({ peakTimes: [ { month: "2026-03", totalHours: 160.3, totalHoursPerDay: 160.3, capacityHours: 200.1, utilizationPct: 80, calendarContextCount: 1, calendarLocations: [{ countryCode: "DE", federalState: "BY", metroCityName: "Munich" }], explainability: { periodStart: "2026-03-01", periodEnd: "2026-03-31", resourceCount: 5, groupCount: 2, baseAvailableHours: 216, effectiveAvailableHours: 200.12, publicHolidayHoursDeduction: 8, absenceDayEquivalent: 1, absenceHoursDeduction: 7.88, remainingCapacityHours: 39.78, overbookedHours: 0, }, }, ], topResources: [ { name: "Ada Lovelace", eid: "E-001", chapter: "CGI", lcr: "123,45 EUR", valueScore: 98, valueScoreBreakdown: { skillDepth: 94, skillBreadth: 86, costEfficiency: 82, chargeability: 88, experience: 96, total: 98, }, valueScoreUpdatedAt: "2026-03-05T00:00:00.000Z", countryCode: "DE", countryName: "Germany", federalState: "BY", metroCityName: "Munich", }, ], demandPipeline: [ { project: "Apollo (APO)", needed: 2, requiredFTEs: 3, allocatedResources: 1, allocatedHours: 80, calendarLocations: [{ countryCode: "DE", countryName: "Germany" }], explainability: { periodStart: "2026-03-01", periodEnd: "2026-06-30", periodWorkingHoursBase: 672, requiredHours: 2016, fillPct: 40, demandSource: "DEMAND_REQUIREMENTS", calendarContextCount: 1, }, }, ], chargeabilityByChapter: [ { chapter: "CGI", headcount: 1, avgTargetPct: 78, avgActualPct: 64, avgExpectedPct: 72, gapToTargetPct: 14, avgTarget: "78%", avgActual: "64%", avgExpected: "72%", explainability: { month: "2026-03", resourceCount: 1, derivedHeadcount: 1, baseAvailableHours: 184, effectiveAvailableHours: 168, actualBookedHours: 107.5, expectedBookedHours: 121, targetBookedHours: 131, publicHolidayHoursDeduction: 8, absenceDayEquivalent: 1, absenceHoursDeduction: 8, unassignedHours: 47, }, }, ], 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, }, }, ], skillGaps: { totalOpenPositions: 4, roleGaps: [ { role: "Pipeline TD", gap: 3, needed: 4, filled: 1, fillRate: 25 }, { role: "Lighting TD", gap: 1, needed: 2, filled: 1, fillRate: 50 }, ], topSkillsInSupply: [ { skill: "houdini", resourceCount: 5 }, { skill: "nuke", resourceCount: 4 }, ], resourcesByRole: [ { role: "Compositor", count: 6 }, { role: "Pipeline TD", count: 2 }, ], }, }); expect(getDashboardChargeabilityOverview).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ includeProposed: false, topN: 10, watchlistThreshold: 15, }), ); } finally { vi.useRealTimers(); } }); it("anonymizes chargeability overview payloads before returning them", async () => { vi.mocked(getDashboardChargeabilityOverview).mockResolvedValue({ avgChargeability: 72, top: [{ id: "res_1" }], watchlist: [{ id: "res_2" }], }); const result = await getDashboardChargeabilityOverviewRead(createContext(), { includeProposed: false, topN: 10, watchlistThreshold: 15, }); expect(anonymizeResources).toHaveBeenCalledTimes(2); expect(result).toEqual({ avgChargeability: 72, top: [{ id: "res_1", anonymized: true }], watchlist: [{ id: "res_2", anonymized: true }], }); }); it("reuses cached budget forecast rows for detail payloads", async () => { vi.mocked(cacheGet).mockResolvedValueOnce([ { projectId: "project_1", projectName: "Apollo", shortCode: "APO", clientId: "client_1", clientName: "Acme", budgetCents: 500_000, spentCents: 320_000, remainingCents: 180_000, burnRate: 120_000, estimatedExhaustionDate: "2026-05-31", pctUsed: 64, activeAssignmentCount: 3, calendarLocations: [ { countryCode: "DE", countryName: "Germany", federalState: "BY", metroCityName: "Munich", activeAssignmentCount: 2, burnRateCents: 80_000, }, ], derivation: { periodStart: "2026-03-01", periodEnd: "2026-03-31", calendarContextCount: 1, holidayAwareAssignmentCount: 2, fallbackAssignmentCount: 1, baseBurnRateCents: 130_000, adjustedBurnRateCents: 120_000, publicHolidayDayEquivalent: 1, publicHolidayCostDeductionCents: 5_000, absenceDayEquivalent: 0.5, absenceCostDeductionCents: 5_000, }, }, ]); const result = await getDashboardBudgetForecastDetail(createContext()); expect(getDashboardBudgetForecast).not.toHaveBeenCalled(); expect(result).toEqual({ forecasts: [ { projectId: "project_1", projectName: "Apollo", shortCode: "APO", clientId: "client_1", clientName: "Acme", budget: "5.000,00 EUR", budgetCents: 500_000, spent: "3.200,00 EUR", spentCents: 320_000, remaining: "1.800,00 EUR", remainingCents: 180_000, projected: "5.000,00 EUR", projectedCents: 500_000, burnRate: "1.200,00 EUR", burnRateCents: 120_000, utilization: "64%", estimatedExhaustionDate: "2026-05-31", activeAssignmentCount: 3, calendarLocations: [ { countryCode: "DE", countryName: "Germany", federalState: "BY", metroCityName: "Munich", activeAssignmentCount: 2, burnRateCents: 80_000, }, ], derivation: { periodStart: "2026-03-01", periodEnd: "2026-03-31", calendarContextCount: 1, holidayAwareAssignmentCount: 2, fallbackAssignmentCount: 1, baseBurnRateCents: 130_000, adjustedBurnRateCents: 120_000, publicHolidayDayEquivalent: 1, publicHolidayCostDeductionCents: 5_000, absenceDayEquivalent: 0.5, absenceCostDeductionCents: 5_000, }, burnStatus: "on_track", }, ], }); }); it("reuses cached project health rows for detail payloads", async () => { vi.mocked(cacheGet).mockResolvedValueOnce([ { id: "project_1", projectName: "Apollo", shortCode: "APO", status: "ACTIVE", clientId: "client_1", clientName: "Acme", budgetHealth: 78, staffingHealth: 66, timelineHealth: 55, compositeScore: 66, budgetCents: 500_000, spentCents: 320_000, remainingBudgetCents: 180_000, budgetUtilizationPercent: 64, demandHeadcountTotal: 5, demandHeadcountFilled: 3, demandHeadcountOpen: 2, demandRequirementCount: 2, plannedEndDate: new Date("2026-06-30T00:00:00.000Z"), daysUntilEndDate: 107, timelineStatus: "ON_TRACK" as const, calendarLocations: [ { countryCode: "DE", countryName: "Germany", federalState: "BY", metroCityName: "Munich", assignmentCount: 3, spentCents: 320_000, }, ], derivation: { periodStart: "2026-03-01", periodEnd: "2026-03-31", calendarContextCount: 1, holidayAwareAssignmentCount: 2, fallbackAssignmentCount: 1, baseSpentCents: 330_000, adjustedSpentCents: 320_000, publicHolidayDayEquivalent: 1, publicHolidayCostDeductionCents: 5_000, absenceDayEquivalent: 0.5, absenceCostDeductionCents: 5_000, }, }, ]); const result = await getDashboardProjectHealthDetail(createContext()); expect(getDashboardProjectHealth).not.toHaveBeenCalled(); expect(result).toEqual({ projects: [ { projectId: "project_1", projectName: "Apollo", shortCode: "APO", status: "ACTIVE", overall: 66, budget: 78, staffing: 66, timeline: 55, rating: "at_risk", budgetBasis: { budgetCents: 500_000, spentCents: 320_000, remainingBudgetCents: 180_000, budgetUtilizationPercent: 64, calendarLocations: [ { countryCode: "DE", countryName: "Germany", federalState: "BY", metroCityName: "Munich", assignmentCount: 3, spentCents: 320_000, }, ], derivation: { periodStart: "2026-03-01", periodEnd: "2026-03-31", calendarContextCount: 1, holidayAwareAssignmentCount: 2, fallbackAssignmentCount: 1, baseSpentCents: 330_000, adjustedSpentCents: 320_000, publicHolidayDayEquivalent: 1, publicHolidayCostDeductionCents: 5_000, absenceDayEquivalent: 0.5, absenceCostDeductionCents: 5_000, }, }, staffingBasis: { demandHeadcountTotal: 5, demandHeadcountFilled: 3, demandHeadcountOpen: 2, demandRequirementCount: 2, }, timelineBasis: { plannedEndDate: "2026-06-30T00:00:00.000Z", daysUntilEndDate: 107, timelineStatus: "ON_TRACK", }, context: { clientId: "client_1", clientName: "Acme", }, }, ], summary: { healthy: 0, atRisk: 1, critical: 0, }, }); }); });