refactor(api): extract dashboard procedures
This commit is contained in:
@@ -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<ReturnType<typeof getDashboardProjectHealth>>) {
|
||||
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<ReturnType<typeof getDashboardBudgetForecast>>) {
|
||||
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<ReturnType<typeof getDashboardOverview>>) {
|
||||
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<typeof getDashboardOverview>[0]) {
|
||||
const cacheKey = "overview";
|
||||
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardOverview>>>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const result = await getDashboardOverview(db);
|
||||
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function getPeakTimesCached(
|
||||
db: Parameters<typeof getDashboardPeakTimes>[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<Awaited<ReturnType<typeof getDashboardPeakTimes>>>(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<typeof getDashboardDemand>[0],
|
||||
input: { startDate: string; endDate: string; groupBy: "project" | "person" | "chapter" },
|
||||
) {
|
||||
const cacheKey = `demand:${input.startDate}:${input.endDate}:${input.groupBy}`;
|
||||
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardDemand>>>(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<typeof getDashboardTopValueResources>[0],
|
||||
input: { limit: number; userRole: string },
|
||||
) {
|
||||
const cacheKey = `topValue:${input.limit}:${input.userRole}`;
|
||||
const cached = await cacheGet<Awaited<ReturnType<typeof anonymizeResources>>>(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<string, unknown> = {};
|
||||
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<ReturnType<typeof getDashboardChargeabilityOverview>>;
|
||||
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<Awaited<ReturnType<typeof getDashboardBudgetForecast>>>(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<Awaited<ReturnType<typeof getDashboardSkillGaps>>>(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<Awaited<ReturnType<typeof getDashboardSkillGapSummary>>>(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<Awaited<ReturnType<typeof getDashboardProjectHealth>>>(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)),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user