diff --git a/packages/api/src/__tests__/dashboard-router.test.ts b/packages/api/src/__tests__/dashboard-router.test.ts index fb6cc74..77a1c01 100644 --- a/packages/api/src/__tests__/dashboard-router.test.ts +++ b/packages/api/src/__tests__/dashboard-router.test.ts @@ -290,6 +290,40 @@ describe("dashboard router", () => { staffingHealth: 40, timelineHealth: 30, compositeScore: 35, + budgetCents: 100_000, + spentCents: 82_000, + remainingBudgetCents: 18_000, + budgetUtilizationPercent: 82, + demandHeadcountTotal: 5, + demandHeadcountFilled: 2, + demandHeadcountOpen: 3, + demandRequirementCount: 2, + plannedEndDate: new Date("2026-06-30T00:00:00.000Z"), + daysUntilEndDate: 12, + timelineStatus: "DUE_SOON", + calendarLocations: [ + { + countryCode: "DE", + countryName: "Germany", + federalState: "BY", + metroCityName: "Munich", + assignmentCount: 2, + spentCents: 52_000, + }, + ], + derivation: { + periodStart: "2026-06-01", + periodEnd: "2026-06-30", + calendarContextCount: 1, + holidayAwareAssignmentCount: 2, + fallbackAssignmentCount: 0, + baseSpentCents: 90_000, + adjustedSpentCents: 82_000, + publicHolidayDayEquivalent: 1, + publicHolidayCostDeductionCents: 5_000, + absenceDayEquivalent: 0.6, + absenceCostDeductionCents: 3_000, + }, }, { id: "project_healthy", @@ -302,6 +336,18 @@ describe("dashboard router", () => { staffingHealth: 92, timelineHealth: 86, compositeScore: 89, + budgetCents: 200_000, + spentCents: 20_000, + remainingBudgetCents: 180_000, + budgetUtilizationPercent: 10, + demandHeadcountTotal: 4, + demandHeadcountFilled: 4, + demandHeadcountOpen: 0, + demandRequirementCount: 1, + plannedEndDate: new Date("2026-09-15T00:00:00.000Z"), + daysUntilEndDate: 89, + timelineStatus: "ON_TRACK", + calendarLocations: [], }, ]); @@ -321,6 +367,50 @@ describe("dashboard router", () => { staffing: 40, timeline: 30, rating: "critical", + budgetBasis: { + budgetCents: 100_000, + spentCents: 82_000, + remainingBudgetCents: 18_000, + budgetUtilizationPercent: 82, + calendarLocations: [ + { + countryCode: "DE", + countryName: "Germany", + federalState: "BY", + metroCityName: "Munich", + assignmentCount: 2, + spentCents: 52_000, + }, + ], + derivation: { + periodStart: "2026-06-01", + periodEnd: "2026-06-30", + calendarContextCount: 1, + holidayAwareAssignmentCount: 2, + fallbackAssignmentCount: 0, + baseSpentCents: 90_000, + adjustedSpentCents: 82_000, + publicHolidayDayEquivalent: 1, + publicHolidayCostDeductionCents: 5_000, + absenceDayEquivalent: 0.6, + absenceCostDeductionCents: 3_000, + }, + }, + staffingBasis: { + demandHeadcountTotal: 5, + demandHeadcountFilled: 2, + demandHeadcountOpen: 3, + demandRequirementCount: 2, + }, + timelineBasis: { + plannedEndDate: "2026-06-30T00:00:00.000Z", + daysUntilEndDate: 12, + timelineStatus: "DUE_SOON", + }, + context: { + clientId: "client_1", + clientName: "Acme", + }, }, { projectId: "project_healthy", @@ -332,6 +422,29 @@ describe("dashboard router", () => { staffing: 92, timeline: 86, rating: "healthy", + budgetBasis: { + budgetCents: 200_000, + spentCents: 20_000, + remainingBudgetCents: 180_000, + budgetUtilizationPercent: 10, + calendarLocations: [], + derivation: null, + }, + staffingBasis: { + demandHeadcountTotal: 4, + demandHeadcountFilled: 4, + demandHeadcountOpen: 0, + demandRequirementCount: 1, + }, + timelineBasis: { + plannedEndDate: "2026-09-15T00:00:00.000Z", + daysUntilEndDate: 89, + timelineStatus: "ON_TRACK", + }, + context: { + clientId: "client_1", + clientName: "Acme", + }, }, ], summary: { @@ -476,6 +589,19 @@ describe("dashboard router", () => { estimatedExhaustionDate: "2026-06-30", pctUsed: 40, activeAssignmentCount: 2, + derivation: { + periodStart: "2026-03-01", + periodEnd: "2026-03-31", + calendarContextCount: 1, + holidayAwareAssignmentCount: 2, + fallbackAssignmentCount: 0, + baseBurnRateCents: 12_000, + adjustedBurnRateCents: 10_000, + publicHolidayDayEquivalent: 1, + publicHolidayCostDeductionCents: 1_000, + absenceDayEquivalent: 0.5, + absenceCostDeductionCents: 1_000, + }, calendarLocations: [ { countryCode: "DE", @@ -496,6 +622,11 @@ describe("dashboard router", () => { expect(result[0]).toMatchObject({ projectName: "Alpha", activeAssignmentCount: 2, + derivation: { + holidayAwareAssignmentCount: 2, + baseBurnRateCents: 12_000, + adjustedBurnRateCents: 10_000, + }, calendarLocations: [ expect.objectContaining({ countryCode: "DE", @@ -522,6 +653,19 @@ describe("dashboard router", () => { estimatedExhaustionDate: "2026-06-30", pctUsed: 40, activeAssignmentCount: 2, + derivation: { + periodStart: "2026-03-01", + periodEnd: "2026-03-31", + calendarContextCount: 1, + holidayAwareAssignmentCount: 2, + fallbackAssignmentCount: 0, + baseBurnRateCents: 12_000, + adjustedBurnRateCents: 10_000, + publicHolidayDayEquivalent: 1, + publicHolidayCostDeductionCents: 1_000, + absenceDayEquivalent: 0.5, + absenceCostDeductionCents: 1_000, + }, calendarLocations: [ { countryCode: "DE", @@ -551,6 +695,13 @@ describe("dashboard router", () => { projectedCents: 100_000, burnRateCents: 10_000, utilization: "40%", + derivation: expect.objectContaining({ + holidayAwareAssignmentCount: 2, + baseBurnRateCents: 12_000, + adjustedBurnRateCents: 10_000, + publicHolidayCostDeductionCents: 1_000, + absenceCostDeductionCents: 1_000, + }), burnStatus: "on_track", calendarLocations: [ expect.objectContaining({ @@ -597,6 +748,7 @@ describe("dashboard router", () => { valueScore: 91, lcrCents: 9_500, countryCode: "DE", + countryName: "Germany", federalState: "BY", metroCityName: "Augsburg", }, @@ -644,6 +796,7 @@ describe("dashboard router", () => { lcr: "95,00 EUR", valueScore: 91, countryCode: "DE", + countryName: "Germany", federalState: "BY", metroCityName: "Augsburg", }, diff --git a/packages/api/src/router/dashboard-detail-support.ts b/packages/api/src/router/dashboard-detail-support.ts new file mode 100644 index 0000000..69e899c --- /dev/null +++ b/packages/api/src/router/dashboard-detail-support.ts @@ -0,0 +1,213 @@ +import { type BudgetForecastRow, getDashboardProjectHealth } from "@capakraken/application"; +import { fmtEur } from "../lib/format-utils.js"; + +type DashboardBudgetForecastCalendarLocation = { + countryCode: string | null; + countryName: string | null; + federalState: string | null; + metroCityName: string | null; + activeAssignmentCount: number; + burnRateCents: number; +}; + +type DashboardBudgetForecastDerivation = { + periodStart: string; + periodEnd: string; + calendarContextCount: number; + holidayAwareAssignmentCount: number; + fallbackAssignmentCount: number; + baseBurnRateCents: number; + adjustedBurnRateCents: number; + publicHolidayDayEquivalent: number; + publicHolidayCostDeductionCents: number; + absenceDayEquivalent: number; + absenceCostDeductionCents: number; +}; + +export type DashboardBudgetForecastDetail = { + forecasts: Array<{ + projectId: string | null; + projectName: string; + shortCode: string; + clientId: string | null; + clientName: string | null; + budget: string; + budgetCents: number; + spent: string; + spentCents: number; + remaining: string; + remainingCents: number; + projected: string; + projectedCents: number; + burnRate: string; + burnRateCents: number; + utilization: string; + estimatedExhaustionDate: string | null; + activeAssignmentCount: number | null; + calendarLocations: DashboardBudgetForecastCalendarLocation[]; + derivation: DashboardBudgetForecastDerivation | null; + burnStatus: "ahead" | "on_track" | "not_started"; + }>; +}; + +type DashboardProjectHealthCalendarLocation = { + countryCode: string | null; + countryName: string | null; + federalState: string | null; + metroCityName: string | null; + assignmentCount: number; + spentCents: number; +}; + +type DashboardProjectHealthDerivation = { + periodStart: string; + periodEnd: string; + calendarContextCount: number; + holidayAwareAssignmentCount: number; + fallbackAssignmentCount: number; + baseSpentCents: number; + adjustedSpentCents: number; + publicHolidayDayEquivalent: number; + publicHolidayCostDeductionCents: number; + absenceDayEquivalent: number; + absenceCostDeductionCents: number; +}; + +export type DashboardProjectHealthDetail = { + projects: Array<{ + projectId: string; + projectName: string; + shortCode: string; + status: string; + overall: number; + budget: number; + staffing: number; + timeline: number; + rating: "healthy" | "at_risk" | "critical"; + budgetBasis: { + budgetCents: number | null; + spentCents: number; + remainingBudgetCents: number | null; + budgetUtilizationPercent: number | null; + calendarLocations: DashboardProjectHealthCalendarLocation[]; + derivation: DashboardProjectHealthDerivation | null; + }; + staffingBasis: { + demandHeadcountTotal: number; + demandHeadcountFilled: number; + demandHeadcountOpen: number; + demandRequirementCount: number; + }; + timelineBasis: { + plannedEndDate: string | null; + daysUntilEndDate: number | null; + timelineStatus: "ON_TRACK" | "DUE_SOON" | "OVERDUE" | "UNSCHEDULED"; + }; + context: { + clientId: string | null; + clientName: string | null; + }; + }>; + summary: { + healthy: number; + atRisk: number; + critical: number; + }; +}; + +export function mapProjectHealthDetailRows( + rows: Awaited>, +): DashboardProjectHealthDetail { + const projects: DashboardProjectHealthDetail["projects"] = rows + .map((project): DashboardProjectHealthDetail["projects"][number] => { + const overall = project.compositeScore; + const rating: DashboardProjectHealthDetail["projects"][number]["rating"] = overall >= 80 + ? "healthy" + : overall >= 50 + ? "at_risk" + : "critical"; + + return { + projectId: project.id, + projectName: project.projectName, + shortCode: project.shortCode, + status: project.status, + overall, + budget: project.budgetHealth, + staffing: project.staffingHealth, + timeline: project.timelineHealth, + rating, + budgetBasis: { + budgetCents: project.budgetCents ?? null, + spentCents: project.spentCents ?? 0, + remainingBudgetCents: project.remainingBudgetCents ?? null, + budgetUtilizationPercent: project.budgetUtilizationPercent ?? null, + calendarLocations: project.calendarLocations ?? [], + derivation: project.derivation ?? null, + }, + staffingBasis: { + demandHeadcountTotal: project.demandHeadcountTotal ?? 0, + demandHeadcountFilled: project.demandHeadcountFilled ?? 0, + demandHeadcountOpen: project.demandHeadcountOpen ?? 0, + demandRequirementCount: project.demandRequirementCount ?? 0, + }, + timelineBasis: { + plannedEndDate: project.plannedEndDate?.toISOString() ?? null, + daysUntilEndDate: project.daysUntilEndDate ?? null, + timelineStatus: project.timelineStatus ?? "UNSCHEDULED", + }, + context: { + clientId: project.clientId ?? null, + clientName: project.clientName ?? null, + }, + }; + }) + .sort((left, right) => left.overall - right.overall); + + return { + projects, + summary: { + healthy: projects.filter((project) => project.rating === "healthy").length, + atRisk: projects.filter((project) => project.rating === "at_risk").length, + critical: projects.filter((project) => project.rating === "critical").length, + }, + }; +} + +export function mapBudgetForecastDetailRows( + rows: BudgetForecastRow[], +): DashboardBudgetForecastDetail { + return { + forecasts: rows.map((forecast) => ({ + projectId: forecast.projectId ?? null, + projectName: forecast.projectName, + shortCode: forecast.shortCode, + clientId: forecast.clientId, + clientName: forecast.clientName, + budget: fmtEur(forecast.budgetCents), + budgetCents: forecast.budgetCents, + spent: fmtEur(forecast.spentCents), + spentCents: forecast.spentCents, + remaining: fmtEur(forecast.remainingCents ?? (forecast.budgetCents - forecast.spentCents)), + remainingCents: forecast.remainingCents ?? (forecast.budgetCents - forecast.spentCents), + projected: forecast.burnRate > 0 + ? fmtEur(forecast.spentCents + Math.max(0, forecast.budgetCents - forecast.spentCents)) + : fmtEur(forecast.spentCents), + projectedCents: forecast.burnRate > 0 + ? Math.max(forecast.spentCents, forecast.budgetCents) + : forecast.spentCents, + burnRate: fmtEur(forecast.burnRate), + burnRateCents: forecast.burnRate, + utilization: `${forecast.pctUsed}%`, + estimatedExhaustionDate: forecast.estimatedExhaustionDate, + activeAssignmentCount: forecast.activeAssignmentCount ?? null, + calendarLocations: forecast.calendarLocations ?? [], + derivation: forecast.derivation ?? null, + burnStatus: forecast.pctUsed >= 100 + ? "ahead" + : forecast.burnRate > 0 + ? "on_track" + : "not_started", + })), + }; +} diff --git a/packages/api/src/router/dashboard-procedure-support.ts b/packages/api/src/router/dashboard-procedure-support.ts index 2cceff8..11c2965 100644 --- a/packages/api/src/router/dashboard-procedure-support.ts +++ b/packages/api/src/router/dashboard-procedure-support.ts @@ -15,6 +15,11 @@ import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymizat import { cacheGet, cacheSet } from "../lib/cache.js"; import { fmtEur } from "../lib/format-utils.js"; import type { TRPCContext } from "../trpc.js"; +import { + type DashboardBudgetForecastDetail, + mapBudgetForecastDetailRows, + mapProjectHealthDetailRows, +} from "./dashboard-detail-support.js"; const DEFAULT_TTL = 60; @@ -28,6 +33,7 @@ type TopValueResourceRow = { valueScore: number | null; lcrCents: number; countryCode: string | null; + countryName: string | null; federalState: string | null; metroCityName: string | null; }; @@ -67,123 +73,10 @@ type DashboardDemandInput = z.infer; type DashboardDetailInput = z.infer; type DashboardChargeabilityOverviewInput = z.infer; -type DashboardBudgetForecastCalendarLocation = { - countryCode: string | null; - countryName: string | null; - federalState: string | null; - metroCityName: string | null; - activeAssignmentCount: number; - burnRateCents: number; -}; - -type DashboardBudgetForecastDerivation = { - periodStart: string; - periodEnd: string; - calendarContextCount: number; - holidayAwareAssignmentCount: number; - fallbackAssignmentCount: number; - baseBurnRateCents: number; - adjustedBurnRateCents: number; - publicHolidayDayEquivalent: number; - publicHolidayCostDeductionCents: number; - absenceDayEquivalent: number; - absenceCostDeductionCents: number; -}; - -type DashboardBudgetForecastDetail = { - forecasts: Array<{ - projectId: string | null; - projectName: string; - shortCode: string; - clientId: string | null; - clientName: string | null; - budget: string; - budgetCents: number; - spent: string; - spentCents: number; - remaining: string; - remainingCents: number; - projected: string; - projectedCents: number; - burnRate: string; - burnRateCents: number; - utilization: string; - estimatedExhaustionDate: string | null; - activeAssignmentCount: number | null; - calendarLocations: DashboardBudgetForecastCalendarLocation[]; - derivation: DashboardBudgetForecastDerivation | null; - burnStatus: "ahead" | "on_track" | "not_started"; - }>; -}; - function round1(value: number): number { return Math.round(value * 10) / 10; } -function mapProjectHealthDetailRows(rows: Awaited>) { - const projects = rows - .map((project) => { - const overall = project.compositeScore; - return { - projectId: project.id, - projectName: project.projectName, - shortCode: project.shortCode, - status: project.status, - overall, - budget: project.budgetHealth, - staffing: project.staffingHealth, - timeline: project.timelineHealth, - rating: overall >= 80 ? "healthy" : overall >= 50 ? "at_risk" : "critical", - }; - }) - .sort((left, right) => left.overall - right.overall); - - return { - projects, - summary: { - healthy: projects.filter((project) => project.rating === "healthy").length, - atRisk: projects.filter((project) => project.rating === "at_risk").length, - critical: projects.filter((project) => project.rating === "critical").length, - }, - }; -} - -function mapBudgetForecastDetailRows(rows: BudgetForecastRow[]): DashboardBudgetForecastDetail { - return { - forecasts: rows.map((forecast) => ({ - projectId: forecast.projectId ?? null, - projectName: forecast.projectName, - shortCode: forecast.shortCode, - clientId: forecast.clientId, - clientName: forecast.clientName, - budget: fmtEur(forecast.budgetCents), - budgetCents: forecast.budgetCents, - spent: fmtEur(forecast.spentCents), - spentCents: forecast.spentCents, - remaining: fmtEur(forecast.remainingCents ?? (forecast.budgetCents - forecast.spentCents)), - remainingCents: forecast.remainingCents ?? (forecast.budgetCents - forecast.spentCents), - projected: forecast.burnRate > 0 - ? fmtEur(forecast.spentCents + Math.max(0, forecast.budgetCents - forecast.spentCents)) - : fmtEur(forecast.spentCents), - projectedCents: forecast.burnRate > 0 - ? Math.max(forecast.spentCents, forecast.budgetCents) - : forecast.spentCents, - burnRate: fmtEur(forecast.burnRate), - burnRateCents: forecast.burnRate, - utilization: `${forecast.pctUsed}%`, - estimatedExhaustionDate: forecast.estimatedExhaustionDate, - activeAssignmentCount: forecast.activeAssignmentCount ?? null, - calendarLocations: forecast.calendarLocations ?? [], - derivation: forecast.derivation ?? null, - burnStatus: forecast.pctUsed >= 100 - ? "ahead" - : forecast.burnRate > 0 - ? "on_track" - : "not_started", - })), - }; -} - function mapStatisticsDetail(overview: Awaited>) { return { activeResources: overview.activeResources, @@ -357,6 +250,7 @@ export async function getDashboardDetail(ctx: DashboardProcedureContext, input: lcr: fmtEur(resource.lcrCents), valueScore: resource.valueScore ?? null, countryCode: resource.countryCode ?? null, + countryName: resource.countryName ?? null, federalState: resource.federalState ?? null, metroCityName: resource.metroCityName ?? null, }));