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>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { rankResources } from "@capakraken/staffing";
|
||||
import { listAssignmentBookings } from "@capakraken/application";
|
||||
import { PermissionKey, toIsoDateOrNull, type WeekdayAvailability } from "@capakraken/shared";
|
||||
import { PermissionKey, toIsoDateOrNull } from "@capakraken/shared";
|
||||
import type { SkillEntry, WeekdayAvailability } from "@capakraken/shared";
|
||||
import { z } from "zod";
|
||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||
import { loadResourceDailyAvailabilityContexts } from "../lib/resource-capacity.js";
|
||||
@@ -30,10 +31,8 @@ type StaffingSuggestionInput = {
|
||||
minProficiency?: number | undefined;
|
||||
};
|
||||
|
||||
type StaffingSuggestionsDbClient =
|
||||
Parameters<typeof listAssignmentBookings>[0]
|
||||
& Parameters<typeof loadResourceDailyAvailabilityContexts>[0]
|
||||
& {
|
||||
type StaffingSuggestionsDbClient = Parameters<typeof listAssignmentBookings>[0] &
|
||||
Parameters<typeof loadResourceDailyAvailabilityContexts>[0] & {
|
||||
resource: {
|
||||
findMany: (args: Record<string, unknown>) => Promise<unknown[]>;
|
||||
};
|
||||
@@ -77,7 +76,7 @@ async function queryStaffingSuggestions(
|
||||
mainSkillsOnly,
|
||||
minProficiency,
|
||||
} = input;
|
||||
const resources = await db.resource.findMany({
|
||||
const resources = (await db.resource.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
...(chapter ? { chapter } : {}),
|
||||
@@ -100,7 +99,7 @@ async function queryStaffingSuggestions(
|
||||
metroCity: { select: { name: true } },
|
||||
areaRole: { select: { name: true } },
|
||||
},
|
||||
}) as StaffingResourceRecord[];
|
||||
})) as StaffingResourceRecord[];
|
||||
const bookings = await listAssignmentBookings(db, {
|
||||
startDate,
|
||||
endDate,
|
||||
@@ -133,7 +132,9 @@ async function queryStaffingSuggestions(
|
||||
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||
const context = contexts.get(resource.id);
|
||||
const resourceBookings = bookingsByResourceId.get(resource.id) ?? [];
|
||||
const activeBookings = resourceBookings.filter((booking) => ACTIVE_STATUSES.has(booking.status));
|
||||
const activeBookings = resourceBookings.filter((booking) =>
|
||||
ACTIVE_STATUSES.has(booking.status),
|
||||
);
|
||||
const capacity = buildResourceCapacitySummary({
|
||||
availability,
|
||||
periodStart: startDate,
|
||||
@@ -192,13 +193,17 @@ async function queryStaffingSuggestions(
|
||||
}
|
||||
|
||||
const allocatedHours = capacity.bookedHours;
|
||||
const remainingHours = capacity.remainingHours;
|
||||
const remainingHoursPerDay = capacity.remainingHoursPerDay;
|
||||
const utilizationPercent =
|
||||
capacity.availableHours > 0
|
||||
? Math.min(100, (allocatedHours / capacity.availableHours) * 100)
|
||||
: 0;
|
||||
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
|
||||
type SkillRow = {
|
||||
skill: string;
|
||||
category?: string;
|
||||
proficiency: number;
|
||||
isMainSkill?: boolean;
|
||||
};
|
||||
let skills = resource.skills as unknown as SkillRow[];
|
||||
if (mainSkillsOnly) {
|
||||
skills = skills.filter((skill) => skill.isMainSkill);
|
||||
@@ -217,7 +222,7 @@ async function queryStaffingSuggestions(
|
||||
fte: resource.fte,
|
||||
chapter: resource.chapter,
|
||||
role: resource.areaRole?.name ?? null,
|
||||
skills: skills as unknown as import("@capakraken/shared").SkillEntry[],
|
||||
skills: skills as unknown as SkillEntry[],
|
||||
lcrCents: resource.lcrCents,
|
||||
chargeabilityTarget: resource.chargeabilityTarget,
|
||||
currentUtilizationPercent: utilizationPercent,
|
||||
@@ -267,97 +272,138 @@ async function queryStaffingSuggestions(
|
||||
budgetLcrCentsPerHour,
|
||||
} as unknown as Parameters<typeof rankResources>[0]);
|
||||
const baseRankIndex = new Map(ranked.map((suggestion, index) => [suggestion.resourceId, index]));
|
||||
return [...ranked].sort((left, right) => {
|
||||
if (Math.abs(left.score - right.score) <= 2) {
|
||||
const leftValue = enrichedResources.find((resource) => resource.id === left.resourceId)?.valueScore ?? 0;
|
||||
const rightValue = enrichedResources.find((resource) => resource.id === right.resourceId)?.valueScore ?? 0;
|
||||
return rightValue - leftValue;
|
||||
}
|
||||
return 0;
|
||||
}).map((suggestion, index) => {
|
||||
const resource = enrichedResources.find((entry) => entry.id === suggestion.resourceId);
|
||||
const fallbackBreakdown = "breakdown" in suggestion
|
||||
? (suggestion as { breakdown?: { skillScore: number; availabilityScore: number; costScore: number; utilizationScore: number } }).breakdown
|
||||
: undefined;
|
||||
const scoreBreakdown = suggestion.scoreBreakdown ?? {
|
||||
skillScore: fallbackBreakdown?.skillScore ?? 0,
|
||||
availabilityScore: fallbackBreakdown?.availabilityScore ?? 0,
|
||||
costScore: fallbackBreakdown?.costScore ?? 0,
|
||||
utilizationScore: fallbackBreakdown?.utilizationScore ?? 0,
|
||||
total: suggestion.score,
|
||||
};
|
||||
const baseRank = (baseRankIndex.get(suggestion.resourceId) ?? index) + 1;
|
||||
const tieBreakerApplied = baseRank !== index + 1;
|
||||
return [...ranked]
|
||||
.sort((left, right) => {
|
||||
if (Math.abs(left.score - right.score) <= 2) {
|
||||
const leftValue =
|
||||
enrichedResources.find((resource) => resource.id === left.resourceId)?.valueScore ?? 0;
|
||||
const rightValue =
|
||||
enrichedResources.find((resource) => resource.id === right.resourceId)?.valueScore ?? 0;
|
||||
return rightValue - leftValue;
|
||||
}
|
||||
return 0;
|
||||
})
|
||||
.map((suggestion, index) => {
|
||||
const resource = enrichedResources.find((entry) => entry.id === suggestion.resourceId);
|
||||
const fallbackBreakdown =
|
||||
"breakdown" in suggestion
|
||||
? (
|
||||
suggestion as {
|
||||
breakdown?: {
|
||||
skillScore: number;
|
||||
availabilityScore: number;
|
||||
costScore: number;
|
||||
utilizationScore: number;
|
||||
};
|
||||
}
|
||||
).breakdown
|
||||
: undefined;
|
||||
const scoreBreakdown = suggestion.scoreBreakdown ?? {
|
||||
skillScore: fallbackBreakdown?.skillScore ?? 0,
|
||||
availabilityScore: fallbackBreakdown?.availabilityScore ?? 0,
|
||||
costScore: fallbackBreakdown?.costScore ?? 0,
|
||||
utilizationScore: fallbackBreakdown?.utilizationScore ?? 0,
|
||||
total: suggestion.score,
|
||||
};
|
||||
const baseRank = (baseRankIndex.get(suggestion.resourceId) ?? index) + 1;
|
||||
const tieBreakerApplied = baseRank !== index + 1;
|
||||
|
||||
return {
|
||||
...suggestion,
|
||||
resourceName: suggestion.resourceName ?? resource?.displayName ?? "",
|
||||
eid: suggestion.eid ?? resource?.eid ?? "",
|
||||
fte: resource?.fte ?? 0,
|
||||
chapter: resource?.chapter ?? null,
|
||||
role: resource?.role ?? null,
|
||||
scoreBreakdown,
|
||||
matchedSkills: suggestion.matchedSkills ?? requiredSkills.filter((skill) =>
|
||||
resource?.skills.some((entry) => entry.skill.toLowerCase() === skill.trim().toLowerCase()),
|
||||
),
|
||||
missingSkills: suggestion.missingSkills ?? requiredSkills.filter((skill) =>
|
||||
!resource?.skills.some((entry) => entry.skill.toLowerCase() === skill.trim().toLowerCase()),
|
||||
),
|
||||
availabilityConflicts: suggestion.availabilityConflicts ?? resource?.conflictDays ?? [],
|
||||
estimatedDailyCostCents: suggestion.estimatedDailyCostCents ?? ((resource?.lcrCents ?? 0) * 8),
|
||||
currentUtilization: suggestion.currentUtilization ?? round1(resource?.currentUtilizationPercent ?? 0),
|
||||
valueScore: resource?.valueScore ?? 0,
|
||||
location: resource?.transparency.location ?? {
|
||||
countryCode: null,
|
||||
countryName: null,
|
||||
federalState: null,
|
||||
metroCityName: null,
|
||||
label: "",
|
||||
},
|
||||
capacity: resource?.transparency.capacity ?? {
|
||||
requestedHoursPerDay: round1(hoursPerDay),
|
||||
requestedHoursTotal: 0,
|
||||
baseWorkingDays: 0,
|
||||
effectiveWorkingDays: 0,
|
||||
baseAvailableHours: 0,
|
||||
effectiveAvailableHours: 0,
|
||||
bookedHours: 0,
|
||||
remainingHours: 0,
|
||||
remainingHoursPerDay: 0,
|
||||
holidayCount: 0,
|
||||
holidayWorkdayCount: 0,
|
||||
holidayHoursDeduction: 0,
|
||||
absenceDayEquivalent: 0,
|
||||
absenceHoursDeduction: 0,
|
||||
},
|
||||
conflicts: resource?.transparency.conflicts ?? {
|
||||
count: 0,
|
||||
conflictDays: [],
|
||||
details: [],
|
||||
},
|
||||
ranking: {
|
||||
rank: index + 1,
|
||||
baseRank,
|
||||
tieBreakerApplied,
|
||||
tieBreakerReason: tieBreakerApplied
|
||||
? "Within 2 score points, higher value score moves the candidate up."
|
||||
: null,
|
||||
model: "Composite ranking across skill fit, availability, cost, and utilization.",
|
||||
components: [
|
||||
{ key: "skillScore", label: "Skills", score: scoreBreakdown.skillScore },
|
||||
{ key: "availabilityScore", label: "Availability", score: scoreBreakdown.availabilityScore },
|
||||
{ key: "costScore", label: "Cost", score: scoreBreakdown.costScore },
|
||||
{ key: "utilizationScore", label: "Utilization", score: scoreBreakdown.utilizationScore },
|
||||
],
|
||||
},
|
||||
remainingHoursPerDay: resource?.transparency.capacity.remainingHoursPerDay ?? 0,
|
||||
remainingHours: resource?.transparency.capacity.remainingHours ?? 0,
|
||||
effectiveAvailableHours: resource?.transparency.capacity.effectiveAvailableHours ?? 0,
|
||||
baseAvailableHours: resource?.transparency.capacity.baseAvailableHours ?? 0,
|
||||
holidayHoursDeduction: resource?.transparency.capacity.holidayHoursDeduction ?? 0,
|
||||
};
|
||||
});
|
||||
return {
|
||||
...suggestion,
|
||||
resourceName: suggestion.resourceName ?? resource?.displayName ?? "",
|
||||
eid: suggestion.eid ?? resource?.eid ?? "",
|
||||
fte: resource?.fte ?? 0,
|
||||
chapter: resource?.chapter ?? null,
|
||||
role: resource?.role ?? null,
|
||||
scoreBreakdown,
|
||||
matchedSkills:
|
||||
suggestion.matchedSkills ??
|
||||
requiredSkills.filter((skill) =>
|
||||
resource?.skills.some(
|
||||
(entry) => entry.skill.toLowerCase() === skill.trim().toLowerCase(),
|
||||
),
|
||||
),
|
||||
missingSkills:
|
||||
suggestion.missingSkills ??
|
||||
requiredSkills.filter(
|
||||
(skill) =>
|
||||
!resource?.skills.some(
|
||||
(entry) => entry.skill.toLowerCase() === skill.trim().toLowerCase(),
|
||||
),
|
||||
),
|
||||
availabilityConflicts: suggestion.availabilityConflicts ?? resource?.conflictDays ?? [],
|
||||
estimatedDailyCostCents:
|
||||
suggestion.estimatedDailyCostCents ?? (resource?.lcrCents ?? 0) * 8,
|
||||
currentUtilization:
|
||||
suggestion.currentUtilization ?? round1(resource?.currentUtilizationPercent ?? 0),
|
||||
valueScore: resource?.valueScore ?? 0,
|
||||
location: resource?.transparency.location ?? {
|
||||
countryCode: null,
|
||||
countryName: null,
|
||||
federalState: null,
|
||||
metroCityName: null,
|
||||
label: "",
|
||||
},
|
||||
capacity: resource?.transparency.capacity ?? {
|
||||
requestedHoursPerDay: round1(hoursPerDay),
|
||||
requestedHoursTotal: 0,
|
||||
baseWorkingDays: 0,
|
||||
effectiveWorkingDays: 0,
|
||||
baseAvailableHours: 0,
|
||||
effectiveAvailableHours: 0,
|
||||
bookedHours: 0,
|
||||
remainingHours: 0,
|
||||
remainingHoursPerDay: 0,
|
||||
holidayCount: 0,
|
||||
holidayWorkdayCount: 0,
|
||||
holidayHoursDeduction: 0,
|
||||
absenceDayEquivalent: 0,
|
||||
absenceHoursDeduction: 0,
|
||||
},
|
||||
conflicts: resource?.transparency.conflicts ?? {
|
||||
count: 0,
|
||||
conflictDays: [],
|
||||
details: [],
|
||||
},
|
||||
ranking: {
|
||||
rank: index + 1,
|
||||
baseRank,
|
||||
tieBreakerApplied,
|
||||
tieBreakerReason: tieBreakerApplied
|
||||
? "Within 2 score points, higher value score moves the candidate up."
|
||||
: null,
|
||||
model: "Composite ranking across skill fit, availability, cost, and utilization.",
|
||||
components: [
|
||||
{ key: "skillScore", label: "Skills", score: scoreBreakdown.skillScore },
|
||||
{
|
||||
key: "availabilityScore",
|
||||
label: "Availability",
|
||||
score: scoreBreakdown.availabilityScore,
|
||||
},
|
||||
{ key: "costScore", label: "Cost", score: scoreBreakdown.costScore },
|
||||
{
|
||||
key: "utilizationScore",
|
||||
label: "Utilization",
|
||||
score: scoreBreakdown.utilizationScore,
|
||||
},
|
||||
],
|
||||
},
|
||||
remainingHoursPerDay: resource?.transparency.capacity.remainingHoursPerDay ?? 0,
|
||||
remainingHours: resource?.transparency.capacity.remainingHours ?? 0,
|
||||
effectiveAvailableHours: resource?.transparency.capacity.effectiveAvailableHours ?? 0,
|
||||
baseAvailableHours: resource?.transparency.capacity.baseAvailableHours ?? 0,
|
||||
holidayHoursDeduction: resource?.transparency.capacity.holidayHoursDeduction ?? 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
const GetProjectStaffingSuggestionsInputSchema = z.object({
|
||||
projectId: z.string().min(1),
|
||||
roleName: z.string().optional(),
|
||||
startDate: z.coerce.date().optional(),
|
||||
endDate: z.coerce.date().optional(),
|
||||
limit: z.number().int().min(1).max(50).optional().default(5),
|
||||
});
|
||||
|
||||
export const staffingSuggestionsReadProcedures = {
|
||||
getSuggestions: planningReadProcedure
|
||||
.input(
|
||||
@@ -380,35 +426,39 @@ export const staffingSuggestionsReadProcedures = {
|
||||
}),
|
||||
getProjectStaffingSuggestions: planningReadProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string().min(1),
|
||||
roleName: z.string().optional(),
|
||||
startDate: z.coerce.date().optional(),
|
||||
endDate: z.coerce.date().optional(),
|
||||
limit: z.number().int().min(1).max(50).optional().default(5),
|
||||
}),
|
||||
GetProjectStaffingSuggestionsInputSchema as z.ZodType<
|
||||
z.infer<typeof GetProjectStaffingSuggestionsInputSchema>,
|
||||
z.ZodTypeDef,
|
||||
z.input<typeof GetProjectStaffingSuggestionsInputSchema>
|
||||
>,
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.VIEW_COSTS);
|
||||
const project = await findUniqueOrThrow(ctx.db.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: {
|
||||
id: true,
|
||||
shortCode: true,
|
||||
name: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
},
|
||||
}), "Project");
|
||||
const project = await findUniqueOrThrow(
|
||||
ctx.db.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: {
|
||||
id: true,
|
||||
shortCode: true,
|
||||
name: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
},
|
||||
}),
|
||||
"Project",
|
||||
);
|
||||
const startDate = input.startDate ?? project.startDate ?? new Date();
|
||||
const endDate = input.endDate ?? project.endDate ?? new Date();
|
||||
const normalizedRoleFilter = input.roleName?.trim().toLowerCase();
|
||||
const suggestions = await queryStaffingSuggestions(ctx.db as unknown as StaffingSuggestionsDbClient, {
|
||||
requiredSkills: [],
|
||||
startDate,
|
||||
endDate,
|
||||
hoursPerDay: 8,
|
||||
});
|
||||
const suggestions = await queryStaffingSuggestions(
|
||||
ctx.db as unknown as StaffingSuggestionsDbClient,
|
||||
{
|
||||
requiredSkills: [],
|
||||
startDate,
|
||||
endDate,
|
||||
hoursPerDay: 8,
|
||||
},
|
||||
);
|
||||
return {
|
||||
project: `${project.name} (${project.shortCode})`,
|
||||
period: `${toIsoDateOrNull(startDate)} to ${toIsoDateOrNull(endDate)}`,
|
||||
|
||||
Reference in New Issue
Block a user