refactor(api): split dashboard detail shaping
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
import { type BudgetForecastRow, getDashboardProjectHealth } from "@capakraken/application";
|
||||
import { fmtEur } from "../lib/format-utils.js";
|
||||
|
||||
type DashboardBudgetForecastCalendarLocation = {
|
||||
countryCode: string | null;
|
||||
countryName: string | null;
|
||||
federalState: string | null;
|
||||
metroCityName: string | null;
|
||||
activeAssignmentCount: number;
|
||||
burnRateCents: number;
|
||||
};
|
||||
|
||||
type DashboardBudgetForecastDerivation = {
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
calendarContextCount: number;
|
||||
holidayAwareAssignmentCount: number;
|
||||
fallbackAssignmentCount: number;
|
||||
baseBurnRateCents: number;
|
||||
adjustedBurnRateCents: number;
|
||||
publicHolidayDayEquivalent: number;
|
||||
publicHolidayCostDeductionCents: number;
|
||||
absenceDayEquivalent: number;
|
||||
absenceCostDeductionCents: number;
|
||||
};
|
||||
|
||||
export type DashboardBudgetForecastDetail = {
|
||||
forecasts: Array<{
|
||||
projectId: string | null;
|
||||
projectName: string;
|
||||
shortCode: string;
|
||||
clientId: string | null;
|
||||
clientName: string | null;
|
||||
budget: string;
|
||||
budgetCents: number;
|
||||
spent: string;
|
||||
spentCents: number;
|
||||
remaining: string;
|
||||
remainingCents: number;
|
||||
projected: string;
|
||||
projectedCents: number;
|
||||
burnRate: string;
|
||||
burnRateCents: number;
|
||||
utilization: string;
|
||||
estimatedExhaustionDate: string | null;
|
||||
activeAssignmentCount: number | null;
|
||||
calendarLocations: DashboardBudgetForecastCalendarLocation[];
|
||||
derivation: DashboardBudgetForecastDerivation | null;
|
||||
burnStatus: "ahead" | "on_track" | "not_started";
|
||||
}>;
|
||||
};
|
||||
|
||||
type DashboardProjectHealthCalendarLocation = {
|
||||
countryCode: string | null;
|
||||
countryName: string | null;
|
||||
federalState: string | null;
|
||||
metroCityName: string | null;
|
||||
assignmentCount: number;
|
||||
spentCents: number;
|
||||
};
|
||||
|
||||
type DashboardProjectHealthDerivation = {
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
calendarContextCount: number;
|
||||
holidayAwareAssignmentCount: number;
|
||||
fallbackAssignmentCount: number;
|
||||
baseSpentCents: number;
|
||||
adjustedSpentCents: number;
|
||||
publicHolidayDayEquivalent: number;
|
||||
publicHolidayCostDeductionCents: number;
|
||||
absenceDayEquivalent: number;
|
||||
absenceCostDeductionCents: number;
|
||||
};
|
||||
|
||||
export type DashboardProjectHealthDetail = {
|
||||
projects: Array<{
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
shortCode: string;
|
||||
status: string;
|
||||
overall: number;
|
||||
budget: number;
|
||||
staffing: number;
|
||||
timeline: number;
|
||||
rating: "healthy" | "at_risk" | "critical";
|
||||
budgetBasis: {
|
||||
budgetCents: number | null;
|
||||
spentCents: number;
|
||||
remainingBudgetCents: number | null;
|
||||
budgetUtilizationPercent: number | null;
|
||||
calendarLocations: DashboardProjectHealthCalendarLocation[];
|
||||
derivation: DashboardProjectHealthDerivation | null;
|
||||
};
|
||||
staffingBasis: {
|
||||
demandHeadcountTotal: number;
|
||||
demandHeadcountFilled: number;
|
||||
demandHeadcountOpen: number;
|
||||
demandRequirementCount: number;
|
||||
};
|
||||
timelineBasis: {
|
||||
plannedEndDate: string | null;
|
||||
daysUntilEndDate: number | null;
|
||||
timelineStatus: "ON_TRACK" | "DUE_SOON" | "OVERDUE" | "UNSCHEDULED";
|
||||
};
|
||||
context: {
|
||||
clientId: string | null;
|
||||
clientName: string | null;
|
||||
};
|
||||
}>;
|
||||
summary: {
|
||||
healthy: number;
|
||||
atRisk: number;
|
||||
critical: number;
|
||||
};
|
||||
};
|
||||
|
||||
export function mapProjectHealthDetailRows(
|
||||
rows: Awaited<ReturnType<typeof getDashboardProjectHealth>>,
|
||||
): DashboardProjectHealthDetail {
|
||||
const projects: DashboardProjectHealthDetail["projects"] = rows
|
||||
.map((project): DashboardProjectHealthDetail["projects"][number] => {
|
||||
const overall = project.compositeScore;
|
||||
const rating: DashboardProjectHealthDetail["projects"][number]["rating"] = overall >= 80
|
||||
? "healthy"
|
||||
: overall >= 50
|
||||
? "at_risk"
|
||||
: "critical";
|
||||
|
||||
return {
|
||||
projectId: project.id,
|
||||
projectName: project.projectName,
|
||||
shortCode: project.shortCode,
|
||||
status: project.status,
|
||||
overall,
|
||||
budget: project.budgetHealth,
|
||||
staffing: project.staffingHealth,
|
||||
timeline: project.timelineHealth,
|
||||
rating,
|
||||
budgetBasis: {
|
||||
budgetCents: project.budgetCents ?? null,
|
||||
spentCents: project.spentCents ?? 0,
|
||||
remainingBudgetCents: project.remainingBudgetCents ?? null,
|
||||
budgetUtilizationPercent: project.budgetUtilizationPercent ?? null,
|
||||
calendarLocations: project.calendarLocations ?? [],
|
||||
derivation: project.derivation ?? null,
|
||||
},
|
||||
staffingBasis: {
|
||||
demandHeadcountTotal: project.demandHeadcountTotal ?? 0,
|
||||
demandHeadcountFilled: project.demandHeadcountFilled ?? 0,
|
||||
demandHeadcountOpen: project.demandHeadcountOpen ?? 0,
|
||||
demandRequirementCount: project.demandRequirementCount ?? 0,
|
||||
},
|
||||
timelineBasis: {
|
||||
plannedEndDate: project.plannedEndDate?.toISOString() ?? null,
|
||||
daysUntilEndDate: project.daysUntilEndDate ?? null,
|
||||
timelineStatus: project.timelineStatus ?? "UNSCHEDULED",
|
||||
},
|
||||
context: {
|
||||
clientId: project.clientId ?? null,
|
||||
clientName: project.clientName ?? null,
|
||||
},
|
||||
};
|
||||
})
|
||||
.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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function mapBudgetForecastDetailRows(
|
||||
rows: BudgetForecastRow[],
|
||||
): DashboardBudgetForecastDetail {
|
||||
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 ?? [],
|
||||
derivation: forecast.derivation ?? null,
|
||||
burnStatus: forecast.pctUsed >= 100
|
||||
? "ahead"
|
||||
: forecast.burnRate > 0
|
||||
? "on_track"
|
||||
: "not_started",
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,11 @@ import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymizat
|
||||
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;
|
||||
|
||||
@@ -28,6 +33,7 @@ type TopValueResourceRow = {
|
||||
valueScore: number | null;
|
||||
lcrCents: number;
|
||||
countryCode: string | null;
|
||||
countryName: string | null;
|
||||
federalState: string | null;
|
||||
metroCityName: string | null;
|
||||
};
|
||||
@@ -67,123 +73,10 @@ type DashboardDemandInput = z.infer<typeof dashboardDemandInputSchema>;
|
||||
type DashboardDetailInput = z.infer<typeof dashboardDetailInputSchema>;
|
||||
type DashboardChargeabilityOverviewInput = z.infer<typeof dashboardChargeabilityOverviewInputSchema>;
|
||||
|
||||
type DashboardBudgetForecastCalendarLocation = {
|
||||
countryCode: string | null;
|
||||
countryName: string | null;
|
||||
federalState: string | null;
|
||||
metroCityName: string | null;
|
||||
activeAssignmentCount: number;
|
||||
burnRateCents: number;
|
||||
};
|
||||
|
||||
type DashboardBudgetForecastDerivation = {
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
calendarContextCount: number;
|
||||
holidayAwareAssignmentCount: number;
|
||||
fallbackAssignmentCount: number;
|
||||
baseBurnRateCents: number;
|
||||
adjustedBurnRateCents: number;
|
||||
publicHolidayDayEquivalent: number;
|
||||
publicHolidayCostDeductionCents: number;
|
||||
absenceDayEquivalent: number;
|
||||
absenceCostDeductionCents: number;
|
||||
};
|
||||
|
||||
type DashboardBudgetForecastDetail = {
|
||||
forecasts: Array<{
|
||||
projectId: string | null;
|
||||
projectName: string;
|
||||
shortCode: string;
|
||||
clientId: string | null;
|
||||
clientName: string | null;
|
||||
budget: string;
|
||||
budgetCents: number;
|
||||
spent: string;
|
||||
spentCents: number;
|
||||
remaining: string;
|
||||
remainingCents: number;
|
||||
projected: string;
|
||||
projectedCents: number;
|
||||
burnRate: string;
|
||||
burnRateCents: number;
|
||||
utilization: string;
|
||||
estimatedExhaustionDate: string | null;
|
||||
activeAssignmentCount: number | null;
|
||||
calendarLocations: DashboardBudgetForecastCalendarLocation[];
|
||||
derivation: DashboardBudgetForecastDerivation | null;
|
||||
burnStatus: "ahead" | "on_track" | "not_started";
|
||||
}>;
|
||||
};
|
||||
|
||||
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: BudgetForecastRow[]): DashboardBudgetForecastDetail {
|
||||
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 ?? [],
|
||||
derivation: forecast.derivation ?? null,
|
||||
burnStatus: forecast.pctUsed >= 100
|
||||
? "ahead"
|
||||
: forecast.burnRate > 0
|
||||
? "on_track"
|
||||
: "not_started",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function mapStatisticsDetail(overview: Awaited<ReturnType<typeof getDashboardOverview>>) {
|
||||
return {
|
||||
activeResources: overview.activeResources,
|
||||
@@ -357,6 +250,7 @@ export async function getDashboardDetail(ctx: DashboardProcedureContext, input:
|
||||
lcr: fmtEur(resource.lcrCents),
|
||||
valueScore: resource.valueScore ?? null,
|
||||
countryCode: resource.countryCode ?? null,
|
||||
countryName: resource.countryName ?? null,
|
||||
federalState: resource.federalState ?? null,
|
||||
metroCityName: resource.metroCityName ?? null,
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user