refactor(api): split dashboard detail shaping
This commit is contained in:
@@ -290,6 +290,40 @@ describe("dashboard router", () => {
|
|||||||
staffingHealth: 40,
|
staffingHealth: 40,
|
||||||
timelineHealth: 30,
|
timelineHealth: 30,
|
||||||
compositeScore: 35,
|
compositeScore: 35,
|
||||||
|
budgetCents: 100_000,
|
||||||
|
spentCents: 82_000,
|
||||||
|
remainingBudgetCents: 18_000,
|
||||||
|
budgetUtilizationPercent: 82,
|
||||||
|
demandHeadcountTotal: 5,
|
||||||
|
demandHeadcountFilled: 2,
|
||||||
|
demandHeadcountOpen: 3,
|
||||||
|
demandRequirementCount: 2,
|
||||||
|
plannedEndDate: new Date("2026-06-30T00:00:00.000Z"),
|
||||||
|
daysUntilEndDate: 12,
|
||||||
|
timelineStatus: "DUE_SOON",
|
||||||
|
calendarLocations: [
|
||||||
|
{
|
||||||
|
countryCode: "DE",
|
||||||
|
countryName: "Germany",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityName: "Munich",
|
||||||
|
assignmentCount: 2,
|
||||||
|
spentCents: 52_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
derivation: {
|
||||||
|
periodStart: "2026-06-01",
|
||||||
|
periodEnd: "2026-06-30",
|
||||||
|
calendarContextCount: 1,
|
||||||
|
holidayAwareAssignmentCount: 2,
|
||||||
|
fallbackAssignmentCount: 0,
|
||||||
|
baseSpentCents: 90_000,
|
||||||
|
adjustedSpentCents: 82_000,
|
||||||
|
publicHolidayDayEquivalent: 1,
|
||||||
|
publicHolidayCostDeductionCents: 5_000,
|
||||||
|
absenceDayEquivalent: 0.6,
|
||||||
|
absenceCostDeductionCents: 3_000,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "project_healthy",
|
id: "project_healthy",
|
||||||
@@ -302,6 +336,18 @@ describe("dashboard router", () => {
|
|||||||
staffingHealth: 92,
|
staffingHealth: 92,
|
||||||
timelineHealth: 86,
|
timelineHealth: 86,
|
||||||
compositeScore: 89,
|
compositeScore: 89,
|
||||||
|
budgetCents: 200_000,
|
||||||
|
spentCents: 20_000,
|
||||||
|
remainingBudgetCents: 180_000,
|
||||||
|
budgetUtilizationPercent: 10,
|
||||||
|
demandHeadcountTotal: 4,
|
||||||
|
demandHeadcountFilled: 4,
|
||||||
|
demandHeadcountOpen: 0,
|
||||||
|
demandRequirementCount: 1,
|
||||||
|
plannedEndDate: new Date("2026-09-15T00:00:00.000Z"),
|
||||||
|
daysUntilEndDate: 89,
|
||||||
|
timelineStatus: "ON_TRACK",
|
||||||
|
calendarLocations: [],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -321,6 +367,50 @@ describe("dashboard router", () => {
|
|||||||
staffing: 40,
|
staffing: 40,
|
||||||
timeline: 30,
|
timeline: 30,
|
||||||
rating: "critical",
|
rating: "critical",
|
||||||
|
budgetBasis: {
|
||||||
|
budgetCents: 100_000,
|
||||||
|
spentCents: 82_000,
|
||||||
|
remainingBudgetCents: 18_000,
|
||||||
|
budgetUtilizationPercent: 82,
|
||||||
|
calendarLocations: [
|
||||||
|
{
|
||||||
|
countryCode: "DE",
|
||||||
|
countryName: "Germany",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityName: "Munich",
|
||||||
|
assignmentCount: 2,
|
||||||
|
spentCents: 52_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
derivation: {
|
||||||
|
periodStart: "2026-06-01",
|
||||||
|
periodEnd: "2026-06-30",
|
||||||
|
calendarContextCount: 1,
|
||||||
|
holidayAwareAssignmentCount: 2,
|
||||||
|
fallbackAssignmentCount: 0,
|
||||||
|
baseSpentCents: 90_000,
|
||||||
|
adjustedSpentCents: 82_000,
|
||||||
|
publicHolidayDayEquivalent: 1,
|
||||||
|
publicHolidayCostDeductionCents: 5_000,
|
||||||
|
absenceDayEquivalent: 0.6,
|
||||||
|
absenceCostDeductionCents: 3_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
staffingBasis: {
|
||||||
|
demandHeadcountTotal: 5,
|
||||||
|
demandHeadcountFilled: 2,
|
||||||
|
demandHeadcountOpen: 3,
|
||||||
|
demandRequirementCount: 2,
|
||||||
|
},
|
||||||
|
timelineBasis: {
|
||||||
|
plannedEndDate: "2026-06-30T00:00:00.000Z",
|
||||||
|
daysUntilEndDate: 12,
|
||||||
|
timelineStatus: "DUE_SOON",
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
clientId: "client_1",
|
||||||
|
clientName: "Acme",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
projectId: "project_healthy",
|
projectId: "project_healthy",
|
||||||
@@ -332,6 +422,29 @@ describe("dashboard router", () => {
|
|||||||
staffing: 92,
|
staffing: 92,
|
||||||
timeline: 86,
|
timeline: 86,
|
||||||
rating: "healthy",
|
rating: "healthy",
|
||||||
|
budgetBasis: {
|
||||||
|
budgetCents: 200_000,
|
||||||
|
spentCents: 20_000,
|
||||||
|
remainingBudgetCents: 180_000,
|
||||||
|
budgetUtilizationPercent: 10,
|
||||||
|
calendarLocations: [],
|
||||||
|
derivation: null,
|
||||||
|
},
|
||||||
|
staffingBasis: {
|
||||||
|
demandHeadcountTotal: 4,
|
||||||
|
demandHeadcountFilled: 4,
|
||||||
|
demandHeadcountOpen: 0,
|
||||||
|
demandRequirementCount: 1,
|
||||||
|
},
|
||||||
|
timelineBasis: {
|
||||||
|
plannedEndDate: "2026-09-15T00:00:00.000Z",
|
||||||
|
daysUntilEndDate: 89,
|
||||||
|
timelineStatus: "ON_TRACK",
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
clientId: "client_1",
|
||||||
|
clientName: "Acme",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
summary: {
|
summary: {
|
||||||
@@ -476,6 +589,19 @@ describe("dashboard router", () => {
|
|||||||
estimatedExhaustionDate: "2026-06-30",
|
estimatedExhaustionDate: "2026-06-30",
|
||||||
pctUsed: 40,
|
pctUsed: 40,
|
||||||
activeAssignmentCount: 2,
|
activeAssignmentCount: 2,
|
||||||
|
derivation: {
|
||||||
|
periodStart: "2026-03-01",
|
||||||
|
periodEnd: "2026-03-31",
|
||||||
|
calendarContextCount: 1,
|
||||||
|
holidayAwareAssignmentCount: 2,
|
||||||
|
fallbackAssignmentCount: 0,
|
||||||
|
baseBurnRateCents: 12_000,
|
||||||
|
adjustedBurnRateCents: 10_000,
|
||||||
|
publicHolidayDayEquivalent: 1,
|
||||||
|
publicHolidayCostDeductionCents: 1_000,
|
||||||
|
absenceDayEquivalent: 0.5,
|
||||||
|
absenceCostDeductionCents: 1_000,
|
||||||
|
},
|
||||||
calendarLocations: [
|
calendarLocations: [
|
||||||
{
|
{
|
||||||
countryCode: "DE",
|
countryCode: "DE",
|
||||||
@@ -496,6 +622,11 @@ describe("dashboard router", () => {
|
|||||||
expect(result[0]).toMatchObject({
|
expect(result[0]).toMatchObject({
|
||||||
projectName: "Alpha",
|
projectName: "Alpha",
|
||||||
activeAssignmentCount: 2,
|
activeAssignmentCount: 2,
|
||||||
|
derivation: {
|
||||||
|
holidayAwareAssignmentCount: 2,
|
||||||
|
baseBurnRateCents: 12_000,
|
||||||
|
adjustedBurnRateCents: 10_000,
|
||||||
|
},
|
||||||
calendarLocations: [
|
calendarLocations: [
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
countryCode: "DE",
|
countryCode: "DE",
|
||||||
@@ -522,6 +653,19 @@ describe("dashboard router", () => {
|
|||||||
estimatedExhaustionDate: "2026-06-30",
|
estimatedExhaustionDate: "2026-06-30",
|
||||||
pctUsed: 40,
|
pctUsed: 40,
|
||||||
activeAssignmentCount: 2,
|
activeAssignmentCount: 2,
|
||||||
|
derivation: {
|
||||||
|
periodStart: "2026-03-01",
|
||||||
|
periodEnd: "2026-03-31",
|
||||||
|
calendarContextCount: 1,
|
||||||
|
holidayAwareAssignmentCount: 2,
|
||||||
|
fallbackAssignmentCount: 0,
|
||||||
|
baseBurnRateCents: 12_000,
|
||||||
|
adjustedBurnRateCents: 10_000,
|
||||||
|
publicHolidayDayEquivalent: 1,
|
||||||
|
publicHolidayCostDeductionCents: 1_000,
|
||||||
|
absenceDayEquivalent: 0.5,
|
||||||
|
absenceCostDeductionCents: 1_000,
|
||||||
|
},
|
||||||
calendarLocations: [
|
calendarLocations: [
|
||||||
{
|
{
|
||||||
countryCode: "DE",
|
countryCode: "DE",
|
||||||
@@ -551,6 +695,13 @@ describe("dashboard router", () => {
|
|||||||
projectedCents: 100_000,
|
projectedCents: 100_000,
|
||||||
burnRateCents: 10_000,
|
burnRateCents: 10_000,
|
||||||
utilization: "40%",
|
utilization: "40%",
|
||||||
|
derivation: expect.objectContaining({
|
||||||
|
holidayAwareAssignmentCount: 2,
|
||||||
|
baseBurnRateCents: 12_000,
|
||||||
|
adjustedBurnRateCents: 10_000,
|
||||||
|
publicHolidayCostDeductionCents: 1_000,
|
||||||
|
absenceCostDeductionCents: 1_000,
|
||||||
|
}),
|
||||||
burnStatus: "on_track",
|
burnStatus: "on_track",
|
||||||
calendarLocations: [
|
calendarLocations: [
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -597,6 +748,7 @@ describe("dashboard router", () => {
|
|||||||
valueScore: 91,
|
valueScore: 91,
|
||||||
lcrCents: 9_500,
|
lcrCents: 9_500,
|
||||||
countryCode: "DE",
|
countryCode: "DE",
|
||||||
|
countryName: "Germany",
|
||||||
federalState: "BY",
|
federalState: "BY",
|
||||||
metroCityName: "Augsburg",
|
metroCityName: "Augsburg",
|
||||||
},
|
},
|
||||||
@@ -644,6 +796,7 @@ describe("dashboard router", () => {
|
|||||||
lcr: "95,00 EUR",
|
lcr: "95,00 EUR",
|
||||||
valueScore: 91,
|
valueScore: 91,
|
||||||
countryCode: "DE",
|
countryCode: "DE",
|
||||||
|
countryName: "Germany",
|
||||||
federalState: "BY",
|
federalState: "BY",
|
||||||
metroCityName: "Augsburg",
|
metroCityName: "Augsburg",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 { cacheGet, cacheSet } from "../lib/cache.js";
|
||||||
import { fmtEur } from "../lib/format-utils.js";
|
import { fmtEur } from "../lib/format-utils.js";
|
||||||
import type { TRPCContext } from "../trpc.js";
|
import type { TRPCContext } from "../trpc.js";
|
||||||
|
import {
|
||||||
|
type DashboardBudgetForecastDetail,
|
||||||
|
mapBudgetForecastDetailRows,
|
||||||
|
mapProjectHealthDetailRows,
|
||||||
|
} from "./dashboard-detail-support.js";
|
||||||
|
|
||||||
const DEFAULT_TTL = 60;
|
const DEFAULT_TTL = 60;
|
||||||
|
|
||||||
@@ -28,6 +33,7 @@ type TopValueResourceRow = {
|
|||||||
valueScore: number | null;
|
valueScore: number | null;
|
||||||
lcrCents: number;
|
lcrCents: number;
|
||||||
countryCode: string | null;
|
countryCode: string | null;
|
||||||
|
countryName: string | null;
|
||||||
federalState: string | null;
|
federalState: string | null;
|
||||||
metroCityName: string | null;
|
metroCityName: string | null;
|
||||||
};
|
};
|
||||||
@@ -67,123 +73,10 @@ type DashboardDemandInput = z.infer<typeof dashboardDemandInputSchema>;
|
|||||||
type DashboardDetailInput = z.infer<typeof dashboardDetailInputSchema>;
|
type DashboardDetailInput = z.infer<typeof dashboardDetailInputSchema>;
|
||||||
type DashboardChargeabilityOverviewInput = z.infer<typeof dashboardChargeabilityOverviewInputSchema>;
|
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 {
|
function round1(value: number): number {
|
||||||
return Math.round(value * 10) / 10;
|
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>>) {
|
function mapStatisticsDetail(overview: Awaited<ReturnType<typeof getDashboardOverview>>) {
|
||||||
return {
|
return {
|
||||||
activeResources: overview.activeResources,
|
activeResources: overview.activeResources,
|
||||||
@@ -357,6 +250,7 @@ export async function getDashboardDetail(ctx: DashboardProcedureContext, input:
|
|||||||
lcr: fmtEur(resource.lcrCents),
|
lcr: fmtEur(resource.lcrCents),
|
||||||
valueScore: resource.valueScore ?? null,
|
valueScore: resource.valueScore ?? null,
|
||||||
countryCode: resource.countryCode ?? null,
|
countryCode: resource.countryCode ?? null,
|
||||||
|
countryName: resource.countryName ?? null,
|
||||||
federalState: resource.federalState ?? null,
|
federalState: resource.federalState ?? null,
|
||||||
metroCityName: resource.metroCityName ?? null,
|
metroCityName: resource.metroCityName ?? null,
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user