214 lines
6.9 KiB
TypeScript
214 lines
6.9 KiB
TypeScript
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",
|
|
})),
|
|
};
|
|
}
|