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; } export const dashboardRouter = createTRPCRouter({ getOverview: controllerProcedure.query(async ({ ctx }) => { return getOverviewCached(ctx.db); }), getStatisticsDetail: controllerProcedure.query(async ({ ctx }) => { const overview = await getOverviewCached(ctx.db); return mapStatisticsDetail(overview); }), 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); }), 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 }); }), 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); }), 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; }), 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; 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; }), getBudgetForecast: controllerProcedure.query(async ({ ctx }) => { 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; }), getBudgetForecastDetail: controllerProcedure.query(async ({ ctx }) => { const budgetForecast = await getDashboardBudgetForecast(ctx.db); return mapBudgetForecastDetailRows(budgetForecast); }), 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); }), });