rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)
CI / Architecture Guardrails (push) Successful in 2m38s
CI / Assistant Split Regression (push) Successful in 3m33s
CI / Typecheck (push) Successful in 3m51s
CI / Lint (push) Successful in 5m2s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Architecture Guardrails (push) Successful in 2m38s
CI / Assistant Split Regression (push) Successful in 3m33s
CI / Typecheck (push) Successful in 3m51s
CI / Lint (push) Successful in 5m2s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61) Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com> Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
This commit was merged in pull request #61.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import type { PrismaClient } from "@capakraken/db";
|
||||
import { toIsoDate, DAY_KEYS, type WeekdayAvailability } from "@capakraken/shared";
|
||||
import type { PrismaClient } from "@nexus/db";
|
||||
import { toIsoDate, DAY_KEYS, type WeekdayAvailability } from "@nexus/shared";
|
||||
import { calculateInclusiveDays, MILLISECONDS_PER_DAY } from "./shared.js";
|
||||
import {
|
||||
calculateEffectiveAllocationCostCents,
|
||||
@@ -46,10 +46,7 @@ export interface BudgetForecastRow {
|
||||
derivation?: BudgetForecastDerivationSummary;
|
||||
}
|
||||
|
||||
function getDailyAvailabilityHours(
|
||||
availability: WeekdayAvailability,
|
||||
date: Date,
|
||||
): number {
|
||||
function getDailyAvailabilityHours(availability: WeekdayAvailability, date: Date): number {
|
||||
const dayKey = DAY_KEYS[date.getUTCDay()];
|
||||
return dayKey ? (availability[dayKey] ?? 0) : 0;
|
||||
}
|
||||
@@ -57,10 +54,12 @@ function getDailyAvailabilityHours(
|
||||
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;
|
||||
return (
|
||||
resource !== null &&
|
||||
resource !== undefined &&
|
||||
resource.availability !== null &&
|
||||
resource.availability !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
function buildLocationKey(input: {
|
||||
@@ -84,7 +83,10 @@ function summarizeBurnDerivation(input: {
|
||||
dailyCostCents: number;
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
context: Awaited<ReturnType<typeof loadDailyAvailabilityContexts>> extends Map<string, infer TValue>
|
||||
context: Awaited<ReturnType<typeof loadDailyAvailabilityContexts>> extends Map<
|
||||
string,
|
||||
infer TValue
|
||||
>
|
||||
? TValue | undefined
|
||||
: never;
|
||||
}) {
|
||||
@@ -107,12 +109,8 @@ function summarizeBurnDerivation(input: {
|
||||
context: input.context,
|
||||
});
|
||||
|
||||
const overlapStart = new Date(
|
||||
Math.max(input.startDate.getTime(), input.periodStart.getTime()),
|
||||
);
|
||||
const overlapEnd = new Date(
|
||||
Math.min(input.endDate.getTime(), input.periodEnd.getTime()),
|
||||
);
|
||||
const overlapStart = new Date(Math.max(input.startDate.getTime(), input.periodStart.getTime()));
|
||||
const overlapEnd = new Date(Math.min(input.endDate.getTime(), input.periodEnd.getTime()));
|
||||
|
||||
let publicHolidayDayEquivalent = 0;
|
||||
let publicHolidayCostDeductionCents = 0;
|
||||
@@ -157,9 +155,7 @@ function summarizeBurnDerivation(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDashboardBudgetForecast(
|
||||
db: PrismaClient,
|
||||
): Promise<BudgetForecastRow[]> {
|
||||
export async function getDashboardBudgetForecast(db: PrismaClient): Promise<BudgetForecastRow[]> {
|
||||
const projects = await db.project.findMany({
|
||||
where: { status: "ACTIVE" },
|
||||
select: {
|
||||
@@ -214,22 +210,24 @@ export async function getDashboardBudgetForecast(
|
||||
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 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
|
||||
@@ -251,21 +249,24 @@ export async function getDashboardBudgetForecast(
|
||||
const monthlyBurnByProject = new Map<string, number>();
|
||||
const activeAssignmentCountByProject = new Map<string, number>();
|
||||
const activeLocationsByProject = new Map<string, Map<string, BudgetForecastLocationSummary>>();
|
||||
const derivationByProject = new Map<string, Omit<BudgetForecastDerivationSummary, "periodStart" | "periodEnd" | "calendarContextCount">>();
|
||||
const derivationByProject = new Map<
|
||||
string,
|
||||
Omit<BudgetForecastDerivationSummary, "periodStart" | "periodEnd" | "calendarContextCount">
|
||||
>();
|
||||
|
||||
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);
|
||||
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(
|
||||
assignment.projectId,
|
||||
@@ -275,16 +276,17 @@ export async function getDashboardBudgetForecast(
|
||||
if (assignment.startDate <= now && assignment.endDate >= now) {
|
||||
const derivation = hasAvailability(assignment.resource)
|
||||
? summarizeBurnDerivation({
|
||||
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),
|
||||
})
|
||||
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),
|
||||
})
|
||||
: null;
|
||||
const monthlyContribution = derivation?.adjustedBurnRateCents ?? (assignment.dailyCostCents ?? 0) * 22;
|
||||
const monthlyContribution =
|
||||
derivation?.adjustedBurnRateCents ?? (assignment.dailyCostCents ?? 0) * 22;
|
||||
const baseMonthlyContribution = derivation?.baseBurnRateCents ?? monthlyContribution;
|
||||
monthlyBurnByProject.set(
|
||||
assignment.projectId,
|
||||
@@ -309,7 +311,8 @@ export async function getDashboardBudgetForecast(
|
||||
if (derivation) {
|
||||
existingDerivation.holidayAwareAssignmentCount += 1;
|
||||
existingDerivation.publicHolidayDayEquivalent += derivation.publicHolidayDayEquivalent;
|
||||
existingDerivation.publicHolidayCostDeductionCents += derivation.publicHolidayCostDeductionCents;
|
||||
existingDerivation.publicHolidayCostDeductionCents +=
|
||||
derivation.publicHolidayCostDeductionCents;
|
||||
existingDerivation.absenceDayEquivalent += derivation.absenceDayEquivalent;
|
||||
existingDerivation.absenceCostDeductionCents += derivation.absenceCostDeductionCents;
|
||||
} else {
|
||||
@@ -343,16 +346,13 @@ export async function getDashboardBudgetForecast(
|
||||
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;
|
||||
const pctUsed =
|
||||
project.budgetCents > 0 ? Math.round((spentCents / project.budgetCents) * 100) : 0;
|
||||
|
||||
let estimatedExhaustionDate: string | null = null;
|
||||
if (burnRate > 0 && project.budgetCents > spentCents) {
|
||||
const monthsRemaining = remainingCents / burnRate;
|
||||
const exhaustionDate = new Date(
|
||||
now.getTime() + monthsRemaining * 30 * MILLISECONDS_PER_DAY,
|
||||
);
|
||||
const exhaustionDate = new Date(now.getTime() + monthsRemaining * 30 * MILLISECONDS_PER_DAY);
|
||||
estimatedExhaustionDate = exhaustionDate.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
@@ -371,17 +371,18 @@ export async function getDashboardBudgetForecast(
|
||||
estimatedExhaustionDate,
|
||||
pctUsed,
|
||||
activeAssignmentCount: activeAssignmentCountByProject.get(project.id) ?? 0,
|
||||
calendarLocations: Array.from(activeLocationsByProject.get(project.id)?.values() ?? [])
|
||||
.sort((left, right) => right.burnRateCents - left.burnRateCents),
|
||||
calendarLocations: Array.from(activeLocationsByProject.get(project.id)?.values() ?? []).sort(
|
||||
(left, right) => right.burnRateCents - left.burnRateCents,
|
||||
),
|
||||
...(derivation
|
||||
? {
|
||||
derivation: {
|
||||
periodStart: toIsoDate(monthStart),
|
||||
periodEnd: toIsoDate(monthEnd),
|
||||
calendarContextCount: activeLocationsByProject.get(project.id)?.size ?? 0,
|
||||
...derivation,
|
||||
},
|
||||
}
|
||||
derivation: {
|
||||
periodStart: toIsoDate(monthStart),
|
||||
periodEnd: toIsoDate(monthEnd),
|
||||
calendarContextCount: activeLocationsByProject.get(project.id)?.size ?? 0,
|
||||
...derivation,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PrismaClient } from "@capakraken/db";
|
||||
import { toIsoDate, DAY_KEYS, type WeekdayAvailability } from "@capakraken/shared";
|
||||
import type { PrismaClient } from "@nexus/db";
|
||||
import { toIsoDate, DAY_KEYS, type WeekdayAvailability } from "@nexus/shared";
|
||||
import {
|
||||
isChargeabilityActualBooking,
|
||||
isChargeabilityRelevantProject,
|
||||
@@ -62,10 +62,7 @@ export interface DashboardChargeabilityOverview {
|
||||
month: string;
|
||||
}
|
||||
|
||||
function getDailyAvailabilityHours(
|
||||
availability: WeekdayAvailability,
|
||||
date: Date,
|
||||
): number {
|
||||
function getDailyAvailabilityHours(availability: WeekdayAvailability, date: Date): number {
|
||||
const dayKey = DAY_KEYS[date.getUTCDay()];
|
||||
return dayKey ? (availability[dayKey] ?? 0) : 0;
|
||||
}
|
||||
@@ -219,8 +216,8 @@ export async function getDashboardChargeabilityOverview(
|
||||
const actualAllocations = resourceBookings.filter((booking) =>
|
||||
isChargeabilityActualBooking(booking, input.includeProposed === true),
|
||||
);
|
||||
const expectedAllocations = resourceBookings.filter(
|
||||
(booking) => isChargeabilityRelevantProject(booking.project, true),
|
||||
const expectedAllocations = resourceBookings.filter((booking) =>
|
||||
isChargeabilityRelevantProject(booking.project, true),
|
||||
);
|
||||
const availableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
@@ -229,35 +226,41 @@ export async function getDashboardChargeabilityOverview(
|
||||
context,
|
||||
});
|
||||
const actualBookedHours = actualAllocations.reduce(
|
||||
(sum, allocation) => sum + calculateEffectiveAllocationHours({
|
||||
availability,
|
||||
startDate: allocation.startDate,
|
||||
endDate: allocation.endDate,
|
||||
hoursPerDay: allocation.hoursPerDay,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
}),
|
||||
(sum, allocation) =>
|
||||
sum +
|
||||
calculateEffectiveAllocationHours({
|
||||
availability,
|
||||
startDate: allocation.startDate,
|
||||
endDate: allocation.endDate,
|
||||
hoursPerDay: allocation.hoursPerDay,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const expectedBookedHours = expectedAllocations.reduce(
|
||||
(sum, allocation) => sum + calculateEffectiveAllocationHours({
|
||||
availability,
|
||||
startDate: allocation.startDate,
|
||||
endDate: allocation.endDate,
|
||||
hoursPerDay: allocation.hoursPerDay,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
}),
|
||||
(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 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 {
|
||||
@@ -288,13 +291,11 @@ export async function getDashboardChargeabilityOverview(
|
||||
|
||||
return {
|
||||
rows: stats,
|
||||
top: [...stats]
|
||||
.sort((left, right) => right.actualChargeability - left.actualChargeability),
|
||||
top: [...stats].sort((left, right) => right.actualChargeability - left.actualChargeability),
|
||||
watchlist: [...stats]
|
||||
.filter(
|
||||
(resource) =>
|
||||
resource.actualChargeability <
|
||||
resource.chargeabilityTarget - input.watchlistThreshold,
|
||||
resource.actualChargeability < resource.chargeabilityTarget - input.watchlistThreshold,
|
||||
)
|
||||
.sort((left, right) => left.actualChargeability - right.actualChargeability),
|
||||
month: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PrismaClient } from "@capakraken/db";
|
||||
import { toIsoDate, type WeekdayAvailability } from "@capakraken/shared";
|
||||
import type { PrismaClient } from "@nexus/db";
|
||||
import { toIsoDate, type WeekdayAvailability } from "@nexus/shared";
|
||||
import { loadDashboardPlanningReadModel } from "./load-dashboard-planning-read-model.js";
|
||||
import { calculateAllocationHours } from "./shared.js";
|
||||
import {
|
||||
@@ -54,7 +54,12 @@ interface ProjectSummary {
|
||||
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;
|
||||
return (
|
||||
resource !== null &&
|
||||
resource !== undefined &&
|
||||
resource.availability !== null &&
|
||||
resource.availability !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
function getDemandFteFactor(hoursPerDay: number, percentage: number): number {
|
||||
@@ -113,18 +118,29 @@ function summarizeCalendarLocations(
|
||||
endDate: Date | string;
|
||||
hoursPerDay: number;
|
||||
}>,
|
||||
resourceInfoById: Map<string, {
|
||||
id: string;
|
||||
availability: WeekdayAvailability;
|
||||
countryCode: string | null | undefined;
|
||||
countryName: 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>,
|
||||
resourceInfoById: Map<
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
availability: WeekdayAvailability;
|
||||
countryCode: string | null | undefined;
|
||||
countryName: 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> }>();
|
||||
const locationMap = new Map<
|
||||
string,
|
||||
DemandCalendarLocationSummary & { resourceIds: Set<string> }
|
||||
>();
|
||||
|
||||
for (const assignment of assignments) {
|
||||
const resourceId = assignment.resource?.id ?? undefined;
|
||||
@@ -184,10 +200,7 @@ export async function getDashboardDemand(
|
||||
});
|
||||
|
||||
const demandRequirementById = new Map(
|
||||
demandRequirements.map((demandRequirement) => [
|
||||
demandRequirement.id,
|
||||
demandRequirement,
|
||||
]),
|
||||
demandRequirements.map((demandRequirement) => [demandRequirement.id, demandRequirement]),
|
||||
);
|
||||
const normalizedAssignments = readModel.assignments;
|
||||
const normalizedDemands = readModel.demands;
|
||||
@@ -204,9 +217,7 @@ export async function getDashboardDemand(
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
}));
|
||||
const resourceInfoById = new Map(
|
||||
resourceProfiles.map((resource) => [resource.id, resource]),
|
||||
);
|
||||
const resourceInfoById = new Map(resourceProfiles.map((resource) => [resource.id, resource]));
|
||||
const contexts = await loadDailyAvailabilityContexts(
|
||||
db,
|
||||
[...new Map(resourceProfiles.map((resource) => [resource.id, resource])).values()],
|
||||
@@ -267,18 +278,16 @@ export async function getDashboardDemand(
|
||||
const projectAssignments = normalizedAssignments.filter(
|
||||
(assignment) => assignment.projectId === projectId,
|
||||
);
|
||||
const projectDemands = normalizedDemands.filter(
|
||||
(demand) => demand.projectId === projectId,
|
||||
);
|
||||
const projectDemands = normalizedDemands.filter((demand) => demand.projectId === projectId);
|
||||
|
||||
const allocatedHours = projectAssignments.reduce(
|
||||
(sum, assignment) => {
|
||||
const resource = assignment.resource?.id
|
||||
? resourceInfoById.get(assignment.resource.id)
|
||||
: undefined;
|
||||
return sum + (
|
||||
resource
|
||||
? calculateEffectiveAllocationHours({
|
||||
const allocatedHours = projectAssignments.reduce((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),
|
||||
@@ -287,22 +296,17 @@ export async function getDashboardDemand(
|
||||
periodEnd: input.endDate,
|
||||
context: contexts.get(resource.id),
|
||||
})
|
||||
: calculateAllocationHours({
|
||||
: calculateAllocationHours({
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
})
|
||||
);
|
||||
},
|
||||
0,
|
||||
);
|
||||
}))
|
||||
);
|
||||
}, 0);
|
||||
const requiredFTEs =
|
||||
projectDemands.length > 0
|
||||
? projectDemands.reduce((sum, demand) => {
|
||||
const demandFteFactor = getDemandFteFactor(
|
||||
demand.hoursPerDay,
|
||||
demand.percentage,
|
||||
);
|
||||
const demandFteFactor = getDemandFteFactor(demand.hoursPerDay, demand.percentage);
|
||||
const explicitDemand = demandRequirementById.get(demand.id);
|
||||
if (!explicitDemand) {
|
||||
return sum + demand.requestedHeadcount * demandFteFactor;
|
||||
@@ -316,12 +320,12 @@ 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 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,
|
||||
@@ -345,9 +349,7 @@ export async function getDashboardDemand(
|
||||
requiredHours,
|
||||
requiredFTEs: Math.round(requiredFTEs * 100) / 100,
|
||||
fillPct,
|
||||
demandSource: projectDemands.length > 0
|
||||
? "DEMAND_REQUIREMENTS"
|
||||
: "PROJECT_STAFFING_REQS",
|
||||
demandSource: projectDemands.length > 0 ? "DEMAND_REQUIREMENTS" : "PROJECT_STAFFING_REQS",
|
||||
calendarLocations,
|
||||
},
|
||||
};
|
||||
@@ -355,10 +357,7 @@ export async function getDashboardDemand(
|
||||
}
|
||||
|
||||
if (input.groupBy === "chapter") {
|
||||
const chapterMap = new Map<
|
||||
string,
|
||||
{ allocatedHours: number; resourceIds: Set<string> }
|
||||
>();
|
||||
const chapterMap = new Map<string, { allocatedHours: number; resourceIds: Set<string> }>();
|
||||
|
||||
for (const assignment of normalizedAssignments) {
|
||||
const chapter = assignment.resource?.chapter ?? "Unassigned";
|
||||
@@ -376,19 +375,19 @@ export async function getDashboardDemand(
|
||||
|
||||
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),
|
||||
})
|
||||
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,
|
||||
});
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
});
|
||||
|
||||
chapterMap.set(chapter, existing);
|
||||
}
|
||||
@@ -449,19 +448,19 @@ export async function getDashboardDemand(
|
||||
|
||||
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),
|
||||
})
|
||||
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,
|
||||
});
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
});
|
||||
existing.projectIds.add(assignment.projectId);
|
||||
|
||||
personMap.set(assignment.resource.id, existing);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { PrismaClient } from "@capakraken/db";
|
||||
import { VacationStatus } from "@capakraken/db";
|
||||
import { AllocationStatus } from "@capakraken/shared";
|
||||
import type { PrismaClient } from "@nexus/db";
|
||||
import { VacationStatus } from "@nexus/db";
|
||||
import { AllocationStatus } from "@nexus/shared";
|
||||
import { buildSplitAllocationReadModel } from "../allocation/build-split-allocation-read-model.js";
|
||||
import { calculateInclusiveDays } from "./shared.js";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import type { WeekdayAvailability } from "@nexus/shared";
|
||||
import {
|
||||
calculateEffectiveAllocationCostCents,
|
||||
loadDailyAvailabilityContexts,
|
||||
@@ -12,7 +12,12 @@ import {
|
||||
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;
|
||||
return (
|
||||
resource !== null &&
|
||||
resource !== undefined &&
|
||||
resource.availability !== null &&
|
||||
resource.availability !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
export async function getDashboardOverview(db: PrismaClient) {
|
||||
@@ -110,12 +115,14 @@ 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 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
|
||||
@@ -136,9 +143,9 @@ export async function getDashboardOverview(db: PrismaClient) {
|
||||
|
||||
const totalCostCents = budgetAssignments.reduce(
|
||||
(sum, assignment) =>
|
||||
sum + (
|
||||
hasAvailability(assignment.resource)
|
||||
? calculateEffectiveAllocationCostCents({
|
||||
sum +
|
||||
(hasAvailability(assignment.resource)
|
||||
? calculateEffectiveAllocationCostCents({
|
||||
availability: assignment.resource.availability as unknown as WeekdayAvailability,
|
||||
startDate: assignment.startDate,
|
||||
endDate: assignment.endDate,
|
||||
@@ -147,9 +154,8 @@ export async function getDashboardOverview(db: PrismaClient) {
|
||||
periodEnd: assignment.endDate,
|
||||
context: contexts.get(assignment.resource.id),
|
||||
})
|
||||
: (assignment.dailyCostCents ?? 0) *
|
||||
calculateInclusiveDays(assignment.startDate, assignment.endDate)
|
||||
),
|
||||
: (assignment.dailyCostCents ?? 0) *
|
||||
calculateInclusiveDays(assignment.startDate, assignment.endDate)),
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -165,22 +171,14 @@ export async function getDashboardOverview(db: PrismaClient) {
|
||||
const remainingBudgetCents = totalBudgetCents - totalCostCents;
|
||||
|
||||
const avgUtilizationPercent =
|
||||
totalBudgetCents > 0
|
||||
? Math.round((totalCostCents / totalBudgetCents) * 100)
|
||||
: 0;
|
||||
totalBudgetCents > 0 ? Math.round((totalCostCents / totalBudgetCents) * 100) : 0;
|
||||
|
||||
const statusCountMap = new Map<string, number>();
|
||||
for (const project of allProjects) {
|
||||
statusCountMap.set(
|
||||
project.status,
|
||||
(statusCountMap.get(project.status) ?? 0) + 1,
|
||||
);
|
||||
statusCountMap.set(project.status, (statusCountMap.get(project.status) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const chapterMap = new Map<
|
||||
string,
|
||||
{ resourceCount: number; chargeabilitySum: number }
|
||||
>();
|
||||
const chapterMap = new Map<string, { resourceCount: number; chargeabilitySum: number }>();
|
||||
for (const resource of allResources) {
|
||||
const chapter = resource.chapter ?? "Unassigned";
|
||||
const existing = chapterMap.get(chapter) ?? {
|
||||
@@ -190,8 +188,7 @@ export async function getDashboardOverview(db: PrismaClient) {
|
||||
|
||||
chapterMap.set(chapter, {
|
||||
resourceCount: existing.resourceCount + 1,
|
||||
chargeabilitySum:
|
||||
existing.chargeabilitySum + (resource.chargeabilityTarget ?? 0),
|
||||
chargeabilitySum: existing.chargeabilitySum + (resource.chargeabilityTarget ?? 0),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -234,9 +231,7 @@ export async function getDashboardOverview(db: PrismaClient) {
|
||||
chapter,
|
||||
resourceCount: data.resourceCount,
|
||||
avgChargeabilityTarget:
|
||||
data.resourceCount > 0
|
||||
? Math.round(data.chargeabilitySum / data.resourceCount)
|
||||
: 0,
|
||||
data.resourceCount > 0 ? Math.round(data.chargeabilitySum / data.resourceCount) : 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PrismaClient } from "@capakraken/db";
|
||||
import { toIsoDate, DAY_KEYS, type WeekdayAvailability } from "@capakraken/shared";
|
||||
import type { PrismaClient } from "@nexus/db";
|
||||
import { toIsoDate, DAY_KEYS, type WeekdayAvailability } from "@nexus/shared";
|
||||
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
|
||||
import { getMonthBucketKey, getWeekBucketKey } from "./shared.js";
|
||||
import {
|
||||
@@ -99,10 +99,7 @@ function buildLocationKey(input: {
|
||||
});
|
||||
}
|
||||
|
||||
function getDailyAvailabilityHours(
|
||||
availability: WeekdayAvailability,
|
||||
date: Date,
|
||||
): number {
|
||||
function getDailyAvailabilityHours(availability: WeekdayAvailability, date: Date): number {
|
||||
const dayKey = DAY_KEYS[date.getUTCDay()];
|
||||
return dayKey ? (availability[dayKey] ?? 0) : 0;
|
||||
}
|
||||
@@ -178,7 +175,10 @@ function summarizeCalendarLocations(
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
): PeakTimesCalendarLocationSummary[] {
|
||||
const locationMap = new Map<string, PeakTimesCalendarLocationSummary & { resourceIds: Set<string> }>();
|
||||
const locationMap = new Map<
|
||||
string,
|
||||
PeakTimesCalendarLocationSummary & { resourceIds: Set<string> }
|
||||
>();
|
||||
|
||||
for (const resource of resources) {
|
||||
const capacityDerivation = summarizeCapacityDerivation(
|
||||
@@ -306,19 +306,19 @@ export async function getDashboardPeakTimes(
|
||||
input.groupBy === "project"
|
||||
? allocation.project.shortCode
|
||||
: input.groupBy === "chapter"
|
||||
? allocation.resource?.chapter ?? "Unassigned"
|
||||
: allocation.resource?.displayName ?? "Unknown";
|
||||
? (allocation.resource?.chapter ?? "Unassigned")
|
||||
: (allocation.resource?.displayName ?? "Unknown");
|
||||
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),
|
||||
})
|
||||
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;
|
||||
@@ -351,15 +351,16 @@ export async function getDashboardPeakTimes(
|
||||
capacityHours += effectiveAvailableHours;
|
||||
derivationTotals.baseAvailableHours += capacityDerivation.baseAvailableHours;
|
||||
derivationTotals.effectiveAvailableHours += capacityDerivation.effectiveAvailableHours;
|
||||
derivationTotals.publicHolidayHoursDeduction += capacityDerivation.publicHolidayHoursDeduction;
|
||||
derivationTotals.publicHolidayHoursDeduction +=
|
||||
capacityDerivation.publicHolidayHoursDeduction;
|
||||
derivationTotals.absenceDayEquivalent += capacityDerivation.absenceDayEquivalent;
|
||||
derivationTotals.absenceHoursDeduction += capacityDerivation.absenceHoursDeduction;
|
||||
|
||||
if (input.groupBy !== "project" && effectiveAvailableHours > 0) {
|
||||
const group =
|
||||
input.groupBy === "chapter"
|
||||
? resource.chapter ?? "Unassigned"
|
||||
: resource.displayName ?? "Unknown";
|
||||
? (resource.chapter ?? "Unassigned")
|
||||
: (resource.displayName ?? "Unknown");
|
||||
const groupCapacityBucket = groupCapacityBuckets.get(bucketKey)!;
|
||||
groupCapacityBucket.set(
|
||||
group,
|
||||
@@ -398,15 +399,11 @@ export async function getDashboardPeakTimes(
|
||||
.map((name) => {
|
||||
const hours = groups.get(name) ?? 0;
|
||||
const groupCapacityHours =
|
||||
input.groupBy === "project" ? undefined : groupCapacities.get(name) ?? 0;
|
||||
input.groupBy === "project" ? undefined : (groupCapacities.get(name) ?? 0);
|
||||
const remainingHours =
|
||||
groupCapacityHours === undefined
|
||||
? undefined
|
||||
: Math.max(0, groupCapacityHours - hours);
|
||||
groupCapacityHours === undefined ? undefined : Math.max(0, groupCapacityHours - hours);
|
||||
const overbookedHours =
|
||||
groupCapacityHours === undefined
|
||||
? undefined
|
||||
: Math.max(0, hours - groupCapacityHours);
|
||||
groupCapacityHours === undefined ? undefined : Math.max(0, hours - groupCapacityHours);
|
||||
return {
|
||||
name,
|
||||
hours,
|
||||
@@ -429,8 +426,9 @@ export async function getDashboardPeakTimes(
|
||||
);
|
||||
const totalHours = [...groups.values()].reduce((sum, hours) => sum + hours, 0);
|
||||
const capacityHours = capacityByBucket.get(period) ?? 0;
|
||||
const capacityDerivation: PeakTimesCapacityDerivationSummary =
|
||||
derivationByBucket.get(period) ?? {
|
||||
const capacityDerivation: PeakTimesCapacityDerivationSummary = derivationByBucket.get(
|
||||
period,
|
||||
) ?? {
|
||||
baseAvailableHours: capacityHours,
|
||||
effectiveAvailableHours: capacityHours,
|
||||
publicHolidayHoursDeduction: 0,
|
||||
@@ -452,9 +450,7 @@ export async function getDashboardPeakTimes(
|
||||
bookedHours: totalHours,
|
||||
remainingHours: remainingCapacityHours,
|
||||
overbookedHours,
|
||||
utilizationPct: capacityHours > 0
|
||||
? Math.round((totalHours / capacityHours) * 100)
|
||||
: 0,
|
||||
utilizationPct: capacityHours > 0 ? Math.round((totalHours / capacityHours) * 100) : 0,
|
||||
groupCount: groupRows.length,
|
||||
resourceCount: resourceMap.size,
|
||||
derivation: {
|
||||
@@ -473,9 +469,7 @@ export async function getDashboardPeakTimes(
|
||||
capacityHours,
|
||||
remainingCapacityHours,
|
||||
overbookedHours,
|
||||
utilizationPct: capacityHours > 0
|
||||
? Math.round((totalHours / capacityHours) * 100)
|
||||
: 0,
|
||||
utilizationPct: capacityHours > 0 ? Math.round((totalHours / capacityHours) * 100) : 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PrismaClient } from "@capakraken/db";
|
||||
import { toIsoDate, MILLISECONDS_PER_DAY, DAY_KEYS, type WeekdayAvailability } from "@capakraken/shared";
|
||||
import type { PrismaClient } from "@nexus/db";
|
||||
import { toIsoDate, MILLISECONDS_PER_DAY, DAY_KEYS, type WeekdayAvailability } from "@nexus/shared";
|
||||
import { calculateInclusiveDays } from "./shared.js";
|
||||
import {
|
||||
calculateEffectiveAllocationCostCents,
|
||||
@@ -54,23 +54,21 @@ export interface ProjectHealthRow {
|
||||
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;
|
||||
return (
|
||||
resource !== null &&
|
||||
resource !== undefined &&
|
||||
resource.availability !== null &&
|
||||
resource.availability !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
function getDailyAvailabilityHours(
|
||||
availability: WeekdayAvailability,
|
||||
date: Date,
|
||||
): number {
|
||||
function getDailyAvailabilityHours(availability: WeekdayAvailability, date: Date): number {
|
||||
const dayKey = DAY_KEYS[date.getUTCDay()];
|
||||
return dayKey ? (availability[dayKey] ?? 0) : 0;
|
||||
}
|
||||
|
||||
function toUtcDayStart(value: Date): Date {
|
||||
return new Date(Date.UTC(
|
||||
value.getUTCFullYear(),
|
||||
value.getUTCMonth(),
|
||||
value.getUTCDate(),
|
||||
));
|
||||
return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()));
|
||||
}
|
||||
|
||||
function buildLocationKey(input: {
|
||||
@@ -92,7 +90,10 @@ function summarizeSpentDerivation(input: {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
dailyCostCents: number;
|
||||
context: Awaited<ReturnType<typeof loadDailyAvailabilityContexts>> extends Map<string, infer TValue>
|
||||
context: Awaited<ReturnType<typeof loadDailyAvailabilityContexts>> extends Map<
|
||||
string,
|
||||
infer TValue
|
||||
>
|
||||
? TValue | undefined
|
||||
: never;
|
||||
}) {
|
||||
@@ -156,9 +157,7 @@ function summarizeSpentDerivation(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDashboardProjectHealth(
|
||||
db: PrismaClient,
|
||||
): Promise<ProjectHealthRow[]> {
|
||||
export async function getDashboardProjectHealth(db: PrismaClient): Promise<ProjectHealthRow[]> {
|
||||
const projects = await db.project.findMany({
|
||||
where: { status: "ACTIVE" },
|
||||
select: {
|
||||
@@ -199,26 +198,28 @@ 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 } },
|
||||
},
|
||||
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 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
|
||||
@@ -237,32 +238,39 @@ export async function getDashboardProjectHealth(
|
||||
contextEnd,
|
||||
);
|
||||
const spentByProject = new Map<string, number>();
|
||||
const derivationByProject = new Map<string, {
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
calendarContextCount: number;
|
||||
holidayAwareAssignmentCount: number;
|
||||
fallbackAssignmentCount: number;
|
||||
baseSpentCents: number;
|
||||
adjustedSpentCents: number;
|
||||
publicHolidayDayEquivalent: number;
|
||||
publicHolidayCostDeductionCents: number;
|
||||
absenceDayEquivalent: number;
|
||||
absenceCostDeductionCents: number;
|
||||
}>();
|
||||
const calendarLocationsByProject = new Map<string, Map<string, NonNullable<ProjectHealthRow["calendarLocations"]>[number]>>();
|
||||
const derivationByProject = new Map<
|
||||
string,
|
||||
{
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
calendarContextCount: number;
|
||||
holidayAwareAssignmentCount: number;
|
||||
fallbackAssignmentCount: number;
|
||||
baseSpentCents: number;
|
||||
adjustedSpentCents: number;
|
||||
publicHolidayDayEquivalent: number;
|
||||
publicHolidayCostDeductionCents: number;
|
||||
absenceDayEquivalent: number;
|
||||
absenceCostDeductionCents: number;
|
||||
}
|
||||
>();
|
||||
const calendarLocationsByProject = new Map<
|
||||
string,
|
||||
Map<string, NonNullable<ProjectHealthRow["calendarLocations"]>[number]>
|
||||
>();
|
||||
for (const a of assignments) {
|
||||
const derivation = hasAvailability(a.resource)
|
||||
? summarizeSpentDerivation({
|
||||
availability: a.resource.availability as unknown as WeekdayAvailability,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
dailyCostCents: a.dailyCostCents ?? 0,
|
||||
context: contexts.get(a.resource.id),
|
||||
})
|
||||
availability: a.resource.availability as unknown as WeekdayAvailability,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
dailyCostCents: a.dailyCostCents ?? 0,
|
||||
context: contexts.get(a.resource.id),
|
||||
})
|
||||
: null;
|
||||
const cost = derivation?.adjustedSpentCents
|
||||
?? (a.dailyCostCents ?? 0) * calculateInclusiveDays(a.startDate, a.endDate);
|
||||
const cost =
|
||||
derivation?.adjustedSpentCents ??
|
||||
(a.dailyCostCents ?? 0) * calculateInclusiveDays(a.startDate, a.endDate);
|
||||
spentByProject.set(a.projectId, (spentByProject.get(a.projectId) ?? 0) + cost);
|
||||
const existingDerivation = derivationByProject.get(a.projectId) ?? {
|
||||
periodStart: toIsoDate(a.startDate),
|
||||
@@ -289,7 +297,8 @@ export async function getDashboardProjectHealth(
|
||||
existingDerivation.baseSpentCents += derivation.baseSpentCents;
|
||||
existingDerivation.adjustedSpentCents += derivation.adjustedSpentCents;
|
||||
existingDerivation.publicHolidayDayEquivalent += derivation.publicHolidayDayEquivalent;
|
||||
existingDerivation.publicHolidayCostDeductionCents += derivation.publicHolidayCostDeductionCents;
|
||||
existingDerivation.publicHolidayCostDeductionCents +=
|
||||
derivation.publicHolidayCostDeductionCents;
|
||||
existingDerivation.absenceDayEquivalent += derivation.absenceDayEquivalent;
|
||||
existingDerivation.absenceCostDeductionCents += derivation.absenceCostDeductionCents;
|
||||
} else {
|
||||
@@ -326,10 +335,7 @@ export async function getDashboardProjectHealth(
|
||||
const rows: ProjectHealthRow[] = projects.map((p) => {
|
||||
// Budget health: 100 - pctUsed (capped at 100)
|
||||
const spentCents = spentByProject.get(p.id) ?? 0;
|
||||
const pctUsed =
|
||||
(p.budgetCents ?? 0) > 0
|
||||
? Math.round((spentCents / p.budgetCents) * 100)
|
||||
: 0;
|
||||
const pctUsed = (p.budgetCents ?? 0) > 0 ? Math.round((spentCents / p.budgetCents) * 100) : 0;
|
||||
const budgetHealth = Math.max(0, 100 - Math.min(pctUsed, 100));
|
||||
|
||||
// Staffing health: filledDemands / totalDemands * 100
|
||||
@@ -348,23 +354,18 @@ export async function getDashboardProjectHealth(
|
||||
const daysUntilEndDate = endDate
|
||||
? Math.round((endDate.getTime() - today.getTime()) / MILLISECONDS_PER_DAY)
|
||||
: null;
|
||||
const timelineStatus = endDate === null
|
||||
? "UNSCHEDULED"
|
||||
: daysUntilEndDate! < 0
|
||||
? "OVERDUE"
|
||||
: daysUntilEndDate! <= 14
|
||||
? "DUE_SOON"
|
||||
: "ON_TRACK";
|
||||
const timelineHealth = endDate === null
|
||||
? 100
|
||||
: endDate > today
|
||||
? 100
|
||||
: 0;
|
||||
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 compositeScore = Math.round((budgetHealth + staffingHealth + timelineHealth) / 3);
|
||||
const remainingBudgetCents = p.budgetCents == null ? null : p.budgetCents - spentCents;
|
||||
const derivation = derivationByProject.get(p.id);
|
||||
|
||||
@@ -390,8 +391,9 @@ export async function getDashboardProjectHealth(
|
||||
plannedEndDate: p.endDate ?? null,
|
||||
daysUntilEndDate,
|
||||
timelineStatus,
|
||||
calendarLocations: Array.from(calendarLocationsByProject.get(p.id)?.values() ?? [])
|
||||
.sort((left, right) => right.spentCents - left.spentCents),
|
||||
calendarLocations: Array.from(calendarLocationsByProject.get(p.id)?.values() ?? []).sort(
|
||||
(left, right) => right.spentCents - left.spentCents,
|
||||
),
|
||||
...(derivation ? { derivation } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PrismaClient } from "@capakraken/db";
|
||||
import type { PrismaClient } from "@nexus/db";
|
||||
|
||||
export interface SkillGapRow {
|
||||
skill: string;
|
||||
@@ -37,9 +37,7 @@ export interface DashboardSkillGapSummary {
|
||||
resourcesByRole: ResourcesByRoleSummaryRow[];
|
||||
}
|
||||
|
||||
export async function getDashboardSkillGaps(
|
||||
db: PrismaClient,
|
||||
): Promise<SkillGapRow[]> {
|
||||
export async function getDashboardSkillGaps(db: PrismaClient): Promise<SkillGapRow[]> {
|
||||
// Count open demand requirements grouped by required skill (from role name)
|
||||
const openDemands = await db.demandRequirement.findMany({
|
||||
where: {
|
||||
@@ -154,14 +152,15 @@ export async function getDashboardSkillGapSummary(
|
||||
|
||||
for (const resource of resources) {
|
||||
const rawSkills = Array.isArray(resource.skills)
|
||||
? resource.skills as Array<Record<string, unknown>>
|
||||
? (resource.skills as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
for (const entry of rawSkills) {
|
||||
const skillName = typeof entry.skill === "string"
|
||||
? entry.skill
|
||||
: typeof entry.name === "string"
|
||||
? entry.name
|
||||
: null;
|
||||
const skillName =
|
||||
typeof entry.skill === "string"
|
||||
? entry.skill
|
||||
: typeof entry.name === "string"
|
||||
? entry.name
|
||||
: null;
|
||||
if (!skillName) continue;
|
||||
skillSupply.set(skillName.toLowerCase(), (skillSupply.get(skillName.toLowerCase()) ?? 0) + 1);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PrismaClient } from "@capakraken/db";
|
||||
import type { ValueScoreBreakdown } from "@capakraken/shared";
|
||||
import type { PrismaClient } from "@nexus/db";
|
||||
import type { ValueScoreBreakdown } from "@nexus/shared";
|
||||
|
||||
export interface GetDashboardTopValueResourcesInput {
|
||||
limit: number;
|
||||
@@ -48,51 +48,54 @@ export async function getDashboardTopValueResources(
|
||||
where: { id: "singleton" },
|
||||
});
|
||||
|
||||
const visibleRoles =
|
||||
(settings?.scoreVisibleRoles as unknown as string[]) ?? ["ADMIN", "MANAGER"];
|
||||
const visibleRoles = (settings?.scoreVisibleRoles as unknown as string[]) ?? ["ADMIN", "MANAGER"];
|
||||
|
||||
if (!visibleRoles.includes(input.userRole)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return db.resource.findMany({
|
||||
where: { isActive: true, valueScore: { not: null } },
|
||||
select: {
|
||||
id: true,
|
||||
eid: true,
|
||||
displayName: true,
|
||||
chapter: true,
|
||||
valueScore: true,
|
||||
valueScoreBreakdown: true,
|
||||
valueScoreUpdatedAt: true,
|
||||
lcrCents: true,
|
||||
country: {
|
||||
select: {
|
||||
code: true,
|
||||
name: true,
|
||||
return db.resource
|
||||
.findMany({
|
||||
where: { isActive: true, valueScore: { not: null } },
|
||||
select: {
|
||||
id: true,
|
||||
eid: true,
|
||||
displayName: true,
|
||||
chapter: true,
|
||||
valueScore: true,
|
||||
valueScoreBreakdown: true,
|
||||
valueScoreUpdatedAt: true,
|
||||
lcrCents: true,
|
||||
country: {
|
||||
select: {
|
||||
code: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
federalState: true,
|
||||
metroCity: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
federalState: true,
|
||||
metroCity: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { valueScore: "desc" },
|
||||
take: input.limit,
|
||||
}).then((resources) => resources.map((resource) => ({
|
||||
id: resource.id,
|
||||
eid: resource.eid,
|
||||
displayName: resource.displayName,
|
||||
chapter: resource.chapter,
|
||||
valueScore: resource.valueScore,
|
||||
valueScoreBreakdown: normalizeValueScoreBreakdown(resource.valueScoreBreakdown),
|
||||
valueScoreUpdatedAt: resource.valueScoreUpdatedAt,
|
||||
lcrCents: resource.lcrCents,
|
||||
countryCode: resource.country?.code ?? null,
|
||||
countryName: resource.country?.name ?? null,
|
||||
federalState: resource.federalState ?? null,
|
||||
metroCityName: resource.metroCity?.name ?? null,
|
||||
})));
|
||||
orderBy: { valueScore: "desc" },
|
||||
take: input.limit,
|
||||
})
|
||||
.then((resources) =>
|
||||
resources.map((resource) => ({
|
||||
id: resource.id,
|
||||
eid: resource.eid,
|
||||
displayName: resource.displayName,
|
||||
chapter: resource.chapter,
|
||||
valueScore: resource.valueScore,
|
||||
valueScoreBreakdown: normalizeValueScoreBreakdown(resource.valueScoreBreakdown),
|
||||
valueScoreUpdatedAt: resource.valueScoreUpdatedAt,
|
||||
lcrCents: resource.lcrCents,
|
||||
countryCode: resource.country?.code ?? null,
|
||||
countryName: resource.country?.name ?? null,
|
||||
federalState: resource.federalState ?? null,
|
||||
metroCityName: resource.metroCity?.name ?? null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PrismaClient } from "@capakraken/db";
|
||||
import { AllocationStatus } from "@capakraken/shared";
|
||||
import type { PrismaClient } from "@nexus/db";
|
||||
import { AllocationStatus } from "@nexus/shared";
|
||||
import { buildSplitAllocationReadModel } from "../allocation/build-split-allocation-read-model.js";
|
||||
|
||||
export const DASHBOARD_PLANNING_ALLOCATION_INCLUDE = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { MILLISECONDS_PER_DAY } from "@capakraken/shared";
|
||||
import { MILLISECONDS_PER_DAY } from "@capakraken/shared";
|
||||
export { MILLISECONDS_PER_DAY } from "@nexus/shared";
|
||||
import { MILLISECONDS_PER_DAY } from "@nexus/shared";
|
||||
|
||||
export function calculateInclusiveDays(startDate: Date, endDate: Date): number {
|
||||
return (endDate.getTime() - startDate.getTime()) / MILLISECONDS_PER_DAY + 1;
|
||||
|
||||
Reference in New Issue
Block a user