feat(planning): ship holiday-aware planning and assistant upgrades
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getDashboardBudgetForecast,
|
||||
getDashboardChargeabilityOverview,
|
||||
getDashboardDemand,
|
||||
getDashboardOverview,
|
||||
getDashboardPeakTimes,
|
||||
getDashboardProjectHealth,
|
||||
getDashboardTopValueResources,
|
||||
} from "../index.js";
|
||||
|
||||
@@ -285,8 +287,28 @@ describe("dashboard use-cases", () => {
|
||||
},
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 } },
|
||||
{ availability: { monday: 6, tuesday: 6, wednesday: 6, thursday: 6, friday: 6 } },
|
||||
{
|
||||
id: "res_1",
|
||||
displayName: "Alice",
|
||||
chapter: "CGI",
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: null,
|
||||
metroCityId: "city_1",
|
||||
country: { code: "DE" },
|
||||
metroCity: { name: "Munich" },
|
||||
},
|
||||
{
|
||||
id: "res_2",
|
||||
displayName: "Bob",
|
||||
chapter: "Lighting",
|
||||
availability: { monday: 6, tuesday: 6, wednesday: 6, thursday: 6, friday: 6 },
|
||||
countryId: "country_de",
|
||||
federalState: null,
|
||||
metroCityId: "city_2",
|
||||
country: { code: "DE" },
|
||||
metroCity: { name: "Hamburg" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
@@ -299,15 +321,113 @@ describe("dashboard use-cases", () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
expect.objectContaining({
|
||||
period: "2026-03",
|
||||
groups: [
|
||||
{ name: "ALPHA", hours: 8 },
|
||||
{ name: "ALPHA", hours: 4 },
|
||||
{ name: "BRAVO", hours: 3 },
|
||||
],
|
||||
totalHours: 11,
|
||||
capacityHours: 308,
|
||||
totalHours: 7,
|
||||
capacityHours: 28,
|
||||
derivation: expect.objectContaining({
|
||||
bookedHours: 7,
|
||||
capacityHours: 28,
|
||||
remainingCapacityHours: 21,
|
||||
overbookedHours: 0,
|
||||
utilizationPct: 25,
|
||||
groupCount: 2,
|
||||
resourceCount: 2,
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("provides department capacity and utilization details for chapter grouping", 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: 8,
|
||||
dailyCostCents: 0,
|
||||
project: { id: "proj_1", name: "Alpha", shortCode: "ALPHA", status: "ACTIVE", orderType: "FIXED" },
|
||||
resource: { id: "res_1", displayName: "Alice", chapter: "CGI" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "res_1",
|
||||
displayName: "Alice",
|
||||
chapter: "CGI",
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: null,
|
||||
metroCityId: "city_1",
|
||||
country: { code: "DE" },
|
||||
metroCity: { name: "Munich" },
|
||||
},
|
||||
{
|
||||
id: "res_2",
|
||||
displayName: "Bob",
|
||||
chapter: "Lighting",
|
||||
availability: { monday: 4, tuesday: 4, wednesday: 4, thursday: 4, friday: 4 },
|
||||
countryId: "country_de",
|
||||
federalState: null,
|
||||
metroCityId: "city_2",
|
||||
country: { code: "DE" },
|
||||
metroCity: { name: "Hamburg" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
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: "chapter",
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
period: "2026-03",
|
||||
groups: [
|
||||
expect.objectContaining({
|
||||
name: "CGI",
|
||||
hours: 8,
|
||||
capacityHours: 8,
|
||||
remainingHours: 0,
|
||||
overbookedHours: 0,
|
||||
utilizationPct: 100,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "Lighting",
|
||||
hours: 0,
|
||||
capacityHours: 4,
|
||||
remainingHours: 4,
|
||||
overbookedHours: 0,
|
||||
utilizationPct: 0,
|
||||
}),
|
||||
],
|
||||
totalHours: 8,
|
||||
capacityHours: 12,
|
||||
derivation: expect.objectContaining({
|
||||
bookedHours: 8,
|
||||
capacityHours: 12,
|
||||
remainingCapacityHours: 4,
|
||||
overbookedHours: 0,
|
||||
utilizationPct: 67,
|
||||
groupCount: 2,
|
||||
resourceCount: 2,
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -513,6 +633,396 @@ describe("dashboard use-cases", () => {
|
||||
expect(withProposed.top[0]?.expectedChargeability).toBe(5);
|
||||
});
|
||||
|
||||
it("excludes regional public holidays from dashboard chargeability availability and bookings", async () => {
|
||||
const db = {
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "assign_holiday",
|
||||
projectId: "proj_1",
|
||||
resourceId: "res_by",
|
||||
status: "CONFIRMED",
|
||||
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
hoursPerDay: 8,
|
||||
dailyCostCents: 0,
|
||||
project: {
|
||||
id: "proj_1",
|
||||
name: "Alpha",
|
||||
shortCode: "ALPHA",
|
||||
status: "ACTIVE",
|
||||
orderType: "CLIENT",
|
||||
dynamicFields: null,
|
||||
},
|
||||
resource: {
|
||||
id: "res_by",
|
||||
displayName: "Bruce",
|
||||
chapter: "CGI",
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "res_by",
|
||||
eid: "bruce.banner",
|
||||
displayName: "Bruce",
|
||||
chapter: "CGI",
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_augsburg",
|
||||
departed: false,
|
||||
chargeabilityTarget: 80,
|
||||
country: {
|
||||
id: "country_de",
|
||||
code: "DE",
|
||||
},
|
||||
metroCity: {
|
||||
id: "city_augsburg",
|
||||
name: "Augsburg",
|
||||
},
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await getDashboardChargeabilityOverview(db as never, {
|
||||
now: new Date("2026-01-15T00:00:00.000Z"),
|
||||
topN: 10,
|
||||
watchlistThreshold: 15,
|
||||
});
|
||||
|
||||
expect(result.top[0]?.actualChargeability).toBe(0);
|
||||
expect(result.top[0]?.expectedChargeability).toBe(0);
|
||||
expect(result.top[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
countryCode: "DE",
|
||||
federalState: "BY",
|
||||
metroCityName: "Augsburg",
|
||||
derivation: expect.objectContaining({
|
||||
weeklyAvailabilityHours: 40,
|
||||
baseAvailableHours: 184,
|
||||
effectiveAvailableHours: 168,
|
||||
publicHolidayCount: 2,
|
||||
publicHolidayWorkdayCount: 2,
|
||||
publicHolidayHoursDeduction: 16,
|
||||
absenceHoursDeduction: 0,
|
||||
actualBookedHours: 0,
|
||||
expectedBookedHours: 0,
|
||||
targetBookedHours: 134.4,
|
||||
unassignedHours: 168,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses holiday-aware capacity in peak times for regional calendars", async () => {
|
||||
const db = {
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "assign_1",
|
||||
projectId: "proj_1",
|
||||
resourceId: "res_by",
|
||||
status: "CONFIRMED",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
hoursPerDay: 8,
|
||||
dailyCostCents: 0,
|
||||
project: { id: "proj_1", name: "Alpha", shortCode: "ALPHA", status: "ACTIVE", orderType: "FIXED" },
|
||||
resource: { id: "res_by", displayName: "Bruce", chapter: "CGI" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "res_by",
|
||||
displayName: "Bruce",
|
||||
chapter: "CGI",
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_munich",
|
||||
country: { code: "DE" },
|
||||
metroCity: { name: "Munich" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await getDashboardPeakTimes(db as never, {
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
granularity: "month",
|
||||
groupBy: "project",
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
period: "2026-01",
|
||||
groups: [{ name: "ALPHA", hours: 8 }],
|
||||
totalHours: 8,
|
||||
capacityHours: 8,
|
||||
derivation: expect.objectContaining({
|
||||
bookedHours: 8,
|
||||
capacityHours: 8,
|
||||
remainingCapacityHours: 0,
|
||||
overbookedHours: 0,
|
||||
utilizationPct: 100,
|
||||
groupCount: 1,
|
||||
resourceCount: 1,
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not burn budget on regional public holidays", async () => {
|
||||
const db = {
|
||||
project: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "proj_1",
|
||||
name: "Alpha",
|
||||
shortCode: "ALPHA",
|
||||
budgetCents: 10_000,
|
||||
startDate: new Date("2026-01-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-12-31T00:00:00.000Z"),
|
||||
clientId: null,
|
||||
client: null,
|
||||
},
|
||||
]),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
projectId: "proj_1",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
dailyCostCents: 1_000,
|
||||
resource: {
|
||||
id: "res_by",
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_munich",
|
||||
country: { code: "DE" },
|
||||
metroCity: { name: "Munich" },
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await getDashboardBudgetForecast(db as never);
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
projectId: "proj_1",
|
||||
shortCode: "ALPHA",
|
||||
spentCents: 1_000,
|
||||
remainingCents: 9_000,
|
||||
activeAssignmentCount: 0,
|
||||
calendarLocations: [],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns burn derivation and calendar basis for active project forecasts", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-15T00:00:00.000Z"));
|
||||
|
||||
try {
|
||||
const db = {
|
||||
project: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "proj_1",
|
||||
name: "Gelddruckmaschine",
|
||||
shortCode: "GDM",
|
||||
budgetCents: 100_000,
|
||||
startDate: new Date("2026-01-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-12-31T00:00:00.000Z"),
|
||||
clientId: "client_1",
|
||||
client: { name: "ACME" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
projectId: "proj_1",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
dailyCostCents: 1_000,
|
||||
resource: {
|
||||
id: "res_by",
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_munich",
|
||||
country: { code: "DE", name: "Deutschland" },
|
||||
metroCity: { name: "Munich" },
|
||||
},
|
||||
},
|
||||
{
|
||||
projectId: "proj_1",
|
||||
startDate: new Date("2026-01-15T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||
dailyCostCents: 2_000,
|
||||
resource: {
|
||||
id: "res_hh",
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "HH",
|
||||
metroCityId: "city_hamburg",
|
||||
country: { code: "DE", name: "Deutschland" },
|
||||
metroCity: { name: "Hamburg" },
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await getDashboardBudgetForecast(db as never);
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
projectId: "proj_1",
|
||||
shortCode: "GDM",
|
||||
budgetCents: 100_000,
|
||||
spentCents: 5_000,
|
||||
remainingCents: 95_000,
|
||||
burnRate: 4_000,
|
||||
activeAssignmentCount: 1,
|
||||
calendarLocations: [
|
||||
expect.objectContaining({
|
||||
countryCode: "DE",
|
||||
countryName: "Deutschland",
|
||||
federalState: "HH",
|
||||
metroCityName: "Hamburg",
|
||||
activeAssignmentCount: 1,
|
||||
burnRateCents: 4_000,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("excludes regional public holidays from overview budget totals", async () => {
|
||||
const db = {
|
||||
assignment: {
|
||||
findMany: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
dailyCostCents: 1_000,
|
||||
resource: {
|
||||
id: "res_by",
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_munich",
|
||||
country: { code: "DE" },
|
||||
metroCity: { name: "Munich" },
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
resource: {
|
||||
count: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(1)
|
||||
.mockResolvedValueOnce(1),
|
||||
findMany: vi.fn().mockResolvedValue([{ chapter: "CGI", chargeabilityTarget: 80 }]),
|
||||
},
|
||||
project: {
|
||||
count: vi.fn().mockResolvedValue(1),
|
||||
findMany: vi.fn().mockResolvedValue([{ status: "ACTIVE", budgetCents: 10_000 }]),
|
||||
},
|
||||
demandRequirement: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
auditLog: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await getDashboardOverview(db as never);
|
||||
|
||||
expect(result.budgetSummary).toEqual({
|
||||
totalBudgetCents: 10_000,
|
||||
totalCostCents: 1_000,
|
||||
avgUtilizationPercent: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("excludes regional public holidays from project health budget usage", async () => {
|
||||
const db = {
|
||||
project: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "proj_1",
|
||||
name: "Alpha",
|
||||
shortCode: "ALPHA",
|
||||
budgetCents: 10_000,
|
||||
endDate: new Date("2026-12-31T00:00:00.000Z"),
|
||||
clientId: null,
|
||||
client: null,
|
||||
demandRequirements: [],
|
||||
},
|
||||
]),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
projectId: "proj_1",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
dailyCostCents: 1_000,
|
||||
resource: {
|
||||
id: "res_by",
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_munich",
|
||||
country: { code: "DE" },
|
||||
metroCity: { name: "Munich" },
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await getDashboardProjectHealth(db as never);
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
shortCode: "ALPHA",
|
||||
budgetHealth: 90,
|
||||
spentCents: 1_000,
|
||||
budgetUtilizationPercent: 10,
|
||||
calendarLocations: [
|
||||
expect.objectContaining({
|
||||
countryCode: "DE",
|
||||
federalState: "BY",
|
||||
metroCityName: "Munich",
|
||||
assignmentCount: 1,
|
||||
spentCents: 1_000,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns distinct resource counts for chapter demand grouping", async () => {
|
||||
const db = {
|
||||
demandRequirement: {
|
||||
@@ -606,14 +1116,21 @@ describe("dashboard use-cases", () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
expect.objectContaining({
|
||||
id: "CGI",
|
||||
name: "CGI",
|
||||
shortCode: "CGI",
|
||||
allocatedHours: 19,
|
||||
requiredFTEs: 0,
|
||||
resourceCount: 2,
|
||||
},
|
||||
derivation: expect.objectContaining({
|
||||
periodWorkingHoursBase: 176,
|
||||
requiredHours: null,
|
||||
fillPct: null,
|
||||
demandSource: "NONE",
|
||||
calendarLocations: [],
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -738,14 +1255,21 @@ describe("dashboard use-cases", () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
expect.objectContaining({
|
||||
id: "proj_1",
|
||||
name: "Alpha",
|
||||
shortCode: "ALPHA",
|
||||
allocatedHours: 18,
|
||||
requiredFTEs: 3.5,
|
||||
resourceCount: 3,
|
||||
},
|
||||
derivation: expect.objectContaining({
|
||||
periodWorkingHoursBase: 176,
|
||||
requiredHours: 616,
|
||||
fillPct: 3,
|
||||
demandSource: "DEMAND_REQUIREMENTS",
|
||||
calendarLocations: [],
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -802,14 +1326,20 @@ describe("dashboard use-cases", () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
expect.objectContaining({
|
||||
id: "proj_1",
|
||||
name: "Alpha",
|
||||
shortCode: "ALPHA",
|
||||
allocatedHours: 8,
|
||||
requiredFTEs: 2,
|
||||
resourceCount: 1,
|
||||
},
|
||||
derivation: expect.objectContaining({
|
||||
periodWorkingHoursBase: 176,
|
||||
requiredHours: 352,
|
||||
fillPct: 2,
|
||||
demandSource: "DEMAND_REQUIREMENTS",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -865,14 +1395,20 @@ describe("dashboard use-cases", () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
expect.objectContaining({
|
||||
id: "proj_1",
|
||||
name: "Alpha",
|
||||
shortCode: "ALPHA",
|
||||
allocatedHours: 16,
|
||||
requiredFTEs: 2,
|
||||
resourceCount: 1,
|
||||
},
|
||||
derivation: expect.objectContaining({
|
||||
periodWorkingHoursBase: 176,
|
||||
requiredHours: 352,
|
||||
fillPct: 5,
|
||||
demandSource: "PROJECT_STAFFING_REQS",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -270,6 +270,7 @@ describe("demand and assignment use-cases", () => {
|
||||
|
||||
const db = {
|
||||
demandRequirement: { findUnique: demandRequirementFindUnique },
|
||||
assignment: { findMany: assignmentFindMany },
|
||||
resource: { findUnique: resourceFindUnique },
|
||||
$transaction: vi.fn(async (callback) =>
|
||||
callback({
|
||||
@@ -437,6 +438,7 @@ describe("demand and assignment use-cases", () => {
|
||||
|
||||
const db = {
|
||||
demandRequirement: { findUnique: demandRequirementFindUnique },
|
||||
assignment: { findMany: assignmentFindMany },
|
||||
resource: { findUnique: resourceFindUnique },
|
||||
$transaction: vi.fn(async (callback) =>
|
||||
callback({
|
||||
@@ -555,6 +557,7 @@ describe("demand and assignment use-cases", () => {
|
||||
|
||||
const db = {
|
||||
demandRequirement: { findUnique: demandRequirementFindUnique },
|
||||
assignment: { findMany: assignmentFindMany },
|
||||
$transaction: vi.fn(async (callback) =>
|
||||
callback({
|
||||
project: { findUnique: projectFindUnique },
|
||||
@@ -685,7 +688,7 @@ describe("demand and assignment use-cases", () => {
|
||||
|
||||
const db = {
|
||||
demandRequirement: { findUnique: demandRequirementFindUnique },
|
||||
assignment: { findUnique: assignmentFindUnique },
|
||||
assignment: { findUnique: assignmentFindUnique, findMany: assignmentFindMany },
|
||||
resource: { findUnique: resourceFindUnique },
|
||||
$transaction: vi.fn(async (callback) =>
|
||||
callback({
|
||||
@@ -805,7 +808,7 @@ describe("demand and assignment use-cases", () => {
|
||||
findMany: allocationFindMany,
|
||||
},
|
||||
demandRequirement: { findUnique: demandRequirementFindUnique },
|
||||
assignment: { findUnique: assignmentFindUnique },
|
||||
assignment: { findUnique: assignmentFindUnique, findMany: assignmentFindMany },
|
||||
resource: { findUnique: resourceFindUnique },
|
||||
$transaction: vi.fn(async (callback) =>
|
||||
callback({
|
||||
@@ -966,7 +969,7 @@ describe("demand and assignment use-cases", () => {
|
||||
findMany: allocationFindMany,
|
||||
},
|
||||
demandRequirement: { findUnique: demandRequirementFindUnique },
|
||||
assignment: { findUnique: assignmentFindUnique },
|
||||
assignment: { findUnique: assignmentFindUnique, findMany: assignmentFindMany },
|
||||
resource: { findUnique: resourceFindUnique },
|
||||
$transaction: vi.fn(async (callback) =>
|
||||
callback({
|
||||
@@ -1102,7 +1105,7 @@ describe("demand and assignment use-cases", () => {
|
||||
demandRequirement: {
|
||||
findUnique: demandRequirementFindUnique,
|
||||
},
|
||||
assignment: { findUnique: assignmentFindUnique },
|
||||
assignment: { findUnique: assignmentFindUnique, findMany: assignmentFindMany },
|
||||
auditLog: { create: auditLogCreate },
|
||||
$transaction: vi.fn(async (callback) =>
|
||||
callback({
|
||||
@@ -1227,7 +1230,7 @@ describe("demand and assignment use-cases", () => {
|
||||
demandRequirement: {
|
||||
findUnique: demandRequirementFindUnique,
|
||||
},
|
||||
assignment: { findUnique: assignmentFindUnique },
|
||||
assignment: { findUnique: assignmentFindUnique, findMany: assignmentFindMany },
|
||||
auditLog: { create: auditLogCreate },
|
||||
$transaction: vi.fn(async (callback) =>
|
||||
callback({
|
||||
|
||||
@@ -80,8 +80,16 @@ export {
|
||||
type GetDashboardTopValueResourcesInput,
|
||||
type GetDashboardDemandInput,
|
||||
type GetDashboardChargeabilityOverviewInput,
|
||||
type DashboardChargeabilityDerivation,
|
||||
type DashboardChargeabilityRow,
|
||||
getDashboardBudgetForecast,
|
||||
type BudgetForecastRow,
|
||||
type BudgetForecastLocationSummary,
|
||||
type PeakTimesPeriodDerivation,
|
||||
type PeakTimesPeriodRow,
|
||||
type DemandCalendarLocationSummary,
|
||||
type DemandRowDerivation,
|
||||
type DashboardDemandRow,
|
||||
getDashboardSkillGaps,
|
||||
type SkillGapRow,
|
||||
getDashboardProjectHealth,
|
||||
|
||||
@@ -1,16 +1,57 @@
|
||||
import type { PrismaClient } from "@capakraken/db";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import { calculateInclusiveDays, MILLISECONDS_PER_DAY } from "./shared.js";
|
||||
import {
|
||||
calculateEffectiveAllocationCostCents,
|
||||
loadDailyAvailabilityContexts,
|
||||
} from "./holiday-capacity.js";
|
||||
|
||||
export interface BudgetForecastLocationSummary {
|
||||
countryCode: string | null;
|
||||
countryName: string | null;
|
||||
federalState: string | null;
|
||||
metroCityName: string | null;
|
||||
activeAssignmentCount: number;
|
||||
burnRateCents: number;
|
||||
}
|
||||
|
||||
export interface BudgetForecastRow {
|
||||
projectId?: string;
|
||||
projectName: string;
|
||||
shortCode: string;
|
||||
clientId: string | null;
|
||||
clientName: string | null;
|
||||
budgetCents: number;
|
||||
spentCents: number;
|
||||
remainingCents?: number;
|
||||
burnRate: number;
|
||||
estimatedExhaustionDate: string | null;
|
||||
pctUsed: number;
|
||||
activeAssignmentCount?: number;
|
||||
calendarLocations?: BudgetForecastLocationSummary[];
|
||||
}
|
||||
|
||||
function hasAvailability<T extends { availability?: unknown }>(
|
||||
resource: T | null | undefined,
|
||||
): resource is T & { availability: WeekdayAvailability } {
|
||||
return resource !== null
|
||||
&& resource !== undefined
|
||||
&& resource.availability !== null
|
||||
&& resource.availability !== undefined;
|
||||
}
|
||||
|
||||
function buildLocationKey(input: {
|
||||
countryCode: string | null | undefined;
|
||||
countryName: string | null | undefined;
|
||||
federalState: string | null | undefined;
|
||||
metroCityName: string | null | undefined;
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
countryCode: input.countryCode ?? null,
|
||||
countryName: input.countryName ?? null,
|
||||
federalState: input.federalState ?? null,
|
||||
metroCityName: input.metroCityName ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDashboardBudgetForecast(
|
||||
@@ -32,7 +73,7 @@ export async function getDashboardBudgetForecast(
|
||||
|
||||
if (projects.length === 0) return [];
|
||||
|
||||
const projectIds = projects.map((p) => p.id);
|
||||
const projectIds = projects.map((project) => project.id);
|
||||
|
||||
const assignments = await db.assignment.findMany({
|
||||
where: {
|
||||
@@ -44,42 +85,142 @@ export async function getDashboardBudgetForecast(
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
dailyCostCents: true,
|
||||
resource: {
|
||||
select: {
|
||||
id: true,
|
||||
availability: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: {
|
||||
select: {
|
||||
code: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
metroCity: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
const contextStart = assignments.length > 0
|
||||
? new Date(
|
||||
Math.min(
|
||||
...assignments.map((assignment) => assignment.startDate.getTime()),
|
||||
monthStart.getTime(),
|
||||
),
|
||||
)
|
||||
: monthStart;
|
||||
const contextEnd = assignments.length > 0
|
||||
? new Date(
|
||||
Math.max(
|
||||
...assignments.map((assignment) => assignment.endDate.getTime()),
|
||||
monthEnd.getTime(),
|
||||
),
|
||||
)
|
||||
: monthEnd;
|
||||
const contexts = await loadDailyAvailabilityContexts(
|
||||
db,
|
||||
assignments
|
||||
.map((assignment) => assignment.resource)
|
||||
.filter(hasAvailability)
|
||||
.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
contextStart,
|
||||
contextEnd,
|
||||
);
|
||||
const spentByProject = new Map<string, number>();
|
||||
const monthlyBurnByProject = new Map<string, number>();
|
||||
const activeAssignmentCountByProject = new Map<string, number>();
|
||||
const activeLocationsByProject = new Map<string, Map<string, BudgetForecastLocationSummary>>();
|
||||
|
||||
for (const a of assignments) {
|
||||
const days = calculateInclusiveDays(a.startDate, a.endDate);
|
||||
const totalCost = (a.dailyCostCents ?? 0) * days;
|
||||
for (const assignment of assignments) {
|
||||
const totalCost = hasAvailability(assignment.resource)
|
||||
? calculateEffectiveAllocationCostCents({
|
||||
availability: assignment.resource.availability as unknown as WeekdayAvailability,
|
||||
startDate: assignment.startDate,
|
||||
endDate: assignment.endDate,
|
||||
dailyCostCents: assignment.dailyCostCents ?? 0,
|
||||
periodStart: assignment.startDate,
|
||||
periodEnd: assignment.endDate,
|
||||
context: contexts.get(assignment.resource.id),
|
||||
})
|
||||
: (assignment.dailyCostCents ?? 0)
|
||||
* calculateInclusiveDays(assignment.startDate, assignment.endDate);
|
||||
|
||||
spentByProject.set(
|
||||
a.projectId,
|
||||
(spentByProject.get(a.projectId) ?? 0) + totalCost,
|
||||
assignment.projectId,
|
||||
(spentByProject.get(assignment.projectId) ?? 0) + totalCost,
|
||||
);
|
||||
|
||||
// Approximate monthly burn from active assignments that overlap today
|
||||
if (a.startDate <= now && a.endDate >= now) {
|
||||
// ~22 working days per month
|
||||
const monthlyContribution = (a.dailyCostCents ?? 0) * 22;
|
||||
if (assignment.startDate <= now && assignment.endDate >= now) {
|
||||
const monthlyContribution = hasAvailability(assignment.resource)
|
||||
? calculateEffectiveAllocationCostCents({
|
||||
availability: assignment.resource.availability as unknown as WeekdayAvailability,
|
||||
startDate: assignment.startDate,
|
||||
endDate: assignment.endDate,
|
||||
dailyCostCents: assignment.dailyCostCents ?? 0,
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
context: contexts.get(assignment.resource.id),
|
||||
})
|
||||
: (assignment.dailyCostCents ?? 0) * 22;
|
||||
monthlyBurnByProject.set(
|
||||
a.projectId,
|
||||
(monthlyBurnByProject.get(a.projectId) ?? 0) + monthlyContribution,
|
||||
assignment.projectId,
|
||||
(monthlyBurnByProject.get(assignment.projectId) ?? 0) + monthlyContribution,
|
||||
);
|
||||
activeAssignmentCountByProject.set(
|
||||
assignment.projectId,
|
||||
(activeAssignmentCountByProject.get(assignment.projectId) ?? 0) + 1,
|
||||
);
|
||||
|
||||
const locationSummaries = activeLocationsByProject.get(assignment.projectId) ?? new Map();
|
||||
const locationKey = buildLocationKey({
|
||||
countryCode: assignment.resource.country?.code,
|
||||
countryName: assignment.resource.country?.name,
|
||||
federalState: assignment.resource.federalState,
|
||||
metroCityName: assignment.resource.metroCity?.name,
|
||||
});
|
||||
const summary = locationSummaries.get(locationKey) ?? {
|
||||
countryCode: assignment.resource.country?.code ?? null,
|
||||
countryName: assignment.resource.country?.name ?? null,
|
||||
federalState: assignment.resource.federalState ?? null,
|
||||
metroCityName: assignment.resource.metroCity?.name ?? null,
|
||||
activeAssignmentCount: 0,
|
||||
burnRateCents: 0,
|
||||
};
|
||||
summary.activeAssignmentCount += 1;
|
||||
summary.burnRateCents += monthlyContribution;
|
||||
locationSummaries.set(locationKey, summary);
|
||||
activeLocationsByProject.set(assignment.projectId, locationSummaries);
|
||||
}
|
||||
}
|
||||
|
||||
const rows: BudgetForecastRow[] = projects.map((p) => {
|
||||
const spentCents = spentByProject.get(p.id) ?? 0;
|
||||
const burnRate = monthlyBurnByProject.get(p.id) ?? 0;
|
||||
const pctUsed =
|
||||
p.budgetCents > 0 ? Math.round((spentCents / p.budgetCents) * 100) : 0;
|
||||
const rows: BudgetForecastRow[] = projects.map((project) => {
|
||||
const spentCents = spentByProject.get(project.id) ?? 0;
|
||||
const burnRate = monthlyBurnByProject.get(project.id) ?? 0;
|
||||
const remainingCents = Math.max(0, project.budgetCents - spentCents);
|
||||
const pctUsed = project.budgetCents > 0
|
||||
? Math.round((spentCents / project.budgetCents) * 100)
|
||||
: 0;
|
||||
|
||||
let estimatedExhaustionDate: string | null = null;
|
||||
if (burnRate > 0 && p.budgetCents > spentCents) {
|
||||
const remainingCents = p.budgetCents - spentCents;
|
||||
if (burnRate > 0 && project.budgetCents > spentCents) {
|
||||
const monthsRemaining = remainingCents / burnRate;
|
||||
const exhaustionDate = new Date(
|
||||
now.getTime() + monthsRemaining * 30 * MILLISECONDS_PER_DAY,
|
||||
@@ -88,18 +229,23 @@ export async function getDashboardBudgetForecast(
|
||||
}
|
||||
|
||||
return {
|
||||
projectName: p.name,
|
||||
shortCode: p.shortCode,
|
||||
clientId: p.clientId,
|
||||
clientName: p.client?.name ?? null,
|
||||
budgetCents: p.budgetCents,
|
||||
projectId: project.id,
|
||||
projectName: project.name,
|
||||
shortCode: project.shortCode,
|
||||
clientId: project.clientId,
|
||||
clientName: project.client?.name ?? null,
|
||||
budgetCents: project.budgetCents,
|
||||
spentCents,
|
||||
remainingCents,
|
||||
burnRate,
|
||||
estimatedExhaustionDate,
|
||||
pctUsed,
|
||||
activeAssignmentCount: activeAssignmentCountByProject.get(project.id) ?? 0,
|
||||
calendarLocations: Array.from(activeLocationsByProject.get(project.id)?.values() ?? [])
|
||||
.sort((left, right) => right.burnRateCents - left.burnRateCents),
|
||||
};
|
||||
});
|
||||
|
||||
rows.sort((a, b) => b.pctUsed - a.pctUsed);
|
||||
rows.sort((left, right) => right.pctUsed - left.pctUsed);
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import type { PrismaClient } from "@capakraken/db";
|
||||
import { computeChargeability } from "@capakraken/engine";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import {
|
||||
isChargeabilityActualBooking,
|
||||
isChargeabilityRelevantProject,
|
||||
} from "../allocation/chargeability-bookings.js";
|
||||
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
|
||||
import {
|
||||
calculateEffectiveAllocationHours,
|
||||
calculateEffectiveAvailableHours,
|
||||
loadDailyAvailabilityContexts,
|
||||
type DailyAvailabilityContext,
|
||||
} from "./holiday-capacity.js";
|
||||
|
||||
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
|
||||
"sunday",
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
];
|
||||
|
||||
export interface GetDashboardChargeabilityOverviewInput {
|
||||
includeProposed?: boolean;
|
||||
@@ -16,6 +31,132 @@ export interface GetDashboardChargeabilityOverviewInput {
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export interface DashboardChargeabilityDerivation {
|
||||
weeklyAvailabilityHours: number;
|
||||
baseWorkingDays: number;
|
||||
effectiveWorkingDayEquivalent: number;
|
||||
baseAvailableHours: number;
|
||||
effectiveAvailableHours: number;
|
||||
publicHolidayCount: number;
|
||||
publicHolidayWorkdayCount: number;
|
||||
publicHolidayHoursDeduction: number;
|
||||
absenceDayEquivalent: number;
|
||||
absenceHoursDeduction: number;
|
||||
actualBookedHours: number;
|
||||
expectedBookedHours: number;
|
||||
targetBookedHours: number;
|
||||
unassignedHours: number;
|
||||
}
|
||||
|
||||
export interface DashboardChargeabilityRow {
|
||||
id: string;
|
||||
eid: string;
|
||||
displayName: string;
|
||||
chapter: string | null;
|
||||
countryId?: string | null;
|
||||
countryCode?: string | null;
|
||||
countryName?: string | null;
|
||||
federalState?: string | null;
|
||||
metroCityName?: string | null;
|
||||
departed: boolean | null;
|
||||
chargeabilityTarget: number;
|
||||
actualChargeability: number;
|
||||
expectedChargeability: number;
|
||||
derivation?: DashboardChargeabilityDerivation;
|
||||
}
|
||||
|
||||
function toIsoDate(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function getDailyAvailabilityHours(
|
||||
availability: WeekdayAvailability,
|
||||
date: Date,
|
||||
): number {
|
||||
const dayKey = DAY_KEYS[date.getUTCDay()];
|
||||
return dayKey ? (availability[dayKey] ?? 0) : 0;
|
||||
}
|
||||
|
||||
function summarizeDerivation(
|
||||
availability: WeekdayAvailability,
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
context: DailyAvailabilityContext | undefined,
|
||||
actualBookedHours: number,
|
||||
expectedBookedHours: number,
|
||||
chargeabilityTarget: number,
|
||||
): DashboardChargeabilityDerivation {
|
||||
let baseWorkingDays = 0;
|
||||
let effectiveWorkingDayEquivalent = 0;
|
||||
let publicHolidayWorkdayCount = 0;
|
||||
let publicHolidayHoursDeduction = 0;
|
||||
let absenceDayEquivalent = 0;
|
||||
let absenceHoursDeduction = 0;
|
||||
|
||||
const weeklyAvailabilityHours = Object.values(availability).reduce(
|
||||
(sum, hours) => sum + (hours ?? 0),
|
||||
0,
|
||||
);
|
||||
const baseAvailableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
context: undefined,
|
||||
});
|
||||
const effectiveAvailableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
context,
|
||||
});
|
||||
|
||||
const cursor = new Date(periodStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(periodEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
const isoDate = toIsoDate(cursor);
|
||||
const baseHours = getDailyAvailabilityHours(availability, cursor);
|
||||
const absenceFraction = Math.min(
|
||||
1,
|
||||
Math.max(0, context?.absenceFractionsByDate.get(isoDate) ?? 0),
|
||||
);
|
||||
const isHoliday = context?.holidayDates.has(isoDate) ?? false;
|
||||
|
||||
if (baseHours > 0) {
|
||||
baseWorkingDays += 1;
|
||||
if (isHoliday) {
|
||||
publicHolidayWorkdayCount += 1;
|
||||
publicHolidayHoursDeduction += baseHours;
|
||||
} else {
|
||||
absenceDayEquivalent += absenceFraction;
|
||||
absenceHoursDeduction += baseHours * absenceFraction;
|
||||
effectiveWorkingDayEquivalent += Math.max(0, 1 - absenceFraction);
|
||||
}
|
||||
}
|
||||
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
weeklyAvailabilityHours,
|
||||
baseWorkingDays,
|
||||
effectiveWorkingDayEquivalent,
|
||||
baseAvailableHours,
|
||||
effectiveAvailableHours,
|
||||
publicHolidayCount: context?.holidayDates.size ?? 0,
|
||||
publicHolidayWorkdayCount,
|
||||
publicHolidayHoursDeduction,
|
||||
absenceDayEquivalent,
|
||||
absenceHoursDeduction,
|
||||
actualBookedHours,
|
||||
expectedBookedHours,
|
||||
targetBookedHours: Math.round((effectiveAvailableHours * chargeabilityTarget) / 10) / 10,
|
||||
unassignedHours: Math.max(0, effectiveAvailableHours - expectedBookedHours),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDashboardChargeabilityOverview(
|
||||
db: PrismaClient,
|
||||
input: GetDashboardChargeabilityOverviewInput,
|
||||
@@ -38,8 +179,23 @@ export async function getDashboardChargeabilityOverview(
|
||||
displayName: true,
|
||||
chapter: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
departed: true,
|
||||
chargeabilityTarget: true,
|
||||
country: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
metroCity: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
availability: true,
|
||||
},
|
||||
});
|
||||
@@ -48,9 +204,24 @@ export async function getDashboardChargeabilityOverview(
|
||||
endDate: end,
|
||||
resourceIds: resources.map((resource) => resource.id),
|
||||
});
|
||||
const contexts = await loadDailyAvailabilityContexts(
|
||||
db,
|
||||
resources.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
start,
|
||||
end,
|
||||
);
|
||||
|
||||
const stats = resources.map((resource) => {
|
||||
const stats: DashboardChargeabilityRow[] = resources.map((resource) => {
|
||||
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||
const context = contexts.get(resource.id);
|
||||
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
|
||||
const actualAllocations = resourceBookings.filter((booking) =>
|
||||
isChargeabilityActualBooking(booking, input.includeProposed === true),
|
||||
@@ -58,18 +229,43 @@ export async function getDashboardChargeabilityOverview(
|
||||
const expectedAllocations = resourceBookings.filter(
|
||||
(booking) => isChargeabilityRelevantProject(booking.project, true),
|
||||
);
|
||||
const actual = computeChargeability(
|
||||
const availableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
actualAllocations,
|
||||
start,
|
||||
end,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
});
|
||||
const actualBookedHours = actualAllocations.reduce(
|
||||
(sum, allocation) => sum + calculateEffectiveAllocationHours({
|
||||
availability,
|
||||
startDate: allocation.startDate,
|
||||
endDate: allocation.endDate,
|
||||
hoursPerDay: allocation.hoursPerDay,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const expected = computeChargeability(
|
||||
availability,
|
||||
expectedAllocations,
|
||||
start,
|
||||
end,
|
||||
const expectedBookedHours = expectedAllocations.reduce(
|
||||
(sum, allocation) => sum + calculateEffectiveAllocationHours({
|
||||
availability,
|
||||
startDate: allocation.startDate,
|
||||
endDate: allocation.endDate,
|
||||
hoursPerDay: allocation.hoursPerDay,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const actualChargeability = availableHours > 0
|
||||
? Math.min(100, Math.round((actualBookedHours / availableHours) * 100))
|
||||
: 0;
|
||||
const expectedChargeability = availableHours > 0
|
||||
? Math.min(100, Math.round((expectedBookedHours / availableHours) * 100))
|
||||
: 0;
|
||||
const chargeabilityTarget = resource.chargeabilityTarget ?? 0;
|
||||
|
||||
return {
|
||||
id: resource.id,
|
||||
@@ -77,10 +273,23 @@ export async function getDashboardChargeabilityOverview(
|
||||
displayName: resource.displayName,
|
||||
chapter: resource.chapter,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code ?? null,
|
||||
countryName: resource.country?.name ?? null,
|
||||
federalState: resource.federalState ?? null,
|
||||
metroCityName: resource.metroCity?.name ?? null,
|
||||
departed: resource.departed,
|
||||
chargeabilityTarget: resource.chargeabilityTarget,
|
||||
actualChargeability: actual.chargeability,
|
||||
expectedChargeability: expected.chargeability,
|
||||
chargeabilityTarget,
|
||||
actualChargeability,
|
||||
expectedChargeability,
|
||||
derivation: summarizeDerivation(
|
||||
availability,
|
||||
start,
|
||||
end,
|
||||
context,
|
||||
actualBookedHours,
|
||||
expectedBookedHours,
|
||||
chargeabilityTarget,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { PrismaClient } from "@capakraken/db";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import { loadDashboardPlanningReadModel } from "./load-dashboard-planning-read-model.js";
|
||||
import { calculateAllocationHours } from "./shared.js";
|
||||
import {
|
||||
calculateEffectiveAllocationHours,
|
||||
calculateEffectiveAvailableHours,
|
||||
loadDailyAvailabilityContexts,
|
||||
} from "./holiday-capacity.js";
|
||||
|
||||
export interface GetDashboardDemandInput {
|
||||
startDate: Date;
|
||||
@@ -8,6 +14,35 @@ export interface GetDashboardDemandInput {
|
||||
groupBy: "project" | "person" | "chapter";
|
||||
}
|
||||
|
||||
export interface DemandCalendarLocationSummary {
|
||||
countryCode: string | null;
|
||||
federalState: string | null;
|
||||
metroCityName: string | null;
|
||||
resourceCount: number;
|
||||
allocatedHours: number;
|
||||
}
|
||||
|
||||
export interface DemandRowDerivation {
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
periodWorkingHoursBase: number;
|
||||
requiredHours: number | null;
|
||||
requiredFTEs: number;
|
||||
fillPct: number | null;
|
||||
demandSource: "DEMAND_REQUIREMENTS" | "PROJECT_STAFFING_REQS" | "NONE";
|
||||
calendarLocations: DemandCalendarLocationSummary[];
|
||||
}
|
||||
|
||||
export interface DashboardDemandRow {
|
||||
id: string;
|
||||
name: string;
|
||||
shortCode: string;
|
||||
allocatedHours: number;
|
||||
requiredFTEs: number;
|
||||
resourceCount: number;
|
||||
derivation?: DemandRowDerivation;
|
||||
}
|
||||
|
||||
interface ProjectSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -15,6 +50,12 @@ interface ProjectSummary {
|
||||
staffingReqs: unknown;
|
||||
}
|
||||
|
||||
function hasAvailability<T extends { availability?: unknown }>(
|
||||
resource: T | null | undefined,
|
||||
): resource is T & { availability: WeekdayAvailability } {
|
||||
return resource !== null && resource !== undefined && resource.availability !== null && resource.availability !== undefined;
|
||||
}
|
||||
|
||||
function getDemandFteFactor(hoursPerDay: number, percentage: number): number {
|
||||
const normalizedPercentage = percentage > 0 ? percentage : (hoursPerDay / 8) * 100;
|
||||
return normalizedPercentage / 100;
|
||||
@@ -24,6 +65,22 @@ function toDate(value: Date | string): Date {
|
||||
return value instanceof Date ? value : new Date(value);
|
||||
}
|
||||
|
||||
function toIsoDate(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function buildLocationKey(input: {
|
||||
countryCode: string | null | undefined;
|
||||
federalState: string | null | undefined;
|
||||
metroCityName: string | null | undefined;
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
countryCode: input.countryCode ?? null,
|
||||
federalState: input.federalState ?? null,
|
||||
metroCityName: input.metroCityName ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function getProjectRequiredFTEs(staffingReqs: unknown): number {
|
||||
const requirements = Array.isArray(staffingReqs) ? staffingReqs : [];
|
||||
return requirements.reduce((sum, requirement) => {
|
||||
@@ -40,10 +97,84 @@ function getProjectRequiredFTEs(staffingReqs: unknown): number {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
const FULL_TIME_AVAILABILITY: WeekdayAvailability = {
|
||||
sunday: 0,
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 0,
|
||||
};
|
||||
|
||||
function summarizeCalendarLocations(
|
||||
assignments: Array<{
|
||||
resource?: { id?: string | null } | null;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
hoursPerDay: number;
|
||||
}>,
|
||||
resourceInfoById: Map<string, {
|
||||
id: string;
|
||||
availability: WeekdayAvailability;
|
||||
countryCode: string | null | undefined;
|
||||
federalState: string | null | undefined;
|
||||
metroCityName: string | null | undefined;
|
||||
}>,
|
||||
contexts: Map<string, Awaited<ReturnType<typeof loadDailyAvailabilityContexts>> extends Map<string, infer T> ? T : never>,
|
||||
input: GetDashboardDemandInput,
|
||||
): DemandCalendarLocationSummary[] {
|
||||
const locationMap = new Map<string, DemandCalendarLocationSummary & { resourceIds: Set<string> }>();
|
||||
|
||||
for (const assignment of assignments) {
|
||||
const resourceId = assignment.resource?.id ?? undefined;
|
||||
const resource = resourceId ? resourceInfoById.get(resourceId) : undefined;
|
||||
if (!resource) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hours = calculateEffectiveAllocationHours({
|
||||
availability: resource.availability,
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
periodStart: input.startDate,
|
||||
periodEnd: input.endDate,
|
||||
context: contexts.get(resource.id),
|
||||
});
|
||||
|
||||
const locationKey = buildLocationKey({
|
||||
countryCode: resource.countryCode,
|
||||
federalState: resource.federalState,
|
||||
metroCityName: resource.metroCityName,
|
||||
});
|
||||
const existing = locationMap.get(locationKey) ?? {
|
||||
countryCode: resource.countryCode ?? null,
|
||||
federalState: resource.federalState ?? null,
|
||||
metroCityName: resource.metroCityName ?? null,
|
||||
resourceCount: 0,
|
||||
allocatedHours: 0,
|
||||
resourceIds: new Set<string>(),
|
||||
};
|
||||
|
||||
existing.allocatedHours += hours;
|
||||
existing.resourceIds.add(resource.id);
|
||||
existing.resourceCount = existing.resourceIds.size;
|
||||
locationMap.set(locationKey, existing);
|
||||
}
|
||||
|
||||
return [...locationMap.values()]
|
||||
.map(({ resourceIds: _resourceIds, ...summary }) => ({
|
||||
...summary,
|
||||
allocatedHours: Math.round(summary.allocatedHours * 10) / 10,
|
||||
}))
|
||||
.sort((left, right) => right.allocatedHours - left.allocatedHours);
|
||||
}
|
||||
|
||||
export async function getDashboardDemand(
|
||||
db: PrismaClient,
|
||||
input: GetDashboardDemandInput,
|
||||
) {
|
||||
): Promise<DashboardDemandRow[]> {
|
||||
const { demandRequirements, assignments, projects, readModel } =
|
||||
await loadDashboardPlanningReadModel(db, {
|
||||
startDate: input.startDate,
|
||||
@@ -58,6 +189,27 @@ export async function getDashboardDemand(
|
||||
);
|
||||
const normalizedAssignments = readModel.assignments;
|
||||
const normalizedDemands = readModel.demands;
|
||||
const resourceProfiles = assignments
|
||||
.map((assignment) => assignment.resource)
|
||||
.filter(hasAvailability)
|
||||
.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
}));
|
||||
const resourceInfoById = new Map(
|
||||
resourceProfiles.map((resource) => [resource.id, resource]),
|
||||
);
|
||||
const contexts = await loadDailyAvailabilityContexts(
|
||||
db,
|
||||
[...new Map(resourceProfiles.map((resource) => [resource.id, resource])).values()],
|
||||
input.startDate,
|
||||
input.endDate,
|
||||
);
|
||||
|
||||
const projectMap = new Map<string, ProjectSummary>(
|
||||
projects.map((project) => [project.id, project]),
|
||||
@@ -87,6 +239,13 @@ export async function getDashboardDemand(
|
||||
);
|
||||
}
|
||||
|
||||
const periodWorkingHoursBase = calculateEffectiveAvailableHours({
|
||||
availability: FULL_TIME_AVAILABILITY,
|
||||
periodStart: input.startDate,
|
||||
periodEnd: input.endDate,
|
||||
context: undefined,
|
||||
});
|
||||
|
||||
if (input.groupBy === "project") {
|
||||
const projectIds = new Set<string>([
|
||||
...projectMap.keys(),
|
||||
@@ -110,13 +269,28 @@ export async function getDashboardDemand(
|
||||
);
|
||||
|
||||
const allocatedHours = projectAssignments.reduce(
|
||||
(sum, assignment) =>
|
||||
sum +
|
||||
calculateAllocationHours({
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
}),
|
||||
(sum, assignment) => {
|
||||
const resource = assignment.resource?.id
|
||||
? resourceInfoById.get(assignment.resource.id)
|
||||
: undefined;
|
||||
return sum + (
|
||||
resource
|
||||
? calculateEffectiveAllocationHours({
|
||||
availability: resource.availability,
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
periodStart: input.startDate,
|
||||
periodEnd: input.endDate,
|
||||
context: contexts.get(resource.id),
|
||||
})
|
||||
: calculateAllocationHours({
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
})
|
||||
);
|
||||
},
|
||||
0,
|
||||
);
|
||||
const requiredFTEs =
|
||||
@@ -139,6 +313,18 @@ export async function getDashboardDemand(
|
||||
return sum + plannedHeadcount * demandFteFactor;
|
||||
}, 0)
|
||||
: getProjectRequiredFTEs(project.staffingReqs);
|
||||
const requiredHours = requiredFTEs > 0
|
||||
? Math.round(requiredFTEs * periodWorkingHoursBase * 10) / 10
|
||||
: null;
|
||||
const fillPct = requiredHours && requiredHours > 0
|
||||
? Math.round((allocatedHours / requiredHours) * 100)
|
||||
: null;
|
||||
const calendarLocations = summarizeCalendarLocations(
|
||||
projectAssignments,
|
||||
resourceInfoById,
|
||||
contexts,
|
||||
input,
|
||||
);
|
||||
|
||||
return {
|
||||
id: project.id,
|
||||
@@ -149,6 +335,18 @@ export async function getDashboardDemand(
|
||||
resourceCount: new Set(
|
||||
projectAssignments.map((assignment) => assignment.resource?.id).filter(Boolean),
|
||||
).size,
|
||||
derivation: {
|
||||
periodStart: toIsoDate(input.startDate),
|
||||
periodEnd: toIsoDate(input.endDate),
|
||||
periodWorkingHoursBase,
|
||||
requiredHours,
|
||||
requiredFTEs: Math.round(requiredFTEs * 100) / 100,
|
||||
fillPct,
|
||||
demandSource: projectDemands.length > 0
|
||||
? "DEMAND_REQUIREMENTS"
|
||||
: "PROJECT_STAFFING_REQS",
|
||||
calendarLocations,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -161,6 +359,9 @@ export async function getDashboardDemand(
|
||||
|
||||
for (const assignment of normalizedAssignments) {
|
||||
const chapter = assignment.resource?.chapter ?? "Unassigned";
|
||||
const resource = assignment.resource?.id
|
||||
? resourceInfoById.get(assignment.resource.id)
|
||||
: undefined;
|
||||
const existing = chapterMap.get(chapter) ?? {
|
||||
allocatedHours: 0,
|
||||
resourceIds: new Set<string>(),
|
||||
@@ -170,23 +371,54 @@ export async function getDashboardDemand(
|
||||
existing.resourceIds.add(assignment.resource.id);
|
||||
}
|
||||
|
||||
existing.allocatedHours += calculateAllocationHours({
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
});
|
||||
existing.allocatedHours += resource
|
||||
? calculateEffectiveAllocationHours({
|
||||
availability: resource.availability,
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
periodStart: input.startDate,
|
||||
periodEnd: input.endDate,
|
||||
context: contexts.get(resource.id),
|
||||
})
|
||||
: calculateAllocationHours({
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
});
|
||||
|
||||
chapterMap.set(chapter, existing);
|
||||
}
|
||||
|
||||
return [...chapterMap.entries()].map(([chapter, data]) => ({
|
||||
id: chapter,
|
||||
name: chapter,
|
||||
shortCode: chapter,
|
||||
allocatedHours: Math.round(data.allocatedHours),
|
||||
requiredFTEs: 0,
|
||||
resourceCount: data.resourceIds.size,
|
||||
}));
|
||||
return [...chapterMap.entries()].map(([chapter, data]) => {
|
||||
const chapterAssignments = normalizedAssignments.filter(
|
||||
(assignment) => (assignment.resource?.chapter ?? "Unassigned") === chapter,
|
||||
);
|
||||
|
||||
return {
|
||||
id: chapter,
|
||||
name: chapter,
|
||||
shortCode: chapter,
|
||||
allocatedHours: Math.round(data.allocatedHours),
|
||||
requiredFTEs: 0,
|
||||
resourceCount: data.resourceIds.size,
|
||||
derivation: {
|
||||
periodStart: toIsoDate(input.startDate),
|
||||
periodEnd: toIsoDate(input.endDate),
|
||||
periodWorkingHoursBase,
|
||||
requiredHours: null,
|
||||
requiredFTEs: 0,
|
||||
fillPct: null,
|
||||
demandSource: "NONE",
|
||||
calendarLocations: summarizeCalendarLocations(
|
||||
chapterAssignments,
|
||||
resourceInfoById,
|
||||
contexts,
|
||||
input,
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const personMap = new Map<
|
||||
@@ -210,23 +442,55 @@ export async function getDashboardDemand(
|
||||
allocatedHours: 0,
|
||||
projectIds: new Set<string>(),
|
||||
};
|
||||
const resource = resourceInfoById.get(assignment.resource.id);
|
||||
|
||||
existing.allocatedHours += calculateAllocationHours({
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
});
|
||||
existing.allocatedHours += resource
|
||||
? calculateEffectiveAllocationHours({
|
||||
availability: resource.availability,
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
periodStart: input.startDate,
|
||||
periodEnd: input.endDate,
|
||||
context: contexts.get(resource.id),
|
||||
})
|
||||
: calculateAllocationHours({
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
});
|
||||
existing.projectIds.add(assignment.projectId);
|
||||
|
||||
personMap.set(assignment.resource.id, existing);
|
||||
}
|
||||
|
||||
return [...personMap.entries()].map(([id, data]) => ({
|
||||
id,
|
||||
name: data.name,
|
||||
shortCode: data.chapter ?? "",
|
||||
allocatedHours: Math.round(data.allocatedHours),
|
||||
requiredFTEs: 0,
|
||||
resourceCount: data.projectIds.size,
|
||||
}));
|
||||
return [...personMap.entries()].map(([id, data]) => {
|
||||
const personAssignments = normalizedAssignments.filter(
|
||||
(assignment) => assignment.resource?.id === id,
|
||||
);
|
||||
|
||||
return {
|
||||
id,
|
||||
name: data.name,
|
||||
shortCode: data.chapter ?? "",
|
||||
allocatedHours: Math.round(data.allocatedHours),
|
||||
requiredFTEs: 0,
|
||||
resourceCount: data.projectIds.size,
|
||||
derivation: {
|
||||
periodStart: toIsoDate(input.startDate),
|
||||
periodEnd: toIsoDate(input.endDate),
|
||||
periodWorkingHoursBase,
|
||||
requiredHours: null,
|
||||
requiredFTEs: 0,
|
||||
fillPct: null,
|
||||
demandSource: "NONE",
|
||||
calendarLocations: summarizeCalendarLocations(
|
||||
personAssignments,
|
||||
resourceInfoById,
|
||||
contexts,
|
||||
input,
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import type { PrismaClient } from "@capakraken/db";
|
||||
import { AllocationStatus } from "@capakraken/shared";
|
||||
import { buildSplitAllocationReadModel } from "../allocation/build-split-allocation-read-model.js";
|
||||
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
|
||||
import { calculateInclusiveDays } from "./shared.js";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import {
|
||||
calculateEffectiveAllocationCostCents,
|
||||
loadDailyAvailabilityContexts,
|
||||
} from "./holiday-capacity.js";
|
||||
|
||||
function hasAvailability<T extends { availability?: unknown }>(
|
||||
resource: T | null | undefined,
|
||||
): resource is T & { availability: WeekdayAvailability } {
|
||||
return resource !== null && resource !== undefined && resource.availability !== null && resource.availability !== undefined;
|
||||
}
|
||||
|
||||
export async function getDashboardOverview(db: PrismaClient) {
|
||||
const [
|
||||
@@ -12,7 +22,7 @@ export async function getDashboardOverview(db: PrismaClient) {
|
||||
allProjects,
|
||||
allDemandRequirements,
|
||||
allAssignments,
|
||||
budgetBookings,
|
||||
budgetAssignments,
|
||||
recentActivity,
|
||||
allResources,
|
||||
] = await Promise.all([
|
||||
@@ -58,7 +68,25 @@ export async function getDashboardOverview(db: PrismaClient) {
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
listAssignmentBookings(db, {}),
|
||||
db.assignment.findMany({
|
||||
where: { status: { not: "CANCELLED" } },
|
||||
select: {
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
dailyCostCents: true,
|
||||
resource: {
|
||||
select: {
|
||||
id: true,
|
||||
availability: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.auditLog.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 10,
|
||||
@@ -77,12 +105,46 @@ export async function getDashboardOverview(db: PrismaClient) {
|
||||
const activeAllocations = planningReadModel.allocations.filter(
|
||||
(allocation) => allocation.status !== AllocationStatus.CANCELLED,
|
||||
).length;
|
||||
const contextStart = budgetAssignments.length > 0
|
||||
? new Date(Math.min(...budgetAssignments.map((assignment) => assignment.startDate.getTime())))
|
||||
: new Date();
|
||||
const contextEnd = budgetAssignments.length > 0
|
||||
? new Date(Math.max(...budgetAssignments.map((assignment) => assignment.endDate.getTime())))
|
||||
: new Date();
|
||||
const contexts = await loadDailyAvailabilityContexts(
|
||||
db,
|
||||
budgetAssignments
|
||||
.map((assignment) => assignment.resource)
|
||||
.filter(hasAvailability)
|
||||
.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
contextStart,
|
||||
contextEnd,
|
||||
);
|
||||
|
||||
const totalCostCents = budgetBookings.reduce(
|
||||
(sum, booking) =>
|
||||
sum +
|
||||
(booking.dailyCostCents ?? 0) *
|
||||
calculateInclusiveDays(booking.startDate, booking.endDate),
|
||||
const totalCostCents = budgetAssignments.reduce(
|
||||
(sum, assignment) =>
|
||||
sum + (
|
||||
hasAvailability(assignment.resource)
|
||||
? calculateEffectiveAllocationCostCents({
|
||||
availability: assignment.resource.availability as unknown as WeekdayAvailability,
|
||||
startDate: assignment.startDate,
|
||||
endDate: assignment.endDate,
|
||||
dailyCostCents: assignment.dailyCostCents ?? 0,
|
||||
periodStart: assignment.startDate,
|
||||
periodEnd: assignment.endDate,
|
||||
context: contexts.get(assignment.resource.id),
|
||||
})
|
||||
: (assignment.dailyCostCents ?? 0) *
|
||||
calculateInclusiveDays(assignment.startDate, assignment.endDate)
|
||||
),
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -90,6 +152,12 @@ export async function getDashboardOverview(db: PrismaClient) {
|
||||
(sum, project) => sum + (project.budgetCents ?? 0),
|
||||
0,
|
||||
);
|
||||
const activeProjectCount = allProjects.filter((project) => project.status === "ACTIVE").length;
|
||||
const inactiveResourceCount = Math.max(totalResources - activeResources, 0);
|
||||
const inactiveProjectCount = Math.max(totalProjects - activeProjectCount, 0);
|
||||
const cancelledAllocations = Math.max(totalAllocations - activeAllocations, 0);
|
||||
const budgetedProjects = allProjects.filter((project) => (project.budgetCents ?? 0) > 0).length;
|
||||
const remainingBudgetCents = totalBudgetCents - totalCostCents;
|
||||
|
||||
const avgUtilizationPercent =
|
||||
totalBudgetCents > 0
|
||||
@@ -125,16 +193,26 @@ export async function getDashboardOverview(db: PrismaClient) {
|
||||
return {
|
||||
totalResources,
|
||||
activeResources,
|
||||
inactiveResources: inactiveResourceCount,
|
||||
totalProjects,
|
||||
activeProjects: allProjects.filter((project) => project.status === "ACTIVE")
|
||||
.length,
|
||||
activeProjects: activeProjectCount,
|
||||
inactiveProjects: inactiveProjectCount,
|
||||
totalAllocations,
|
||||
activeAllocations,
|
||||
cancelledAllocations,
|
||||
budgetSummary: {
|
||||
totalBudgetCents,
|
||||
totalCostCents,
|
||||
avgUtilizationPercent,
|
||||
},
|
||||
budgetBasis: {
|
||||
remainingBudgetCents,
|
||||
budgetedProjects,
|
||||
unbudgetedProjects: Math.max(totalProjects - budgetedProjects, 0),
|
||||
trackedAssignmentCount: budgetAssignments.length,
|
||||
windowStart: budgetAssignments.length > 0 ? contextStart : null,
|
||||
windowEnd: budgetAssignments.length > 0 ? contextEnd : null,
|
||||
},
|
||||
recentActivity: recentActivity.map((activity) => ({
|
||||
id: activity.id,
|
||||
entityType: activity.entityType,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { PrismaClient } from "@capakraken/db";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
|
||||
import { getAverageDailyAvailabilityHours, getMonthBucketKey, getWeekBucketKey } from "./shared.js";
|
||||
import { getMonthBucketKey, getWeekBucketKey } from "./shared.js";
|
||||
import {
|
||||
calculateEffectiveAllocationHours,
|
||||
calculateEffectiveAvailableHours,
|
||||
enumerateIsoDates,
|
||||
loadDailyAvailabilityContexts,
|
||||
} from "./holiday-capacity.js";
|
||||
|
||||
export interface GetDashboardPeakTimesInput {
|
||||
startDate: Date;
|
||||
@@ -9,67 +16,253 @@ export interface GetDashboardPeakTimesInput {
|
||||
groupBy: "project" | "chapter" | "resource";
|
||||
}
|
||||
|
||||
export interface PeakTimesPeriodDerivation {
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
resourceCount: number;
|
||||
groupCount: number;
|
||||
bookedHours: number;
|
||||
capacityHours: number;
|
||||
remainingCapacityHours: number;
|
||||
overbookedHours: number;
|
||||
utilizationPct: number;
|
||||
}
|
||||
|
||||
export interface PeakTimesGroupRow {
|
||||
name: string;
|
||||
hours: number;
|
||||
capacityHours: number | undefined;
|
||||
remainingHours: number | undefined;
|
||||
overbookedHours: number | undefined;
|
||||
utilizationPct: number | undefined;
|
||||
}
|
||||
|
||||
export interface PeakTimesPeriodRow {
|
||||
period: string;
|
||||
groups: PeakTimesGroupRow[];
|
||||
totalHours: number;
|
||||
capacityHours: number;
|
||||
periodStart?: string;
|
||||
periodEnd?: string;
|
||||
bookedHours?: number;
|
||||
remainingHours?: number;
|
||||
overbookedHours?: number;
|
||||
utilizationPct?: number;
|
||||
groupCount?: number;
|
||||
resourceCount?: number;
|
||||
derivation: PeakTimesPeriodDerivation;
|
||||
}
|
||||
|
||||
export async function getDashboardPeakTimes(
|
||||
db: PrismaClient,
|
||||
input: GetDashboardPeakTimesInput,
|
||||
) {
|
||||
const allocations = await listAssignmentBookings(db, {
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
});
|
||||
): Promise<PeakTimesPeriodRow[]> {
|
||||
const [allocations, resources] = await Promise.all([
|
||||
listAssignmentBookings(db, {
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
}),
|
||||
db.resource.findMany({
|
||||
where: { isActive: true },
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true,
|
||||
chapter: true,
|
||||
availability: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: {
|
||||
select: {
|
||||
code: true,
|
||||
},
|
||||
},
|
||||
metroCity: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const buckets = new Map<string, Map<string, number>>();
|
||||
|
||||
const groupCapacityBuckets = new Map<string, Map<string, number>>();
|
||||
const getBucketKey = input.granularity === "week" ? getWeekBucketKey : getMonthBucketKey;
|
||||
const resourceMap = new Map(
|
||||
resources.map((resource) => [
|
||||
resource.id,
|
||||
{
|
||||
...resource,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const contexts = await loadDailyAvailabilityContexts(
|
||||
db,
|
||||
resources.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
input.startDate,
|
||||
input.endDate,
|
||||
);
|
||||
const bucketPeriods = new Map<string, { start: Date; end: Date }>();
|
||||
|
||||
for (const isoDate of enumerateIsoDates(input.startDate, input.endDate)) {
|
||||
const date = new Date(`${isoDate}T00:00:00.000Z`);
|
||||
const bucketKey = getBucketKey(date);
|
||||
const existing = bucketPeriods.get(bucketKey);
|
||||
if (!existing) {
|
||||
bucketPeriods.set(bucketKey, { start: date, end: date });
|
||||
continue;
|
||||
}
|
||||
if (date < existing.start) {
|
||||
existing.start = date;
|
||||
}
|
||||
if (date > existing.end) {
|
||||
existing.end = date;
|
||||
}
|
||||
}
|
||||
for (const bucketKey of bucketPeriods.keys()) {
|
||||
buckets.set(bucketKey, new Map());
|
||||
groupCapacityBuckets.set(bucketKey, new Map());
|
||||
}
|
||||
|
||||
for (const allocation of allocations) {
|
||||
const allocStart = new Date(
|
||||
Math.max(allocation.startDate.getTime(), input.startDate.getTime()),
|
||||
);
|
||||
const allocEnd = new Date(
|
||||
Math.min(allocation.endDate.getTime(), input.endDate.getTime()),
|
||||
);
|
||||
const resource = allocation.resourceId ? resourceMap.get(allocation.resourceId) : undefined;
|
||||
const group =
|
||||
input.groupBy === "project"
|
||||
? allocation.project.shortCode
|
||||
: input.groupBy === "chapter"
|
||||
? allocation.resource?.chapter ?? "Unassigned"
|
||||
: allocation.resource?.displayName ?? "Unknown";
|
||||
|
||||
const cursor = new Date(allocStart);
|
||||
while (cursor <= allocEnd) {
|
||||
const bucketKey = getBucketKey(cursor);
|
||||
if (!buckets.has(bucketKey)) {
|
||||
buckets.set(bucketKey, new Map());
|
||||
for (const [bucketKey, bucketPeriod] of bucketPeriods.entries()) {
|
||||
const hours = resource
|
||||
? calculateEffectiveAllocationHours({
|
||||
availability: resource.availability,
|
||||
startDate: allocation.startDate,
|
||||
endDate: allocation.endDate,
|
||||
hoursPerDay: allocation.hoursPerDay,
|
||||
periodStart: bucketPeriod.start,
|
||||
periodEnd: bucketPeriod.end,
|
||||
context: contexts.get(resource.id),
|
||||
})
|
||||
: 0;
|
||||
if (hours <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const bucket = buckets.get(bucketKey)!;
|
||||
bucket.set(group, (bucket.get(group) ?? 0) + allocation.hoursPerDay);
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
bucket.set(group, (bucket.get(group) ?? 0) + hours);
|
||||
}
|
||||
}
|
||||
const capacityByBucket = new Map<string, number>();
|
||||
for (const [bucketKey, bucketPeriod] of bucketPeriods.entries()) {
|
||||
let capacityHours = 0;
|
||||
for (const resource of resourceMap.values()) {
|
||||
const effectiveAvailableHours = calculateEffectiveAvailableHours({
|
||||
availability: resource.availability,
|
||||
periodStart: bucketPeriod.start,
|
||||
periodEnd: bucketPeriod.end,
|
||||
context: contexts.get(resource.id),
|
||||
});
|
||||
capacityHours += effectiveAvailableHours;
|
||||
|
||||
const resources = await db.resource.findMany({
|
||||
where: { isActive: true },
|
||||
select: { availability: true },
|
||||
});
|
||||
if (input.groupBy !== "project" && effectiveAvailableHours > 0) {
|
||||
const group =
|
||||
input.groupBy === "chapter"
|
||||
? resource.chapter ?? "Unassigned"
|
||||
: resource.displayName ?? "Unknown";
|
||||
const groupCapacityBucket = groupCapacityBuckets.get(bucketKey)!;
|
||||
groupCapacityBucket.set(
|
||||
group,
|
||||
(groupCapacityBucket.get(group) ?? 0) + effectiveAvailableHours,
|
||||
);
|
||||
}
|
||||
}
|
||||
capacityByBucket.set(bucketKey, capacityHours);
|
||||
}
|
||||
|
||||
const dailyCapacityHours = resources.reduce(
|
||||
(sum, resource) =>
|
||||
sum +
|
||||
getAverageDailyAvailabilityHours(
|
||||
resource.availability as Record<string, number | null | undefined>,
|
||||
),
|
||||
0,
|
||||
);
|
||||
|
||||
return [...buckets.entries()]
|
||||
return [...bucketPeriods.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([period, groups]) => ({
|
||||
period,
|
||||
groups: [...groups.entries()].map(([name, hours]) => ({ name, hours })),
|
||||
totalHours: [...groups.values()].reduce((sum, hours) => sum + hours, 0),
|
||||
capacityHours:
|
||||
dailyCapacityHours * (input.granularity === "week" ? 5 : 22),
|
||||
}));
|
||||
.map(([period, bucketPeriod]) => {
|
||||
const groups = buckets.get(period) ?? new Map<string, number>();
|
||||
const groupCapacities = groupCapacityBuckets.get(period) ?? new Map<string, number>();
|
||||
const groupNames = new Set<string>([
|
||||
...groups.keys(),
|
||||
...(input.groupBy === "project" ? [] : groupCapacities.keys()),
|
||||
]);
|
||||
const groupRows = [...groupNames]
|
||||
.map((name) => {
|
||||
const hours = groups.get(name) ?? 0;
|
||||
const groupCapacityHours =
|
||||
input.groupBy === "project" ? undefined : groupCapacities.get(name) ?? 0;
|
||||
const remainingHours =
|
||||
groupCapacityHours === undefined
|
||||
? undefined
|
||||
: Math.max(0, groupCapacityHours - hours);
|
||||
const overbookedHours =
|
||||
groupCapacityHours === undefined
|
||||
? undefined
|
||||
: Math.max(0, hours - groupCapacityHours);
|
||||
return {
|
||||
name,
|
||||
hours,
|
||||
capacityHours: groupCapacityHours,
|
||||
remainingHours,
|
||||
overbookedHours,
|
||||
utilizationPct:
|
||||
groupCapacityHours && groupCapacityHours > 0
|
||||
? Math.round((hours / groupCapacityHours) * 100)
|
||||
: groupCapacityHours === 0
|
||||
? 0
|
||||
: undefined,
|
||||
};
|
||||
})
|
||||
.sort(
|
||||
(left, right) =>
|
||||
(right.utilizationPct ?? -1) - (left.utilizationPct ?? -1) ||
|
||||
right.hours - left.hours ||
|
||||
left.name.localeCompare(right.name),
|
||||
);
|
||||
const totalHours = [...groups.values()].reduce((sum, hours) => sum + hours, 0);
|
||||
const capacityHours = capacityByBucket.get(period) ?? 0;
|
||||
const remainingCapacityHours = Math.max(0, capacityHours - totalHours);
|
||||
const overbookedHours = Math.max(0, totalHours - capacityHours);
|
||||
|
||||
return {
|
||||
period,
|
||||
groups: groupRows,
|
||||
totalHours,
|
||||
capacityHours,
|
||||
periodStart: bucketPeriod.start.toISOString().slice(0, 10),
|
||||
periodEnd: bucketPeriod.end.toISOString().slice(0, 10),
|
||||
bookedHours: totalHours,
|
||||
remainingHours: remainingCapacityHours,
|
||||
overbookedHours,
|
||||
utilizationPct: capacityHours > 0
|
||||
? Math.round((totalHours / capacityHours) * 100)
|
||||
: 0,
|
||||
groupCount: groupRows.length,
|
||||
resourceCount: resourceMap.size,
|
||||
derivation: {
|
||||
periodStart: bucketPeriod.start.toISOString().slice(0, 10),
|
||||
periodEnd: bucketPeriod.end.toISOString().slice(0, 10),
|
||||
resourceCount: resourceMap.size,
|
||||
groupCount: groupRows.length,
|
||||
bookedHours: totalHours,
|
||||
capacityHours,
|
||||
remainingCapacityHours,
|
||||
overbookedHours,
|
||||
utilizationPct: capacityHours > 0
|
||||
? Math.round((totalHours / capacityHours) * 100)
|
||||
: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { PrismaClient } from "@capakraken/db";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import { calculateInclusiveDays } from "./shared.js";
|
||||
import {
|
||||
calculateEffectiveAllocationCostCents,
|
||||
loadDailyAvailabilityContexts,
|
||||
} from "./holiday-capacity.js";
|
||||
|
||||
export interface ProjectHealthRow {
|
||||
id: string;
|
||||
@@ -11,6 +16,53 @@ export interface ProjectHealthRow {
|
||||
staffingHealth: number;
|
||||
timelineHealth: number;
|
||||
compositeScore: number;
|
||||
budgetCents?: number | null;
|
||||
spentCents?: number;
|
||||
remainingBudgetCents?: number | null;
|
||||
budgetUtilizationPercent?: number | null;
|
||||
demandHeadcountTotal?: number;
|
||||
demandHeadcountFilled?: number;
|
||||
demandHeadcountOpen?: number;
|
||||
demandRequirementCount?: number;
|
||||
plannedEndDate?: Date | null;
|
||||
daysUntilEndDate?: number | null;
|
||||
timelineStatus?: "ON_TRACK" | "DUE_SOON" | "OVERDUE" | "UNSCHEDULED";
|
||||
calendarLocations?: Array<{
|
||||
countryCode: string | null;
|
||||
countryName: string | null;
|
||||
federalState: string | null;
|
||||
metroCityName: string | null;
|
||||
assignmentCount: number;
|
||||
spentCents: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
function hasAvailability<T extends { availability?: unknown }>(
|
||||
resource: T | null | undefined,
|
||||
): resource is T & { availability: WeekdayAvailability } {
|
||||
return resource !== null && resource !== undefined && resource.availability !== null && resource.availability !== undefined;
|
||||
}
|
||||
|
||||
function toUtcDayStart(value: Date): Date {
|
||||
return new Date(Date.UTC(
|
||||
value.getUTCFullYear(),
|
||||
value.getUTCMonth(),
|
||||
value.getUTCDate(),
|
||||
));
|
||||
}
|
||||
|
||||
function buildLocationKey(input: {
|
||||
countryCode: string | null | undefined;
|
||||
countryName: string | null | undefined;
|
||||
federalState: string | null | undefined;
|
||||
metroCityName: string | null | undefined;
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
countryCode: input.countryCode ?? null,
|
||||
countryName: input.countryName ?? null,
|
||||
federalState: input.federalState ?? null,
|
||||
metroCityName: input.metroCityName ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDashboardProjectHealth(
|
||||
@@ -55,14 +107,79 @@ export async function getDashboardProjectHealth(
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
dailyCostCents: true,
|
||||
},
|
||||
resource: {
|
||||
select: {
|
||||
id: true,
|
||||
availability: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true, name: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const contextStart = assignments.length > 0
|
||||
? new Date(Math.min(...assignments.map((assignment) => assignment.startDate.getTime())))
|
||||
: new Date();
|
||||
const contextEnd = assignments.length > 0
|
||||
? new Date(Math.max(...assignments.map((assignment) => assignment.endDate.getTime())))
|
||||
: new Date();
|
||||
const contexts = await loadDailyAvailabilityContexts(
|
||||
db,
|
||||
assignments
|
||||
.map((assignment) => assignment.resource)
|
||||
.filter(hasAvailability)
|
||||
.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
contextStart,
|
||||
contextEnd,
|
||||
);
|
||||
const spentByProject = new Map<string, number>();
|
||||
const calendarLocationsByProject = new Map<string, Map<string, NonNullable<ProjectHealthRow["calendarLocations"]>[number]>>();
|
||||
for (const a of assignments) {
|
||||
const days = calculateInclusiveDays(a.startDate, a.endDate);
|
||||
const cost = (a.dailyCostCents ?? 0) * days;
|
||||
const cost = hasAvailability(a.resource)
|
||||
? calculateEffectiveAllocationCostCents({
|
||||
availability: a.resource.availability as unknown as WeekdayAvailability,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
dailyCostCents: a.dailyCostCents ?? 0,
|
||||
periodStart: a.startDate,
|
||||
periodEnd: a.endDate,
|
||||
context: contexts.get(a.resource.id),
|
||||
})
|
||||
: (a.dailyCostCents ?? 0) * calculateInclusiveDays(a.startDate, a.endDate);
|
||||
spentByProject.set(a.projectId, (spentByProject.get(a.projectId) ?? 0) + cost);
|
||||
if (a.resource) {
|
||||
const projectLocations = calendarLocationsByProject.get(a.projectId) ?? new Map();
|
||||
const locationKey = buildLocationKey({
|
||||
countryCode: a.resource.country?.code,
|
||||
countryName: a.resource.country?.name,
|
||||
federalState: a.resource.federalState,
|
||||
metroCityName: a.resource.metroCity?.name,
|
||||
});
|
||||
const summary = projectLocations.get(locationKey) ?? {
|
||||
countryCode: a.resource.country?.code ?? null,
|
||||
countryName: a.resource.country?.name ?? null,
|
||||
federalState: a.resource.federalState ?? null,
|
||||
metroCityName: a.resource.metroCity?.name ?? null,
|
||||
assignmentCount: 0,
|
||||
spentCents: 0,
|
||||
};
|
||||
summary.assignmentCount += 1;
|
||||
summary.spentCents += cost;
|
||||
projectLocations.set(locationKey, summary);
|
||||
calendarLocationsByProject.set(a.projectId, projectLocations);
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
@@ -71,7 +188,7 @@ export async function getDashboardProjectHealth(
|
||||
// Budget health: 100 - pctUsed (capped at 100)
|
||||
const spentCents = spentByProject.get(p.id) ?? 0;
|
||||
const pctUsed =
|
||||
p.budgetCents > 0
|
||||
(p.budgetCents ?? 0) > 0
|
||||
? Math.round((spentCents / p.budgetCents) * 100)
|
||||
: 0;
|
||||
const budgetHealth = Math.max(0, 100 - Math.min(pctUsed, 100));
|
||||
@@ -87,12 +204,29 @@ export async function getDashboardProjectHealth(
|
||||
totalDemands > 0 ? Math.round((filledDemands / totalDemands) * 100) : 100;
|
||||
|
||||
// Timeline health: 100 if end date > today, else 0
|
||||
const timelineHealth = p.endDate > now ? 100 : 0;
|
||||
const endDate = p.endDate ? toUtcDayStart(p.endDate) : null;
|
||||
const today = toUtcDayStart(now);
|
||||
const daysUntilEndDate = endDate
|
||||
? Math.round((endDate.getTime() - today.getTime()) / 86_400_000)
|
||||
: null;
|
||||
const timelineStatus = endDate === null
|
||||
? "UNSCHEDULED"
|
||||
: daysUntilEndDate! < 0
|
||||
? "OVERDUE"
|
||||
: daysUntilEndDate! <= 14
|
||||
? "DUE_SOON"
|
||||
: "ON_TRACK";
|
||||
const timelineHealth = endDate === null
|
||||
? 100
|
||||
: endDate > today
|
||||
? 100
|
||||
: 0;
|
||||
|
||||
// Composite = average of 3 dimensions
|
||||
const compositeScore = Math.round(
|
||||
(budgetHealth + staffingHealth + timelineHealth) / 3,
|
||||
);
|
||||
const remainingBudgetCents = p.budgetCents == null ? null : p.budgetCents - spentCents;
|
||||
|
||||
return {
|
||||
id: p.id,
|
||||
@@ -104,6 +238,19 @@ export async function getDashboardProjectHealth(
|
||||
staffingHealth,
|
||||
timelineHealth,
|
||||
compositeScore,
|
||||
budgetCents: p.budgetCents ?? null,
|
||||
spentCents,
|
||||
remainingBudgetCents,
|
||||
budgetUtilizationPercent: p.budgetCents && p.budgetCents > 0 ? pctUsed : null,
|
||||
demandHeadcountTotal: totalDemands,
|
||||
demandHeadcountFilled: filledDemands,
|
||||
demandHeadcountOpen: Math.max(totalDemands - filledDemands, 0),
|
||||
demandRequirementCount: p.demandRequirements.length,
|
||||
plannedEndDate: p.endDate ?? null,
|
||||
daysUntilEndDate,
|
||||
timelineStatus,
|
||||
calendarLocations: Array.from(calendarLocationsByProject.get(p.id)?.values() ?? [])
|
||||
.sort((left, right) => right.spentCents - left.spentCents),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
import { VacationStatus } from "@capakraken/db";
|
||||
import { getPublicHolidays, type WeekdayAvailability } from "@capakraken/shared";
|
||||
|
||||
const MILLISECONDS_PER_DAY = 86_400_000;
|
||||
|
||||
type CalendarScope = "COUNTRY" | "STATE" | "CITY";
|
||||
|
||||
type HolidayCalendarEntryRecord = {
|
||||
date: Date;
|
||||
isRecurringAnnual: boolean;
|
||||
};
|
||||
|
||||
type HolidayCalendarRecord = {
|
||||
entries: HolidayCalendarEntryRecord[];
|
||||
};
|
||||
|
||||
type VacationRecord = {
|
||||
resourceId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
type: string;
|
||||
isHalfDay: boolean;
|
||||
};
|
||||
|
||||
type ResourceHolidayProfile = {
|
||||
id: string;
|
||||
availability: WeekdayAvailability;
|
||||
countryId: string | null | undefined;
|
||||
countryCode: string | null | undefined;
|
||||
federalState: string | null | undefined;
|
||||
metroCityId: string | null | undefined;
|
||||
metroCityName: string | null | undefined;
|
||||
};
|
||||
|
||||
type DashboardHolidayDbClient = {
|
||||
holidayCalendar?: {
|
||||
findMany: (args: {
|
||||
where: Record<string, unknown>;
|
||||
include: { entries: true };
|
||||
orderBy: Array<Record<string, "asc" | "desc">>;
|
||||
}) => Promise<unknown[]>;
|
||||
};
|
||||
vacation?: {
|
||||
findMany: (args: {
|
||||
where: Record<string, unknown>;
|
||||
select: Record<string, boolean | Record<string, boolean>>;
|
||||
}) => Promise<unknown[]>;
|
||||
};
|
||||
};
|
||||
|
||||
type DailyAvailabilityContext = {
|
||||
holidayDates: Set<string>;
|
||||
absenceFractionsByDate: Map<string, number>;
|
||||
};
|
||||
|
||||
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
|
||||
"sunday",
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
];
|
||||
|
||||
const CITY_HOLIDAY_RULES: Array<{
|
||||
countryCode: string;
|
||||
cityName: string;
|
||||
resolveDates: (year: number) => string[];
|
||||
}> = [
|
||||
{
|
||||
countryCode: "DE",
|
||||
cityName: "Augsburg",
|
||||
resolveDates: (year) => [`${year}-08-08`],
|
||||
},
|
||||
];
|
||||
|
||||
function toIsoDate(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function normalizeCityName(cityName?: string | null): string | null {
|
||||
const normalized = cityName?.trim().toLowerCase();
|
||||
return normalized && normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeStateCode(stateCode?: string | null): string | null {
|
||||
const normalized = stateCode?.trim().toUpperCase();
|
||||
return normalized && normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function getDailyAvailabilityHours(
|
||||
availability: WeekdayAvailability,
|
||||
date: Date,
|
||||
): number {
|
||||
const key = DAY_KEYS[date.getUTCDay()];
|
||||
return key ? (availability[key] ?? 0) : 0;
|
||||
}
|
||||
|
||||
function listBuiltinHolidayDates(input: {
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
countryCode: string | null | undefined;
|
||||
federalState: string | null | undefined;
|
||||
metroCityName: string | null | undefined;
|
||||
}): Set<string> {
|
||||
const dates = new Set<string>();
|
||||
const startIso = toIsoDate(input.periodStart);
|
||||
const endIso = toIsoDate(input.periodEnd);
|
||||
const startYear = input.periodStart.getUTCFullYear();
|
||||
const endYear = input.periodEnd.getUTCFullYear();
|
||||
|
||||
if (input.countryCode === "DE") {
|
||||
for (let year = startYear; year <= endYear; year += 1) {
|
||||
for (const holiday of getPublicHolidays(year, input.federalState ?? undefined)) {
|
||||
if (holiday.date >= startIso && holiday.date <= endIso) {
|
||||
dates.add(holiday.date);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedCityName = normalizeCityName(input.metroCityName);
|
||||
if (input.countryCode && normalizedCityName) {
|
||||
for (const rule of CITY_HOLIDAY_RULES) {
|
||||
if (
|
||||
rule.countryCode === input.countryCode
|
||||
&& normalizeCityName(rule.cityName) === normalizedCityName
|
||||
) {
|
||||
for (let year = startYear; year <= endYear; year += 1) {
|
||||
for (const date of rule.resolveDates(year)) {
|
||||
if (date >= startIso && date <= endIso) {
|
||||
dates.add(date);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
function resolveCalendarEntryDates(
|
||||
calendars: HolidayCalendarRecord[],
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
): Set<string> {
|
||||
const dates = new Set<string>();
|
||||
const startIso = toIsoDate(periodStart);
|
||||
const endIso = toIsoDate(periodEnd);
|
||||
const startYear = periodStart.getUTCFullYear();
|
||||
const endYear = periodEnd.getUTCFullYear();
|
||||
|
||||
for (const calendar of calendars) {
|
||||
for (const entry of calendar.entries) {
|
||||
const baseDate = new Date(entry.date);
|
||||
for (let year = startYear; year <= endYear; year += 1) {
|
||||
const effectiveDate = entry.isRecurringAnnual
|
||||
? new Date(Date.UTC(year, baseDate.getUTCMonth(), baseDate.getUTCDate()))
|
||||
: baseDate;
|
||||
const isoDate = toIsoDate(effectiveDate);
|
||||
if (isoDate >= startIso && isoDate <= endIso) {
|
||||
dates.add(isoDate);
|
||||
}
|
||||
if (!entry.isRecurringAnnual) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
async function loadCustomHolidayDates(
|
||||
db: DashboardHolidayDbClient,
|
||||
input: {
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
countryId: string | null | undefined;
|
||||
federalState: string | null | undefined;
|
||||
metroCityId: string | null | undefined;
|
||||
},
|
||||
): Promise<Set<string>> {
|
||||
if (!input.countryId || typeof db.holidayCalendar?.findMany !== "function") {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const stateCode = normalizeStateCode(input.federalState);
|
||||
const metroCityId = input.metroCityId?.trim() || null;
|
||||
const calendars = await db.holidayCalendar.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
countryId: input.countryId,
|
||||
OR: [
|
||||
{ scopeType: "COUNTRY" as CalendarScope },
|
||||
...(stateCode ? [{ scopeType: "STATE" as CalendarScope, stateCode }] : []),
|
||||
...(metroCityId ? [{ scopeType: "CITY" as CalendarScope, metroCityId }] : []),
|
||||
],
|
||||
},
|
||||
include: { entries: true },
|
||||
orderBy: [{ priority: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
|
||||
return resolveCalendarEntryDates(
|
||||
calendars as HolidayCalendarRecord[],
|
||||
input.periodStart,
|
||||
input.periodEnd,
|
||||
);
|
||||
}
|
||||
|
||||
function buildHolidayProfileKey(profile: ResourceHolidayProfile): string {
|
||||
return JSON.stringify({
|
||||
countryId: profile.countryId ?? null,
|
||||
countryCode: profile.countryCode ?? null,
|
||||
federalState: profile.federalState ?? null,
|
||||
metroCityId: profile.metroCityId ?? null,
|
||||
metroCityName: profile.metroCityName ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadDailyAvailabilityContexts(
|
||||
db: DashboardHolidayDbClient,
|
||||
resources: ResourceHolidayProfile[],
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
): Promise<Map<string, DailyAvailabilityContext>> {
|
||||
const profileHolidayCache = new Map<string, Promise<Set<string>>>();
|
||||
const resourceIds = resources.map((resource) => resource.id);
|
||||
|
||||
const vacations = resourceIds.length > 0 && typeof db.vacation?.findMany === "function"
|
||||
? await db.vacation.findMany({
|
||||
where: {
|
||||
resourceId: { in: resourceIds },
|
||||
status: VacationStatus.APPROVED,
|
||||
startDate: { lte: periodEnd },
|
||||
endDate: { gte: periodStart },
|
||||
},
|
||||
select: {
|
||||
resourceId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
type: true,
|
||||
isHalfDay: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const vacationsByResourceId = new Map<string, VacationRecord[]>();
|
||||
for (const vacation of vacations as VacationRecord[]) {
|
||||
const items = vacationsByResourceId.get(vacation.resourceId) ?? [];
|
||||
items.push(vacation);
|
||||
vacationsByResourceId.set(vacation.resourceId, items);
|
||||
}
|
||||
|
||||
const contexts = new Map<string, DailyAvailabilityContext>();
|
||||
|
||||
for (const resource of resources) {
|
||||
const profileKey = buildHolidayProfileKey(resource);
|
||||
const holidayPromise = profileHolidayCache.get(profileKey)
|
||||
?? (async () => {
|
||||
const builtin = listBuiltinHolidayDates({
|
||||
periodStart,
|
||||
periodEnd,
|
||||
countryCode: resource.countryCode,
|
||||
federalState: resource.federalState,
|
||||
metroCityName: resource.metroCityName,
|
||||
});
|
||||
const custom = await loadCustomHolidayDates(db, {
|
||||
periodStart,
|
||||
periodEnd,
|
||||
countryId: resource.countryId,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
});
|
||||
return new Set([...builtin, ...custom]);
|
||||
})();
|
||||
|
||||
if (!profileHolidayCache.has(profileKey)) {
|
||||
profileHolidayCache.set(profileKey, holidayPromise);
|
||||
}
|
||||
|
||||
const holidayDates = new Set(await holidayPromise);
|
||||
const absenceFractionsByDate = new Map<string, number>();
|
||||
const resourceVacations = vacationsByResourceId.get(resource.id) ?? [];
|
||||
|
||||
for (const vacation of resourceVacations) {
|
||||
const overlapStart = new Date(
|
||||
Math.max(vacation.startDate.getTime(), periodStart.getTime()),
|
||||
);
|
||||
const overlapEnd = new Date(
|
||||
Math.min(vacation.endDate.getTime(), periodEnd.getTime()),
|
||||
);
|
||||
|
||||
if (overlapStart > overlapEnd) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const cursor = new Date(overlapStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(overlapEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
const isoDate = toIsoDate(cursor);
|
||||
const fraction = vacation.isHalfDay ? 0.5 : 1;
|
||||
|
||||
if (vacation.type === "PUBLIC_HOLIDAY") {
|
||||
holidayDates.add(isoDate);
|
||||
}
|
||||
|
||||
const existing = absenceFractionsByDate.get(isoDate) ?? 0;
|
||||
if (vacation.type === "PUBLIC_HOLIDAY" || !holidayDates.has(isoDate)) {
|
||||
absenceFractionsByDate.set(isoDate, Math.max(existing, fraction));
|
||||
}
|
||||
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const isoDate of holidayDates) {
|
||||
const existing = absenceFractionsByDate.get(isoDate) ?? 0;
|
||||
absenceFractionsByDate.set(isoDate, Math.max(existing, 1));
|
||||
}
|
||||
|
||||
contexts.set(resource.id, {
|
||||
holidayDates,
|
||||
absenceFractionsByDate,
|
||||
});
|
||||
}
|
||||
|
||||
return contexts;
|
||||
}
|
||||
|
||||
function calculateDayAvailabilityFraction(
|
||||
context: DailyAvailabilityContext | undefined,
|
||||
isoDate: string,
|
||||
): number {
|
||||
const fraction = context?.absenceFractionsByDate.get(isoDate) ?? 0;
|
||||
return Math.max(0, 1 - fraction);
|
||||
}
|
||||
|
||||
export function calculateEffectiveAvailableHours(input: {
|
||||
availability: WeekdayAvailability;
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
context: DailyAvailabilityContext | undefined;
|
||||
}): number {
|
||||
let hours = 0;
|
||||
const cursor = new Date(input.periodStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(input.periodEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
const baseHours = getDailyAvailabilityHours(input.availability, cursor);
|
||||
if (baseHours > 0) {
|
||||
hours += baseHours * calculateDayAvailabilityFraction(input.context, toIsoDate(cursor));
|
||||
}
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return hours;
|
||||
}
|
||||
|
||||
export function calculateEffectiveAllocationHours(input: {
|
||||
availability: WeekdayAvailability;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
hoursPerDay: number;
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
context: DailyAvailabilityContext | undefined;
|
||||
}): number {
|
||||
let hours = 0;
|
||||
const overlapStart = new Date(
|
||||
Math.max(input.startDate.getTime(), input.periodStart.getTime()),
|
||||
);
|
||||
const overlapEnd = new Date(
|
||||
Math.min(input.endDate.getTime(), input.periodEnd.getTime()),
|
||||
);
|
||||
|
||||
if (overlapStart > overlapEnd) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const cursor = new Date(overlapStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(overlapEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
const baseHours = getDailyAvailabilityHours(input.availability, cursor);
|
||||
if (baseHours > 0) {
|
||||
hours += input.hoursPerDay * calculateDayAvailabilityFraction(input.context, toIsoDate(cursor));
|
||||
}
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return hours;
|
||||
}
|
||||
|
||||
export function calculateEffectiveAllocationCostCents(input: {
|
||||
availability: WeekdayAvailability;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
dailyCostCents: number;
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
context: DailyAvailabilityContext | undefined;
|
||||
}): number {
|
||||
let costCents = 0;
|
||||
const overlapStart = new Date(
|
||||
Math.max(input.startDate.getTime(), input.periodStart.getTime()),
|
||||
);
|
||||
const overlapEnd = new Date(
|
||||
Math.min(input.endDate.getTime(), input.periodEnd.getTime()),
|
||||
);
|
||||
|
||||
if (overlapStart > overlapEnd) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const cursor = new Date(overlapStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(overlapEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
const baseHours = getDailyAvailabilityHours(input.availability, cursor);
|
||||
if (baseHours > 0) {
|
||||
costCents += input.dailyCostCents * calculateDayAvailabilityFraction(input.context, toIsoDate(cursor));
|
||||
}
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return Math.round(costCents);
|
||||
}
|
||||
|
||||
export function enumerateIsoDates(
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
): string[] {
|
||||
const dates: string[] = [];
|
||||
const cursor = new Date(periodStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(periodEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
dates.push(toIsoDate(cursor));
|
||||
cursor.setTime(cursor.getTime() + MILLISECONDS_PER_DAY);
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
export type { DailyAvailabilityContext, ResourceHolidayProfile };
|
||||
@@ -5,6 +5,8 @@ export {
|
||||
export {
|
||||
getDashboardPeakTimes,
|
||||
type GetDashboardPeakTimesInput,
|
||||
type PeakTimesPeriodDerivation,
|
||||
type PeakTimesPeriodRow,
|
||||
} from "./get-peak-times.js";
|
||||
|
||||
export {
|
||||
@@ -15,16 +17,22 @@ export {
|
||||
export {
|
||||
getDashboardDemand,
|
||||
type GetDashboardDemandInput,
|
||||
type DemandCalendarLocationSummary,
|
||||
type DemandRowDerivation,
|
||||
type DashboardDemandRow,
|
||||
} from "./get-demand.js";
|
||||
|
||||
export {
|
||||
getDashboardChargeabilityOverview,
|
||||
type GetDashboardChargeabilityOverviewInput,
|
||||
type DashboardChargeabilityDerivation,
|
||||
type DashboardChargeabilityRow,
|
||||
} from "./get-chargeability-overview.js";
|
||||
|
||||
export {
|
||||
getDashboardBudgetForecast,
|
||||
type BudgetForecastRow,
|
||||
type BudgetForecastLocationSummary,
|
||||
} from "./get-budget-forecast.js";
|
||||
|
||||
export {
|
||||
|
||||
@@ -18,6 +18,20 @@ export const DASHBOARD_PLANNING_ALLOCATION_INCLUDE = {
|
||||
chapter: true,
|
||||
eid: true,
|
||||
lcrCents: true,
|
||||
availability: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: {
|
||||
select: {
|
||||
code: true,
|
||||
},
|
||||
},
|
||||
metroCity: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import XLSX from "xlsx";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
export type WorksheetCellValue = boolean | Date | number | string | null;
|
||||
export type WorksheetMatrix = WorksheetCellValue[][];
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { PrismaClient } from "@capakraken/db";
|
||||
import { computeValueScore } from "@capakraken/staffing";
|
||||
import { VALUE_SCORE_WEIGHTS } from "@capakraken/shared";
|
||||
import { VALUE_SCORE_WEIGHTS, type WeekdayAvailability } from "@capakraken/shared";
|
||||
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
|
||||
import {
|
||||
calculateEffectiveAllocationHours,
|
||||
calculateEffectiveAvailableHours,
|
||||
loadDailyAvailabilityContexts,
|
||||
} from "../dashboard/holiday-capacity.js";
|
||||
|
||||
type ResourceValueScoreDbClient = Partial<Pick<PrismaClient, "systemSettings">> &
|
||||
type ResourceValueScoreDbClient = Partial<Pick<PrismaClient, "systemSettings" | "holidayCalendar" | "vacation">> &
|
||||
Pick<PrismaClient, "assignment" | "resource" | "$transaction">;
|
||||
|
||||
export interface RecomputeResourceValueScoresInput {
|
||||
@@ -30,6 +35,12 @@ export async function recomputeResourceValueScores(
|
||||
skills: true,
|
||||
lcrCents: true,
|
||||
chargeabilityTarget: true,
|
||||
availability: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
}),
|
||||
db.systemSettings?.findUnique?.({ where: { id: "singleton" } }) ?? Promise.resolve(null),
|
||||
@@ -39,11 +50,27 @@ export async function recomputeResourceValueScores(
|
||||
return { updated: 0 };
|
||||
}
|
||||
|
||||
const periodStart = new Date(Date.now() - daysBack * 24 * 60 * 60 * 1000);
|
||||
const periodEnd = new Date();
|
||||
const bookings = await listAssignmentBookings(db, {
|
||||
startDate: new Date(Date.now() - daysBack * 24 * 60 * 60 * 1000),
|
||||
endDate: new Date(),
|
||||
startDate: periodStart,
|
||||
endDate: periodEnd,
|
||||
resourceIds: resources.map((resource) => resource.id),
|
||||
});
|
||||
const contexts = await loadDailyAvailabilityContexts(
|
||||
db,
|
||||
resources.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
periodStart,
|
||||
periodEnd,
|
||||
);
|
||||
|
||||
const defaultWeights = {
|
||||
skillDepth: VALUE_SCORE_WEIGHTS.SKILL_DEPTH,
|
||||
@@ -65,20 +92,29 @@ export async function recomputeResourceValueScores(
|
||||
yearsExperience?: number;
|
||||
};
|
||||
|
||||
const totalWorkDays = daysBack * (5 / 7);
|
||||
const availableHours = totalWorkDays * 8;
|
||||
|
||||
const updates = resources.map((resource) => {
|
||||
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
|
||||
const bookedHours = resourceBookings.reduce((sum, booking) => {
|
||||
const days = Math.max(
|
||||
0,
|
||||
(new Date(booking.endDate).getTime() - new Date(booking.startDate).getTime()) /
|
||||
(1000 * 60 * 60 * 24) +
|
||||
1,
|
||||
);
|
||||
return sum + booking.hoursPerDay * days;
|
||||
}, 0);
|
||||
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||
const context = contexts.get(resource.id);
|
||||
const availableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
context,
|
||||
});
|
||||
const bookedHours = resourceBookings.reduce(
|
||||
(sum, booking) =>
|
||||
sum + calculateEffectiveAllocationHours({
|
||||
availability,
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const currentChargeability =
|
||||
availableHours > 0 ? Math.min(100, (bookedHours / availableHours) * 100) : 0;
|
||||
const skills = (resource.skills as unknown as SkillRow[]) ?? [];
|
||||
|
||||
Reference in New Issue
Block a user