import { type BudgetForecastRow, 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"; import { type DashboardBudgetForecastDetail, mapBudgetForecastDetailRows, mapProjectHealthDetailRows, } from "./dashboard-detail-support.js"; const DEFAULT_TTL = 60; type DashboardProcedureContext = Pick; type TopValueResourceRow = { id: string; eid: string; displayName: string; chapter: string | null; valueScore: number | null; valueScoreBreakdown: import("@capakraken/shared").ValueScoreBreakdown | null; valueScoreUpdatedAt: Date | null; lcrCents: number; countryCode: string | null; countryName: 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 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, calendarContextCount: entry.derivation?.calendarContextCount ?? 0, calendarLocations: entry.derivation?.calendarLocations ?? [], })); } 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, valueScoreBreakdown: resource.valueScoreBreakdown ?? null, valueScoreUpdatedAt: resource.valueScoreUpdatedAt?.toISOString() ?? null, countryCode: resource.countryCode ?? null, countryName: resource.countryName ?? 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, ): Promise { 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, ): Promise { const budgetForecast: BudgetForecastRow[] = 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); }