chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import type { PrismaClient } from "@planarchy/db";
|
||||
import { computeChargeability } from "@planarchy/engine";
|
||||
import type { WeekdayAvailability } from "@planarchy/shared";
|
||||
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
|
||||
|
||||
export interface GetDashboardChargeabilityOverviewInput {
|
||||
topN: number;
|
||||
watchlistThreshold: number;
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export async function getDashboardChargeabilityOverview(
|
||||
db: PrismaClient,
|
||||
input: GetDashboardChargeabilityOverviewInput,
|
||||
) {
|
||||
const now = input.now ?? new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
const resources = await db.resource.findMany({
|
||||
where: { isActive: true },
|
||||
select: {
|
||||
id: true,
|
||||
eid: true,
|
||||
displayName: true,
|
||||
chapter: true,
|
||||
chargeabilityTarget: true,
|
||||
availability: true,
|
||||
},
|
||||
});
|
||||
const bookings = await listAssignmentBookings(db, {
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
resourceIds: resources.map((resource) => resource.id),
|
||||
});
|
||||
|
||||
const stats = resources.map((resource) => {
|
||||
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
|
||||
const actualAllocations = resourceBookings.filter(
|
||||
(booking) =>
|
||||
(booking.status === "CONFIRMED" || booking.status === "ACTIVE") &&
|
||||
booking.project.status !== "DRAFT" &&
|
||||
booking.project.status !== "CANCELLED",
|
||||
);
|
||||
const actual = computeChargeability(
|
||||
availability,
|
||||
actualAllocations,
|
||||
start,
|
||||
end,
|
||||
);
|
||||
const expected = computeChargeability(
|
||||
availability,
|
||||
resourceBookings,
|
||||
start,
|
||||
end,
|
||||
);
|
||||
|
||||
return {
|
||||
id: resource.id,
|
||||
eid: resource.eid,
|
||||
displayName: resource.displayName,
|
||||
chapter: resource.chapter,
|
||||
chargeabilityTarget: resource.chargeabilityTarget,
|
||||
actualChargeability: actual.chargeability,
|
||||
expectedChargeability: expected.chargeability,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
top: [...stats]
|
||||
.sort((left, right) => right.actualChargeability - left.actualChargeability)
|
||||
.slice(0, input.topN),
|
||||
watchlist: [...stats]
|
||||
.filter(
|
||||
(resource) =>
|
||||
resource.actualChargeability <
|
||||
resource.chargeabilityTarget - input.watchlistThreshold,
|
||||
)
|
||||
.sort((left, right) => left.actualChargeability - right.actualChargeability)
|
||||
.slice(0, input.topN),
|
||||
month: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { PrismaClient } from "@planarchy/db";
|
||||
import { AllocationStatus } from "@planarchy/shared";
|
||||
import { buildSplitAllocationReadModel } from "../allocation/build-split-allocation-read-model.js";
|
||||
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
|
||||
import { calculateInclusiveDays } from "./shared.js";
|
||||
|
||||
export async function getDashboardOverview(db: PrismaClient) {
|
||||
const [
|
||||
totalResources,
|
||||
activeResources,
|
||||
totalProjects,
|
||||
allProjects,
|
||||
allDemandRequirements,
|
||||
allAssignments,
|
||||
budgetBookings,
|
||||
recentActivity,
|
||||
allResources,
|
||||
] = await Promise.all([
|
||||
db.resource.count(),
|
||||
db.resource.count({ where: { isActive: true } }),
|
||||
db.project.count(),
|
||||
db.project.findMany({ select: { status: true, budgetCents: true } }),
|
||||
db.demandRequirement.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
projectId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
hoursPerDay: true,
|
||||
percentage: true,
|
||||
role: true,
|
||||
roleId: true,
|
||||
headcount: true,
|
||||
status: true,
|
||||
metadata: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
db.assignment.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
demandRequirementId: true,
|
||||
resourceId: true,
|
||||
projectId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
hoursPerDay: true,
|
||||
percentage: true,
|
||||
role: true,
|
||||
roleId: true,
|
||||
dailyCostCents: true,
|
||||
status: true,
|
||||
metadata: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
listAssignmentBookings(db, {}),
|
||||
db.auditLog.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 10,
|
||||
select: { id: true, entityType: true, action: true, createdAt: true },
|
||||
}),
|
||||
db.resource.findMany({
|
||||
select: { chapter: true, chargeabilityTarget: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const planningReadModel = buildSplitAllocationReadModel({
|
||||
demandRequirements: allDemandRequirements,
|
||||
assignments: allAssignments,
|
||||
});
|
||||
const totalAllocations = planningReadModel.allocations.length;
|
||||
const activeAllocations = planningReadModel.allocations.filter(
|
||||
(allocation) => allocation.status !== AllocationStatus.CANCELLED,
|
||||
).length;
|
||||
|
||||
const totalCostCents = budgetBookings.reduce(
|
||||
(sum, booking) =>
|
||||
sum +
|
||||
(booking.dailyCostCents ?? 0) *
|
||||
calculateInclusiveDays(booking.startDate, booking.endDate),
|
||||
0,
|
||||
);
|
||||
|
||||
const totalBudgetCents = allProjects.reduce(
|
||||
(sum, project) => sum + (project.budgetCents ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const avgUtilizationPercent =
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
const chapterMap = new Map<
|
||||
string,
|
||||
{ resourceCount: number; chargeabilitySum: number }
|
||||
>();
|
||||
for (const resource of allResources) {
|
||||
const chapter = resource.chapter ?? "Unassigned";
|
||||
const existing = chapterMap.get(chapter) ?? {
|
||||
resourceCount: 0,
|
||||
chargeabilitySum: 0,
|
||||
};
|
||||
|
||||
chapterMap.set(chapter, {
|
||||
resourceCount: existing.resourceCount + 1,
|
||||
chargeabilitySum:
|
||||
existing.chargeabilitySum + (resource.chargeabilityTarget ?? 0),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
totalResources,
|
||||
activeResources,
|
||||
totalProjects,
|
||||
activeProjects: allProjects.filter((project) => project.status === "ACTIVE")
|
||||
.length,
|
||||
totalAllocations,
|
||||
activeAllocations,
|
||||
budgetSummary: {
|
||||
totalBudgetCents,
|
||||
totalCostCents,
|
||||
avgUtilizationPercent,
|
||||
},
|
||||
recentActivity: recentActivity.map((activity) => ({
|
||||
id: activity.id,
|
||||
entityType: activity.entityType,
|
||||
action: activity.action,
|
||||
createdAt: activity.createdAt,
|
||||
})),
|
||||
projectsByStatus: [...statusCountMap.entries()].map(([status, count]) => ({
|
||||
status,
|
||||
count,
|
||||
})),
|
||||
chapterUtilization: [...chapterMap.entries()].map(([chapter, data]) => ({
|
||||
chapter,
|
||||
resourceCount: data.resourceCount,
|
||||
avgChargeabilityTarget:
|
||||
data.resourceCount > 0
|
||||
? Math.round(data.chargeabilitySum / data.resourceCount)
|
||||
: 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { PrismaClient } from "@planarchy/db";
|
||||
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
|
||||
import { getAverageDailyAvailabilityHours, getMonthBucketKey, getWeekBucketKey } from "./shared.js";
|
||||
|
||||
export interface GetDashboardPeakTimesInput {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
granularity: "week" | "month";
|
||||
groupBy: "project" | "chapter" | "resource";
|
||||
}
|
||||
|
||||
export async function getDashboardPeakTimes(
|
||||
db: PrismaClient,
|
||||
input: GetDashboardPeakTimesInput,
|
||||
) {
|
||||
const allocations = await listAssignmentBookings(db, {
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
});
|
||||
|
||||
const buckets = new Map<string, Map<string, number>>();
|
||||
|
||||
const getBucketKey = input.granularity === "week" ? getWeekBucketKey : getMonthBucketKey;
|
||||
|
||||
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 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());
|
||||
}
|
||||
|
||||
const bucket = buckets.get(bucketKey)!;
|
||||
bucket.set(group, (bucket.get(group) ?? 0) + allocation.hoursPerDay);
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const resources = await db.resource.findMany({
|
||||
where: { isActive: true },
|
||||
select: { availability: true },
|
||||
});
|
||||
|
||||
const dailyCapacityHours = resources.reduce(
|
||||
(sum, resource) =>
|
||||
sum +
|
||||
getAverageDailyAvailabilityHours(
|
||||
resource.availability as Record<string, number | null | undefined>,
|
||||
),
|
||||
0,
|
||||
);
|
||||
|
||||
return [...buckets.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),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { PrismaClient } from "@planarchy/db";
|
||||
|
||||
export interface GetDashboardTopValueResourcesInput {
|
||||
limit: number;
|
||||
userRole: string;
|
||||
}
|
||||
|
||||
export async function getDashboardTopValueResources(
|
||||
db: PrismaClient,
|
||||
input: GetDashboardTopValueResourcesInput,
|
||||
) {
|
||||
const settings = await db.systemSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
});
|
||||
|
||||
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,
|
||||
lcrCents: true,
|
||||
},
|
||||
orderBy: { valueScore: "desc" },
|
||||
take: input.limit,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export {
|
||||
getDashboardOverview,
|
||||
} from "./get-overview.js";
|
||||
|
||||
export {
|
||||
getDashboardPeakTimes,
|
||||
type GetDashboardPeakTimesInput,
|
||||
} from "./get-peak-times.js";
|
||||
|
||||
export {
|
||||
getDashboardTopValueResources,
|
||||
type GetDashboardTopValueResourcesInput,
|
||||
} from "./get-top-value-resources.js";
|
||||
|
||||
export {
|
||||
getDashboardDemand,
|
||||
type GetDashboardDemandInput,
|
||||
} from "./get-demand.js";
|
||||
|
||||
export {
|
||||
getDashboardChargeabilityOverview,
|
||||
type GetDashboardChargeabilityOverviewInput,
|
||||
} from "./get-chargeability-overview.js";
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { PrismaClient } from "@planarchy/db";
|
||||
import { AllocationStatus } from "@planarchy/shared";
|
||||
import { buildSplitAllocationReadModel } from "../allocation/build-split-allocation-read-model.js";
|
||||
|
||||
export const DASHBOARD_PLANNING_ALLOCATION_INCLUDE = {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
shortCode: true,
|
||||
staffingReqs: true,
|
||||
},
|
||||
},
|
||||
resource: {
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true,
|
||||
chapter: true,
|
||||
eid: true,
|
||||
lcrCents: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const DASHBOARD_PLANNING_DEMAND_INCLUDE = {
|
||||
project: DASHBOARD_PLANNING_ALLOCATION_INCLUDE.project,
|
||||
} as const;
|
||||
|
||||
export const DASHBOARD_PLANNING_ASSIGNMENT_INCLUDE = {
|
||||
project: DASHBOARD_PLANNING_ALLOCATION_INCLUDE.project,
|
||||
resource: DASHBOARD_PLANNING_ALLOCATION_INCLUDE.resource,
|
||||
} as const;
|
||||
|
||||
type DashboardPlanningReadDbClient = Pick<
|
||||
PrismaClient,
|
||||
"demandRequirement" | "assignment" | "project"
|
||||
>;
|
||||
|
||||
export interface LoadDashboardPlanningReadModelInput {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||
export async function loadDashboardPlanningReadModel(
|
||||
db: DashboardPlanningReadDbClient,
|
||||
input: LoadDashboardPlanningReadModelInput,
|
||||
) {
|
||||
const activeWindowFilter = {
|
||||
status: { not: AllocationStatus.CANCELLED },
|
||||
startDate: { lte: input.endDate },
|
||||
endDate: { gte: input.startDate },
|
||||
} as const;
|
||||
|
||||
const [demandRequirements, assignments, projects] = await Promise.all([
|
||||
db.demandRequirement.findMany({
|
||||
where: activeWindowFilter,
|
||||
include: DASHBOARD_PLANNING_DEMAND_INCLUDE,
|
||||
}),
|
||||
db.assignment.findMany({
|
||||
where: activeWindowFilter,
|
||||
include: DASHBOARD_PLANNING_ASSIGNMENT_INCLUDE,
|
||||
}),
|
||||
db.project.findMany({
|
||||
where: activeWindowFilter,
|
||||
select: { id: true, shortCode: true, name: true, staffingReqs: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
demandRequirements,
|
||||
assignments,
|
||||
projects,
|
||||
readModel: buildSplitAllocationReadModel({
|
||||
demandRequirements,
|
||||
assignments,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export const MILLISECONDS_PER_DAY = 86_400_000;
|
||||
|
||||
export function calculateInclusiveDays(startDate: Date, endDate: Date): number {
|
||||
return (endDate.getTime() - startDate.getTime()) / MILLISECONDS_PER_DAY + 1;
|
||||
}
|
||||
|
||||
export function calculateAllocationHours(input: {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
hoursPerDay: number;
|
||||
}): number {
|
||||
return input.hoursPerDay * calculateInclusiveDays(input.startDate, input.endDate);
|
||||
}
|
||||
|
||||
export function getMonthBucketKey(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function getWeekBucketKey(date: Date): string {
|
||||
const weekStart = new Date(date);
|
||||
const day = weekStart.getDay();
|
||||
const diff = weekStart.getDate() - day + (day === 0 ? -6 : 1);
|
||||
weekStart.setDate(diff);
|
||||
return weekStart.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function getAverageDailyAvailabilityHours(
|
||||
availability: Record<string, number | null | undefined> | null | undefined,
|
||||
): number {
|
||||
if (!availability) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const totalWeeklyHours = Object.values(availability).reduce(
|
||||
(sum: number, hours) => sum + (hours ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return totalWeeklyHours / 5;
|
||||
}
|
||||
Reference in New Issue
Block a user