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( 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, contexts: Map> extends Map ? T : never>, input: GetDashboardDemandInput, ): DemandCalendarLocationSummary[] { const locationMap = new Map }>(); 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(), }; 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 { 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( 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(); 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([ ...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 } >(); 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(), }; 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; } >(); 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(), }; 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, ), }, }; }); }