571 lines
21 KiB
TypeScript
571 lines
21 KiB
TypeScript
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<TRPCContext, "db" | "dbUser" | "session">;
|
|
|
|
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<typeof dashboardPeakTimesInputSchema>;
|
|
type DashboardTopValueResourcesInput = z.infer<typeof dashboardTopValueResourcesInputSchema>;
|
|
type DashboardDemandInput = z.infer<typeof dashboardDemandInputSchema>;
|
|
type DashboardDetailInput = z.infer<typeof dashboardDetailInputSchema>;
|
|
type DashboardChargeabilityOverviewInput = z.infer<typeof dashboardChargeabilityOverviewInputSchema>;
|
|
type DashboardChargeabilityOverviewRead = Awaited<ReturnType<typeof getDashboardChargeabilityOverview>>;
|
|
|
|
function round1(value: number): number {
|
|
return Math.round(value * 10) / 10;
|
|
}
|
|
|
|
function formatPct(value: number): string {
|
|
return `${Math.round(value)}%`;
|
|
}
|
|
|
|
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: DashboardPeakTimesInput,
|
|
) {
|
|
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: DashboardDemandInput,
|
|
) {
|
|
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 getChargeabilityOverviewCached(
|
|
db: Parameters<typeof getDashboardChargeabilityOverview>[0],
|
|
input: DashboardChargeabilityOverviewInput,
|
|
): Promise<DashboardChargeabilityOverviewRead> {
|
|
const cacheKey = `chargeability:${input.includeProposed}:${input.topN}:${input.watchlistThreshold}:${(input.countryIds ?? []).join(",")}:${input.departed ?? ""}`;
|
|
const cached = await cacheGet<DashboardChargeabilityOverviewRead>(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<typeof getDashboardTopValueResources>[0],
|
|
input: { limit: number; userRole: string },
|
|
): Promise<TopValueResourceRow[]> {
|
|
const cacheKey = `topValue:${input.limit}:${input.userRole}`;
|
|
const cached = await cacheGet<TopValueResourceRow[]>(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<string, {
|
|
headcount: number;
|
|
avgTargetSum: number;
|
|
avgActualSum: number;
|
|
avgExpectedSum: number;
|
|
derivedHeadcount: number;
|
|
baseAvailableHours: number;
|
|
effectiveAvailableHours: number;
|
|
actualBookedHours: number;
|
|
expectedBookedHours: number;
|
|
targetBookedHours: number;
|
|
publicHolidayHoursDeduction: number;
|
|
absenceDayEquivalent: number;
|
|
absenceHoursDeduction: number;
|
|
unassignedHours: number;
|
|
}>();
|
|
|
|
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<string, unknown> = {};
|
|
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<BudgetForecastRow[]> {
|
|
const cacheKey = "budgetForecast";
|
|
const cached = await cacheGet<BudgetForecastRow[]>(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<DashboardBudgetForecastDetail> {
|
|
const budgetForecast: BudgetForecastRow[] = await getDashboardBudgetForecastRead(ctx);
|
|
return mapBudgetForecastDetailRows(budgetForecast);
|
|
}
|
|
|
|
export async function getDashboardSkillGapsRead(ctx: DashboardProcedureContext) {
|
|
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;
|
|
}
|
|
|
|
export async function getDashboardSkillGapSummaryRead(ctx: DashboardProcedureContext) {
|
|
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;
|
|
}
|
|
|
|
export async function getDashboardProjectHealthRead(ctx: DashboardProcedureContext) {
|
|
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;
|
|
}
|
|
|
|
export async function getDashboardProjectHealthDetail(ctx: DashboardProcedureContext) {
|
|
const projectHealth = await getDashboardProjectHealthRead(ctx);
|
|
return mapProjectHealthDetailRows(projectHealth);
|
|
}
|