From 9d6fffc7759376337ad6ee12963995b08058cdec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 20:54:54 +0200 Subject: [PATCH] refactor(api): extract dashboard procedures --- docs/api-router-procedure-support-backlog.md | 2 +- .../dashboard-procedure-support.test.ts | 253 +++++++++++ .../src/__tests__/dashboard-router.test.ts | 32 +- .../src/router/dashboard-procedure-support.ts | 425 ++++++++++++++++++ packages/api/src/router/dashboard.ts | 417 ++--------------- 5 files changed, 745 insertions(+), 384 deletions(-) create mode 100644 packages/api/src/__tests__/dashboard-procedure-support.test.ts create mode 100644 packages/api/src/router/dashboard-procedure-support.ts diff --git a/docs/api-router-procedure-support-backlog.md b/docs/api-router-procedure-support-backlog.md index 7be4d8a..b328412 100644 --- a/docs/api-router-procedure-support-backlog.md +++ b/docs/api-router-procedure-support-backlog.md @@ -17,13 +17,13 @@ Done - `import-export` - `chargeability-report` - `notification` +- `dashboard` Ready next - none in the conflict-safe backlog Deferred or blocked - `assistant-tools` -- `dashboard` - `entitlement` - `resource-read-shared` - `resource-summary-read` diff --git a/packages/api/src/__tests__/dashboard-procedure-support.test.ts b/packages/api/src/__tests__/dashboard-procedure-support.test.ts new file mode 100644 index 0000000..c808263 --- /dev/null +++ b/packages/api/src/__tests__/dashboard-procedure-support.test.ts @@ -0,0 +1,253 @@ +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 { + getDashboardChargeabilityOverview, + getDashboardDemand, + getDashboardOverview, + getDashboardPeakTimes, + getDashboardTopValueResources, +} from "@capakraken/application"; +import { anonymizeResources } from "../lib/anonymization.js"; +import { + getDashboardChargeabilityOverviewRead, + getDashboardDetail, + 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 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 }, + ]); + vi.mocked(getDashboardTopValueResources).mockResolvedValue([ + { + id: "res_1", + eid: "E-001", + displayName: "Ada Lovelace", + chapter: "CGI", + lcrCents: 12345, + valueScore: 98, + countryCode: "DE", + federalState: "BY", + metroCityName: "Munich", + }, + ]); + vi.mocked(getDashboardDemand).mockResolvedValue([ + { + name: "Apollo", + shortCode: "APO", + requiredFTEs: 3, + resourceCount: 1, + allocatedHours: 80, + derivation: { calendarLocations: [{ countryCode: "DE" }] }, + }, + ]); + + 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, + }, + ], + topResources: [ + { + name: "Ada Lovelace", + eid: "E-001", + chapter: "CGI", + lcr: "123,45 EUR", + valueScore: 98, + countryCode: "DE", + federalState: "BY", + metroCityName: "Munich", + }, + ], + demandPipeline: [ + { + project: "Apollo (APO)", + needed: 2, + requiredFTEs: 3, + allocatedResources: 1, + allocatedHours: 80, + calendarLocations: [{ countryCode: "DE" }], + }, + ], + chargeabilityByChapter: [ + { + chapter: "CGI", + headcount: 5, + avgTarget: "78%", + }, + ], + }); + } 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 }], + }); + }); +}); diff --git a/packages/api/src/__tests__/dashboard-router.test.ts b/packages/api/src/__tests__/dashboard-router.test.ts index 48ef13d..fb6cc74 100644 --- a/packages/api/src/__tests__/dashboard-router.test.ts +++ b/packages/api/src/__tests__/dashboard-router.test.ts @@ -348,8 +348,28 @@ describe("dashboard router", () => { describe("getTopValueResources", () => { it("returns sorted resources with default limit", async () => { const resources = [ - { id: "res_1", displayName: "Alice", valueScore: 95 }, - { id: "res_2", displayName: "Bob", valueScore: 88 }, + { + id: "res_1", + eid: "alice", + displayName: "Alice", + chapter: "Delivery", + valueScore: 95, + lcrCents: 12_300, + countryCode: "DE", + federalState: "BY", + metroCityName: "Munich", + }, + { + id: "res_2", + eid: "bob", + displayName: "Bob", + chapter: "Data", + valueScore: 88, + lcrCents: 10_800, + countryCode: "US", + federalState: "CA", + metroCityName: "San Francisco", + }, ]; vi.mocked(getDashboardTopValueResources).mockResolvedValue(resources); @@ -357,7 +377,7 @@ describe("dashboard router", () => { const caller = createControllerCaller({}); const result = await caller.getTopValueResources({ limit: 10 }); - expect(result).toHaveLength(2); + expect(result).toEqual(resources); expect(getDashboardTopValueResources).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ limit: 10 }), @@ -576,6 +596,9 @@ describe("dashboard router", () => { chapter: "Delivery", valueScore: 91, lcrCents: 9_500, + countryCode: "DE", + federalState: "BY", + metroCityName: "Augsburg", }, ]); vi.mocked(getDashboardDemand).mockResolvedValue([ @@ -620,6 +643,9 @@ describe("dashboard router", () => { chapter: "Delivery", lcr: "95,00 EUR", valueScore: 91, + countryCode: "DE", + federalState: "BY", + metroCityName: "Augsburg", }, ], demandPipeline: [ diff --git a/packages/api/src/router/dashboard-procedure-support.ts b/packages/api/src/router/dashboard-procedure-support.ts new file mode 100644 index 0000000..54d2da5 --- /dev/null +++ b/packages/api/src/router/dashboard-procedure-support.ts @@ -0,0 +1,425 @@ +import { + getDashboardBudgetForecast, + getDashboardChargeabilityOverview, + getDashboardDemand, + getDashboardOverview, + getDashboardPeakTimes, + getDashboardProjectHealth, + getDashboardSkillGaps, + getDashboardSkillGapSummary, + getDashboardTopValueResources, +} from "@capakraken/application"; +import { z } from "zod"; +import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js"; +import { cacheGet, cacheSet } from "../lib/cache.js"; +import { fmtEur } from "../lib/format-utils.js"; +import type { TRPCContext } from "../trpc.js"; + +const DEFAULT_TTL = 60; + +type DashboardProcedureContext = Pick; + +type TopValueResourceRow = { + id: string; + eid: string; + displayName: string; + chapter: string | null; + valueScore: number | null; + lcrCents: number; + countryCode: string | null; + federalState: string | null; + metroCityName: string | null; +}; + +export const dashboardPeakTimesInputSchema = z.object({ + startDate: z.string().datetime(), + endDate: z.string().datetime(), + granularity: z.enum(["week", "month"]).default("month"), + groupBy: z.enum(["project", "chapter", "resource"]).default("project"), +}); + +export const dashboardTopValueResourcesInputSchema = z.object({ + limit: z.number().int().min(1).max(50).default(10), +}); + +export const dashboardDemandInputSchema = z.object({ + startDate: z.string().datetime(), + endDate: z.string().datetime(), + groupBy: z.enum(["project", "person", "chapter"]).default("project"), +}); + +export const dashboardDetailInputSchema = z.object({ + section: z.string().optional().default("all"), +}); + +export const dashboardChargeabilityOverviewInputSchema = z.object({ + includeProposed: z.boolean().default(false), + topN: z.number().int().min(1).max(50).default(10), + watchlistThreshold: z.number().default(15), + countryIds: z.array(z.string()).optional(), + departed: z.boolean().optional(), +}); + +type DashboardPeakTimesInput = z.infer; +type DashboardTopValueResourcesInput = z.infer; +type DashboardDemandInput = z.infer; +type DashboardDetailInput = z.infer; +type DashboardChargeabilityOverviewInput = z.infer; + +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: Awaited>) { + 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 ?? [], + burnStatus: forecast.pctUsed >= 100 + ? "ahead" + : forecast.burnRate > 0 + ? "on_track" + : "not_started", + })), + }; +} + +function mapStatisticsDetail(overview: Awaited>) { + return { + activeResources: overview.activeResources, + totalProjects: overview.totalProjects, + activeProjects: overview.activeProjects, + totalAllocations: overview.totalAllocations, + approvedVacations: overview.approvedVacations, + totalEstimates: overview.totalEstimates, + totalBudget: overview.budgetSummary.totalBudgetCents > 0 + ? fmtEur(overview.budgetSummary.totalBudgetCents) + : "N/A", + projectsByStatus: Object.fromEntries( + overview.projectsByStatus.map((entry) => [entry.status, entry.count]), + ), + topChapters: [...overview.chapterUtilization] + .sort((left, right) => right.resourceCount - left.resourceCount) + .slice(0, 10) + .map((chapter) => ({ + chapter: chapter.chapter, + count: chapter.resourceCount, + })), + }; +} + +async function getOverviewCached(db: Parameters[0]) { + const cacheKey = "overview"; + const cached = await cacheGet>>(cacheKey); + if (cached) return cached; + + const result = await getDashboardOverview(db); + await cacheSet(cacheKey, result, DEFAULT_TTL); + return result; +} + +async function getPeakTimesCached( + db: Parameters[0], + input: DashboardPeakTimesInput, +) { + const cacheKey = `peakTimes:${input.startDate}:${input.endDate}:${input.granularity}:${input.groupBy}`; + const cached = await cacheGet>>(cacheKey); + if (cached) return cached; + + const result = await getDashboardPeakTimes(db, { + startDate: new Date(input.startDate), + endDate: new Date(input.endDate), + granularity: input.granularity, + groupBy: input.groupBy, + }); + await cacheSet(cacheKey, result, DEFAULT_TTL); + return result; +} + +async function getDemandCached( + db: Parameters[0], + input: DashboardDemandInput, +) { + const cacheKey = `demand:${input.startDate}:${input.endDate}:${input.groupBy}`; + const cached = await cacheGet>>(cacheKey); + if (cached) return cached; + + const result = await getDashboardDemand(db, { + startDate: new Date(input.startDate), + endDate: new Date(input.endDate), + groupBy: input.groupBy, + }); + await cacheSet(cacheKey, result, DEFAULT_TTL); + return result; +} + +async function getTopValueResourcesCached( + db: Parameters[0], + input: { limit: number; userRole: string }, +): Promise { + const cacheKey = `topValue:${input.limit}:${input.userRole}`; + const cached = await cacheGet(cacheKey); + if (cached) return cached; + + const [resources, directory] = await Promise.all([ + getDashboardTopValueResources(db, { + limit: input.limit, + userRole: input.userRole, + }), + getAnonymizationDirectory(db), + ]); + const result: TopValueResourceRow[] = anonymizeResources(resources, directory); + await cacheSet(cacheKey, result, DEFAULT_TTL); + return result; +} + +function getUserRole(ctx: DashboardProcedureContext) { + return (ctx.session?.user as { role?: string } | undefined)?.role + ?? ctx.dbUser?.systemRole + ?? "USER"; +} + +export async function getDashboardOverviewRead(ctx: DashboardProcedureContext) { + return getOverviewCached(ctx.db); +} + +export async function getDashboardStatisticsDetail(ctx: DashboardProcedureContext) { + const overview = await getOverviewCached(ctx.db); + return mapStatisticsDetail(overview); +} + +export async function getDashboardPeakTimesRead( + ctx: DashboardProcedureContext, + input: DashboardPeakTimesInput, +) { + return getPeakTimesCached(ctx.db, input); +} + +export async function getDashboardTopValueResourcesRead( + ctx: DashboardProcedureContext, + input: DashboardTopValueResourcesInput, +) { + return getTopValueResourcesCached(ctx.db, { limit: input.limit, userRole: getUserRole(ctx) }); +} + +export async function getDashboardDemandRead( + ctx: DashboardProcedureContext, + input: DashboardDemandInput, +) { + return getDemandCached(ctx.db, input); +} + +export async function getDashboardDetail(ctx: DashboardProcedureContext, input: DashboardDetailInput) { + const section = input.section; + const result: Record = {}; + const needsOverview = ( + section === "all" + || section === "peak_times" + || section === "demand_pipeline" + || section === "chargeability_overview" + ); + const overview = needsOverview ? await getOverviewCached(ctx.db) : null; + const now = new Date(); + const rangeStart = overview?.budgetBasis.windowStart + ? new Date(overview.budgetBasis.windowStart) + : new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + const rangeEnd = overview?.budgetBasis.windowEnd + ? new Date(overview.budgetBasis.windowEnd) + : new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 5, 0)); + const userRole = getUserRole(ctx); + + if (section === "all" || section === "peak_times") { + const peakTimes = await getPeakTimesCached(ctx.db, { + startDate: rangeStart.toISOString(), + endDate: rangeEnd.toISOString(), + granularity: "month", + groupBy: "project", + }); + + result.peakTimes = [...peakTimes] + .sort((left, right) => right.totalHours - left.totalHours) + .slice(0, 6) + .map((entry) => ({ + month: entry.period, + totalHours: round1(entry.totalHours), + totalHoursPerDay: round1(entry.totalHours), + capacityHours: round1(entry.capacityHours), + utilizationPct: entry.utilizationPct ?? null, + })); + } + + if (section === "all" || section === "top_resources") { + const resources = await getTopValueResourcesCached(ctx.db, { limit: 10, userRole }); + result.topResources = resources.map((resource) => ({ + name: resource.displayName, + eid: resource.eid, + chapter: resource.chapter ?? null, + lcr: fmtEur(resource.lcrCents), + valueScore: resource.valueScore ?? null, + countryCode: resource.countryCode ?? null, + federalState: resource.federalState ?? null, + metroCityName: resource.metroCityName ?? null, + })); + } + + if (section === "all" || section === "demand_pipeline") { + const demandRows = await getDemandCached(ctx.db, { + startDate: rangeStart.toISOString(), + endDate: rangeEnd.toISOString(), + groupBy: "project", + }); + result.demandPipeline = demandRows + .map((row) => ({ + project: `${row.name} (${row.shortCode})`, + needed: Math.max(0, round1(row.requiredFTEs - row.resourceCount)), + requiredFTEs: row.requiredFTEs, + allocatedResources: row.resourceCount, + allocatedHours: row.allocatedHours, + calendarLocations: row.derivation?.calendarLocations ?? [], + })) + .filter((row) => row.needed > 0) + .sort((left, right) => right.needed - left.needed) + .slice(0, 15); + } + + if (section === "all" || section === "chargeability_overview") { + result.chargeabilityByChapter = (overview?.chapterUtilization ?? []).map((chapter) => ({ + chapter: chapter.chapter ?? "Unassigned", + headcount: chapter.resourceCount, + avgTarget: `${Math.round(chapter.avgChargeabilityTarget)}%`, + })); + } + + return result; +} + +export async function getDashboardChargeabilityOverviewRead( + ctx: DashboardProcedureContext, + input: DashboardChargeabilityOverviewInput, +) { + const cacheKey = `chargeability:${input.includeProposed}:${input.topN}:${input.watchlistThreshold}:${(input.countryIds ?? []).join(",")}:${input.departed ?? ""}`; + const cached = await cacheGet<{ + top: unknown[]; + watchlist: unknown[]; + [key: string]: unknown; + }>(cacheKey); + if (cached) return cached; + + const [overview, directory] = await Promise.all([ + getDashboardChargeabilityOverview(ctx.db, { + includeProposed: input.includeProposed, + topN: input.topN, + watchlistThreshold: input.watchlistThreshold, + ...(input.countryIds !== undefined ? { countryIds: input.countryIds } : {}), + ...(input.departed !== undefined ? { departed: input.departed } : {}), + }), + getAnonymizationDirectory(ctx.db), + ]); + + const result = { + ...overview, + top: anonymizeResources(overview.top, directory), + watchlist: anonymizeResources(overview.watchlist, directory), + }; + await cacheSet(cacheKey, result, DEFAULT_TTL); + return result; +} + +export async function getDashboardBudgetForecastRead(ctx: DashboardProcedureContext) { + const cacheKey = "budgetForecast"; + const cached = await cacheGet>>(cacheKey); + if (cached) return cached; + + const result = await getDashboardBudgetForecast(ctx.db); + await cacheSet(cacheKey, result, DEFAULT_TTL); + return result; +} + +export async function getDashboardBudgetForecastDetail(ctx: DashboardProcedureContext) { + const budgetForecast = await getDashboardBudgetForecast(ctx.db); + return mapBudgetForecastDetailRows(budgetForecast); +} + +export async function getDashboardSkillGapsRead(ctx: DashboardProcedureContext) { + const cacheKey = "skillGaps"; + const cached = await cacheGet>>(cacheKey); + if (cached) return cached; + + const result = await getDashboardSkillGaps(ctx.db); + await cacheSet(cacheKey, result, DEFAULT_TTL); + return result; +} + +export async function getDashboardSkillGapSummaryRead(ctx: DashboardProcedureContext) { + const cacheKey = "skillGapSummary"; + const cached = await cacheGet>>(cacheKey); + if (cached) return cached; + + const result = await getDashboardSkillGapSummary(ctx.db); + await cacheSet(cacheKey, result, DEFAULT_TTL); + return result; +} + +export async function getDashboardProjectHealthRead(ctx: DashboardProcedureContext) { + const cacheKey = "projectHealth"; + const cached = await cacheGet>>(cacheKey); + if (cached) return cached; + + const result = await getDashboardProjectHealth(ctx.db); + await cacheSet(cacheKey, result, DEFAULT_TTL); + return result; +} + +export async function getDashboardProjectHealthDetail(ctx: DashboardProcedureContext) { + const projectHealth = await getDashboardProjectHealth(ctx.db); + return mapProjectHealthDetailRows(projectHealth); +} diff --git a/packages/api/src/router/dashboard.ts b/packages/api/src/router/dashboard.ts index 298c6f9..67dae99 100644 --- a/packages/api/src/router/dashboard.ts +++ b/packages/api/src/router/dashboard.ts @@ -1,402 +1,59 @@ -import { z } from "zod"; import { createTRPCRouter, controllerProcedure } from "../trpc.js"; import { - getDashboardChargeabilityOverview, - getDashboardDemand, - getDashboardOverview, - getDashboardPeakTimes, - getDashboardTopValueResources, - getDashboardBudgetForecast, - getDashboardSkillGaps, - getDashboardSkillGapSummary, - getDashboardProjectHealth, -} from "@capakraken/application"; -import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js"; -import { cacheGet, cacheSet } from "../lib/cache.js"; -import { fmtEur } from "../lib/format-utils.js"; - -const DEFAULT_TTL = 60; // seconds - -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: Awaited>) { - 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 ?? [], - burnStatus: forecast.pctUsed >= 100 - ? "ahead" - : forecast.burnRate > 0 - ? "on_track" - : "not_started", - })), - }; -} - -function mapStatisticsDetail(overview: Awaited>) { - return { - activeResources: overview.activeResources, - totalProjects: overview.totalProjects, - activeProjects: overview.activeProjects, - totalAllocations: overview.totalAllocations, - approvedVacations: overview.approvedVacations, - totalEstimates: overview.totalEstimates, - totalBudget: overview.budgetSummary.totalBudgetCents > 0 - ? fmtEur(overview.budgetSummary.totalBudgetCents) - : "N/A", - projectsByStatus: Object.fromEntries( - overview.projectsByStatus.map((entry) => [entry.status, entry.count]), - ), - topChapters: [...overview.chapterUtilization] - .sort((left, right) => right.resourceCount - left.resourceCount) - .slice(0, 10) - .map((chapter) => ({ - chapter: chapter.chapter, - count: chapter.resourceCount, - })), - }; -} - -async function getOverviewCached(db: Parameters[0]) { - const cacheKey = "overview"; - const cached = await cacheGet>>(cacheKey); - if (cached) return cached; - - const result = await getDashboardOverview(db); - await cacheSet(cacheKey, result, DEFAULT_TTL); - return result; -} - -async function getPeakTimesCached( - db: Parameters[0], - input: { startDate: string; endDate: string; granularity: "week" | "month"; groupBy: "project" | "chapter" | "resource" }, -) { - const cacheKey = `peakTimes:${input.startDate}:${input.endDate}:${input.granularity}:${input.groupBy}`; - const cached = await cacheGet>>(cacheKey); - if (cached) return cached; - - const result = await getDashboardPeakTimes(db, { - startDate: new Date(input.startDate), - endDate: new Date(input.endDate), - granularity: input.granularity, - groupBy: input.groupBy, - }); - await cacheSet(cacheKey, result, DEFAULT_TTL); - return result; -} - -async function getDemandCached( - db: Parameters[0], - input: { startDate: string; endDate: string; groupBy: "project" | "person" | "chapter" }, -) { - const cacheKey = `demand:${input.startDate}:${input.endDate}:${input.groupBy}`; - const cached = await cacheGet>>(cacheKey); - if (cached) return cached; - - const result = await getDashboardDemand(db, { - startDate: new Date(input.startDate), - endDate: new Date(input.endDate), - groupBy: input.groupBy, - }); - await cacheSet(cacheKey, result, DEFAULT_TTL); - return result; -} - -async function getTopValueResourcesCached( - db: Parameters[0], - input: { limit: number; userRole: string }, -) { - const cacheKey = `topValue:${input.limit}:${input.userRole}`; - const cached = await cacheGet>>(cacheKey); - if (cached) return cached; - - const [resources, directory] = await Promise.all([ - getDashboardTopValueResources(db, { - limit: input.limit, - userRole: input.userRole, - }), - getAnonymizationDirectory(db), - ]); - const result = anonymizeResources(resources, directory); - await cacheSet(cacheKey, result, DEFAULT_TTL); - return result; -} + dashboardChargeabilityOverviewInputSchema, + dashboardDemandInputSchema, + dashboardDetailInputSchema, + dashboardPeakTimesInputSchema, + dashboardTopValueResourcesInputSchema, + getDashboardBudgetForecastDetail, + getDashboardBudgetForecastRead, + getDashboardChargeabilityOverviewRead, + getDashboardDemandRead, + getDashboardDetail, + getDashboardOverviewRead, + getDashboardPeakTimesRead, + getDashboardProjectHealthDetail, + getDashboardProjectHealthRead, + getDashboardSkillGapSummaryRead, + getDashboardSkillGapsRead, + getDashboardStatisticsDetail, + getDashboardTopValueResourcesRead, +} from "./dashboard-procedure-support.js"; export const dashboardRouter = createTRPCRouter({ - getOverview: controllerProcedure.query(async ({ ctx }) => { - return getOverviewCached(ctx.db); - }), + getOverview: controllerProcedure.query(({ ctx }) => getDashboardOverviewRead(ctx)), - getStatisticsDetail: controllerProcedure.query(async ({ ctx }) => { - const overview = await getOverviewCached(ctx.db); - return mapStatisticsDetail(overview); - }), + getStatisticsDetail: controllerProcedure.query(({ ctx }) => getDashboardStatisticsDetail(ctx)), getPeakTimes: controllerProcedure - .input( - z.object({ - startDate: z.string().datetime(), - endDate: z.string().datetime(), - granularity: z.enum(["week", "month"]).default("month"), - groupBy: z.enum(["project", "chapter", "resource"]).default("project"), - }), - ) - .query(async ({ ctx, input }) => { - return getPeakTimesCached(ctx.db, input); - }), + .input(dashboardPeakTimesInputSchema) + .query(({ ctx, input }) => getDashboardPeakTimesRead(ctx, input)), getTopValueResources: controllerProcedure - .input(z.object({ limit: z.number().int().min(1).max(50).default(10) })) - .query(async ({ ctx, input }) => { - const userRole = - (ctx.session.user as { role?: string } | undefined)?.role ?? "USER"; - return getTopValueResourcesCached(ctx.db, { limit: input.limit, userRole }); - }), + .input(dashboardTopValueResourcesInputSchema) + .query(({ ctx, input }) => getDashboardTopValueResourcesRead(ctx, input)), getDemand: controllerProcedure - .input( - z.object({ - startDate: z.string().datetime(), - endDate: z.string().datetime(), - groupBy: z.enum(["project", "person", "chapter"]).default("project"), - }), - ) - .query(async ({ ctx, input }) => { - return getDemandCached(ctx.db, input); - }), + .input(dashboardDemandInputSchema) + .query(({ ctx, input }) => getDashboardDemandRead(ctx, input)), getDetail: controllerProcedure - .input(z.object({ section: z.string().optional().default("all") })) - .query(async ({ ctx, input }) => { - const section = input.section; - const result: Record = {}; - const needsOverview = - section === "all" - || section === "peak_times" - || section === "demand_pipeline" - || section === "chargeability_overview"; - const overview = needsOverview ? await getOverviewCached(ctx.db) : null; - const now = new Date(); - const rangeStart = overview?.budgetBasis.windowStart - ? new Date(overview.budgetBasis.windowStart) - : new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); - const rangeEnd = overview?.budgetBasis.windowEnd - ? new Date(overview.budgetBasis.windowEnd) - : new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 5, 0)); - const userRole = - (ctx.session.user as { role?: string } | undefined)?.role - ?? ctx.dbUser?.systemRole - ?? "USER"; - - if (section === "all" || section === "peak_times") { - const peakTimes = await getPeakTimesCached(ctx.db, { - startDate: rangeStart.toISOString(), - endDate: rangeEnd.toISOString(), - granularity: "month", - groupBy: "project", - }); - - result.peakTimes = [...peakTimes] - .sort((left, right) => right.totalHours - left.totalHours) - .slice(0, 6) - .map((entry) => ({ - month: entry.period, - totalHours: round1(entry.totalHours), - totalHoursPerDay: round1(entry.totalHours), - capacityHours: round1(entry.capacityHours), - utilizationPct: entry.utilizationPct ?? null, - })); - } - - if (section === "all" || section === "top_resources") { - const resources = await getTopValueResourcesCached(ctx.db, { limit: 10, userRole }); - result.topResources = resources.map((resource) => { - const topResource = resource as { - displayName: string; - eid: string; - chapter: string | null; - lcrCents: number; - valueScore: number | null; - }; - return { - name: topResource.displayName, - eid: topResource.eid, - chapter: topResource.chapter ?? null, - lcr: fmtEur(topResource.lcrCents), - valueScore: topResource.valueScore ?? null, - }; - }); - } - - if (section === "all" || section === "demand_pipeline") { - const demandRows = await getDemandCached(ctx.db, { - startDate: rangeStart.toISOString(), - endDate: rangeEnd.toISOString(), - groupBy: "project", - }); - result.demandPipeline = demandRows - .map((row) => ({ - project: `${row.name} (${row.shortCode})`, - needed: Math.max(0, round1(row.requiredFTEs - row.resourceCount)), - requiredFTEs: row.requiredFTEs, - allocatedResources: row.resourceCount, - allocatedHours: row.allocatedHours, - calendarLocations: row.derivation?.calendarLocations ?? [], - })) - .filter((row) => row.needed > 0) - .sort((left, right) => right.needed - left.needed) - .slice(0, 15); - } - - if (section === "all" || section === "chargeability_overview") { - result.chargeabilityByChapter = (overview?.chapterUtilization ?? []).map((chapter) => ({ - chapter: chapter.chapter ?? "Unassigned", - headcount: chapter.resourceCount, - avgTarget: `${Math.round(chapter.avgChargeabilityTarget)}%`, - })); - } - - return result; - }), + .input(dashboardDetailInputSchema) + .query(({ ctx, input }) => getDashboardDetail(ctx, input)), getChargeabilityOverview: controllerProcedure - .input( - z.object({ - includeProposed: z.boolean().default(false), - topN: z.number().int().min(1).max(50).default(10), - watchlistThreshold: z.number().default(15), - countryIds: z.array(z.string()).optional(), - departed: z.boolean().optional(), - }), - ) - .query(async ({ ctx, input }) => { - const cacheKey = `chargeability:${input.includeProposed}:${input.topN}:${input.watchlistThreshold}:${(input.countryIds ?? []).join(",")}:${input.departed ?? ""}`; - type ChargeResult = Awaited>; - const cached = await cacheGet<{ - top: unknown[]; - watchlist: unknown[]; - [key: string]: unknown; - }>(cacheKey); - if (cached) return cached; + .input(dashboardChargeabilityOverviewInputSchema) + .query(({ ctx, input }) => getDashboardChargeabilityOverviewRead(ctx, input)), - const [overview, directory] = await Promise.all([ - getDashboardChargeabilityOverview(ctx.db, { - includeProposed: input.includeProposed, - topN: input.topN, - watchlistThreshold: input.watchlistThreshold, - ...(input.countryIds !== undefined ? { countryIds: input.countryIds } : {}), - ...(input.departed !== undefined ? { departed: input.departed } : {}), - }), - getAnonymizationDirectory(ctx.db), - ]); + getBudgetForecast: controllerProcedure.query(({ ctx }) => getDashboardBudgetForecastRead(ctx)), - const result = { - ...overview, - top: anonymizeResources(overview.top, directory), - watchlist: anonymizeResources(overview.watchlist, directory), - }; - await cacheSet(cacheKey, result, DEFAULT_TTL); - return result; - }), + getBudgetForecastDetail: controllerProcedure.query(({ ctx }) => getDashboardBudgetForecastDetail(ctx)), - getBudgetForecast: controllerProcedure.query(async ({ ctx }) => { - const cacheKey = "budgetForecast"; - const cached = await cacheGet>>(cacheKey); - if (cached) return cached; + getSkillGaps: controllerProcedure.query(({ ctx }) => getDashboardSkillGapsRead(ctx)), - const result = await getDashboardBudgetForecast(ctx.db); - await cacheSet(cacheKey, result, DEFAULT_TTL); - return result; - }), + getSkillGapSummary: controllerProcedure.query(({ ctx }) => getDashboardSkillGapSummaryRead(ctx)), - getBudgetForecastDetail: controllerProcedure.query(async ({ ctx }) => { - const budgetForecast = await getDashboardBudgetForecast(ctx.db); - return mapBudgetForecastDetailRows(budgetForecast); - }), + getProjectHealth: controllerProcedure.query(({ ctx }) => getDashboardProjectHealthRead(ctx)), - getSkillGaps: controllerProcedure.query(async ({ ctx }) => { - const cacheKey = "skillGaps"; - const cached = await cacheGet>>(cacheKey); - if (cached) return cached; - - const result = await getDashboardSkillGaps(ctx.db); - await cacheSet(cacheKey, result, DEFAULT_TTL); - return result; - }), - - getSkillGapSummary: controllerProcedure.query(async ({ ctx }) => { - const cacheKey = "skillGapSummary"; - const cached = await cacheGet>>(cacheKey); - if (cached) return cached; - - const result = await getDashboardSkillGapSummary(ctx.db); - await cacheSet(cacheKey, result, DEFAULT_TTL); - return result; - }), - - getProjectHealth: controllerProcedure.query(async ({ ctx }) => { - const cacheKey = "projectHealth"; - const cached = await cacheGet>>(cacheKey); - if (cached) return cached; - - const result = await getDashboardProjectHealth(ctx.db); - await cacheSet(cacheKey, result, DEFAULT_TTL); - return result; - }), - - getProjectHealthDetail: controllerProcedure.query(async ({ ctx }) => { - const projectHealth = await getDashboardProjectHealth(ctx.db); - return mapProjectHealthDetailRows(projectHealth); - }), + getProjectHealthDetail: controllerProcedure.query(({ ctx }) => getDashboardProjectHealthDetail(ctx)), });