Files
CapaKraken/packages/api/src/router/staffing-capacity-read.ts
T
Hartmut 9bd3781c03 fix(types): flatten tRPC Zod schema types to resolve TS2589 inference depth errors
Cast Zod schemas with .refine()/.superRefine() to z.ZodType<InferredType> at the
procedure level. This short-circuits TypeScript's deep type recursion through
tRPC's middleware chain, eliminating 4 of 5 @ts-expect-error TS2589 suppressions
in web components (VacationModal, ProjectModal, UsersClient, CountriesClient).

Applied same pattern to allocation, timeline, staffing, dashboard, project, and
resource query/mutation procedures to reduce client-side type depth.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 15:28:12 +02:00

396 lines
13 KiB
TypeScript

import { listAssignmentBookings } from "@capakraken/application";
import { MILLISECONDS_PER_DAY, type WeekdayAvailability } from "@capakraken/shared";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import {
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
countEffectiveWorkingDays,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
import { planningReadProcedure } from "../trpc.js";
import {
ACTIVE_STATUSES,
averagePerWorkingDay,
calculateAllocatedHoursForDay,
getEffectiveDayAvailability,
round1,
toIsoDate,
} from "./staffing-shared.js";
const SearchCapacityInputSchema = z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
minHoursPerDay: z.number().optional().default(4),
roleName: z.string().optional(),
chapter: z.string().optional(),
limit: z.number().int().min(1).max(100).optional().default(20),
});
export const staffingCapacityReadProcedures = {
searchCapacity: planningReadProcedure
.input(
SearchCapacityInputSchema as z.ZodType<
z.infer<typeof SearchCapacityInputSchema>,
z.ZodTypeDef,
z.input<typeof SearchCapacityInputSchema>
>,
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = { isActive: true };
if (input.roleName) {
where.areaRole = { name: { contains: input.roleName, mode: "insensitive" } };
}
if (input.chapter) {
where.chapter = { contains: input.chapter, mode: "insensitive" };
}
const resources = await ctx.db.resource.findMany({
where,
select: {
id: true,
displayName: true,
eid: true,
fte: true,
availability: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
areaRole: { select: { name: true } },
chapter: true,
},
take: 100,
});
const bookings = await listAssignmentBookings(ctx.db, {
startDate: input.startDate,
endDate: input.endDate,
resourceIds: resources.map((resource) => resource.id),
});
const bookingsByResourceId = new Map<string, typeof bookings>();
for (const booking of bookings) {
if (!booking.resourceId) {
continue;
}
const existing = bookingsByResourceId.get(booking.resourceId) ?? [];
existing.push(booking);
bookingsByResourceId.set(booking.resourceId, existing);
}
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.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 results = resources
.map((resource) => {
const availability = resource.availability as unknown as WeekdayAvailability;
const context = contexts.get(resource.id);
const workingDays = countEffectiveWorkingDays({
availability,
periodStart: input.startDate,
periodEnd: input.endDate,
context,
});
const availableHours = calculateEffectiveAvailableHours({
availability,
periodStart: input.startDate,
periodEnd: input.endDate,
context,
});
const bookedHours = (bookingsByResourceId.get(resource.id) ?? []).reduce(
(sum, booking) =>
sum +
calculateEffectiveBookedHours({
availability,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
periodStart: input.startDate,
periodEnd: input.endDate,
context,
}),
0,
);
const remainingHours = Math.max(0, availableHours - bookedHours);
return {
id: resource.id,
name: resource.displayName,
eid: resource.eid,
role: resource.areaRole?.name ?? null,
chapter: resource.chapter,
workingDays,
availableHours: round1(remainingHours),
availableHoursPerDay: averagePerWorkingDay(remainingHours, workingDays),
};
})
.filter((resource) => resource.availableHoursPerDay >= input.minHoursPerDay)
.sort((left, right) => right.availableHours - left.availableHours)
.slice(0, input.limit);
return {
period: `${toIsoDate(input.startDate)} to ${toIsoDate(input.endDate)}`,
minHoursFilter: input.minHoursPerDay,
results,
totalFound: results.length,
};
}),
analyzeUtilization: planningReadProcedure
.input(
z.object({
resourceId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
}),
)
.query(async ({ ctx, input }) => {
const resource = await findUniqueOrThrow(
ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: {
id: true,
displayName: true,
chargeabilityTarget: true,
availability: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
}),
"Resource",
);
const resourceBookings = await listAssignmentBookings(ctx.db, {
startDate: input.startDate,
endDate: input.endDate,
resourceIds: [resource.id],
});
const availability = resource.availability as unknown as WeekdayAvailability;
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
[
{
id: resource.id,
availability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
},
],
input.startDate,
input.endDate,
);
const context = contexts.get(resource.id);
const activeBookings = resourceBookings.map((booking) => ({
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
status: booking.status,
projectName: booking.project.name,
isChargeable: booking.project.orderType === "CHARGEABLE",
}));
const overallocatedDays: string[] = [];
const underutilizedDays: string[] = [];
let totalAvailableHours = 0;
let totalChargeableHours = 0;
const cursor = new Date(input.startDate);
cursor.setUTCHours(0, 0, 0, 0);
const end = new Date(input.endDate);
end.setUTCHours(0, 0, 0, 0);
while (cursor <= end) {
const availableHoursForDay = getEffectiveDayAvailability(availability, cursor, context);
if (availableHoursForDay > 0) {
const { allocatedHours, chargeableHours } = calculateAllocatedHoursForDay({
bookings: activeBookings,
date: cursor,
context,
});
totalAvailableHours += availableHoursForDay;
totalChargeableHours += chargeableHours;
if (allocatedHours > availableHoursForDay) {
overallocatedDays.push(toIsoDate(cursor));
} else if (allocatedHours < availableHoursForDay * 0.5) {
underutilizedDays.push(toIsoDate(cursor));
}
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
const currentChargeability =
totalAvailableHours > 0 ? (totalChargeableHours / totalAvailableHours) * 100 : 0;
return {
resourceId: resource.id,
resourceName: resource.displayName,
chargeabilityTarget: resource.chargeabilityTarget,
currentChargeability,
chargeabilityGap: resource.chargeabilityTarget - currentChargeability,
allocations: activeBookings
.filter((booking) => ACTIVE_STATUSES.has(booking.status))
.map((booking) => ({
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
projectName: booking.projectName,
isChargeable: booking.isChargeable,
})),
overallocatedDays,
underutilizedDays,
};
}),
findCapacity: planningReadProcedure
.input(
z.object({
resourceId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
minAvailableHoursPerDay: z.number().optional().default(4),
}),
)
.query(async ({ ctx, input }) => {
const resource = await findUniqueOrThrow(
ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: {
id: true,
displayName: true,
availability: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
}),
"Resource",
);
const resourceBookings = await listAssignmentBookings(ctx.db, {
startDate: input.startDate,
endDate: input.endDate,
resourceIds: [resource.id],
});
const availability = resource.availability as unknown as WeekdayAvailability;
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
[
{
id: resource.id,
availability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
},
],
input.startDate,
input.endDate,
);
const context = contexts.get(resource.id);
const windows: Array<{
resourceId: string;
resourceName: string;
startDate: Date;
endDate: Date;
availableHoursPerDay: number;
availableDays: number;
totalAvailableHours: number;
}> = [];
let windowStart: Date | null = null;
let windowAvailableDays = 0;
let windowTotalHours = 0;
let windowMinHours = Number.POSITIVE_INFINITY;
const closeWindow = (closeDate: Date) => {
if (windowStart && windowAvailableDays > 0) {
const previousDay = new Date(closeDate);
previousDay.setUTCDate(previousDay.getUTCDate() - 1);
windows.push({
resourceId: resource.id,
resourceName: resource.displayName,
startDate: new Date(windowStart),
endDate: previousDay,
availableHoursPerDay: Number.isFinite(windowMinHours) ? windowMinHours : 0,
availableDays: windowAvailableDays,
totalAvailableHours: Math.round(windowTotalHours * 10) / 10,
});
}
windowStart = null;
windowAvailableDays = 0;
windowTotalHours = 0;
windowMinHours = Number.POSITIVE_INFINITY;
};
const cursor = new Date(input.startDate);
cursor.setUTCHours(0, 0, 0, 0);
const end = new Date(input.endDate);
end.setUTCHours(0, 0, 0, 0);
while (cursor <= end) {
const availableHoursForDay = getEffectiveDayAvailability(availability, cursor, context);
if (availableHoursForDay <= 0) {
closeWindow(cursor);
cursor.setUTCDate(cursor.getUTCDate() + 1);
continue;
}
const { allocatedHours } = calculateAllocatedHoursForDay({
bookings: resourceBookings.map((booking) => ({
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
status: booking.status,
})),
date: cursor,
context,
});
const freeHours = Math.max(0, availableHoursForDay - allocatedHours);
if (freeHours >= input.minAvailableHoursPerDay) {
if (!windowStart) {
windowStart = new Date(cursor);
}
windowAvailableDays += 1;
windowTotalHours += freeHours;
windowMinHours = Math.min(windowMinHours, freeHours);
} else {
closeWindow(cursor);
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
closeWindow(new Date(end.getTime() + MILLISECONDS_PER_DAY));
return windows;
}),
};