From f2bcf4b7f0304f6f732eb52465abfc93f10c8edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 22:35:02 +0200 Subject: [PATCH] fix(application): normalize dashboard top value score breakdown --- .../src/__tests__/dashboard.test.ts | 160 +++++++++++++++++- .../dashboard/get-top-value-resources.ts | 66 +++++++- 2 files changed, 221 insertions(+), 5 deletions(-) diff --git a/packages/application/src/__tests__/dashboard.test.ts b/packages/application/src/__tests__/dashboard.test.ts index e3a8f98..495a8a9 100644 --- a/packages/application/src/__tests__/dashboard.test.ts +++ b/packages/application/src/__tests__/dashboard.test.ts @@ -352,6 +352,11 @@ describe("dashboard use-cases", () => { totalHours: 7, capacityHours: 28, derivation: expect.objectContaining({ + baseAvailableHours: 28, + effectiveAvailableHours: 28, + publicHolidayHoursDeduction: 0, + absenceDayEquivalent: 0, + absenceHoursDeduction: 0, bookedHours: 7, capacityHours: 28, remainingCapacityHours: 21, @@ -441,6 +446,11 @@ describe("dashboard use-cases", () => { totalHours: 8, capacityHours: 12, derivation: expect.objectContaining({ + baseAvailableHours: 12, + effectiveAvailableHours: 12, + publicHolidayHoursDeduction: 0, + absenceDayEquivalent: 0, + absenceHoursDeduction: 0, bookedHours: 8, capacityHours: 12, remainingCapacityHours: 4, @@ -473,16 +483,68 @@ describe("dashboard use-cases", () => { expect(hidden).toEqual([]); expect(db.resource.findMany).not.toHaveBeenCalled(); - db.resource.findMany.mockResolvedValue([{ id: "res_1", valueScore: 99 }]); + db.resource.findMany.mockResolvedValue([ + { + id: "res_1", + eid: "alice", + displayName: "Alice", + chapter: "Delivery", + valueScore: 99, + valueScoreBreakdown: { + skillDepth: 90, + skillBreadth: 80, + costEfficiency: 85, + chargeability: 88, + experience: 92, + total: 99, + }, + valueScoreUpdatedAt: new Date("2026-03-01T00:00:00.000Z"), + lcrCents: 12_300, + country: { code: "DE", name: "Germany" }, + federalState: "BY", + metroCity: { name: "Munich" }, + }, + ]); const visible = await getDashboardTopValueResources(db as never, { limit: 1, userRole: "ADMIN", }); - expect(visible).toEqual([{ id: "res_1", valueScore: 99 }]); + expect(visible).toEqual([ + { + id: "res_1", + eid: "alice", + displayName: "Alice", + chapter: "Delivery", + valueScore: 99, + valueScoreBreakdown: { + skillDepth: 90, + skillBreadth: 80, + costEfficiency: 85, + chargeability: 88, + experience: 92, + total: 99, + }, + valueScoreUpdatedAt: new Date("2026-03-01T00:00:00.000Z"), + lcrCents: 12_300, + countryCode: "DE", + countryName: "Germany", + federalState: "BY", + metroCityName: "Munich", + }, + ]); expect(db.resource.findMany).toHaveBeenCalledWith( - expect.objectContaining({ take: 1 }), + expect.objectContaining({ + take: 1, + select: expect.objectContaining({ + valueScoreBreakdown: true, + valueScoreUpdatedAt: true, + country: { select: { code: true, name: true } }, + federalState: true, + metroCity: { select: { name: true } }, + }), + }), ); }); @@ -789,6 +851,11 @@ describe("dashboard use-cases", () => { totalHours: 8, capacityHours: 8, derivation: expect.objectContaining({ + baseAvailableHours: 16, + effectiveAvailableHours: 8, + publicHolidayHoursDeduction: 8, + absenceDayEquivalent: 0, + absenceHoursDeduction: 0, bookedHours: 8, capacityHours: 8, remainingCapacityHours: 0, @@ -801,6 +868,69 @@ describe("dashboard use-cases", () => { ]); }); + it("exposes holiday and approved-absence deductions in peak times derivation", async () => { + const db = { + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + resource: { + findMany: vi.fn().mockResolvedValue([ + { + id: "res_by", + displayName: "Bruce", + chapter: "CGI", + availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, + countryId: "country_de", + federalState: "BY", + metroCityId: "city_munich", + country: { code: "DE" }, + metroCity: { name: "Munich" }, + }, + ]), + }, + vacation: { + findMany: vi.fn().mockResolvedValue([ + { + resourceId: "res_by", + startDate: new Date("2026-01-05T00:00:00.000Z"), + endDate: new Date("2026-01-05T00:00:00.000Z"), + type: "VACATION", + isHalfDay: false, + }, + ]), + }, + }; + + const result = await getDashboardPeakTimes(db as never, { + startDate: new Date("2026-01-05T00:00:00.000Z"), + endDate: new Date("2026-01-06T00:00:00.000Z"), + granularity: "month", + groupBy: "chapter", + }); + + expect(result).toEqual([ + expect.objectContaining({ + period: "2026-01", + totalHours: 0, + capacityHours: 0, + derivation: expect.objectContaining({ + baseAvailableHours: 16, + effectiveAvailableHours: 0, + publicHolidayHoursDeduction: 8, + absenceDayEquivalent: 1, + absenceHoursDeduction: 8, + bookedHours: 0, + capacityHours: 0, + remainingCapacityHours: 0, + overbookedHours: 0, + utilizationPct: 0, + groupCount: 0, + resourceCount: 1, + }), + }), + ]); + }); + it("does not burn budget on regional public holidays", async () => { const db = { project: { @@ -919,6 +1049,17 @@ describe("dashboard use-cases", () => { remainingCents: 95_000, burnRate: 4_000, activeAssignmentCount: 1, + derivation: expect.objectContaining({ + calendarContextCount: 1, + holidayAwareAssignmentCount: 1, + fallbackAssignmentCount: 0, + baseBurnRateCents: 4_000, + adjustedBurnRateCents: 4_000, + publicHolidayDayEquivalent: 0, + publicHolidayCostDeductionCents: 0, + absenceDayEquivalent: 0, + absenceCostDeductionCents: 0, + }), calendarLocations: [ expect.objectContaining({ countryCode: "DE", @@ -1040,6 +1181,19 @@ describe("dashboard use-cases", () => { budgetHealth: 90, spentCents: 1_000, budgetUtilizationPercent: 10, + derivation: expect.objectContaining({ + periodStart: "2026-01-05", + periodEnd: "2026-01-06", + calendarContextCount: 1, + holidayAwareAssignmentCount: 1, + fallbackAssignmentCount: 0, + baseSpentCents: 2_000, + adjustedSpentCents: 1_000, + publicHolidayDayEquivalent: 1, + publicHolidayCostDeductionCents: 1_000, + absenceDayEquivalent: 0, + absenceCostDeductionCents: 0, + }), calendarLocations: [ expect.objectContaining({ countryCode: "DE", diff --git a/packages/application/src/use-cases/dashboard/get-top-value-resources.ts b/packages/application/src/use-cases/dashboard/get-top-value-resources.ts index 743cd0c..a3b267e 100644 --- a/packages/application/src/use-cases/dashboard/get-top-value-resources.ts +++ b/packages/application/src/use-cases/dashboard/get-top-value-resources.ts @@ -1,14 +1,49 @@ import type { PrismaClient } from "@capakraken/db"; +import type { ValueScoreBreakdown } from "@capakraken/shared"; export interface GetDashboardTopValueResourcesInput { limit: number; userRole: string; } +export interface DashboardTopValueResourceRow { + id: string; + eid: string; + displayName: string; + chapter: string | null; + valueScore: number | null; + valueScoreBreakdown: ValueScoreBreakdown | null; + valueScoreUpdatedAt: Date | null; + lcrCents: number; + countryCode: string | null; + countryName: string | null; + federalState: string | null; + metroCityName: string | null; +} + +function isValueScoreBreakdown(value: unknown): value is ValueScoreBreakdown { + if (typeof value !== "object" || value === null) { + return false; + } + + return [ + "skillDepth", + "skillBreadth", + "costEfficiency", + "chargeability", + "experience", + "total", + ].every((key) => typeof (value as Record)[key] === "number"); +} + +function normalizeValueScoreBreakdown(value: unknown): ValueScoreBreakdown | null { + return isValueScoreBreakdown(value) ? value : null; +} + export async function getDashboardTopValueResources( db: PrismaClient, input: GetDashboardTopValueResourcesInput, -) { +): Promise { const settings = await db.systemSettings.findUnique({ where: { id: "singleton" }, }); @@ -28,9 +63,36 @@ export async function getDashboardTopValueResources( displayName: true, chapter: true, valueScore: true, + valueScoreBreakdown: true, + valueScoreUpdatedAt: true, lcrCents: true, + country: { + select: { + code: true, + name: true, + }, + }, + federalState: true, + metroCity: { + select: { + name: true, + }, + }, }, orderBy: { valueScore: "desc" }, take: input.limit, - }); + }).then((resources) => resources.map((resource) => ({ + id: resource.id, + eid: resource.eid, + displayName: resource.displayName, + chapter: resource.chapter, + valueScore: resource.valueScore, + valueScoreBreakdown: normalizeValueScoreBreakdown(resource.valueScoreBreakdown), + valueScoreUpdatedAt: resource.valueScoreUpdatedAt, + lcrCents: resource.lcrCents, + countryCode: resource.country?.code ?? null, + countryName: resource.country?.name ?? null, + federalState: resource.federalState ?? null, + metroCityName: resource.metroCity?.name ?? null, + }))); }