import { describe, expect, it, vi } from "vitest"; import { getDashboardSkillGaps, getDashboardSkillGapSummary, getDashboardTopValueResources, getDashboardProjectHealth, getDashboardPeakTimes, } from "../index.js"; // --------------------------------------------------------------------------- // getDashboardSkillGaps // --------------------------------------------------------------------------- describe("getDashboardSkillGaps", () => { it("computes gaps between open demands and skilled resources", async () => { const db = { demandRequirement: { findMany: vi.fn().mockResolvedValue([ { role: "Compositor", roleId: "role_comp", roleEntity: { name: "Compositor" }, headcount: 3, metadata: null, }, { role: "FX Artist", roleId: "role_fx", roleEntity: { name: "FX Artist" }, headcount: 2, metadata: null, }, ]), }, resource: { findMany: vi.fn().mockResolvedValue([ { skills: [ { name: "Compositor", level: 4 }, { name: "FX Artist", level: 2 }, ], }, { skills: [{ name: "Compositor", level: 3 }] }, ]), }, }; const result = await getDashboardSkillGaps(db as never); // FX Artist: demand 2, supply 0 (level 2 < 3) → gap -2 (largest shortage first) // Compositor: demand 3, supply 2 → gap -1 expect(result).toHaveLength(2); expect(result[0]).toEqual({ skill: "FX Artist", demand: 2, supply: 0, gap: -2 }); expect(result[1]).toEqual({ skill: "Compositor", demand: 3, supply: 2, gap: -1 }); }); it("returns empty array when there are no open demands", async () => { const db = { demandRequirement: { findMany: vi.fn().mockResolvedValue([]), }, resource: { findMany: vi.fn(), }, }; const result = await getDashboardSkillGaps(db as never); expect(result).toEqual([]); // resource.findMany should never be called when there are no demands expect(db.resource.findMany).not.toHaveBeenCalled(); }); it("extracts required skills from demand metadata when present", async () => { const db = { demandRequirement: { findMany: vi.fn().mockResolvedValue([ { role: "Generalist", roleId: "role_gen", roleEntity: { name: "Generalist" }, headcount: 2, metadata: { requiredSkills: ["Houdini", "Nuke"] }, }, ]), }, resource: { findMany: vi.fn().mockResolvedValue([ { skills: [{ name: "Houdini", level: 5 }] }, { skills: [ { name: "Nuke", level: 3 }, { name: "Houdini", level: 3 }, ], }, ]), }, }; const result = await getDashboardSkillGaps(db as never); // Both skills demanded: 2 each // Houdini supply: 2, gap 0; Nuke supply: 1, gap -1 const nukeRow = result.find((row) => row.skill === "Nuke"); const houdiniRow = result.find((row) => row.skill === "Houdini"); expect(nukeRow).toEqual({ skill: "Nuke", demand: 2, supply: 1, gap: -1 }); expect(houdiniRow).toEqual({ skill: "Houdini", demand: 2, supply: 2, gap: 0 }); // Sorted by gap ascending → Nuke first expect(result[0]?.skill).toBe("Nuke"); }); it("caps output at 10 rows sorted by largest shortage first", async () => { const manyDemands = Array.from({ length: 12 }, (_, i) => ({ role: `Role_${i}`, roleId: `role_${i}`, roleEntity: { name: `Role_${i}` }, headcount: i + 1, metadata: null, })); const db = { demandRequirement: { findMany: vi.fn().mockResolvedValue(manyDemands), }, resource: { findMany: vi.fn().mockResolvedValue([]), }, }; const result = await getDashboardSkillGaps(db as never); expect(result).toHaveLength(10); // All gaps are negative (no supply); sorted by ascending gap → largest demand first for (let i = 0; i < result.length - 1; i++) { expect(result[i]?.gap ?? 0).toBeLessThanOrEqual(result[i + 1]?.gap ?? 0); } }); }); // --------------------------------------------------------------------------- // getDashboardSkillGapSummary // --------------------------------------------------------------------------- describe("getDashboardSkillGapSummary", () => { it("returns role gaps, open positions total, skill supply and resource-by-role counts", async () => { const db = { demandRequirement: { findMany: vi.fn().mockResolvedValue([ { role: "Compositor", headcount: 3, roleEntity: { name: "Compositor" }, _count: { assignments: 1 }, }, { role: "Compositor", headcount: 2, roleEntity: { name: "Compositor" }, _count: { assignments: 2 }, }, { role: "FX Artist", headcount: 2, roleEntity: { name: "FX Artist" }, _count: { assignments: 0 }, }, ]), }, resource: { findMany: vi.fn().mockResolvedValue([ { skills: [{ name: "Houdini" }, { skill: "Nuke" }], areaRole: { name: "FX Artist" }, }, { skills: [{ name: "Houdini" }], areaRole: { name: "Compositor" }, }, ]), }, }; const result = await getDashboardSkillGapSummary(db as never); // Compositor: needed=5, filled=min(1,3)+min(2,2)=1+2=3, gap=2 // FX Artist: needed=2, filled=0, gap=2 expect(result.roleGaps).toHaveLength(2); expect(result.totalOpenPositions).toBe(4); const compositorGap = result.roleGaps.find((row) => row.role === "Compositor"); expect(compositorGap).toMatchObject({ needed: 5, filled: 3, gap: 2, fillRate: 60 }); const fxGap = result.roleGaps.find((row) => row.role === "FX Artist"); expect(fxGap).toMatchObject({ needed: 2, filled: 0, gap: 2, fillRate: 0 }); // Skill supply: "houdini" appears twice expect(result.skillSupplyTop10[0]).toEqual({ skill: "houdini", resourceCount: 2 }); // Resources by role expect(result.resourcesByRole).toContainEqual({ role: "FX Artist", count: 1 }); expect(result.resourcesByRole).toContainEqual({ role: "Compositor", count: 1 }); }); it("returns empty role gaps and zero open positions when all demands are filled", async () => { const db = { demandRequirement: { findMany: vi.fn().mockResolvedValue([ { role: "Compositor", headcount: 2, roleEntity: { name: "Compositor" }, _count: { assignments: 3 }, // more than headcount → capped at headcount }, ]), }, resource: { findMany: vi.fn().mockResolvedValue([]), }, }; const result = await getDashboardSkillGapSummary(db as never); expect(result.roleGaps).toEqual([]); expect(result.totalOpenPositions).toBe(0); }); it("returns empty summary when no demands exist", async () => { const db = { demandRequirement: { findMany: vi.fn().mockResolvedValue([]), }, resource: { findMany: vi.fn().mockResolvedValue([]), }, }; const result = await getDashboardSkillGapSummary(db as never); expect(result.roleGaps).toEqual([]); expect(result.totalOpenPositions).toBe(0); expect(result.skillSupplyTop10).toEqual([]); expect(result.resourcesByRole).toEqual([]); }); }); // --------------------------------------------------------------------------- // getDashboardTopValueResources // --------------------------------------------------------------------------- describe("getDashboardTopValueResources", () => { it("returns resources with value scores for authorised roles", async () => { const db = { systemSettings: { findUnique: vi.fn().mockResolvedValue({ scoreVisibleRoles: ["ADMIN", "MANAGER"], }), }, resource: { findMany: vi.fn().mockResolvedValue([ { id: "res_1", eid: "E001", displayName: "Alice", chapter: "CGI", valueScore: 95, valueScoreBreakdown: { skillDepth: 20, skillBreadth: 15, costEfficiency: 20, chargeability: 20, experience: 20, total: 95, }, valueScoreUpdatedAt: new Date("2026-01-01T00:00:00.000Z"), lcrCents: 80_000, country: { code: "DE", name: "Germany" }, federalState: "BY", metroCity: { name: "Munich" }, }, ]), }, }; const result = await getDashboardTopValueResources(db as never, { limit: 10, userRole: "ADMIN", }); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ id: "res_1", eid: "E001", displayName: "Alice", chapter: "CGI", valueScore: 95, lcrCents: 80_000, countryCode: "DE", countryName: "Germany", federalState: "BY", metroCityName: "Munich", }); expect(result[0]?.valueScoreBreakdown).toMatchObject({ total: 95 }); }); it("returns empty array for roles not in the visible-roles list", async () => { const db = { systemSettings: { findUnique: vi.fn().mockResolvedValue({ scoreVisibleRoles: ["ADMIN"], }), }, resource: { findMany: vi.fn(), }, }; const result = await getDashboardTopValueResources(db as never, { limit: 10, userRole: "VIEWER", }); expect(result).toEqual([]); expect(db.resource.findMany).not.toHaveBeenCalled(); }); it("falls back to default visible roles when settings are null", async () => { const db = { systemSettings: { findUnique: vi.fn().mockResolvedValue(null), }, resource: { findMany: vi.fn().mockResolvedValue([]), }, }; // Default visible roles are ["ADMIN", "MANAGER"] const adminResult = await getDashboardTopValueResources(db as never, { limit: 5, userRole: "ADMIN", }); expect(adminResult).toEqual([]); expect(db.resource.findMany).toHaveBeenCalledOnce(); const viewerResult = await getDashboardTopValueResources(db as never, { limit: 5, userRole: "VIEWER", }); expect(viewerResult).toEqual([]); // findMany should not have been called for VIEWER expect(db.resource.findMany).toHaveBeenCalledOnce(); }); it("normalises a malformed valueScoreBreakdown to null", async () => { const db = { systemSettings: { findUnique: vi.fn().mockResolvedValue({ scoreVisibleRoles: ["ADMIN"], }), }, resource: { findMany: vi.fn().mockResolvedValue([ { id: "res_2", eid: "E002", displayName: "Bob", chapter: null, valueScore: 42, valueScoreBreakdown: { corrupted: true }, // not a valid breakdown valueScoreUpdatedAt: null, lcrCents: 60_000, country: null, federalState: null, metroCity: null, }, ]), }, }; const result = await getDashboardTopValueResources(db as never, { limit: 5, userRole: "ADMIN", }); expect(result[0]?.valueScoreBreakdown).toBeNull(); expect(result[0]?.countryCode).toBeNull(); expect(result[0]?.metroCityName).toBeNull(); }); }); // --------------------------------------------------------------------------- // getDashboardProjectHealth // --------------------------------------------------------------------------- describe("getDashboardProjectHealth", () => { it("computes budget, staffing and timeline health scores for an active project", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-04-10T00:00:00.000Z")); try { const db = { project: { findMany: vi.fn().mockResolvedValue([ { id: "proj_1", name: "Alpha", shortCode: "ALPHA", status: "ACTIVE", budgetCents: 100_000, endDate: new Date("2026-12-31T00:00:00.000Z"), // ON_TRACK clientId: "client_1", client: { name: "ACME" }, demandRequirements: [ { id: "dr_1", headcount: 2, status: "CONFIRMED", assignments: [{ id: "a1" }], // 1 of 2 filled }, ], }, ]), }, assignment: { findMany: vi.fn().mockResolvedValue([ { projectId: "proj_1", startDate: new Date("2026-03-01T00:00:00.000Z"), endDate: new Date("2026-03-03T00:00:00.000Z"), dailyCostCents: 10_000, resource: { id: "res_1", availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, countryId: null, federalState: null, metroCityId: null, country: null, metroCity: null, }, }, ]), }, }; const result = await getDashboardProjectHealth(db as never); expect(result).toHaveLength(1); const row = result[0]!; expect(row.shortCode).toBe("ALPHA"); expect(row.clientName).toBe("ACME"); // budgetCents=100_000, spentCents=20_000 (2 working days Mon+Tue) // pctUsed=20 → budgetHealth=80 expect(row.budgetHealth).toBe(80); expect(row.spentCents).toBe(20_000); expect(row.budgetUtilizationPercent).toBe(20); // staffingHealth: 1 filled / 2 needed = 50 expect(row.staffingHealth).toBe(50); // timelineHealth: end date is in the future → 100 expect(row.timelineHealth).toBe(100); expect(row.timelineStatus).toBe("ON_TRACK"); // compositeScore = round((80+50+100)/3) = round(76.67) = 77 expect(row.compositeScore).toBe(77); expect(row.demandHeadcountTotal).toBe(2); expect(row.demandHeadcountFilled).toBe(1); expect(row.demandHeadcountOpen).toBe(1); } finally { vi.useRealTimers(); } }); it("returns empty array when no active projects exist", async () => { const db = { project: { findMany: vi.fn().mockResolvedValue([]), }, assignment: { findMany: vi.fn(), }, }; const result = await getDashboardProjectHealth(db as never); expect(result).toEqual([]); expect(db.assignment.findMany).not.toHaveBeenCalled(); }); it("marks a project OVERDUE and sets timelineHealth=0 when end date has passed", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-04-10T00:00:00.000Z")); try { const db = { project: { findMany: vi.fn().mockResolvedValue([ { id: "proj_2", name: "Beta", shortCode: "BETA", status: "ACTIVE", budgetCents: null, endDate: new Date("2026-01-01T00:00:00.000Z"), // in the past clientId: null, client: null, demandRequirements: [], }, ]), }, assignment: { findMany: vi.fn().mockResolvedValue([]), }, }; const result = await getDashboardProjectHealth(db as never); expect(result).toHaveLength(1); const row = result[0]!; expect(row.timelineHealth).toBe(0); expect(row.timelineStatus).toBe("OVERDUE"); // No budget defined → budgetHealth=100, no staffing demands → staffingHealth=100 // compositeScore = round((100+100+0)/3) = 67 expect(row.compositeScore).toBe(67); } finally { vi.useRealTimers(); } }); it("sorts results by composite score ascending (worst-health project first)", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-04-10T00:00:00.000Z")); try { const db = { project: { findMany: vi.fn().mockResolvedValue([ { id: "proj_good", name: "Healthy", shortCode: "GOOD", status: "ACTIVE", budgetCents: 100_000, endDate: new Date("2026-12-31T00:00:00.000Z"), clientId: null, client: null, demandRequirements: [], }, { id: "proj_bad", name: "Overdue", shortCode: "BAD", status: "ACTIVE", budgetCents: 1_000, endDate: new Date("2025-01-01T00:00:00.000Z"), // overdue clientId: null, client: null, demandRequirements: [ { id: "dr_1", headcount: 5, status: "CONFIRMED", assignments: [], // 0 filled }, ], }, ]), }, assignment: { findMany: vi.fn().mockResolvedValue([ { projectId: "proj_bad", startDate: new Date("2026-03-01T00:00:00.000Z"), endDate: new Date("2026-03-31T00:00:00.000Z"), dailyCostCents: 100, resource: { id: "res_1", availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, countryId: null, federalState: null, metroCityId: null, country: null, metroCity: null, }, }, ]), }, }; const result = await getDashboardProjectHealth(db as never); expect(result).toHaveLength(2); // "bad" project has lower composite score → sorted first expect(result[0]?.shortCode).toBe("BAD"); expect(result[1]?.shortCode).toBe("GOOD"); expect(result[0]!.compositeScore).toBeLessThan(result[1]!.compositeScore); } finally { vi.useRealTimers(); } }); }); // --------------------------------------------------------------------------- // getDashboardPeakTimes – resource-level grouping // --------------------------------------------------------------------------- describe("getDashboardPeakTimes (resource groupBy)", () => { it("groups booked hours by individual resource name", async () => { const db = { assignment: { findMany: vi.fn().mockResolvedValue([ { id: "assign_1", projectId: "proj_1", resourceId: "res_1", status: "ACTIVE", startDate: new Date("2026-03-02T00:00:00.000Z"), endDate: new Date("2026-03-02T00:00:00.000Z"), hoursPerDay: 6, dailyCostCents: 0, project: { id: "proj_1", name: "Alpha", shortCode: "ALPHA", status: "ACTIVE", orderType: "FIXED", }, resource: { id: "res_1", displayName: "Alice", chapter: "CGI" }, }, { id: "assign_2", projectId: "proj_1", resourceId: "res_2", status: "ACTIVE", startDate: new Date("2026-03-02T00:00:00.000Z"), endDate: new Date("2026-03-02T00:00:00.000Z"), hoursPerDay: 4, dailyCostCents: 0, project: { id: "proj_1", name: "Alpha", shortCode: "ALPHA", status: "ACTIVE", orderType: "FIXED", }, resource: { id: "res_2", displayName: "Bob", chapter: "Lighting" }, }, ]), }, resource: { findMany: vi.fn().mockResolvedValue([ { id: "res_1", displayName: "Alice", chapter: "CGI", availability: { monday: 6, tuesday: 6, wednesday: 6, thursday: 6, friday: 6 }, countryId: null, federalState: null, metroCityId: null, country: null, metroCity: null, }, { id: "res_2", displayName: "Bob", chapter: "Lighting", availability: { monday: 4, tuesday: 4, wednesday: 4, thursday: 4, friday: 4 }, countryId: null, federalState: null, metroCityId: null, country: null, metroCity: null, }, ]), }, }; const result = await getDashboardPeakTimes(db as never, { startDate: new Date("2026-03-02T00:00:00.000Z"), endDate: new Date("2026-03-02T00:00:00.000Z"), granularity: "month", groupBy: "resource", }); expect(result).toHaveLength(1); const period = result[0]!; expect(period.period).toBe("2026-03"); const alice = period.groups.find((g) => g.name === "Alice"); const bob = period.groups.find((g) => g.name === "Bob"); expect(alice).toBeDefined(); expect(alice?.hours).toBe(6); expect(alice?.capacityHours).toBe(6); expect(alice?.utilizationPct).toBe(100); expect(bob).toBeDefined(); expect(bob?.hours).toBe(4); expect(bob?.capacityHours).toBe(4); }); it("returns empty periods list when date range yields no resources and no assignments", async () => { const db = { assignment: { findMany: vi.fn().mockResolvedValue([]), }, resource: { findMany: vi.fn().mockResolvedValue([]), }, }; const result = await getDashboardPeakTimes(db as never, { startDate: new Date("2026-03-01T00:00:00.000Z"), endDate: new Date("2026-03-01T00:00:00.000Z"), granularity: "week", groupBy: "project", }); expect(result).toHaveLength(1); const period = result[0]!; expect(period.groups).toEqual([]); expect(period.totalHours).toBe(0); expect(period.capacityHours).toBe(0); expect(period.utilizationPct).toBe(0); }); });