chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
import type { PrismaClient } from "@planarchy/db";
|
||||
import { loadDashboardPlanningReadModel } from "./load-dashboard-planning-read-model.js";
|
||||
import { calculateAllocationHours } from "./shared.js";
|
||||
|
||||
export interface GetDashboardDemandInput {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
groupBy: "project" | "person" | "chapter";
|
||||
}
|
||||
|
||||
interface ProjectSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
shortCode: string;
|
||||
staffingReqs: unknown;
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
|
||||
export async function getDashboardDemand(
|
||||
db: PrismaClient,
|
||||
input: GetDashboardDemandInput,
|
||||
) {
|
||||
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 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,
|
||||
);
|
||||
}
|
||||
|
||||
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) =>
|
||||
sum +
|
||||
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);
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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 existing = chapterMap.get(chapter) ?? {
|
||||
allocatedHours: 0,
|
||||
resourceIds: new Set<string>(),
|
||||
};
|
||||
|
||||
if (assignment.resource?.id) {
|
||||
existing.resourceIds.add(assignment.resource.id);
|
||||
}
|
||||
|
||||
existing.allocatedHours += 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,
|
||||
}));
|
||||
}
|
||||
|
||||
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>(),
|
||||
};
|
||||
|
||||
existing.allocatedHours += 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,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user