269 lines
8.6 KiB
TypeScript
269 lines
8.6 KiB
TypeScript
import type { PrismaClient } from "@capakraken/db";
|
|
import type { WeekdayAvailability } from "@capakraken/shared";
|
|
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
|
|
import { getMonthBucketKey, getWeekBucketKey } from "./shared.js";
|
|
import {
|
|
calculateEffectiveAllocationHours,
|
|
calculateEffectiveAvailableHours,
|
|
enumerateIsoDates,
|
|
loadDailyAvailabilityContexts,
|
|
} from "./holiday-capacity.js";
|
|
|
|
export interface GetDashboardPeakTimesInput {
|
|
startDate: Date;
|
|
endDate: Date;
|
|
granularity: "week" | "month";
|
|
groupBy: "project" | "chapter" | "resource";
|
|
}
|
|
|
|
export interface PeakTimesPeriodDerivation {
|
|
periodStart: string;
|
|
periodEnd: string;
|
|
resourceCount: number;
|
|
groupCount: number;
|
|
bookedHours: number;
|
|
capacityHours: number;
|
|
remainingCapacityHours: number;
|
|
overbookedHours: number;
|
|
utilizationPct: number;
|
|
}
|
|
|
|
export interface PeakTimesGroupRow {
|
|
name: string;
|
|
hours: number;
|
|
capacityHours: number | undefined;
|
|
remainingHours: number | undefined;
|
|
overbookedHours: number | undefined;
|
|
utilizationPct: number | undefined;
|
|
}
|
|
|
|
export interface PeakTimesPeriodRow {
|
|
period: string;
|
|
groups: PeakTimesGroupRow[];
|
|
totalHours: number;
|
|
capacityHours: number;
|
|
periodStart?: string;
|
|
periodEnd?: string;
|
|
bookedHours?: number;
|
|
remainingHours?: number;
|
|
overbookedHours?: number;
|
|
utilizationPct?: number;
|
|
groupCount?: number;
|
|
resourceCount?: number;
|
|
derivation: PeakTimesPeriodDerivation;
|
|
}
|
|
|
|
export async function getDashboardPeakTimes(
|
|
db: PrismaClient,
|
|
input: GetDashboardPeakTimesInput,
|
|
): Promise<PeakTimesPeriodRow[]> {
|
|
const [allocations, resources] = await Promise.all([
|
|
listAssignmentBookings(db, {
|
|
startDate: input.startDate,
|
|
endDate: input.endDate,
|
|
}),
|
|
db.resource.findMany({
|
|
where: { isActive: true },
|
|
select: {
|
|
id: true,
|
|
displayName: true,
|
|
chapter: true,
|
|
availability: true,
|
|
countryId: true,
|
|
federalState: true,
|
|
metroCityId: true,
|
|
country: {
|
|
select: {
|
|
code: true,
|
|
},
|
|
},
|
|
metroCity: {
|
|
select: {
|
|
name: true,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
]);
|
|
|
|
const buckets = new Map<string, Map<string, number>>();
|
|
const groupCapacityBuckets = new Map<string, Map<string, number>>();
|
|
const getBucketKey = input.granularity === "week" ? getWeekBucketKey : getMonthBucketKey;
|
|
const resourceMap = new Map(
|
|
resources.map((resource) => [
|
|
resource.id,
|
|
{
|
|
...resource,
|
|
availability: resource.availability as unknown as WeekdayAvailability,
|
|
},
|
|
]),
|
|
);
|
|
const contexts = await loadDailyAvailabilityContexts(
|
|
db,
|
|
resources.map((resource) => ({
|
|
id: resource.id,
|
|
availability: resource.availability as unknown as WeekdayAvailability,
|
|
countryId: resource.countryId,
|
|
countryCode: resource.country?.code,
|
|
federalState: resource.federalState,
|
|
metroCityId: resource.metroCityId,
|
|
metroCityName: resource.metroCity?.name,
|
|
})),
|
|
input.startDate,
|
|
input.endDate,
|
|
);
|
|
const bucketPeriods = new Map<string, { start: Date; end: Date }>();
|
|
|
|
for (const isoDate of enumerateIsoDates(input.startDate, input.endDate)) {
|
|
const date = new Date(`${isoDate}T00:00:00.000Z`);
|
|
const bucketKey = getBucketKey(date);
|
|
const existing = bucketPeriods.get(bucketKey);
|
|
if (!existing) {
|
|
bucketPeriods.set(bucketKey, { start: date, end: date });
|
|
continue;
|
|
}
|
|
if (date < existing.start) {
|
|
existing.start = date;
|
|
}
|
|
if (date > existing.end) {
|
|
existing.end = date;
|
|
}
|
|
}
|
|
for (const bucketKey of bucketPeriods.keys()) {
|
|
buckets.set(bucketKey, new Map());
|
|
groupCapacityBuckets.set(bucketKey, new Map());
|
|
}
|
|
|
|
for (const allocation of allocations) {
|
|
const resource = allocation.resourceId ? resourceMap.get(allocation.resourceId) : undefined;
|
|
const group =
|
|
input.groupBy === "project"
|
|
? allocation.project.shortCode
|
|
: input.groupBy === "chapter"
|
|
? 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),
|
|
})
|
|
: 0;
|
|
if (hours <= 0) {
|
|
continue;
|
|
}
|
|
const bucket = buckets.get(bucketKey)!;
|
|
bucket.set(group, (bucket.get(group) ?? 0) + hours);
|
|
}
|
|
}
|
|
const capacityByBucket = new Map<string, number>();
|
|
for (const [bucketKey, bucketPeriod] of bucketPeriods.entries()) {
|
|
let capacityHours = 0;
|
|
for (const resource of resourceMap.values()) {
|
|
const effectiveAvailableHours = calculateEffectiveAvailableHours({
|
|
availability: resource.availability,
|
|
periodStart: bucketPeriod.start,
|
|
periodEnd: bucketPeriod.end,
|
|
context: contexts.get(resource.id),
|
|
});
|
|
capacityHours += effectiveAvailableHours;
|
|
|
|
if (input.groupBy !== "project" && effectiveAvailableHours > 0) {
|
|
const group =
|
|
input.groupBy === "chapter"
|
|
? resource.chapter ?? "Unassigned"
|
|
: resource.displayName ?? "Unknown";
|
|
const groupCapacityBucket = groupCapacityBuckets.get(bucketKey)!;
|
|
groupCapacityBucket.set(
|
|
group,
|
|
(groupCapacityBucket.get(group) ?? 0) + effectiveAvailableHours,
|
|
);
|
|
}
|
|
}
|
|
capacityByBucket.set(bucketKey, capacityHours);
|
|
}
|
|
|
|
return [...bucketPeriods.entries()]
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([period, bucketPeriod]) => {
|
|
const groups = buckets.get(period) ?? new Map<string, number>();
|
|
const groupCapacities = groupCapacityBuckets.get(period) ?? new Map<string, number>();
|
|
const groupNames = new Set<string>([
|
|
...groups.keys(),
|
|
...(input.groupBy === "project" ? [] : groupCapacities.keys()),
|
|
]);
|
|
const groupRows = [...groupNames]
|
|
.map((name) => {
|
|
const hours = groups.get(name) ?? 0;
|
|
const groupCapacityHours =
|
|
input.groupBy === "project" ? undefined : groupCapacities.get(name) ?? 0;
|
|
const remainingHours =
|
|
groupCapacityHours === undefined
|
|
? undefined
|
|
: Math.max(0, groupCapacityHours - hours);
|
|
const overbookedHours =
|
|
groupCapacityHours === undefined
|
|
? undefined
|
|
: Math.max(0, hours - groupCapacityHours);
|
|
return {
|
|
name,
|
|
hours,
|
|
capacityHours: groupCapacityHours,
|
|
remainingHours,
|
|
overbookedHours,
|
|
utilizationPct:
|
|
groupCapacityHours && groupCapacityHours > 0
|
|
? Math.round((hours / groupCapacityHours) * 100)
|
|
: groupCapacityHours === 0
|
|
? 0
|
|
: undefined,
|
|
};
|
|
})
|
|
.sort(
|
|
(left, right) =>
|
|
(right.utilizationPct ?? -1) - (left.utilizationPct ?? -1) ||
|
|
right.hours - left.hours ||
|
|
left.name.localeCompare(right.name),
|
|
);
|
|
const totalHours = [...groups.values()].reduce((sum, hours) => sum + hours, 0);
|
|
const capacityHours = capacityByBucket.get(period) ?? 0;
|
|
const remainingCapacityHours = Math.max(0, capacityHours - totalHours);
|
|
const overbookedHours = Math.max(0, totalHours - capacityHours);
|
|
|
|
return {
|
|
period,
|
|
groups: groupRows,
|
|
totalHours,
|
|
capacityHours,
|
|
periodStart: bucketPeriod.start.toISOString().slice(0, 10),
|
|
periodEnd: bucketPeriod.end.toISOString().slice(0, 10),
|
|
bookedHours: totalHours,
|
|
remainingHours: remainingCapacityHours,
|
|
overbookedHours,
|
|
utilizationPct: capacityHours > 0
|
|
? Math.round((totalHours / capacityHours) * 100)
|
|
: 0,
|
|
groupCount: groupRows.length,
|
|
resourceCount: resourceMap.size,
|
|
derivation: {
|
|
periodStart: bucketPeriod.start.toISOString().slice(0, 10),
|
|
periodEnd: bucketPeriod.end.toISOString().slice(0, 10),
|
|
resourceCount: resourceMap.size,
|
|
groupCount: groupRows.length,
|
|
bookedHours: totalHours,
|
|
capacityHours,
|
|
remainingCapacityHours,
|
|
overbookedHours,
|
|
utilizationPct: capacityHours > 0
|
|
? Math.round((totalHours / capacityHours) * 100)
|
|
: 0,
|
|
},
|
|
};
|
|
});
|
|
}
|