1106 lines
35 KiB
TypeScript
1106 lines
35 KiB
TypeScript
import { SystemRole } from "@capakraken/shared";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("@capakraken/application", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("@capakraken/application")>();
|
|
return {
|
|
...actual,
|
|
getDashboardOverview: vi.fn(),
|
|
getDashboardPeakTimes: vi.fn(),
|
|
getDashboardDemand: vi.fn(),
|
|
getDashboardTopValueResources: vi.fn(),
|
|
getDashboardChargeabilityOverview: vi.fn(),
|
|
getDashboardBudgetForecast: vi.fn(),
|
|
getDashboardSkillGapSummary: vi.fn(),
|
|
getDashboardProjectHealth: vi.fn(),
|
|
};
|
|
});
|
|
|
|
vi.mock("../lib/cache.js", () => ({
|
|
cacheGet: vi.fn().mockResolvedValue(null),
|
|
cacheSet: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
vi.mock("../lib/anonymization.js", () => ({
|
|
anonymizeResources: vi.fn((resources: unknown[]) => resources),
|
|
getAnonymizationDirectory: vi.fn().mockResolvedValue(null),
|
|
}));
|
|
|
|
import {
|
|
getDashboardOverview,
|
|
getDashboardPeakTimes,
|
|
getDashboardDemand,
|
|
getDashboardTopValueResources,
|
|
getDashboardChargeabilityOverview,
|
|
getDashboardBudgetForecast,
|
|
getDashboardProjectHealth,
|
|
getDashboardSkillGapSummary,
|
|
} from "@capakraken/application";
|
|
import { dashboardRouter } from "../router/dashboard.js";
|
|
import { createCallerFactory } from "../trpc.js";
|
|
|
|
const createCaller = createCallerFactory(dashboardRouter);
|
|
|
|
function createProtectedCaller(db: Record<string, unknown>) {
|
|
return createCaller({
|
|
session: {
|
|
user: { email: "user@example.com", name: "User", image: null },
|
|
expires: "2099-01-01T00:00:00.000Z",
|
|
},
|
|
db: db as never,
|
|
dbUser: {
|
|
id: "user_1",
|
|
systemRole: SystemRole.USER,
|
|
permissionOverrides: null,
|
|
},
|
|
});
|
|
}
|
|
|
|
function createControllerCaller(db: Record<string, unknown>) {
|
|
return createCaller({
|
|
session: {
|
|
user: { email: "controller@example.com", name: "Controller", image: null },
|
|
expires: "2099-01-01T00:00:00.000Z",
|
|
},
|
|
db: db as never,
|
|
dbUser: {
|
|
id: "user_2",
|
|
systemRole: SystemRole.CONTROLLER,
|
|
permissionOverrides: null,
|
|
},
|
|
});
|
|
}
|
|
|
|
function createUnauthenticatedCaller(db: Record<string, unknown>) {
|
|
return createCaller({
|
|
session: null,
|
|
db: db as never,
|
|
dbUser: null,
|
|
});
|
|
}
|
|
|
|
describe("dashboard router", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
// ─── getOverview ──────────────────────────────────────────────────────────
|
|
|
|
describe("getOverview", () => {
|
|
it("returns expected shape with resource and project counts", async () => {
|
|
const overview = {
|
|
totalResources: 42,
|
|
activeResources: 38,
|
|
totalProjects: 15,
|
|
activeProjects: 10,
|
|
draftProjects: 3,
|
|
completedProjects: 2,
|
|
totalBudgetCents: 5_000_000_00,
|
|
avgWinProbability: 78,
|
|
};
|
|
|
|
vi.mocked(getDashboardOverview).mockResolvedValue(overview);
|
|
|
|
const caller = createControllerCaller({});
|
|
const result = await caller.getOverview();
|
|
|
|
expect(result).toMatchObject({
|
|
totalResources: 42,
|
|
activeResources: 38,
|
|
totalProjects: 15,
|
|
activeProjects: 10,
|
|
});
|
|
expect(getDashboardOverview).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("rejects unauthenticated users", async () => {
|
|
const caller = createUnauthenticatedCaller({});
|
|
await expect(caller.getOverview()).rejects.toThrow("Authentication required");
|
|
});
|
|
});
|
|
|
|
describe("getStatisticsDetail", () => {
|
|
it("returns assistant-friendly statistics derived from the canonical dashboard overview", async () => {
|
|
vi.mocked(getDashboardOverview).mockResolvedValue({
|
|
totalResources: 12,
|
|
activeResources: 10,
|
|
inactiveResources: 2,
|
|
totalProjects: 7,
|
|
activeProjects: 4,
|
|
inactiveProjects: 3,
|
|
totalAllocations: 21,
|
|
activeAllocations: 18,
|
|
cancelledAllocations: 3,
|
|
approvedVacations: 6,
|
|
totalEstimates: 9,
|
|
budgetSummary: {
|
|
totalBudgetCents: 1_234_56,
|
|
totalCostCents: 654_32,
|
|
avgUtilizationPercent: 53,
|
|
},
|
|
budgetBasis: {
|
|
remainingBudgetCents: 58_024,
|
|
budgetedProjects: 5,
|
|
unbudgetedProjects: 2,
|
|
trackedAssignmentCount: 18,
|
|
windowStart: null,
|
|
windowEnd: null,
|
|
},
|
|
projectsByStatus: [
|
|
{ status: "ACTIVE", count: 4 },
|
|
{ status: "DRAFT", count: 2 },
|
|
{ status: "DONE", count: 1 },
|
|
],
|
|
chapterUtilization: [
|
|
{ chapter: "CGI", resourceCount: 5, avgChargeabilityTarget: 78 },
|
|
{ chapter: "Compositing", resourceCount: 3, avgChargeabilityTarget: 74 },
|
|
{ chapter: "Unassigned", resourceCount: 2, avgChargeabilityTarget: 0 },
|
|
],
|
|
recentActivity: [],
|
|
});
|
|
|
|
const caller = createControllerCaller({});
|
|
const result = await caller.getStatisticsDetail();
|
|
|
|
expect(getDashboardOverview).toHaveBeenCalledTimes(1);
|
|
expect(result).toEqual({
|
|
activeResources: 10,
|
|
totalProjects: 7,
|
|
activeProjects: 4,
|
|
totalAllocations: 21,
|
|
approvedVacations: 6,
|
|
totalEstimates: 9,
|
|
totalBudget: "1.234,56 EUR",
|
|
projectsByStatus: {
|
|
ACTIVE: 4,
|
|
DRAFT: 2,
|
|
DONE: 1,
|
|
},
|
|
topChapters: [
|
|
{ chapter: "CGI", count: 5 },
|
|
{ chapter: "Compositing", count: 3 },
|
|
{ chapter: "Unassigned", count: 2 },
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── getPeakTimes ─────────────────────────────────────────────────────────
|
|
|
|
describe("getPeakTimes", () => {
|
|
it("returns array of time periods", async () => {
|
|
const peakData = [
|
|
{ period: "2026-03", totalHours: 1200, entries: 15 },
|
|
{ period: "2026-04", totalHours: 1400, entries: 18 },
|
|
];
|
|
|
|
vi.mocked(getDashboardPeakTimes).mockResolvedValue(peakData);
|
|
|
|
const caller = createControllerCaller({});
|
|
const result = await caller.getPeakTimes({
|
|
startDate: "2026-03-01T00:00:00.000Z",
|
|
endDate: "2026-06-30T00:00:00.000Z",
|
|
granularity: "month",
|
|
groupBy: "project",
|
|
});
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0]).toHaveProperty("period", "2026-03");
|
|
expect(getDashboardPeakTimes).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
granularity: "month",
|
|
groupBy: "project",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("passes week granularity to application layer", async () => {
|
|
vi.mocked(getDashboardPeakTimes).mockResolvedValue([]);
|
|
|
|
const caller = createControllerCaller({});
|
|
await caller.getPeakTimes({
|
|
startDate: "2026-03-01T00:00:00.000Z",
|
|
endDate: "2026-03-31T00:00:00.000Z",
|
|
granularity: "week",
|
|
groupBy: "chapter",
|
|
});
|
|
|
|
expect(getDashboardPeakTimes).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
granularity: "week",
|
|
groupBy: "chapter",
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── getDemand ────────────────────────────────────────────────────────────
|
|
|
|
describe("getDemand", () => {
|
|
it("returns demand entries grouped by project", async () => {
|
|
const demandData = [
|
|
{ groupKey: "Project Alpha", totalHours: 500, headcount: 3 },
|
|
{ groupKey: "Project Beta", totalHours: 300, headcount: 2 },
|
|
];
|
|
|
|
vi.mocked(getDashboardDemand).mockResolvedValue(demandData);
|
|
|
|
const caller = createControllerCaller({});
|
|
const result = await caller.getDemand({
|
|
startDate: "2026-01-01T00:00:00.000Z",
|
|
endDate: "2026-12-31T00:00:00.000Z",
|
|
groupBy: "project",
|
|
});
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(getDashboardDemand).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({ groupBy: "project" }),
|
|
);
|
|
});
|
|
|
|
it("supports grouping by chapter", async () => {
|
|
vi.mocked(getDashboardDemand).mockResolvedValue([]);
|
|
|
|
const caller = createControllerCaller({});
|
|
await caller.getDemand({
|
|
startDate: "2026-06-01T00:00:00.000Z",
|
|
endDate: "2026-06-30T00:00:00.000Z",
|
|
groupBy: "chapter",
|
|
});
|
|
|
|
expect(getDashboardDemand).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({ groupBy: "chapter" }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("getProjectHealthDetail", () => {
|
|
it("returns assistant-friendly health detail derived from the canonical dashboard read model", async () => {
|
|
vi.mocked(getDashboardProjectHealth).mockResolvedValue([
|
|
{
|
|
id: "project_critical",
|
|
projectName: "Critical Project",
|
|
shortCode: "CRIT",
|
|
status: "ACTIVE",
|
|
clientId: "client_1",
|
|
clientName: "Acme",
|
|
budgetHealth: 25,
|
|
staffingHealth: 40,
|
|
timelineHealth: 30,
|
|
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",
|
|
projectName: "Healthy Project",
|
|
shortCode: "HLTH",
|
|
status: "ACTIVE",
|
|
clientId: "client_1",
|
|
clientName: "Acme",
|
|
budgetHealth: 90,
|
|
staffingHealth: 92,
|
|
timelineHealth: 86,
|
|
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: [],
|
|
},
|
|
]);
|
|
|
|
const caller = createControllerCaller({});
|
|
const result = await caller.getProjectHealthDetail();
|
|
|
|
expect(getDashboardProjectHealth).toHaveBeenCalledTimes(1);
|
|
expect(result).toEqual({
|
|
projects: [
|
|
{
|
|
projectId: "project_critical",
|
|
projectName: "Critical Project",
|
|
shortCode: "CRIT",
|
|
status: "ACTIVE",
|
|
overall: 35,
|
|
budget: 25,
|
|
staffing: 40,
|
|
timeline: 30,
|
|
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",
|
|
projectName: "Healthy Project",
|
|
shortCode: "HLTH",
|
|
status: "ACTIVE",
|
|
overall: 89,
|
|
budget: 90,
|
|
staffing: 92,
|
|
timeline: 86,
|
|
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: {
|
|
healthy: 1,
|
|
atRisk: 0,
|
|
critical: 1,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── getTopValueResources ─────────────────────────────────────────────────
|
|
|
|
describe("getTopValueResources", () => {
|
|
it("returns sorted resources with default limit", async () => {
|
|
const resources = [
|
|
{
|
|
id: "res_1",
|
|
eid: "alice",
|
|
displayName: "Alice",
|
|
chapter: "Delivery",
|
|
valueScore: 95,
|
|
valueScoreBreakdown: {
|
|
skillDepth: 88,
|
|
skillBreadth: 79,
|
|
costEfficiency: 84,
|
|
chargeability: 76,
|
|
experience: 90,
|
|
total: 95,
|
|
},
|
|
valueScoreUpdatedAt: new Date("2026-03-01T00:00:00.000Z"),
|
|
lcrCents: 12_300,
|
|
countryCode: "DE",
|
|
countryName: "Germany",
|
|
federalState: "BY",
|
|
metroCityName: "Munich",
|
|
},
|
|
{
|
|
id: "res_2",
|
|
eid: "bob",
|
|
displayName: "Bob",
|
|
chapter: "Data",
|
|
valueScore: 88,
|
|
valueScoreBreakdown: {
|
|
skillDepth: 80,
|
|
skillBreadth: 77,
|
|
costEfficiency: 91,
|
|
chargeability: 72,
|
|
experience: 81,
|
|
total: 88,
|
|
},
|
|
valueScoreUpdatedAt: new Date("2026-03-02T00:00:00.000Z"),
|
|
lcrCents: 10_800,
|
|
countryCode: "US",
|
|
countryName: "United States",
|
|
federalState: "CA",
|
|
metroCityName: "San Francisco",
|
|
},
|
|
];
|
|
|
|
vi.mocked(getDashboardTopValueResources).mockResolvedValue(resources);
|
|
|
|
const caller = createControllerCaller({});
|
|
const result = await caller.getTopValueResources({ limit: 10 });
|
|
|
|
expect(result).toEqual(resources);
|
|
expect(getDashboardTopValueResources).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({ limit: 10 }),
|
|
);
|
|
});
|
|
|
|
it("respects custom limit", async () => {
|
|
vi.mocked(getDashboardTopValueResources).mockResolvedValue([]);
|
|
|
|
const caller = createControllerCaller({});
|
|
await caller.getTopValueResources({ limit: 5 });
|
|
|
|
expect(getDashboardTopValueResources).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({ limit: 5 }),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── getChargeabilityOverview ─────────────────────────────────────────────
|
|
|
|
describe("getChargeabilityOverview", () => {
|
|
it("returns chargeability data with top and watchlist arrays", async () => {
|
|
const overview = {
|
|
avgChargeability: 72,
|
|
top: [{ id: "res_1", displayName: "Alice", chargeability: 95 }],
|
|
watchlist: [{ id: "res_3", displayName: "Carol", chargeability: 30 }],
|
|
};
|
|
|
|
vi.mocked(getDashboardChargeabilityOverview).mockResolvedValue(overview);
|
|
|
|
const caller = createControllerCaller({});
|
|
const result = await caller.getChargeabilityOverview({
|
|
includeProposed: false,
|
|
topN: 10,
|
|
watchlistThreshold: 15,
|
|
});
|
|
|
|
expect(result).toHaveProperty("top");
|
|
expect(result).toHaveProperty("watchlist");
|
|
expect(result.top).toHaveLength(1);
|
|
expect(result.watchlist).toHaveLength(1);
|
|
});
|
|
|
|
it("passes includeProposed flag to application layer", async () => {
|
|
vi.mocked(getDashboardChargeabilityOverview).mockResolvedValue({
|
|
avgChargeability: 60,
|
|
top: [],
|
|
watchlist: [],
|
|
});
|
|
|
|
const caller = createControllerCaller({});
|
|
await caller.getChargeabilityOverview({
|
|
includeProposed: true,
|
|
topN: 5,
|
|
watchlistThreshold: 20,
|
|
});
|
|
|
|
expect(getDashboardChargeabilityOverview).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
includeProposed: true,
|
|
topN: 5,
|
|
watchlistThreshold: 20,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("requires controller role — blocks USER", async () => {
|
|
const caller = createProtectedCaller({});
|
|
await expect(
|
|
caller.getChargeabilityOverview({
|
|
includeProposed: false,
|
|
topN: 10,
|
|
watchlistThreshold: 15,
|
|
}),
|
|
).rejects.toThrow(
|
|
expect.objectContaining({ code: "FORBIDDEN" }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("getBudgetForecast", () => {
|
|
it("returns budget forecast rows with calendar location context", async () => {
|
|
vi.mocked(getDashboardBudgetForecast).mockResolvedValue([
|
|
{
|
|
projectId: "project_1",
|
|
projectName: "Alpha",
|
|
shortCode: "ALPHA",
|
|
clientId: "client_1",
|
|
clientName: "Client One",
|
|
budgetCents: 100_000,
|
|
spentCents: 40_000,
|
|
remainingCents: 60_000,
|
|
burnRate: 10_000,
|
|
estimatedExhaustionDate: "2026-06-30",
|
|
pctUsed: 40,
|
|
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: [
|
|
{
|
|
countryCode: "DE",
|
|
countryName: "Germany",
|
|
federalState: "BY",
|
|
metroCityName: "Munich",
|
|
activeAssignmentCount: 2,
|
|
burnRateCents: 10_000,
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
const caller = createControllerCaller({});
|
|
const result = await caller.getBudgetForecast();
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]).toMatchObject({
|
|
projectName: "Alpha",
|
|
activeAssignmentCount: 2,
|
|
derivation: {
|
|
holidayAwareAssignmentCount: 2,
|
|
baseBurnRateCents: 12_000,
|
|
adjustedBurnRateCents: 10_000,
|
|
},
|
|
calendarLocations: [
|
|
expect.objectContaining({
|
|
countryCode: "DE",
|
|
federalState: "BY",
|
|
metroCityName: "Munich",
|
|
}),
|
|
],
|
|
});
|
|
expect(getDashboardBudgetForecast).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("returns assistant-friendly budget forecast detail derived from the canonical dashboard read model", async () => {
|
|
vi.mocked(getDashboardBudgetForecast).mockResolvedValue([
|
|
{
|
|
projectId: "project_1",
|
|
projectName: "Alpha",
|
|
shortCode: "ALPHA",
|
|
clientId: "client_1",
|
|
clientName: "Client One",
|
|
budgetCents: 100_000,
|
|
spentCents: 40_000,
|
|
remainingCents: 60_000,
|
|
burnRate: 10_000,
|
|
estimatedExhaustionDate: "2026-06-30",
|
|
pctUsed: 40,
|
|
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: [
|
|
{
|
|
countryCode: "DE",
|
|
countryName: "Germany",
|
|
federalState: "BY",
|
|
metroCityName: "Munich",
|
|
activeAssignmentCount: 2,
|
|
burnRateCents: 10_000,
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
const caller = createControllerCaller({});
|
|
const result = await caller.getBudgetForecastDetail();
|
|
|
|
expect(getDashboardBudgetForecast).toHaveBeenCalledTimes(1);
|
|
expect(result).toEqual({
|
|
forecasts: [
|
|
expect.objectContaining({
|
|
projectId: "project_1",
|
|
projectName: "Alpha",
|
|
shortCode: "ALPHA",
|
|
budgetCents: 100_000,
|
|
spentCents: 40_000,
|
|
remainingCents: 60_000,
|
|
projectedCents: 100_000,
|
|
burnRateCents: 10_000,
|
|
utilization: "40%",
|
|
derivation: expect.objectContaining({
|
|
holidayAwareAssignmentCount: 2,
|
|
baseBurnRateCents: 12_000,
|
|
adjustedBurnRateCents: 10_000,
|
|
publicHolidayCostDeductionCents: 1_000,
|
|
absenceCostDeductionCents: 1_000,
|
|
}),
|
|
burnStatus: "on_track",
|
|
calendarLocations: [
|
|
expect.objectContaining({
|
|
countryCode: "DE",
|
|
federalState: "BY",
|
|
metroCityName: "Munich",
|
|
}),
|
|
],
|
|
}),
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("getDetail", () => {
|
|
it("returns the canonical assistant dashboard detail payload", async () => {
|
|
vi.mocked(getDashboardOverview).mockResolvedValue({
|
|
budgetBasis: {
|
|
windowStart: "2026-01-01T00:00:00.000Z",
|
|
windowEnd: "2026-06-30T00:00:00.000Z",
|
|
},
|
|
chapterUtilization: [
|
|
{
|
|
chapter: "Delivery",
|
|
resourceCount: 4,
|
|
avgChargeabilityTarget: 78,
|
|
},
|
|
],
|
|
});
|
|
vi.mocked(getDashboardPeakTimes).mockResolvedValue([
|
|
{
|
|
period: "2026-03",
|
|
totalHours: 320.4,
|
|
capacityHours: 400.2,
|
|
utilizationPct: 80,
|
|
derivation: {
|
|
periodStart: "2026-03-01",
|
|
periodEnd: "2026-03-31",
|
|
calendarContextCount: 1,
|
|
resourceCount: 4,
|
|
groupCount: 1,
|
|
baseAvailableHours: 416.2,
|
|
effectiveAvailableHours: 400.2,
|
|
publicHolidayHoursDeduction: 16,
|
|
absenceDayEquivalent: 1.5,
|
|
absenceHoursDeduction: 12.5,
|
|
calendarLocations: [
|
|
{
|
|
countryCode: "DE",
|
|
countryName: "Germany",
|
|
federalState: "BY",
|
|
metroCityName: "Augsburg",
|
|
resourceCount: 4,
|
|
effectiveAvailableHours: 400.2,
|
|
},
|
|
],
|
|
bookedHours: 320.4,
|
|
capacityHours: 400.2,
|
|
remainingCapacityHours: 79.8,
|
|
overbookedHours: 0,
|
|
utilizationPct: 80,
|
|
},
|
|
},
|
|
]);
|
|
vi.mocked(getDashboardSkillGapSummary).mockResolvedValue({
|
|
roleGaps: [
|
|
{ role: "Pipeline TD", needed: 4, filled: 1, gap: 3, fillRate: 25 },
|
|
{ role: "Lighting TD", needed: 2, filled: 1, gap: 1, fillRate: 50 },
|
|
],
|
|
totalOpenPositions: 4,
|
|
skillSupplyTop10: [
|
|
{ skill: "houdini", resourceCount: 5 },
|
|
{ skill: "nuke", resourceCount: 4 },
|
|
],
|
|
resourcesByRole: [
|
|
{ role: "Compositor", count: 6 },
|
|
{ role: "Pipeline TD", count: 2 },
|
|
],
|
|
});
|
|
vi.mocked(getDashboardTopValueResources).mockResolvedValue([
|
|
{
|
|
id: "res_1",
|
|
eid: "pparker",
|
|
displayName: "Peter Parker",
|
|
chapter: "Delivery",
|
|
valueScore: 91,
|
|
valueScoreBreakdown: {
|
|
skillDepth: 85,
|
|
skillBreadth: 74,
|
|
costEfficiency: 93,
|
|
chargeability: 78,
|
|
experience: 88,
|
|
total: 91,
|
|
},
|
|
valueScoreUpdatedAt: new Date("2026-03-03T00:00:00.000Z"),
|
|
lcrCents: 9_500,
|
|
countryCode: "DE",
|
|
countryName: "Germany",
|
|
federalState: "BY",
|
|
metroCityName: "Augsburg",
|
|
},
|
|
]);
|
|
vi.mocked(getDashboardDemand).mockResolvedValue([
|
|
{
|
|
id: "project_1",
|
|
name: "Gelddruckmaschine",
|
|
shortCode: "GDM",
|
|
allocatedHours: 120,
|
|
requiredFTEs: 4,
|
|
resourceCount: 2,
|
|
derivation: {
|
|
periodStart: "2026-01-01",
|
|
periodEnd: "2026-06-30",
|
|
periodWorkingHoursBase: 1040,
|
|
requiredHours: 2080,
|
|
requiredFTEs: 4,
|
|
fillPct: 50,
|
|
demandSource: "DEMAND_REQUIREMENTS",
|
|
calendarLocations: [
|
|
{
|
|
countryCode: "DE",
|
|
countryName: "Germany",
|
|
federalState: "BY",
|
|
metroCityName: "Augsburg",
|
|
resourceCount: 2,
|
|
allocatedHours: 120,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
]);
|
|
vi.mocked(getDashboardChargeabilityOverview).mockResolvedValue({
|
|
rows: [
|
|
{
|
|
id: "res_1",
|
|
eid: "pparker",
|
|
displayName: "Peter Parker",
|
|
chapter: "Delivery",
|
|
chargeabilityTarget: 78,
|
|
actualChargeability: 70,
|
|
expectedChargeability: 76,
|
|
derivation: {
|
|
weeklyAvailabilityHours: 40,
|
|
baseWorkingDays: 22,
|
|
effectiveWorkingDayEquivalent: 21,
|
|
baseAvailableHours: 176,
|
|
effectiveAvailableHours: 168,
|
|
publicHolidayCount: 1,
|
|
publicHolidayWorkdayCount: 1,
|
|
publicHolidayHoursDeduction: 8,
|
|
absenceDayEquivalent: 0,
|
|
absenceHoursDeduction: 0,
|
|
actualBookedHours: 117.6,
|
|
expectedBookedHours: 127.7,
|
|
targetBookedHours: 131,
|
|
unassignedHours: 40.3,
|
|
},
|
|
},
|
|
],
|
|
top: [],
|
|
watchlist: [],
|
|
month: "2026-03",
|
|
});
|
|
vi.mocked(getDashboardProjectHealth).mockResolvedValue([
|
|
{
|
|
id: "project_1",
|
|
projectName: "Gelddruckmaschine",
|
|
shortCode: "GDM",
|
|
status: "ACTIVE",
|
|
clientId: "client_1",
|
|
clientName: "Acme",
|
|
budgetHealth: 58,
|
|
staffingHealth: 46,
|
|
timelineHealth: 61,
|
|
compositeScore: 55,
|
|
budgetUtilizationPercent: 73,
|
|
remainingBudgetCents: 88_000,
|
|
demandHeadcountTotal: 4,
|
|
demandHeadcountFilled: 2,
|
|
demandHeadcountOpen: 2,
|
|
demandRequirementCount: 2,
|
|
plannedEndDate: new Date("2026-09-30T00:00:00.000Z"),
|
|
daysUntilEndDate: 183,
|
|
timelineStatus: "DUE_SOON",
|
|
derivation: {
|
|
periodStart: "2026-01-01",
|
|
periodEnd: "2026-06-30",
|
|
calendarContextCount: 1,
|
|
holidayAwareAssignmentCount: 2,
|
|
fallbackAssignmentCount: 0,
|
|
baseSpentCents: 140_000,
|
|
adjustedSpentCents: 132_000,
|
|
publicHolidayDayEquivalent: 1,
|
|
publicHolidayCostDeductionCents: 5_000,
|
|
absenceDayEquivalent: 0.5,
|
|
absenceCostDeductionCents: 3_000,
|
|
},
|
|
},
|
|
]);
|
|
|
|
const caller = createControllerCaller({});
|
|
const result = await caller.getDetail({ section: "all" });
|
|
|
|
expect(result).toEqual({
|
|
peakTimes: [
|
|
{
|
|
month: "2026-03",
|
|
totalHours: 320.4,
|
|
totalHoursPerDay: 320.4,
|
|
capacityHours: 400.2,
|
|
utilizationPct: 80,
|
|
calendarContextCount: 1,
|
|
calendarLocations: [
|
|
{
|
|
countryCode: "DE",
|
|
countryName: "Germany",
|
|
federalState: "BY",
|
|
metroCityName: "Augsburg",
|
|
resourceCount: 4,
|
|
effectiveAvailableHours: 400.2,
|
|
},
|
|
],
|
|
explainability: {
|
|
periodStart: "2026-03-01",
|
|
periodEnd: "2026-03-31",
|
|
resourceCount: 4,
|
|
groupCount: 1,
|
|
baseAvailableHours: 416.2,
|
|
effectiveAvailableHours: 400.2,
|
|
publicHolidayHoursDeduction: 16,
|
|
absenceDayEquivalent: 1.5,
|
|
absenceHoursDeduction: 12.5,
|
|
remainingCapacityHours: 79.8,
|
|
overbookedHours: 0,
|
|
},
|
|
},
|
|
],
|
|
topResources: [
|
|
{
|
|
name: "Peter Parker",
|
|
eid: "pparker",
|
|
chapter: "Delivery",
|
|
lcr: "95,00 EUR",
|
|
valueScore: 91,
|
|
valueScoreBreakdown: {
|
|
skillDepth: 85,
|
|
skillBreadth: 74,
|
|
costEfficiency: 93,
|
|
chargeability: 78,
|
|
experience: 88,
|
|
total: 91,
|
|
},
|
|
valueScoreUpdatedAt: "2026-03-03T00:00:00.000Z",
|
|
countryCode: "DE",
|
|
countryName: "Germany",
|
|
federalState: "BY",
|
|
metroCityName: "Augsburg",
|
|
},
|
|
],
|
|
demandPipeline: [
|
|
{
|
|
project: "Gelddruckmaschine (GDM)",
|
|
needed: 2,
|
|
requiredFTEs: 4,
|
|
allocatedResources: 2,
|
|
allocatedHours: 120,
|
|
calendarLocations: [
|
|
{
|
|
countryCode: "DE",
|
|
countryName: "Germany",
|
|
federalState: "BY",
|
|
metroCityName: "Augsburg",
|
|
resourceCount: 2,
|
|
allocatedHours: 120,
|
|
},
|
|
],
|
|
explainability: {
|
|
periodStart: "2026-01-01",
|
|
periodEnd: "2026-06-30",
|
|
periodWorkingHoursBase: 1040,
|
|
requiredHours: 2080,
|
|
fillPct: 50,
|
|
demandSource: "DEMAND_REQUIREMENTS",
|
|
calendarContextCount: 1,
|
|
},
|
|
},
|
|
],
|
|
chargeabilityByChapter: [
|
|
{
|
|
chapter: "Delivery",
|
|
headcount: 1,
|
|
avgTargetPct: 78,
|
|
avgActualPct: 70,
|
|
avgExpectedPct: 76,
|
|
gapToTargetPct: 8,
|
|
avgTarget: "78%",
|
|
avgActual: "70%",
|
|
avgExpected: "76%",
|
|
explainability: {
|
|
month: "2026-03",
|
|
resourceCount: 1,
|
|
derivedHeadcount: 1,
|
|
baseAvailableHours: 176,
|
|
effectiveAvailableHours: 168,
|
|
actualBookedHours: 117.6,
|
|
expectedBookedHours: 127.7,
|
|
targetBookedHours: 131,
|
|
publicHolidayHoursDeduction: 8,
|
|
absenceDayEquivalent: 0,
|
|
absenceHoursDeduction: 0,
|
|
unassignedHours: 40.3,
|
|
},
|
|
},
|
|
],
|
|
projectHealth: [
|
|
{
|
|
project: "Gelddruckmaschine (GDM)",
|
|
status: "ACTIVE",
|
|
overall: 55,
|
|
rating: "at_risk",
|
|
budget: 58,
|
|
staffing: 46,
|
|
timeline: 61,
|
|
timelineStatus: "DUE_SOON",
|
|
daysUntilEndDate: 183,
|
|
demandHeadcountOpen: 2,
|
|
explainability: {
|
|
demandHeadcountTotal: 4,
|
|
demandHeadcountFilled: 2,
|
|
demandHeadcountOpen: 2,
|
|
demandRequirementCount: 2,
|
|
plannedEndDate: "2026-09-30T00:00:00.000Z",
|
|
budgetUtilizationPercent: 73,
|
|
remainingBudgetCents: 88_000,
|
|
calendarContextCount: 1,
|
|
holidayAwareAssignmentCount: 2,
|
|
publicHolidayCostDeductionCents: 5_000,
|
|
absenceCostDeductionCents: 3_000,
|
|
},
|
|
},
|
|
],
|
|
skillGaps: {
|
|
totalOpenPositions: 4,
|
|
roleGaps: [
|
|
{ role: "Pipeline TD", gap: 3, needed: 4, filled: 1, fillRate: 25 },
|
|
{ role: "Lighting TD", gap: 1, needed: 2, filled: 1, fillRate: 50 },
|
|
],
|
|
topSkillsInSupply: [
|
|
{ skill: "houdini", resourceCount: 5 },
|
|
{ skill: "nuke", resourceCount: 4 },
|
|
],
|
|
resourcesByRole: [
|
|
{ role: "Compositor", count: 6 },
|
|
{ role: "Pipeline TD", count: 2 },
|
|
],
|
|
},
|
|
});
|
|
expect(getDashboardPeakTimes).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
startDate: new Date("2026-01-01T00:00:00.000Z"),
|
|
endDate: new Date("2026-06-30T00:00:00.000Z"),
|
|
granularity: "month",
|
|
groupBy: "project",
|
|
}),
|
|
);
|
|
expect(getDashboardChargeabilityOverview).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
includeProposed: false,
|
|
topN: 10,
|
|
watchlistThreshold: 15,
|
|
}),
|
|
);
|
|
expect(getDashboardSkillGapSummary).toHaveBeenCalledWith(expect.anything());
|
|
expect(getDashboardProjectHealth).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
});
|