1df208dbcc
Allocation bars that have active optimistic overrides (post-drag, awaiting server confirmation) now pulse subtly via animate-pulse. The pending set is derived from the existing optimisticAllocations map keys, requiring no additional state. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
500 lines
16 KiB
TypeScript
500 lines
16 KiB
TypeScript
import type { PrismaClient } from "@capakraken/db";
|
|
import { toIsoDate, 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;
|
|
endDate: Date;
|
|
groupBy: "project" | "person" | "chapter";
|
|
}
|
|
|
|
export interface DemandCalendarLocationSummary {
|
|
countryCode: string | null;
|
|
countryName: 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;
|
|
shortCode: string;
|
|
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;
|
|
}
|
|
|
|
function toDate(value: Date | string): Date {
|
|
return value instanceof Date ? value : new Date(value);
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
function getProjectRequiredFTEs(staffingReqs: unknown): number {
|
|
const requirements = Array.isArray(staffingReqs) ? staffingReqs : [];
|
|
return requirements.reduce((sum, requirement) => {
|
|
if (
|
|
typeof requirement === "object" &&
|
|
requirement !== null &&
|
|
"fteCount" in requirement &&
|
|
typeof requirement.fteCount === "number"
|
|
) {
|
|
return sum + requirement.fteCount;
|
|
}
|
|
|
|
return sum;
|
|
}, 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;
|
|
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> }>();
|
|
|
|
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,
|
|
countryName: resource.countryName,
|
|
federalState: resource.federalState,
|
|
metroCityName: resource.metroCityName,
|
|
});
|
|
const existing = locationMap.get(locationKey) ?? {
|
|
countryCode: resource.countryCode ?? null,
|
|
countryName: resource.countryName ?? 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,
|
|
endDate: input.endDate,
|
|
});
|
|
|
|
const demandRequirementById = new Map(
|
|
demandRequirements.map((demandRequirement) => [
|
|
demandRequirement.id,
|
|
demandRequirement,
|
|
]),
|
|
);
|
|
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,
|
|
countryName: resource.country?.name,
|
|
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]),
|
|
);
|
|
for (const allocation of readModel.allocations) {
|
|
if (!allocation.project || projectMap.has(allocation.project.id)) {
|
|
continue;
|
|
}
|
|
|
|
projectMap.set(allocation.project.id, {
|
|
id: allocation.project.id,
|
|
name: allocation.project.name,
|
|
shortCode: allocation.project.shortCode,
|
|
staffingReqs: allocation.project.staffingReqs,
|
|
});
|
|
}
|
|
|
|
const assignmentCountByDemandRequirementId = new Map<string, number>();
|
|
for (const assignment of assignments) {
|
|
if (!assignment.demandRequirementId) {
|
|
continue;
|
|
}
|
|
|
|
assignmentCountByDemandRequirementId.set(
|
|
assignment.demandRequirementId,
|
|
(assignmentCountByDemandRequirementId.get(assignment.demandRequirementId) ?? 0) + 1,
|
|
);
|
|
}
|
|
|
|
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(),
|
|
...normalizedAssignments.map((assignment) => assignment.projectId),
|
|
...normalizedDemands.map((demand) => demand.projectId),
|
|
]);
|
|
|
|
return [...projectIds].map((projectId) => {
|
|
const project = projectMap.get(projectId) ?? {
|
|
id: projectId,
|
|
name: projectId,
|
|
shortCode: projectId,
|
|
staffingReqs: [],
|
|
};
|
|
|
|
const projectAssignments = normalizedAssignments.filter(
|
|
(assignment) => assignment.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({
|
|
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 =
|
|
projectDemands.length > 0
|
|
? projectDemands.reduce((sum, demand) => {
|
|
const demandFteFactor = getDemandFteFactor(
|
|
demand.hoursPerDay,
|
|
demand.percentage,
|
|
);
|
|
const explicitDemand = demandRequirementById.get(demand.id);
|
|
if (!explicitDemand) {
|
|
return sum + demand.requestedHeadcount * demandFteFactor;
|
|
}
|
|
|
|
const linkedAssignmentCount =
|
|
assignmentCountByDemandRequirementId.get(explicitDemand.id) ?? 0;
|
|
const plannedHeadcount =
|
|
linkedAssignmentCount +
|
|
(explicitDemand.status === "COMPLETED" ? 0 : explicitDemand.headcount);
|
|
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,
|
|
name: project.name,
|
|
shortCode: project.shortCode,
|
|
allocatedHours: Math.round(allocatedHours),
|
|
requiredFTEs: Math.round(requiredFTEs * 100) / 100,
|
|
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,
|
|
},
|
|
};
|
|
});
|
|
}
|
|
|
|
if (input.groupBy === "chapter") {
|
|
const chapterMap = new Map<
|
|
string,
|
|
{ allocatedHours: number; resourceIds: Set<string> }
|
|
>();
|
|
|
|
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>(),
|
|
};
|
|
|
|
if (assignment.resource?.id) {
|
|
existing.resourceIds.add(assignment.resource.id);
|
|
}
|
|
|
|
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]) => {
|
|
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<
|
|
string,
|
|
{
|
|
name: string;
|
|
chapter: string | null;
|
|
allocatedHours: number;
|
|
projectIds: Set<string>;
|
|
}
|
|
>();
|
|
|
|
for (const assignment of normalizedAssignments) {
|
|
if (!assignment.resource) {
|
|
continue;
|
|
}
|
|
|
|
const existing = personMap.get(assignment.resource.id) ?? {
|
|
name: assignment.resource.displayName,
|
|
chapter: assignment.resource.chapter ?? null,
|
|
allocatedHours: 0,
|
|
projectIds: new Set<string>(),
|
|
};
|
|
const resource = resourceInfoById.get(assignment.resource.id);
|
|
|
|
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]) => {
|
|
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,
|
|
),
|
|
},
|
|
};
|
|
});
|
|
}
|