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; type DashboardChargeabilityOverviewRead = Awaited>; function round1(value: number): number { return Math.round(value * 10) / 10; } function formatPct(value: number): string { return `${Math.round(value)}%`; } 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 getChargeabilityOverviewCached( db: Parameters[0], input: DashboardChargeabilityOverviewInput, ): Promise { const cacheKey = `chargeability:${input.includeProposed}:${input.topN}:${input.watchlistThreshold}:${(input.countryIds ?? []).join(",")}:${input.departed ?? ""}`; const cached = await cacheGet(cacheKey); if (cached) return cached; const result = await getDashboardChargeabilityOverview(db, { includeProposed: input.includeProposed, topN: input.topN, watchlistThreshold: input.watchlistThreshold, ...(input.countryIds !== undefined ? { countryIds: input.countryIds } : {}), ...(input.departed !== undefined ? { departed: input.departed } : {}), }); 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"; } function mapChargeabilityByChapter( rows: DashboardChargeabilityOverviewRead["rows"], month: string, ) { const chapterMap = new Map(); for (const row of rows) { const chapter = row.chapter ?? "Unassigned"; const summary = chapterMap.get(chapter) ?? { headcount: 0, avgTargetSum: 0, avgActualSum: 0, avgExpectedSum: 0, derivedHeadcount: 0, baseAvailableHours: 0, effectiveAvailableHours: 0, actualBookedHours: 0, expectedBookedHours: 0, targetBookedHours: 0, publicHolidayHoursDeduction: 0, absenceDayEquivalent: 0, absenceHoursDeduction: 0, unassignedHours: 0, }; summary.headcount += 1; summary.avgTargetSum += row.chargeabilityTarget; summary.avgActualSum += row.actualChargeability; summary.avgExpectedSum += row.expectedChargeability; if (row.derivation) { summary.derivedHeadcount += 1; summary.baseAvailableHours += row.derivation.baseAvailableHours; summary.effectiveAvailableHours += row.derivation.effectiveAvailableHours; summary.actualBookedHours += row.derivation.actualBookedHours; summary.expectedBookedHours += row.derivation.expectedBookedHours; summary.targetBookedHours += row.derivation.targetBookedHours; summary.publicHolidayHoursDeduction += row.derivation.publicHolidayHoursDeduction; summary.absenceDayEquivalent += row.derivation.absenceDayEquivalent; summary.absenceHoursDeduction += row.derivation.absenceHoursDeduction; summary.unassignedHours += row.derivation.unassignedHours; } chapterMap.set(chapter, summary); } return [...chapterMap.entries()] .map(([chapter, summary]) => ({ chapter, headcount: summary.headcount, avgTargetPct: Math.round(summary.avgTargetSum / summary.headcount), avgActualPct: Math.round(summary.avgActualSum / summary.headcount), avgExpectedPct: Math.round(summary.avgExpectedSum / summary.headcount), gapToTargetPct: Math.round((summary.avgTargetSum - summary.avgActualSum) / summary.headcount), avgTarget: formatPct(summary.avgTargetSum / summary.headcount), avgActual: formatPct(summary.avgActualSum / summary.headcount), avgExpected: formatPct(summary.avgExpectedSum / summary.headcount), explainability: summary.derivedHeadcount > 0 ? { month, resourceCount: summary.headcount, derivedHeadcount: summary.derivedHeadcount, baseAvailableHours: round1(summary.baseAvailableHours), effectiveAvailableHours: round1(summary.effectiveAvailableHours), actualBookedHours: round1(summary.actualBookedHours), expectedBookedHours: round1(summary.expectedBookedHours), targetBookedHours: round1(summary.targetBookedHours), publicHolidayHoursDeduction: round1(summary.publicHolidayHoursDeduction), absenceDayEquivalent: round1(summary.absenceDayEquivalent), absenceHoursDeduction: round1(summary.absenceHoursDeduction), unassignedHours: round1(summary.unassignedHours), } : null, })) .sort((left, right) => ( right.headcount - left.headcount || left.chapter.localeCompare(right.chapter) )); } function getProjectHealthRating(overall: number): "healthy" | "at_risk" | "critical" { if (overall >= 80) return "healthy"; if (overall >= 50) return "at_risk"; return "critical"; } 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" ); 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 ?? [], explainability: entry.derivation ? { periodStart: entry.derivation.periodStart, periodEnd: entry.derivation.periodEnd, resourceCount: entry.derivation.resourceCount, groupCount: entry.derivation.groupCount, baseAvailableHours: entry.derivation.baseAvailableHours, effectiveAvailableHours: entry.derivation.effectiveAvailableHours, publicHolidayHoursDeduction: entry.derivation.publicHolidayHoursDeduction, absenceDayEquivalent: entry.derivation.absenceDayEquivalent, absenceHoursDeduction: entry.derivation.absenceHoursDeduction, remainingCapacityHours: entry.derivation.remainingCapacityHours, overbookedHours: entry.derivation.overbookedHours, } : 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, 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 ?? [], explainability: row.derivation ? { periodStart: row.derivation.periodStart, periodEnd: row.derivation.periodEnd, periodWorkingHoursBase: row.derivation.periodWorkingHoursBase, requiredHours: row.derivation.requiredHours, fillPct: row.derivation.fillPct, demandSource: row.derivation.demandSource, calendarContextCount: row.derivation.calendarLocations.length, } : null, })) .filter((row) => row.needed > 0) .sort((left, right) => right.needed - left.needed) .slice(0, 15); } if (section === "all" || section === "chargeability_overview") { const chargeabilityOverview = await getChargeabilityOverviewCached(ctx.db, { includeProposed: false, topN: 10, watchlistThreshold: 15, }); result.chargeabilityByChapter = mapChargeabilityByChapter( chargeabilityOverview.rows, chargeabilityOverview.month, ); } if (section === "all" || section === "project_health") { const projectHealth = await getDashboardProjectHealthRead(ctx); result.projectHealth = [...projectHealth] .sort((left, right) => left.compositeScore - right.compositeScore) .slice(0, 10) .map((project) => ({ project: `${project.projectName} (${project.shortCode})`, status: project.status, overall: project.compositeScore, rating: getProjectHealthRating(project.compositeScore), budget: project.budgetHealth, staffing: project.staffingHealth, timeline: project.timelineHealth, timelineStatus: project.timelineStatus ?? "UNSCHEDULED", daysUntilEndDate: project.daysUntilEndDate ?? null, demandHeadcountOpen: project.demandHeadcountOpen ?? 0, explainability: { demandHeadcountTotal: project.demandHeadcountTotal ?? 0, demandHeadcountFilled: project.demandHeadcountFilled ?? 0, demandHeadcountOpen: project.demandHeadcountOpen ?? 0, demandRequirementCount: project.demandRequirementCount ?? 0, plannedEndDate: project.plannedEndDate?.toISOString() ?? null, budgetUtilizationPercent: project.budgetUtilizationPercent ?? null, remainingBudgetCents: project.remainingBudgetCents ?? null, calendarContextCount: project.derivation?.calendarContextCount ?? 0, holidayAwareAssignmentCount: project.derivation?.holidayAwareAssignmentCount ?? 0, publicHolidayCostDeductionCents: project.derivation?.publicHolidayCostDeductionCents ?? 0, absenceCostDeductionCents: project.derivation?.absenceCostDeductionCents ?? 0, }, })); } if (section === "all" || section === "skill_gaps") { const skillGapSummary = await getDashboardSkillGapSummaryRead(ctx); result.skillGaps = { totalOpenPositions: skillGapSummary.totalOpenPositions, roleGaps: skillGapSummary.roleGaps .slice(0, 10) .map((gap) => ({ role: gap.role, gap: gap.gap, needed: gap.needed, filled: gap.filled, fillRate: gap.fillRate, })), topSkillsInSupply: skillGapSummary.skillSupplyTop10.slice(0, 5), resourcesByRole: skillGapSummary.resourcesByRole.slice(0, 5), }; } return result; } export async function getDashboardChargeabilityOverviewRead( ctx: DashboardProcedureContext, input: DashboardChargeabilityOverviewInput, ) { const [overview, directory] = await Promise.all([ getChargeabilityOverviewCached(ctx.db, input), getAnonymizationDirectory(ctx.db), ]); const { rows: _rows, top, watchlist, ...rest } = overview; return { ...rest, top: anonymizeResources(top, directory), watchlist: anonymizeResources(watchlist, directory), }; } 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 getDashboardBudgetForecastRead(ctx); 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 getDashboardProjectHealthRead(ctx); return mapProjectHealthDetailRows(projectHealth); }