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:
2026-04-10 15:28:12 +02:00
parent 0d79f97d7a
commit 9bd3781c03
21 changed files with 460 additions and 304 deletions
@@ -71,7 +71,7 @@ export function CountriesClient() {
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const { data: countries, isLoading } = trpc.country.list.useQuery(); const { data: countries, isLoading } = trpc.country.list.useQuery();
// @ts-expect-error TS2589: tRPC infers union type too deeply for nullable JSONB scheduleRules schema // @ts-expect-error TS2589: tRPC type instantiation depth — intermittent with country schema flattening
const createMut = trpc.country.create.useMutation({ const createMut = trpc.country.create.useMutation({
onSuccess: () => { onSuccess: () => {
void utils.country.list.invalidate(); void utils.country.list.invalidate();
@@ -102,7 +102,6 @@ export function SystemRolesClient() {
staleTime: 10_000, staleTime: 10_000,
}); });
// @ts-expect-error TS2589: tRPC infers union type too deeply for the role config update payload
const updateMutation = trpc.systemRoleConfig.update.useMutation({ const updateMutation = trpc.systemRoleConfig.update.useMutation({
onSuccess: async () => { onSuccess: async () => {
await utils.systemRoleConfig.list.invalidate(); await utils.systemRoleConfig.list.invalidate();
@@ -155,7 +155,6 @@ export function UsersClient() {
onError: (err) => setActionError(err.message), onError: (err) => setActionError(err.message),
}); });
// @ts-expect-error TS2589: tRPC infers union type too deeply for nullable overrides schema
const setPermissionsMutation = trpc.user.setPermissions.useMutation({ const setPermissionsMutation = trpc.user.setPermissions.useMutation({
onSuccess: async () => { onSuccess: async () => {
await utils.user.list.invalidate(); await utils.user.list.invalidate();
@@ -263,7 +263,8 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
if (actions) { if (actions) {
for (const action of actions) { for (const action of actions) {
if (action.type === "navigate" && action.url) { if (action.type === "navigate" && action.url) {
router.push(action.url as string & {}); // eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(action.url as any);
} else if (action.type === "invalidate" && action.scope) { } else if (action.type === "invalidate" && action.scope) {
// Invalidate relevant tRPC queries so the UI refreshes // Invalidate relevant tRPC queries so the UI refreshes
for (const scope of action.scope) { for (const scope of action.scope) {
@@ -109,7 +109,6 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
}); });
const { data: clientList } = trpc.clientEntity.list.useQuery(undefined, { staleTime: 60_000 }); const { data: clientList } = trpc.clientEntity.list.useQuery(undefined, { staleTime: 60_000 });
// @ts-expect-error TS2589: tRPC infers union type too deeply for CreateProjectSchema with .refine()
const createMutation = trpc.project.create.useMutation({ const createMutation = trpc.project.create.useMutation({
onSuccess: async () => { onSuccess: async () => {
await utils.project.listWithCosts.invalidate(); await utils.project.listWithCosts.invalidate();
@@ -142,7 +142,6 @@ export function VacationModal({
const utils = trpc.useUtils(); const utils = trpc.useUtils();
// @ts-expect-error TS2589: tRPC infers union type too deeply for CreateVacationRequestSchema with .superRefine()
const createMutation = trpc.vacation.create.useMutation({ const createMutation = trpc.vacation.create.useMutation({
onSuccess: async () => { onSuccess: async () => {
await utils.vacation.list.invalidate(); await utils.vacation.list.invalidate();
@@ -1,7 +1,8 @@
import { validateAvailability } from "@capakraken/engine"; import { validateAvailability } from "@capakraken/engine";
import { import type {
type AllocationConflictCheckResult, AllocationConflictCheckResult,
type WeekdayAvailability, AllocationStatus,
WeekdayAvailability,
} from "@capakraken/shared"; } from "@capakraken/shared";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
@@ -24,7 +25,7 @@ export const allocationConflictProcedures = {
* Read-only — no mutations. * Read-only — no mutations.
*/ */
checkConflicts: managerProcedure checkConflicts: managerProcedure
.input(CheckConflictsInputSchema) .input(CheckConflictsInputSchema as z.ZodType<z.infer<typeof CheckConflictsInputSchema>>)
.query(async ({ ctx, input }): Promise<AllocationConflictCheckResult> => { .query(async ({ ctx, input }): Promise<AllocationConflictCheckResult> => {
const resource = await ctx.db.resource.findUnique({ const resource = await ctx.db.resource.findUnique({
where: { id: input.resourceId }, where: { id: input.resourceId },
@@ -39,8 +40,7 @@ export const allocationConflictProcedures = {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
} }
const fallbackDailyHours = const fallbackDailyHours = (resource.country?.dailyWorkingHours ?? 8) * (resource.fte ?? 1);
(resource.country?.dailyWorkingHours ?? 8) * (resource.fte ?? 1);
const availability = (resource.availability as WeekdayAvailability | null) ?? { const availability = (resource.availability as WeekdayAvailability | null) ?? {
monday: fallbackDailyHours, monday: fallbackDailyHours,
tuesday: fallbackDailyHours, tuesday: fallbackDailyHours,
@@ -82,7 +82,12 @@ export const allocationConflictProcedures = {
input.endDate, input.endDate,
input.hoursPerDay, input.hoursPerDay,
availability, availability,
existingAssignments as { startDate: Date; endDate: Date; hoursPerDay: number; status: import("@capakraken/shared").AllocationStatus }[], existingAssignments as {
startDate: Date;
endDate: Date;
hoursPerDay: number;
status: AllocationStatus;
}[],
); );
// Compute max overbook percentage for the worst day // Compute max overbook percentage for the worst day
@@ -90,9 +95,7 @@ export const allocationConflictProcedures = {
for (const conflict of availabilityResult.conflicts) { for (const conflict of availabilityResult.conflicts) {
const totalBooked = conflict.existingHours + conflict.requestedHours; const totalBooked = conflict.existingHours + conflict.requestedHours;
const overbookPct = const overbookPct =
conflict.availableHours > 0 conflict.availableHours > 0 ? (totalBooked / conflict.availableHours - 1) * 100 : 100;
? ((totalBooked / conflict.availableHours) - 1) * 100
: 100;
if (overbookPct > maxOverbookPercent) maxOverbookPercent = overbookPct; if (overbookPct > maxOverbookPercent) maxOverbookPercent = overbookPct;
} }
@@ -1,7 +1,4 @@
import { import { createAssignment, updateAssignment } from "@capakraken/application";
createAssignment,
updateAssignment,
} from "@capakraken/application";
import { import {
AllocationStatus, AllocationStatus,
CreateAllocationSchema, CreateAllocationSchema,
@@ -10,6 +7,12 @@ import {
UpdateAllocationSchema, UpdateAllocationSchema,
UpdateAssignmentSchema, UpdateAssignmentSchema,
} from "@capakraken/shared"; } from "@capakraken/shared";
import type {
CreateAllocationInput,
CreateAssignmentInput,
UpdateAllocationInput,
UpdateAssignmentInput,
} from "@capakraken/shared";
import { z } from "zod"; import { z } from "zod";
import { findUniqueOrThrow } from "../../db/helpers.js"; import { findUniqueOrThrow } from "../../db/helpers.js";
import { import {
@@ -28,40 +31,36 @@ import {
ensureAssignmentRecord, ensureAssignmentRecord,
updateAllocationWithAudit, updateAllocationWithAudit,
} from "./assignment-mutations.js"; } from "./assignment-mutations.js";
import { import { managerProcedure, requirePermission } from "../../trpc.js";
managerProcedure,
requirePermission,
} from "../../trpc.js";
export const allocationAssignmentProcedures = { export const allocationAssignmentProcedures = {
create: managerProcedure create: managerProcedure
.input(CreateAllocationSchema) .input(CreateAllocationSchema as z.ZodType<CreateAllocationInput>)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const allocation = await ctx.db.$transaction(async (tx) => const allocation = await ctx.db.$transaction(async (tx) =>
createAllocationReadModelEntry( createAllocationReadModelEntry(tx as Parameters<typeof createAssignment>[0], input),
tx as Parameters<typeof createAssignment>[0], );
input,
));
publishAllocationCreated(ctx.db, { publishAllocationCreated(
ctx.db,
{
id: allocation.id, id: allocation.id,
projectId: allocation.projectId, projectId: allocation.projectId,
resourceId: allocation.resourceId, resourceId: allocation.resourceId,
}, { dispatchWebhook: true }); },
{ dispatchWebhook: true },
);
return allocation; return allocation;
}), }),
createAssignment: managerProcedure createAssignment: managerProcedure
.input(CreateAssignmentSchema) .input(CreateAssignmentSchema as z.ZodType<CreateAssignmentInput>)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const assignment = await ctx.db.$transaction(async (tx) => { const assignment = await ctx.db.$transaction(async (tx) => {
return createAssignment( return createAssignment(tx as unknown as Parameters<typeof createAssignment>[0], input);
tx as unknown as Parameters<typeof createAssignment>[0],
input,
);
}); });
publishAllocationCreated(ctx.db, { publishAllocationCreated(ctx.db, {
@@ -74,14 +73,16 @@ export const allocationAssignmentProcedures = {
}), }),
ensureAssignment: managerProcedure ensureAssignment: managerProcedure
.input(z.object({ .input(
z.object({
resourceId: z.string(), resourceId: z.string(),
projectId: z.string(), projectId: z.string(),
startDate: z.coerce.date(), startDate: z.coerce.date(),
endDate: z.coerce.date(), endDate: z.coerce.date(),
hoursPerDay: z.number().min(0.5).max(24), hoursPerDay: z.number().min(0.5).max(24),
role: z.string().optional(), role: z.string().optional(),
})) }),
)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const result = await ensureAssignmentRecord(ctx.db, { const result = await ensureAssignmentRecord(ctx.db, {
@@ -94,11 +95,15 @@ export const allocationAssignmentProcedures = {
}); });
if (result.action === "reactivated") { if (result.action === "reactivated") {
publishAllocationUpdated(ctx.db, { publishAllocationUpdated(
ctx.db,
{
id: result.assignment.id, id: result.assignment.id,
projectId: result.assignment.projectId, projectId: result.assignment.projectId,
resourceId: result.assignment.resourceId, resourceId: result.assignment.resourceId,
}, { dispatchWebhook: true }); },
{ dispatchWebhook: true },
);
return result; return result;
} }
@@ -113,7 +118,12 @@ export const allocationAssignmentProcedures = {
}), }),
updateAssignment: managerProcedure updateAssignment: managerProcedure
.input(z.object({ id: z.string(), data: UpdateAssignmentSchema })) .input(
z.object({ id: z.string(), data: UpdateAssignmentSchema }) as z.ZodType<{
id: string;
data: UpdateAssignmentInput;
}>,
)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const existing = await findUniqueOrThrow( const existing = await findUniqueOrThrow(
@@ -132,18 +142,27 @@ export const allocationAssignmentProcedures = {
); );
}); });
publishAllocationUpdated(ctx.db, { publishAllocationUpdated(
ctx.db,
{
id: updated.id, id: updated.id,
projectId: updated.projectId, projectId: updated.projectId,
resourceId: updated.resourceId, resourceId: updated.resourceId,
resourceIds: [existing.resourceId, updated.resourceId], resourceIds: [existing.resourceId, updated.resourceId],
}, { dispatchWebhook: true }); },
{ dispatchWebhook: true },
);
return updated; return updated;
}), }),
update: managerProcedure update: managerProcedure
.input(z.object({ id: z.string(), data: UpdateAllocationSchema })) .input(
z.object({ id: z.string(), data: UpdateAllocationSchema }) as z.ZodType<{
id: string;
data: UpdateAllocationInput;
}>,
)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const { existing, updated } = await updateAllocationWithAudit(ctx.db, input.id, input.data); const { existing, updated } = await updateAllocationWithAudit(ctx.db, input.id, input.data);
@@ -177,9 +196,7 @@ export const allocationAssignmentProcedures = {
return { success: true }; return { success: true };
}), }),
delete: managerProcedure delete: managerProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const existing = await deleteAllocationWithAudit(ctx.db, input.id); const existing = await deleteAllocationWithAudit(ctx.db, input.id);
@@ -198,11 +215,14 @@ export const allocationAssignmentProcedures = {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const existing = await batchDeleteAllocationsWithAudit(ctx.db, input.ids); const existing = await batchDeleteAllocationsWithAudit(ctx.db, input.ids);
publishBatchAllocationDeletes(ctx.db, existing.map((allocation) => ({ publishBatchAllocationDeletes(
ctx.db,
existing.map((allocation) => ({
id: allocation.entry.id, id: allocation.entry.id,
projectId: allocation.projectId, projectId: allocation.projectId,
resourceId: allocation.entry.resourceId, resourceId: allocation.entry.resourceId,
}))); })),
);
return { count: existing.length }; return { count: existing.length };
}), }),
@@ -218,11 +238,14 @@ export const allocationAssignmentProcedures = {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const updated = await batchUpdateAllocationStatusWithAudit(ctx.db, input); const updated = await batchUpdateAllocationStatusWithAudit(ctx.db, input);
publishBatchAllocationStatusUpdates(ctx.db, updated.map((allocation) => ({ publishBatchAllocationStatusUpdates(
ctx.db,
updated.map((allocation) => ({
id: allocation.id, id: allocation.id,
projectId: allocation.projectId, projectId: allocation.projectId,
resourceId: allocation.resourceId, resourceId: allocation.resourceId,
}))); })),
);
return { count: updated.length }; return { count: updated.length };
}), }),
+21 -20
View File
@@ -1,3 +1,4 @@
import type { Prisma } from "@capakraken/db";
import { import {
deleteDemandRequirement, deleteDemandRequirement,
fillOpenDemand, fillOpenDemand,
@@ -10,6 +11,11 @@ import {
PermissionKey, PermissionKey,
UpdateDemandRequirementSchema, UpdateDemandRequirementSchema,
} from "@capakraken/shared"; } from "@capakraken/shared";
import type {
CreateDemandRequirementInput,
FillDemandRequirementInput,
FillOpenDemandByAllocationInput,
} from "@capakraken/shared";
import { z } from "zod"; import { z } from "zod";
import { findUniqueOrThrow } from "../../db/helpers.js"; import { findUniqueOrThrow } from "../../db/helpers.js";
import { import {
@@ -25,25 +31,20 @@ import {
invalidateDashboardCacheInBackground, invalidateDashboardCacheInBackground,
} from "./effects.js"; } from "./effects.js";
import { DEMAND_INCLUDE } from "./shared.js"; import { DEMAND_INCLUDE } from "./shared.js";
import { import { buildCreateDemandRequirementInput, getDemandRequirementByIdOrThrow } from "./support.js";
buildCreateDemandRequirementInput, import { managerProcedure, requirePermission } from "../../trpc.js";
getDemandRequirementByIdOrThrow,
} from "./support.js";
import {
managerProcedure,
requirePermission,
} from "../../trpc.js";
export const allocationDemandProcedures = { export const allocationDemandProcedures = {
createDemandRequirement: managerProcedure createDemandRequirement: managerProcedure
.input(CreateDemandRequirementSchema) .input(CreateDemandRequirementSchema as z.ZodType<CreateDemandRequirementInput>)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
return createDemandRequirementWithEffects(ctx.db, input); return createDemandRequirementWithEffects(ctx.db, input);
}), }),
createDemand: managerProcedure createDemand: managerProcedure
.input(z.object({ .input(
z.object({
projectId: z.string(), projectId: z.string(),
role: z.string().optional(), role: z.string().optional(),
roleId: z.string().optional(), roleId: z.string().optional(),
@@ -53,13 +54,11 @@ export const allocationDemandProcedures = {
endDate: z.coerce.date(), endDate: z.coerce.date(),
budgetCents: z.number().int().min(0).optional(), budgetCents: z.number().int().min(0).optional(),
metadata: z.record(z.string(), z.unknown()).optional(), metadata: z.record(z.string(), z.unknown()).optional(),
})) }),
)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
return createDemandRequirementWithEffects( return createDemandRequirementWithEffects(ctx.db, buildCreateDemandRequirementInput(input));
ctx.db,
buildCreateDemandRequirementInput(input),
);
}), }),
updateDemandRequirement: managerProcedure updateDemandRequirement: managerProcedure
@@ -110,7 +109,7 @@ export const allocationDemandProcedures = {
entityType: "DemandRequirement", entityType: "DemandRequirement",
entityId: input.id, entityId: input.id,
action: "DELETE", action: "DELETE",
changes: { before: existing } as unknown as import("@capakraken/db").Prisma.InputJsonValue, changes: { before: existing } as unknown as Prisma.InputJsonValue,
}, },
}); });
}); });
@@ -127,17 +126,19 @@ export const allocationDemandProcedures = {
}), }),
fillDemandRequirement: managerProcedure fillDemandRequirement: managerProcedure
.input(FillDemandRequirementSchema) .input(FillDemandRequirementSchema as z.ZodType<FillDemandRequirementInput>)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
return fillDemandRequirementWithEffects(ctx.db, input); return fillDemandRequirementWithEffects(ctx.db, input);
}), }),
assignResourceToDemand: managerProcedure assignResourceToDemand: managerProcedure
.input(z.object({ .input(
z.object({
demandRequirementId: z.string(), demandRequirementId: z.string(),
resourceId: z.string(), resourceId: z.string(),
})) }),
)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const result = await fillDemandRequirementWithEffects(ctx.db, input); const result = await fillDemandRequirementWithEffects(ctx.db, input);
@@ -153,7 +154,7 @@ export const allocationDemandProcedures = {
}), }),
fillOpenDemandByAllocation: managerProcedure fillOpenDemandByAllocation: managerProcedure
.input(FillOpenDemandByAllocationSchema) .input(FillOpenDemandByAllocationSchema as z.ZodType<FillOpenDemandByAllocationInput>)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
+9 -1
View File
@@ -23,6 +23,8 @@ import {
updateMetroCity, updateMetroCity,
} from "./country-procedure-support.js"; } from "./country-procedure-support.js";
import { CreateCountrySchema, CreateMetroCitySchema } from "@capakraken/shared"; import { CreateCountrySchema, CreateMetroCitySchema } from "@capakraken/shared";
import type { CreateCountryInput } from "@capakraken/shared";
import type { z } from "zod";
export const countryRouter = createTRPCRouter({ export const countryRouter = createTRPCRouter({
list: protectedProcedure list: protectedProcedure
@@ -46,7 +48,13 @@ export const countryRouter = createTRPCRouter({
.query(({ ctx, input }) => getMetroCityById(ctx, input)), .query(({ ctx, input }) => getMetroCityById(ctx, input)),
create: adminProcedure create: adminProcedure
.input(CreateCountrySchema) .input(
CreateCountrySchema as z.ZodType<
CreateCountryInput,
z.ZodTypeDef,
z.input<typeof CreateCountrySchema>
>,
)
.mutation(({ ctx, input }) => createCountry(ctx, input)), .mutation(({ ctx, input }) => createCountry(ctx, input)),
update: adminProcedure update: adminProcedure
+14 -3
View File
@@ -1,3 +1,4 @@
import type { z } from "zod";
import { createTRPCRouter, controllerProcedure } from "../trpc.js"; import { createTRPCRouter, controllerProcedure } from "../trpc.js";
import { import {
dashboardChargeabilityOverviewInputSchema, dashboardChargeabilityOverviewInputSchema,
@@ -34,7 +35,13 @@ export const dashboardRouter = createTRPCRouter({
.query(({ ctx, input }) => getDashboardTopValueResourcesRead(ctx, input)), .query(({ ctx, input }) => getDashboardTopValueResourcesRead(ctx, input)),
getDemand: controllerProcedure getDemand: controllerProcedure
.input(dashboardDemandInputSchema) .input(
dashboardDemandInputSchema as z.ZodType<
z.infer<typeof dashboardDemandInputSchema>,
z.ZodTypeDef,
z.input<typeof dashboardDemandInputSchema>
>,
)
.query(({ ctx, input }) => getDashboardDemandRead(ctx, input)), .query(({ ctx, input }) => getDashboardDemandRead(ctx, input)),
getDetail: controllerProcedure getDetail: controllerProcedure
@@ -47,7 +54,9 @@ export const dashboardRouter = createTRPCRouter({
getBudgetForecast: controllerProcedure.query(({ ctx }) => getDashboardBudgetForecastRead(ctx)), getBudgetForecast: controllerProcedure.query(({ ctx }) => getDashboardBudgetForecastRead(ctx)),
getBudgetForecastDetail: controllerProcedure.query(({ ctx }) => getDashboardBudgetForecastDetail(ctx)), getBudgetForecastDetail: controllerProcedure.query(({ ctx }) =>
getDashboardBudgetForecastDetail(ctx),
),
getSkillGaps: controllerProcedure.query(({ ctx }) => getDashboardSkillGapsRead(ctx)), getSkillGaps: controllerProcedure.query(({ ctx }) => getDashboardSkillGapsRead(ctx)),
@@ -55,5 +64,7 @@ export const dashboardRouter = createTRPCRouter({
getProjectHealth: controllerProcedure.query(({ ctx }) => getDashboardProjectHealthRead(ctx)), getProjectHealth: controllerProcedure.query(({ ctx }) => getDashboardProjectHealthRead(ctx)),
getProjectHealthDetail: controllerProcedure.query(({ ctx }) => getDashboardProjectHealthDetail(ctx)), getProjectHealthDetail: controllerProcedure.query(({ ctx }) =>
getDashboardProjectHealthDetail(ctx),
),
}); });
+12 -7
View File
@@ -4,13 +4,19 @@ import { z } from "zod";
import { CursorInputSchema, paginateCursor } from "../db/pagination.js"; import { CursorInputSchema, paginateCursor } from "../db/pagination.js";
import { controllerProcedure } from "../trpc.js"; import { controllerProcedure } from "../trpc.js";
const ListWithCostsInputSchema = CursorInputSchema.extend({
status: z.nativeEnum(ProjectStatus).optional(),
search: z.string().optional(),
});
export const projectCostReadProcedures = { export const projectCostReadProcedures = {
listWithCosts: controllerProcedure listWithCosts: controllerProcedure
.input( .input(
CursorInputSchema.extend({ ListWithCostsInputSchema as z.ZodType<
status: z.nativeEnum(ProjectStatus).optional(), z.infer<typeof ListWithCostsInputSchema>,
search: z.string().optional(), z.ZodTypeDef,
}), z.input<typeof ListWithCostsInputSchema>
>,
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const { status, search, cursor } = input; const { status, search, cursor } = input;
@@ -58,9 +64,8 @@ export const projectCostReadProcedures = {
totalCostCents += booking.dailyCostCents * days; totalCostCents += booking.dailyCostCents * days;
totalPersonDays += (booking.hoursPerDay * days) / 8; totalPersonDays += (booking.hoursPerDay * days) / 8;
} }
const utilizationPercent = project.budgetCents > 0 const utilizationPercent =
? Math.round((totalCostCents / project.budgetCents) * 100) project.budgetCents > 0 ? Math.round((totalCostCents / project.budgetCents) * 100) : 0;
: 0;
return { return {
...project, ...project,
totalCostCents: Math.round(totalCostCents), totalCostCents: Math.round(totalCostCents),
+33 -14
View File
@@ -1,4 +1,11 @@
import { BlueprintTarget, CreateProjectSchema, PermissionKey, UpdateProjectSchema } from "@capakraken/shared"; import {
BlueprintTarget,
CreateProjectSchema,
PermissionKey,
UpdateProjectSchema,
} from "@capakraken/shared";
import type { CreateProjectInput } from "@capakraken/shared";
import type { Prisma } from "@capakraken/db";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js"; import { findUniqueOrThrow } from "../db/helpers.js";
@@ -21,10 +28,12 @@ function buildProjectCreateData(
status: input.status, status: input.status,
responsiblePerson: input.responsiblePerson, responsiblePerson: input.responsiblePerson,
...(input.color !== undefined ? { color: input.color } : {}), ...(input.color !== undefined ? { color: input.color } : {}),
staffingReqs: input.staffingReqs as unknown as import("@capakraken/db").Prisma.InputJsonValue, staffingReqs: input.staffingReqs as unknown as Prisma.InputJsonValue,
dynamicFields: input.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue, dynamicFields: input.dynamicFields as unknown as Prisma.InputJsonValue,
blueprintId: input.blueprintId, blueprintId: input.blueprintId,
...(input.utilizationCategoryId !== undefined ? { utilizationCategoryId: input.utilizationCategoryId || null } : {}), ...(input.utilizationCategoryId !== undefined
? { utilizationCategoryId: input.utilizationCategoryId || null }
: {}),
...(input.clientId !== undefined ? { clientId: input.clientId || null } : {}), ...(input.clientId !== undefined ? { clientId: input.clientId || null } : {}),
} as unknown as Parameters<TRPCContext["db"]["project"]["create"]>[0]["data"]; } as unknown as Parameters<TRPCContext["db"]["project"]["create"]>[0]["data"];
} }
@@ -41,24 +50,32 @@ function buildProjectUpdateData(
...(input.startDate !== undefined ? { startDate: input.startDate } : {}), ...(input.startDate !== undefined ? { startDate: input.startDate } : {}),
...(input.endDate !== undefined ? { endDate: input.endDate } : {}), ...(input.endDate !== undefined ? { endDate: input.endDate } : {}),
...(input.status !== undefined ? { status: input.status } : {}), ...(input.status !== undefined ? { status: input.status } : {}),
...(input.responsiblePerson !== undefined ? { responsiblePerson: input.responsiblePerson } : {}), ...(input.responsiblePerson !== undefined
? { responsiblePerson: input.responsiblePerson }
: {}),
...(input.color !== undefined ? { color: input.color } : {}), ...(input.color !== undefined ? { color: input.color } : {}),
...(input.staffingReqs !== undefined ? { staffingReqs: input.staffingReqs as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}), ...(input.staffingReqs !== undefined
...(input.dynamicFields !== undefined ? { dynamicFields: input.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}), ? { staffingReqs: input.staffingReqs as unknown as Prisma.InputJsonValue }
: {}),
...(input.dynamicFields !== undefined
? { dynamicFields: input.dynamicFields as unknown as Prisma.InputJsonValue }
: {}),
...(input.blueprintId !== undefined ? { blueprintId: input.blueprintId } : {}), ...(input.blueprintId !== undefined ? { blueprintId: input.blueprintId } : {}),
...(input.utilizationCategoryId !== undefined ? { utilizationCategoryId: input.utilizationCategoryId || null } : {}), ...(input.utilizationCategoryId !== undefined
? { utilizationCategoryId: input.utilizationCategoryId || null }
: {}),
...(input.clientId !== undefined ? { clientId: input.clientId || null } : {}), ...(input.clientId !== undefined ? { clientId: input.clientId || null } : {}),
...(input.shoringThreshold !== undefined ? { shoringThreshold: input.shoringThreshold } : {}), ...(input.shoringThreshold !== undefined ? { shoringThreshold: input.shoringThreshold } : {}),
...(input.onshoreCountryCode !== undefined ? { onshoreCountryCode: input.onshoreCountryCode } : {}), ...(input.onshoreCountryCode !== undefined
? { onshoreCountryCode: input.onshoreCountryCode }
: {}),
} as unknown as Parameters<TRPCContext["db"]["project"]["update"]>[0]["data"]; } as unknown as Parameters<TRPCContext["db"]["project"]["update"]>[0]["data"];
} }
export function createProjectMutationProcedures( export function createProjectMutationProcedures(backgroundEffects: ProjectBackgroundEffects) {
backgroundEffects: ProjectBackgroundEffects,
) {
return { return {
create: managerProcedure create: managerProcedure
.input(CreateProjectSchema) .input(CreateProjectSchema as z.ZodType<CreateProjectInput>)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
@@ -119,7 +136,9 @@ export function createProjectMutationProcedures(
); );
const nextBlueprintId = input.data.blueprintId ?? existing.blueprintId ?? undefined; const nextBlueprintId = input.data.blueprintId ?? existing.blueprintId ?? undefined;
const nextDynamicFields = (input.data.dynamicFields ?? existing.dynamicFields ?? {}) as Record<string, unknown>; const nextDynamicFields = (input.data.dynamicFields ??
existing.dynamicFields ??
{}) as Record<string, unknown>;
await assertBlueprintDynamicFields({ await assertBlueprintDynamicFields({
db: ctx.db, db: ctx.db,
+10 -3
View File
@@ -1,3 +1,4 @@
import type { z } from "zod";
import { projectCostReadProcedures } from "./project-cost-read.js"; import { projectCostReadProcedures } from "./project-cost-read.js";
import { projectCoverProcedures } from "./project-cover.js"; import { projectCoverProcedures } from "./project-cover.js";
import { projectIdentifierReadProcedures } from "./project-identifier-read.js"; import { projectIdentifierReadProcedures } from "./project-identifier-read.js";
@@ -21,13 +22,20 @@ export const projectRouter = createTRPCRouter({
...projectCoverProcedures, ...projectCoverProcedures,
...projectIdentifierReadProcedures, ...projectIdentifierReadProcedures,
...createProjectLifecycleProcedures({ ...createProjectLifecycleProcedures({
invalidateDashboardCacheInBackground: projectBackgroundEffects.invalidateDashboardCacheInBackground, invalidateDashboardCacheInBackground:
projectBackgroundEffects.invalidateDashboardCacheInBackground,
dispatchProjectWebhookInBackground: projectBackgroundEffects.dispatchProjectWebhookInBackground, dispatchProjectWebhookInBackground: projectBackgroundEffects.dispatchProjectWebhookInBackground,
}), }),
...createProjectMutationProcedures(projectBackgroundEffects), ...createProjectMutationProcedures(projectBackgroundEffects),
list: controllerProcedure list: controllerProcedure
.input(ProjectListInputSchema) .input(
ProjectListInputSchema as z.ZodType<
z.infer<typeof ProjectListInputSchema>,
z.ZodTypeDef,
z.input<typeof ProjectListInputSchema>
>,
)
.query(({ ctx, input }) => listProjects(ctx, input)), .query(({ ctx, input }) => listProjects(ctx, input)),
getById: controllerProcedure getById: controllerProcedure
@@ -37,5 +45,4 @@ export const projectRouter = createTRPCRouter({
getShoringRatio: controllerProcedure getShoringRatio: controllerProcedure
.input(ProjectShoringRatioInputSchema) .input(ProjectShoringRatioInputSchema)
.query(({ ctx, input }) => getProjectShoringRatioData(ctx, input)), .query(({ ctx, input }) => getProjectShoringRatioData(ctx, input)),
}); });
@@ -1,8 +1,6 @@
import type { z } from "zod";
import { protectedProcedure, resourceOverviewProcedure } from "../trpc.js"; import { protectedProcedure, resourceOverviewProcedure } from "../trpc.js";
import { import { ResourceDirectoryQuerySchema, ResourceListQuerySchema } from "./resource-read-shared.js";
ResourceDirectoryQuerySchema,
ResourceListQuerySchema,
} from "./resource-read-shared.js";
import { import {
ResolveResponsiblePersonNameInputSchema, ResolveResponsiblePersonNameInputSchema,
ResourceChargeabilitySummaryInputSchema, ResourceChargeabilitySummaryInputSchema,
@@ -38,7 +36,13 @@ export const resourceSummaryReadProcedures = {
.query(({ ctx, input }) => listResourceDirectory(ctx, input)), .query(({ ctx, input }) => listResourceDirectory(ctx, input)),
listStaff: resourceOverviewProcedure listStaff: resourceOverviewProcedure
.input(ResourceListQuerySchema) .input(
ResourceListQuerySchema as z.ZodType<
z.infer<typeof ResourceListQuerySchema>,
z.ZodTypeDef,
z.input<typeof ResourceListQuerySchema>
>,
)
.query(({ ctx, input }) => listStaffResourceEntries(ctx, input)), .query(({ ctx, input }) => listStaffResourceEntries(ctx, input)),
chapters: protectedProcedure.query(({ ctx }) => listResourceChapters(ctx)), chapters: protectedProcedure.query(({ ctx }) => listResourceChapters(ctx)),
@@ -18,17 +18,23 @@ import {
toIsoDate, toIsoDate,
} from "./staffing-shared.js"; } from "./staffing-shared.js";
export const staffingCapacityReadProcedures = { const SearchCapacityInputSchema = z.object({
searchCapacity: planningReadProcedure
.input(
z.object({
startDate: z.coerce.date(), startDate: z.coerce.date(),
endDate: z.coerce.date(), endDate: z.coerce.date(),
minHoursPerDay: z.number().optional().default(4), minHoursPerDay: z.number().optional().default(4),
roleName: z.string().optional(), roleName: z.string().optional(),
chapter: z.string().optional(), chapter: z.string().optional(),
limit: z.number().int().min(1).max(100).optional().default(20), 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 }) => { .query(async ({ ctx, input }) => {
const where: Record<string, unknown> = { isActive: true }; const where: Record<string, unknown> = { isActive: true };
@@ -107,7 +113,8 @@ export const staffingCapacityReadProcedures = {
}); });
const bookedHours = (bookingsByResourceId.get(resource.id) ?? []).reduce( const bookedHours = (bookingsByResourceId.get(resource.id) ?? []).reduce(
(sum, booking) => (sum, booking) =>
sum + calculateEffectiveBookedHours({ sum +
calculateEffectiveBookedHours({
availability, availability,
startDate: booking.startDate, startDate: booking.startDate,
endDate: booking.endDate, endDate: booking.endDate,
@@ -179,7 +186,8 @@ export const staffingCapacityReadProcedures = {
const availability = resource.availability as unknown as WeekdayAvailability; const availability = resource.availability as unknown as WeekdayAvailability;
const contexts = await loadResourceDailyAvailabilityContexts( const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db, ctx.db,
[{ [
{
id: resource.id, id: resource.id,
availability, availability,
countryId: resource.countryId, countryId: resource.countryId,
@@ -187,7 +195,8 @@ export const staffingCapacityReadProcedures = {
federalState: resource.federalState, federalState: resource.federalState,
metroCityId: resource.metroCityId, metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name, metroCityName: resource.metroCity?.name,
}], },
],
input.startDate, input.startDate,
input.endDate, input.endDate,
); );
@@ -231,9 +240,8 @@ export const staffingCapacityReadProcedures = {
cursor.setUTCDate(cursor.getUTCDate() + 1); cursor.setUTCDate(cursor.getUTCDate() + 1);
} }
const currentChargeability = totalAvailableHours > 0 const currentChargeability =
? (totalChargeableHours / totalAvailableHours) * 100 totalAvailableHours > 0 ? (totalChargeableHours / totalAvailableHours) * 100 : 0;
: 0;
return { return {
resourceId: resource.id, resourceId: resource.id,
@@ -291,7 +299,8 @@ export const staffingCapacityReadProcedures = {
const availability = resource.availability as unknown as WeekdayAvailability; const availability = resource.availability as unknown as WeekdayAvailability;
const contexts = await loadResourceDailyAvailabilityContexts( const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db, ctx.db,
[{ [
{
id: resource.id, id: resource.id,
availability, availability,
countryId: resource.countryId, countryId: resource.countryId,
@@ -299,7 +308,8 @@ export const staffingCapacityReadProcedures = {
federalState: resource.federalState, federalState: resource.federalState,
metroCityId: resource.metroCityId, metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name, metroCityName: resource.metroCity?.name,
}], },
],
input.startDate, input.startDate,
input.endDate, input.endDate,
); );
@@ -1,6 +1,7 @@
import { rankResources } from "@capakraken/staffing"; import { rankResources } from "@capakraken/staffing";
import { listAssignmentBookings } from "@capakraken/application"; 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 { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js"; import { findUniqueOrThrow } from "../db/helpers.js";
import { loadResourceDailyAvailabilityContexts } from "../lib/resource-capacity.js"; import { loadResourceDailyAvailabilityContexts } from "../lib/resource-capacity.js";
@@ -30,10 +31,8 @@ type StaffingSuggestionInput = {
minProficiency?: number | undefined; minProficiency?: number | undefined;
}; };
type StaffingSuggestionsDbClient = type StaffingSuggestionsDbClient = Parameters<typeof listAssignmentBookings>[0] &
Parameters<typeof listAssignmentBookings>[0] Parameters<typeof loadResourceDailyAvailabilityContexts>[0] & {
& Parameters<typeof loadResourceDailyAvailabilityContexts>[0]
& {
resource: { resource: {
findMany: (args: Record<string, unknown>) => Promise<unknown[]>; findMany: (args: Record<string, unknown>) => Promise<unknown[]>;
}; };
@@ -77,7 +76,7 @@ async function queryStaffingSuggestions(
mainSkillsOnly, mainSkillsOnly,
minProficiency, minProficiency,
} = input; } = input;
const resources = await db.resource.findMany({ const resources = (await db.resource.findMany({
where: { where: {
isActive: true, isActive: true,
...(chapter ? { chapter } : {}), ...(chapter ? { chapter } : {}),
@@ -100,7 +99,7 @@ async function queryStaffingSuggestions(
metroCity: { select: { name: true } }, metroCity: { select: { name: true } },
areaRole: { select: { name: true } }, areaRole: { select: { name: true } },
}, },
}) as StaffingResourceRecord[]; })) as StaffingResourceRecord[];
const bookings = await listAssignmentBookings(db, { const bookings = await listAssignmentBookings(db, {
startDate, startDate,
endDate, endDate,
@@ -133,7 +132,9 @@ async function queryStaffingSuggestions(
const availability = resource.availability as unknown as WeekdayAvailability; const availability = resource.availability as unknown as WeekdayAvailability;
const context = contexts.get(resource.id); const context = contexts.get(resource.id);
const resourceBookings = bookingsByResourceId.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({ const capacity = buildResourceCapacitySummary({
availability, availability,
periodStart: startDate, periodStart: startDate,
@@ -192,13 +193,17 @@ async function queryStaffingSuggestions(
} }
const allocatedHours = capacity.bookedHours; const allocatedHours = capacity.bookedHours;
const remainingHours = capacity.remainingHours;
const remainingHoursPerDay = capacity.remainingHoursPerDay; const remainingHoursPerDay = capacity.remainingHoursPerDay;
const utilizationPercent = const utilizationPercent =
capacity.availableHours > 0 capacity.availableHours > 0
? Math.min(100, (allocatedHours / capacity.availableHours) * 100) ? Math.min(100, (allocatedHours / capacity.availableHours) * 100)
: 0; : 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[]; let skills = resource.skills as unknown as SkillRow[];
if (mainSkillsOnly) { if (mainSkillsOnly) {
skills = skills.filter((skill) => skill.isMainSkill); skills = skills.filter((skill) => skill.isMainSkill);
@@ -217,7 +222,7 @@ async function queryStaffingSuggestions(
fte: resource.fte, fte: resource.fte,
chapter: resource.chapter, chapter: resource.chapter,
role: resource.areaRole?.name ?? null, role: resource.areaRole?.name ?? null,
skills: skills as unknown as import("@capakraken/shared").SkillEntry[], skills: skills as unknown as SkillEntry[],
lcrCents: resource.lcrCents, lcrCents: resource.lcrCents,
chargeabilityTarget: resource.chargeabilityTarget, chargeabilityTarget: resource.chargeabilityTarget,
currentUtilizationPercent: utilizationPercent, currentUtilizationPercent: utilizationPercent,
@@ -267,17 +272,31 @@ async function queryStaffingSuggestions(
budgetLcrCentsPerHour, budgetLcrCentsPerHour,
} as unknown as Parameters<typeof rankResources>[0]); } as unknown as Parameters<typeof rankResources>[0]);
const baseRankIndex = new Map(ranked.map((suggestion, index) => [suggestion.resourceId, index])); const baseRankIndex = new Map(ranked.map((suggestion, index) => [suggestion.resourceId, index]));
return [...ranked].sort((left, right) => { return [...ranked]
.sort((left, right) => {
if (Math.abs(left.score - right.score) <= 2) { if (Math.abs(left.score - right.score) <= 2) {
const leftValue = enrichedResources.find((resource) => resource.id === left.resourceId)?.valueScore ?? 0; const leftValue =
const rightValue = enrichedResources.find((resource) => resource.id === right.resourceId)?.valueScore ?? 0; enrichedResources.find((resource) => resource.id === left.resourceId)?.valueScore ?? 0;
const rightValue =
enrichedResources.find((resource) => resource.id === right.resourceId)?.valueScore ?? 0;
return rightValue - leftValue; return rightValue - leftValue;
} }
return 0; return 0;
}).map((suggestion, index) => { })
.map((suggestion, index) => {
const resource = enrichedResources.find((entry) => entry.id === suggestion.resourceId); const resource = enrichedResources.find((entry) => entry.id === suggestion.resourceId);
const fallbackBreakdown = "breakdown" in suggestion const fallbackBreakdown =
? (suggestion as { breakdown?: { skillScore: number; availabilityScore: number; costScore: number; utilizationScore: number } }).breakdown "breakdown" in suggestion
? (
suggestion as {
breakdown?: {
skillScore: number;
availabilityScore: number;
costScore: number;
utilizationScore: number;
};
}
).breakdown
: undefined; : undefined;
const scoreBreakdown = suggestion.scoreBreakdown ?? { const scoreBreakdown = suggestion.scoreBreakdown ?? {
skillScore: fallbackBreakdown?.skillScore ?? 0, skillScore: fallbackBreakdown?.skillScore ?? 0,
@@ -297,15 +316,26 @@ async function queryStaffingSuggestions(
chapter: resource?.chapter ?? null, chapter: resource?.chapter ?? null,
role: resource?.role ?? null, role: resource?.role ?? null,
scoreBreakdown, scoreBreakdown,
matchedSkills: suggestion.matchedSkills ?? requiredSkills.filter((skill) => matchedSkills:
resource?.skills.some((entry) => entry.skill.toLowerCase() === skill.trim().toLowerCase()), 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(),
), ),
missingSkills: suggestion.missingSkills ?? requiredSkills.filter((skill) =>
!resource?.skills.some((entry) => entry.skill.toLowerCase() === skill.trim().toLowerCase()),
), ),
availabilityConflicts: suggestion.availabilityConflicts ?? resource?.conflictDays ?? [], availabilityConflicts: suggestion.availabilityConflicts ?? resource?.conflictDays ?? [],
estimatedDailyCostCents: suggestion.estimatedDailyCostCents ?? ((resource?.lcrCents ?? 0) * 8), estimatedDailyCostCents:
currentUtilization: suggestion.currentUtilization ?? round1(resource?.currentUtilizationPercent ?? 0), suggestion.estimatedDailyCostCents ?? (resource?.lcrCents ?? 0) * 8,
currentUtilization:
suggestion.currentUtilization ?? round1(resource?.currentUtilizationPercent ?? 0),
valueScore: resource?.valueScore ?? 0, valueScore: resource?.valueScore ?? 0,
location: resource?.transparency.location ?? { location: resource?.transparency.location ?? {
countryCode: null, countryCode: null,
@@ -345,9 +375,17 @@ async function queryStaffingSuggestions(
model: "Composite ranking across skill fit, availability, cost, and utilization.", model: "Composite ranking across skill fit, availability, cost, and utilization.",
components: [ components: [
{ key: "skillScore", label: "Skills", score: scoreBreakdown.skillScore }, { key: "skillScore", label: "Skills", score: scoreBreakdown.skillScore },
{ key: "availabilityScore", label: "Availability", score: scoreBreakdown.availabilityScore }, {
key: "availabilityScore",
label: "Availability",
score: scoreBreakdown.availabilityScore,
},
{ key: "costScore", label: "Cost", score: scoreBreakdown.costScore }, { key: "costScore", label: "Cost", score: scoreBreakdown.costScore },
{ key: "utilizationScore", label: "Utilization", score: scoreBreakdown.utilizationScore }, {
key: "utilizationScore",
label: "Utilization",
score: scoreBreakdown.utilizationScore,
},
], ],
}, },
remainingHoursPerDay: resource?.transparency.capacity.remainingHoursPerDay ?? 0, remainingHoursPerDay: resource?.transparency.capacity.remainingHoursPerDay ?? 0,
@@ -358,6 +396,14 @@ async function queryStaffingSuggestions(
}; };
}); });
} }
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 = { export const staffingSuggestionsReadProcedures = {
getSuggestions: planningReadProcedure getSuggestions: planningReadProcedure
.input( .input(
@@ -380,17 +426,16 @@ export const staffingSuggestionsReadProcedures = {
}), }),
getProjectStaffingSuggestions: planningReadProcedure getProjectStaffingSuggestions: planningReadProcedure
.input( .input(
z.object({ GetProjectStaffingSuggestionsInputSchema as z.ZodType<
projectId: z.string().min(1), z.infer<typeof GetProjectStaffingSuggestionsInputSchema>,
roleName: z.string().optional(), z.ZodTypeDef,
startDate: z.coerce.date().optional(), z.input<typeof GetProjectStaffingSuggestionsInputSchema>
endDate: z.coerce.date().optional(), >,
limit: z.number().int().min(1).max(50).optional().default(5),
}),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.VIEW_COSTS); requirePermission(ctx, PermissionKey.VIEW_COSTS);
const project = await findUniqueOrThrow(ctx.db.project.findUnique({ const project = await findUniqueOrThrow(
ctx.db.project.findUnique({
where: { id: input.projectId }, where: { id: input.projectId },
select: { select: {
id: true, id: true,
@@ -399,16 +444,21 @@ export const staffingSuggestionsReadProcedures = {
startDate: true, startDate: true,
endDate: true, endDate: true,
}, },
}), "Project"); }),
"Project",
);
const startDate = input.startDate ?? project.startDate ?? new Date(); const startDate = input.startDate ?? project.startDate ?? new Date();
const endDate = input.endDate ?? project.endDate ?? new Date(); const endDate = input.endDate ?? project.endDate ?? new Date();
const normalizedRoleFilter = input.roleName?.trim().toLowerCase(); const normalizedRoleFilter = input.roleName?.trim().toLowerCase();
const suggestions = await queryStaffingSuggestions(ctx.db as unknown as StaffingSuggestionsDbClient, { const suggestions = await queryStaffingSuggestions(
ctx.db as unknown as StaffingSuggestionsDbClient,
{
requiredSkills: [], requiredSkills: [],
startDate, startDate,
endDate, endDate,
hoursPerDay: 8, hoursPerDay: 8,
}); },
);
return { return {
project: `${project.name} (${project.shortCode})`, project: `${project.name} (${project.shortCode})`,
period: `${toIsoDateOrNull(startDate)} to ${toIsoDateOrNull(endDate)}`, period: `${toIsoDateOrNull(startDate)} to ${toIsoDateOrNull(endDate)}`,
@@ -1,3 +1,4 @@
import type { z } from "zod";
import { adminProcedure, createTRPCRouter } from "../trpc.js"; import { adminProcedure, createTRPCRouter } from "../trpc.js";
import { import {
listSystemRoleConfigs, listSystemRoleConfigs,
@@ -11,6 +12,10 @@ export const systemRoleConfigRouter = createTRPCRouter({
/** Update a role's default permissions, label, description, and color */ /** Update a role's default permissions, label, description, and color */
update: adminProcedure update: adminProcedure
.input(systemRoleConfigUpdateInputSchema) .input(
systemRoleConfigUpdateInputSchema as z.ZodType<
z.infer<typeof systemRoleConfigUpdateInputSchema>
>,
)
.mutation(({ ctx, input }) => updateSystemRoleConfig(ctx, input)), .mutation(({ ctx, input }) => updateSystemRoleConfig(ctx, input)),
}); });
@@ -1,4 +1,6 @@
import { PermissionKey, ShiftProjectSchema } from "@capakraken/shared"; import { PermissionKey, ShiftProjectSchema } from "@capakraken/shared";
import type { ShiftProjectInput } from "@capakraken/shared";
import type { z } from "zod";
import { managerProcedure, requirePermission } from "../trpc.js"; import { managerProcedure, requirePermission } from "../trpc.js";
import { timelineAllocationMutationProcedures } from "./timeline-allocation-mutations.js"; import { timelineAllocationMutationProcedures } from "./timeline-allocation-mutations.js";
import { applyTimelineProjectShiftMutation } from "./timeline-shift-router-support.js"; import { applyTimelineProjectShiftMutation } from "./timeline-shift-router-support.js";
@@ -7,7 +9,7 @@ export const timelineMutationProcedures = {
...timelineAllocationMutationProcedures, ...timelineAllocationMutationProcedures,
applyShift: managerProcedure applyShift: managerProcedure
.input(ShiftProjectSchema) .input(ShiftProjectSchema as z.ZodType<ShiftProjectInput>)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
+11 -2
View File
@@ -1,4 +1,11 @@
import { adminProcedure, createTRPCRouter, managerProcedure, protectedProcedure, publicProcedure } from "../trpc.js"; import type { z } from "zod";
import {
adminProcedure,
createTRPCRouter,
managerProcedure,
protectedProcedure,
publicProcedure,
} from "../trpc.js";
import { import {
autoLinkUsersByEmail, autoLinkUsersByEmail,
countActiveUsers, countActiveUsers,
@@ -92,7 +99,9 @@ export const userRouter = createTRPCRouter({
.mutation(({ ctx, input }) => toggleFavoriteProject(ctx, input)), .mutation(({ ctx, input }) => toggleFavoriteProject(ctx, input)),
setPermissions: adminProcedure setPermissions: adminProcedure
.input(SetUserPermissionsInputSchema) .input(
SetUserPermissionsInputSchema as z.ZodType<z.infer<typeof SetUserPermissionsInputSchema>>,
)
.mutation(({ ctx, input }) => setUserPermissions(ctx, input)), .mutation(({ ctx, input }) => setUserPermissions(ctx, input)),
resetPermissions: adminProcedure resetPermissions: adminProcedure
+3 -1
View File
@@ -1,7 +1,9 @@
import { createTRPCRouter, protectedProcedure } from "../trpc.js"; import { createTRPCRouter, protectedProcedure } from "../trpc.js";
import type { z } from "zod";
import { import {
createVacationRequest, createVacationRequest,
CreateVacationRequestSchema, CreateVacationRequestSchema,
type CreateVacationRequestInput,
} from "./vacation-create-support.js"; } from "./vacation-create-support.js";
import { vacationManagementProcedures } from "./vacation-management-procedures.js"; import { vacationManagementProcedures } from "./vacation-management-procedures.js";
import { vacationReadProcedures } from "./vacation-read.js"; import { vacationReadProcedures } from "./vacation-read.js";
@@ -11,6 +13,6 @@ export const vacationRouter = createTRPCRouter({
...vacationManagementProcedures, ...vacationManagementProcedures,
create: protectedProcedure create: protectedProcedure
.input(CreateVacationRequestSchema) .input(CreateVacationRequestSchema as z.ZodType<CreateVacationRequestInput>)
.mutation(({ ctx, input }) => createVacationRequest(ctx, input)), .mutation(({ ctx, input }) => createVacationRequest(ctx, input)),
}); });