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:
@@ -71,7 +71,7 @@ export function CountriesClient() {
|
||||
const utils = trpc.useUtils();
|
||||
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({
|
||||
onSuccess: () => {
|
||||
void utils.country.list.invalidate();
|
||||
|
||||
@@ -102,7 +102,6 @@ export function SystemRolesClient() {
|
||||
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({
|
||||
onSuccess: async () => {
|
||||
await utils.systemRoleConfig.list.invalidate();
|
||||
|
||||
@@ -155,7 +155,6 @@ export function UsersClient() {
|
||||
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({
|
||||
onSuccess: async () => {
|
||||
await utils.user.list.invalidate();
|
||||
|
||||
@@ -263,7 +263,8 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
|
||||
if (actions) {
|
||||
for (const action of actions) {
|
||||
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) {
|
||||
// Invalidate relevant tRPC queries so the UI refreshes
|
||||
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 });
|
||||
|
||||
// @ts-expect-error TS2589: tRPC infers union type too deeply for CreateProjectSchema with .refine()
|
||||
const createMutation = trpc.project.create.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.project.listWithCosts.invalidate();
|
||||
|
||||
@@ -142,7 +142,6 @@ export function VacationModal({
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// @ts-expect-error TS2589: tRPC infers union type too deeply for CreateVacationRequestSchema with .superRefine()
|
||||
const createMutation = trpc.vacation.create.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.vacation.list.invalidate();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { validateAvailability } from "@capakraken/engine";
|
||||
import {
|
||||
type AllocationConflictCheckResult,
|
||||
type WeekdayAvailability,
|
||||
import type {
|
||||
AllocationConflictCheckResult,
|
||||
AllocationStatus,
|
||||
WeekdayAvailability,
|
||||
} from "@capakraken/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
@@ -24,7 +25,7 @@ export const allocationConflictProcedures = {
|
||||
* Read-only — no mutations.
|
||||
*/
|
||||
checkConflicts: managerProcedure
|
||||
.input(CheckConflictsInputSchema)
|
||||
.input(CheckConflictsInputSchema as z.ZodType<z.infer<typeof CheckConflictsInputSchema>>)
|
||||
.query(async ({ ctx, input }): Promise<AllocationConflictCheckResult> => {
|
||||
const resource = await ctx.db.resource.findUnique({
|
||||
where: { id: input.resourceId },
|
||||
@@ -39,8 +40,7 @@ export const allocationConflictProcedures = {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
|
||||
}
|
||||
|
||||
const fallbackDailyHours =
|
||||
(resource.country?.dailyWorkingHours ?? 8) * (resource.fte ?? 1);
|
||||
const fallbackDailyHours = (resource.country?.dailyWorkingHours ?? 8) * (resource.fte ?? 1);
|
||||
const availability = (resource.availability as WeekdayAvailability | null) ?? {
|
||||
monday: fallbackDailyHours,
|
||||
tuesday: fallbackDailyHours,
|
||||
@@ -82,7 +82,12 @@ export const allocationConflictProcedures = {
|
||||
input.endDate,
|
||||
input.hoursPerDay,
|
||||
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
|
||||
@@ -90,9 +95,7 @@ export const allocationConflictProcedures = {
|
||||
for (const conflict of availabilityResult.conflicts) {
|
||||
const totalBooked = conflict.existingHours + conflict.requestedHours;
|
||||
const overbookPct =
|
||||
conflict.availableHours > 0
|
||||
? ((totalBooked / conflict.availableHours) - 1) * 100
|
||||
: 100;
|
||||
conflict.availableHours > 0 ? (totalBooked / conflict.availableHours - 1) * 100 : 100;
|
||||
if (overbookPct > maxOverbookPercent) maxOverbookPercent = overbookPct;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
createAssignment,
|
||||
updateAssignment,
|
||||
} from "@capakraken/application";
|
||||
import { createAssignment, updateAssignment } from "@capakraken/application";
|
||||
import {
|
||||
AllocationStatus,
|
||||
CreateAllocationSchema,
|
||||
@@ -10,6 +7,12 @@ import {
|
||||
UpdateAllocationSchema,
|
||||
UpdateAssignmentSchema,
|
||||
} from "@capakraken/shared";
|
||||
import type {
|
||||
CreateAllocationInput,
|
||||
CreateAssignmentInput,
|
||||
UpdateAllocationInput,
|
||||
UpdateAssignmentInput,
|
||||
} from "@capakraken/shared";
|
||||
import { z } from "zod";
|
||||
import { findUniqueOrThrow } from "../../db/helpers.js";
|
||||
import {
|
||||
@@ -28,40 +31,36 @@ import {
|
||||
ensureAssignmentRecord,
|
||||
updateAllocationWithAudit,
|
||||
} from "./assignment-mutations.js";
|
||||
import {
|
||||
managerProcedure,
|
||||
requirePermission,
|
||||
} from "../../trpc.js";
|
||||
import { managerProcedure, requirePermission } from "../../trpc.js";
|
||||
|
||||
export const allocationAssignmentProcedures = {
|
||||
create: managerProcedure
|
||||
.input(CreateAllocationSchema)
|
||||
.input(CreateAllocationSchema as z.ZodType<CreateAllocationInput>)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||
const allocation = await ctx.db.$transaction(async (tx) =>
|
||||
createAllocationReadModelEntry(
|
||||
tx as Parameters<typeof createAssignment>[0],
|
||||
input,
|
||||
));
|
||||
createAllocationReadModelEntry(tx as Parameters<typeof createAssignment>[0], input),
|
||||
);
|
||||
|
||||
publishAllocationCreated(ctx.db, {
|
||||
id: allocation.id,
|
||||
projectId: allocation.projectId,
|
||||
resourceId: allocation.resourceId,
|
||||
}, { dispatchWebhook: true });
|
||||
publishAllocationCreated(
|
||||
ctx.db,
|
||||
{
|
||||
id: allocation.id,
|
||||
projectId: allocation.projectId,
|
||||
resourceId: allocation.resourceId,
|
||||
},
|
||||
{ dispatchWebhook: true },
|
||||
);
|
||||
return allocation;
|
||||
}),
|
||||
|
||||
createAssignment: managerProcedure
|
||||
.input(CreateAssignmentSchema)
|
||||
.input(CreateAssignmentSchema as z.ZodType<CreateAssignmentInput>)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||
|
||||
const assignment = await ctx.db.$transaction(async (tx) => {
|
||||
return createAssignment(
|
||||
tx as unknown as Parameters<typeof createAssignment>[0],
|
||||
input,
|
||||
);
|
||||
return createAssignment(tx as unknown as Parameters<typeof createAssignment>[0], input);
|
||||
});
|
||||
|
||||
publishAllocationCreated(ctx.db, {
|
||||
@@ -74,14 +73,16 @@ export const allocationAssignmentProcedures = {
|
||||
}),
|
||||
|
||||
ensureAssignment: managerProcedure
|
||||
.input(z.object({
|
||||
resourceId: z.string(),
|
||||
projectId: z.string(),
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
hoursPerDay: z.number().min(0.5).max(24),
|
||||
role: z.string().optional(),
|
||||
}))
|
||||
.input(
|
||||
z.object({
|
||||
resourceId: z.string(),
|
||||
projectId: z.string(),
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
hoursPerDay: z.number().min(0.5).max(24),
|
||||
role: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||
const result = await ensureAssignmentRecord(ctx.db, {
|
||||
@@ -94,11 +95,15 @@ export const allocationAssignmentProcedures = {
|
||||
});
|
||||
|
||||
if (result.action === "reactivated") {
|
||||
publishAllocationUpdated(ctx.db, {
|
||||
id: result.assignment.id,
|
||||
projectId: result.assignment.projectId,
|
||||
resourceId: result.assignment.resourceId,
|
||||
}, { dispatchWebhook: true });
|
||||
publishAllocationUpdated(
|
||||
ctx.db,
|
||||
{
|
||||
id: result.assignment.id,
|
||||
projectId: result.assignment.projectId,
|
||||
resourceId: result.assignment.resourceId,
|
||||
},
|
||||
{ dispatchWebhook: true },
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -113,7 +118,12 @@ export const allocationAssignmentProcedures = {
|
||||
}),
|
||||
|
||||
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 }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||
const existing = await findUniqueOrThrow(
|
||||
@@ -132,18 +142,27 @@ export const allocationAssignmentProcedures = {
|
||||
);
|
||||
});
|
||||
|
||||
publishAllocationUpdated(ctx.db, {
|
||||
id: updated.id,
|
||||
projectId: updated.projectId,
|
||||
resourceId: updated.resourceId,
|
||||
resourceIds: [existing.resourceId, updated.resourceId],
|
||||
}, { dispatchWebhook: true });
|
||||
publishAllocationUpdated(
|
||||
ctx.db,
|
||||
{
|
||||
id: updated.id,
|
||||
projectId: updated.projectId,
|
||||
resourceId: updated.resourceId,
|
||||
resourceIds: [existing.resourceId, updated.resourceId],
|
||||
},
|
||||
{ dispatchWebhook: true },
|
||||
);
|
||||
|
||||
return updated;
|
||||
}),
|
||||
|
||||
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 }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||
const { existing, updated } = await updateAllocationWithAudit(ctx.db, input.id, input.data);
|
||||
@@ -177,20 +196,18 @@ export const allocationAssignmentProcedures = {
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
delete: managerProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||
const existing = await deleteAllocationWithAudit(ctx.db, input.id);
|
||||
delete: managerProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||
const existing = await deleteAllocationWithAudit(ctx.db, input.id);
|
||||
|
||||
publishAllocationDeleted(ctx.db, {
|
||||
id: existing.entry.id,
|
||||
projectId: existing.projectId,
|
||||
resourceId: existing.entry.resourceId,
|
||||
});
|
||||
publishAllocationDeleted(ctx.db, {
|
||||
id: existing.entry.id,
|
||||
projectId: existing.projectId,
|
||||
resourceId: existing.entry.resourceId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
batchDelete: managerProcedure
|
||||
.input(z.object({ ids: z.array(z.string()).min(1).max(100) }))
|
||||
@@ -198,11 +215,14 @@ export const allocationAssignmentProcedures = {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||
const existing = await batchDeleteAllocationsWithAudit(ctx.db, input.ids);
|
||||
|
||||
publishBatchAllocationDeletes(ctx.db, existing.map((allocation) => ({
|
||||
id: allocation.entry.id,
|
||||
projectId: allocation.projectId,
|
||||
resourceId: allocation.entry.resourceId,
|
||||
})));
|
||||
publishBatchAllocationDeletes(
|
||||
ctx.db,
|
||||
existing.map((allocation) => ({
|
||||
id: allocation.entry.id,
|
||||
projectId: allocation.projectId,
|
||||
resourceId: allocation.entry.resourceId,
|
||||
})),
|
||||
);
|
||||
|
||||
return { count: existing.length };
|
||||
}),
|
||||
@@ -218,11 +238,14 @@ export const allocationAssignmentProcedures = {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||
const updated = await batchUpdateAllocationStatusWithAudit(ctx.db, input);
|
||||
|
||||
publishBatchAllocationStatusUpdates(ctx.db, updated.map((allocation) => ({
|
||||
id: allocation.id,
|
||||
projectId: allocation.projectId,
|
||||
resourceId: allocation.resourceId,
|
||||
})));
|
||||
publishBatchAllocationStatusUpdates(
|
||||
ctx.db,
|
||||
updated.map((allocation) => ({
|
||||
id: allocation.id,
|
||||
projectId: allocation.projectId,
|
||||
resourceId: allocation.resourceId,
|
||||
})),
|
||||
);
|
||||
|
||||
return { count: updated.length };
|
||||
}),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Prisma } from "@capakraken/db";
|
||||
import {
|
||||
deleteDemandRequirement,
|
||||
fillOpenDemand,
|
||||
@@ -10,6 +11,11 @@ import {
|
||||
PermissionKey,
|
||||
UpdateDemandRequirementSchema,
|
||||
} from "@capakraken/shared";
|
||||
import type {
|
||||
CreateDemandRequirementInput,
|
||||
FillDemandRequirementInput,
|
||||
FillOpenDemandByAllocationInput,
|
||||
} from "@capakraken/shared";
|
||||
import { z } from "zod";
|
||||
import { findUniqueOrThrow } from "../../db/helpers.js";
|
||||
import {
|
||||
@@ -25,41 +31,34 @@ import {
|
||||
invalidateDashboardCacheInBackground,
|
||||
} from "./effects.js";
|
||||
import { DEMAND_INCLUDE } from "./shared.js";
|
||||
import {
|
||||
buildCreateDemandRequirementInput,
|
||||
getDemandRequirementByIdOrThrow,
|
||||
} from "./support.js";
|
||||
import {
|
||||
managerProcedure,
|
||||
requirePermission,
|
||||
} from "../../trpc.js";
|
||||
import { buildCreateDemandRequirementInput, getDemandRequirementByIdOrThrow } from "./support.js";
|
||||
import { managerProcedure, requirePermission } from "../../trpc.js";
|
||||
|
||||
export const allocationDemandProcedures = {
|
||||
createDemandRequirement: managerProcedure
|
||||
.input(CreateDemandRequirementSchema)
|
||||
.input(CreateDemandRequirementSchema as z.ZodType<CreateDemandRequirementInput>)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||
return createDemandRequirementWithEffects(ctx.db, input);
|
||||
}),
|
||||
|
||||
createDemand: managerProcedure
|
||||
.input(z.object({
|
||||
projectId: z.string(),
|
||||
role: z.string().optional(),
|
||||
roleId: z.string().optional(),
|
||||
headcount: z.number().int().positive().default(1),
|
||||
hoursPerDay: z.number().min(0.5).max(24),
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
budgetCents: z.number().int().min(0).optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
}))
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
role: z.string().optional(),
|
||||
roleId: z.string().optional(),
|
||||
headcount: z.number().int().positive().default(1),
|
||||
hoursPerDay: z.number().min(0.5).max(24),
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
budgetCents: z.number().int().min(0).optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||
return createDemandRequirementWithEffects(
|
||||
ctx.db,
|
||||
buildCreateDemandRequirementInput(input),
|
||||
);
|
||||
return createDemandRequirementWithEffects(ctx.db, buildCreateDemandRequirementInput(input));
|
||||
}),
|
||||
|
||||
updateDemandRequirement: managerProcedure
|
||||
@@ -110,7 +109,7 @@ export const allocationDemandProcedures = {
|
||||
entityType: "DemandRequirement",
|
||||
entityId: input.id,
|
||||
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
|
||||
.input(FillDemandRequirementSchema)
|
||||
.input(FillDemandRequirementSchema as z.ZodType<FillDemandRequirementInput>)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||
return fillDemandRequirementWithEffects(ctx.db, input);
|
||||
}),
|
||||
|
||||
assignResourceToDemand: managerProcedure
|
||||
.input(z.object({
|
||||
demandRequirementId: z.string(),
|
||||
resourceId: z.string(),
|
||||
}))
|
||||
.input(
|
||||
z.object({
|
||||
demandRequirementId: z.string(),
|
||||
resourceId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||
const result = await fillDemandRequirementWithEffects(ctx.db, input);
|
||||
@@ -153,7 +154,7 @@ export const allocationDemandProcedures = {
|
||||
}),
|
||||
|
||||
fillOpenDemandByAllocation: managerProcedure
|
||||
.input(FillOpenDemandByAllocationSchema)
|
||||
.input(FillOpenDemandByAllocationSchema as z.ZodType<FillOpenDemandByAllocationInput>)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
updateMetroCity,
|
||||
} from "./country-procedure-support.js";
|
||||
import { CreateCountrySchema, CreateMetroCitySchema } from "@capakraken/shared";
|
||||
import type { CreateCountryInput } from "@capakraken/shared";
|
||||
import type { z } from "zod";
|
||||
|
||||
export const countryRouter = createTRPCRouter({
|
||||
list: protectedProcedure
|
||||
@@ -46,7 +48,13 @@ export const countryRouter = createTRPCRouter({
|
||||
.query(({ ctx, input }) => getMetroCityById(ctx, input)),
|
||||
|
||||
create: adminProcedure
|
||||
.input(CreateCountrySchema)
|
||||
.input(
|
||||
CreateCountrySchema as z.ZodType<
|
||||
CreateCountryInput,
|
||||
z.ZodTypeDef,
|
||||
z.input<typeof CreateCountrySchema>
|
||||
>,
|
||||
)
|
||||
.mutation(({ ctx, input }) => createCountry(ctx, input)),
|
||||
|
||||
update: adminProcedure
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { z } from "zod";
|
||||
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
|
||||
import {
|
||||
dashboardChargeabilityOverviewInputSchema,
|
||||
@@ -34,7 +35,13 @@ export const dashboardRouter = createTRPCRouter({
|
||||
.query(({ ctx, input }) => getDashboardTopValueResourcesRead(ctx, input)),
|
||||
|
||||
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)),
|
||||
|
||||
getDetail: controllerProcedure
|
||||
@@ -47,7 +54,9 @@ export const dashboardRouter = createTRPCRouter({
|
||||
|
||||
getBudgetForecast: controllerProcedure.query(({ ctx }) => getDashboardBudgetForecastRead(ctx)),
|
||||
|
||||
getBudgetForecastDetail: controllerProcedure.query(({ ctx }) => getDashboardBudgetForecastDetail(ctx)),
|
||||
getBudgetForecastDetail: controllerProcedure.query(({ ctx }) =>
|
||||
getDashboardBudgetForecastDetail(ctx),
|
||||
),
|
||||
|
||||
getSkillGaps: controllerProcedure.query(({ ctx }) => getDashboardSkillGapsRead(ctx)),
|
||||
|
||||
@@ -55,5 +64,7 @@ export const dashboardRouter = createTRPCRouter({
|
||||
|
||||
getProjectHealth: controllerProcedure.query(({ ctx }) => getDashboardProjectHealthRead(ctx)),
|
||||
|
||||
getProjectHealthDetail: controllerProcedure.query(({ ctx }) => getDashboardProjectHealthDetail(ctx)),
|
||||
getProjectHealthDetail: controllerProcedure.query(({ ctx }) =>
|
||||
getDashboardProjectHealthDetail(ctx),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -4,13 +4,19 @@ import { z } from "zod";
|
||||
import { CursorInputSchema, paginateCursor } from "../db/pagination.js";
|
||||
import { controllerProcedure } from "../trpc.js";
|
||||
|
||||
const ListWithCostsInputSchema = CursorInputSchema.extend({
|
||||
status: z.nativeEnum(ProjectStatus).optional(),
|
||||
search: z.string().optional(),
|
||||
});
|
||||
|
||||
export const projectCostReadProcedures = {
|
||||
listWithCosts: controllerProcedure
|
||||
.input(
|
||||
CursorInputSchema.extend({
|
||||
status: z.nativeEnum(ProjectStatus).optional(),
|
||||
search: z.string().optional(),
|
||||
}),
|
||||
ListWithCostsInputSchema as z.ZodType<
|
||||
z.infer<typeof ListWithCostsInputSchema>,
|
||||
z.ZodTypeDef,
|
||||
z.input<typeof ListWithCostsInputSchema>
|
||||
>,
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { status, search, cursor } = input;
|
||||
@@ -58,9 +64,8 @@ export const projectCostReadProcedures = {
|
||||
totalCostCents += booking.dailyCostCents * days;
|
||||
totalPersonDays += (booking.hoursPerDay * days) / 8;
|
||||
}
|
||||
const utilizationPercent = project.budgetCents > 0
|
||||
? Math.round((totalCostCents / project.budgetCents) * 100)
|
||||
: 0;
|
||||
const utilizationPercent =
|
||||
project.budgetCents > 0 ? Math.round((totalCostCents / project.budgetCents) * 100) : 0;
|
||||
return {
|
||||
...project,
|
||||
totalCostCents: Math.round(totalCostCents),
|
||||
|
||||
@@ -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 { z } from "zod";
|
||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||
@@ -21,10 +28,12 @@ function buildProjectCreateData(
|
||||
status: input.status,
|
||||
responsiblePerson: input.responsiblePerson,
|
||||
...(input.color !== undefined ? { color: input.color } : {}),
|
||||
staffingReqs: input.staffingReqs as unknown as import("@capakraken/db").Prisma.InputJsonValue,
|
||||
dynamicFields: input.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue,
|
||||
staffingReqs: input.staffingReqs as unknown as Prisma.InputJsonValue,
|
||||
dynamicFields: input.dynamicFields as unknown as Prisma.InputJsonValue,
|
||||
blueprintId: input.blueprintId,
|
||||
...(input.utilizationCategoryId !== undefined ? { utilizationCategoryId: input.utilizationCategoryId || null } : {}),
|
||||
...(input.utilizationCategoryId !== undefined
|
||||
? { utilizationCategoryId: input.utilizationCategoryId || null }
|
||||
: {}),
|
||||
...(input.clientId !== undefined ? { clientId: input.clientId || null } : {}),
|
||||
} as unknown as Parameters<TRPCContext["db"]["project"]["create"]>[0]["data"];
|
||||
}
|
||||
@@ -41,24 +50,32 @@ function buildProjectUpdateData(
|
||||
...(input.startDate !== undefined ? { startDate: input.startDate } : {}),
|
||||
...(input.endDate !== undefined ? { endDate: input.endDate } : {}),
|
||||
...(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.staffingReqs !== undefined ? { staffingReqs: input.staffingReqs as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}),
|
||||
...(input.dynamicFields !== undefined ? { dynamicFields: input.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}),
|
||||
...(input.staffingReqs !== undefined
|
||||
? { 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.utilizationCategoryId !== undefined ? { utilizationCategoryId: input.utilizationCategoryId || null } : {}),
|
||||
...(input.utilizationCategoryId !== undefined
|
||||
? { utilizationCategoryId: input.utilizationCategoryId || null }
|
||||
: {}),
|
||||
...(input.clientId !== undefined ? { clientId: input.clientId || null } : {}),
|
||||
...(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"];
|
||||
}
|
||||
|
||||
export function createProjectMutationProcedures(
|
||||
backgroundEffects: ProjectBackgroundEffects,
|
||||
) {
|
||||
export function createProjectMutationProcedures(backgroundEffects: ProjectBackgroundEffects) {
|
||||
return {
|
||||
create: managerProcedure
|
||||
.input(CreateProjectSchema)
|
||||
.input(CreateProjectSchema as z.ZodType<CreateProjectInput>)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
|
||||
|
||||
@@ -119,7 +136,9 @@ export function createProjectMutationProcedures(
|
||||
);
|
||||
|
||||
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({
|
||||
db: ctx.db,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { z } from "zod";
|
||||
import { projectCostReadProcedures } from "./project-cost-read.js";
|
||||
import { projectCoverProcedures } from "./project-cover.js";
|
||||
import { projectIdentifierReadProcedures } from "./project-identifier-read.js";
|
||||
@@ -21,13 +22,20 @@ export const projectRouter = createTRPCRouter({
|
||||
...projectCoverProcedures,
|
||||
...projectIdentifierReadProcedures,
|
||||
...createProjectLifecycleProcedures({
|
||||
invalidateDashboardCacheInBackground: projectBackgroundEffects.invalidateDashboardCacheInBackground,
|
||||
invalidateDashboardCacheInBackground:
|
||||
projectBackgroundEffects.invalidateDashboardCacheInBackground,
|
||||
dispatchProjectWebhookInBackground: projectBackgroundEffects.dispatchProjectWebhookInBackground,
|
||||
}),
|
||||
...createProjectMutationProcedures(projectBackgroundEffects),
|
||||
|
||||
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)),
|
||||
|
||||
getById: controllerProcedure
|
||||
@@ -37,5 +45,4 @@ export const projectRouter = createTRPCRouter({
|
||||
getShoringRatio: controllerProcedure
|
||||
.input(ProjectShoringRatioInputSchema)
|
||||
.query(({ ctx, input }) => getProjectShoringRatioData(ctx, input)),
|
||||
|
||||
});
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { z } from "zod";
|
||||
import { protectedProcedure, resourceOverviewProcedure } from "../trpc.js";
|
||||
import {
|
||||
ResourceDirectoryQuerySchema,
|
||||
ResourceListQuerySchema,
|
||||
} from "./resource-read-shared.js";
|
||||
import { ResourceDirectoryQuerySchema, ResourceListQuerySchema } from "./resource-read-shared.js";
|
||||
import {
|
||||
ResolveResponsiblePersonNameInputSchema,
|
||||
ResourceChargeabilitySummaryInputSchema,
|
||||
@@ -38,7 +36,13 @@ export const resourceSummaryReadProcedures = {
|
||||
.query(({ ctx, input }) => listResourceDirectory(ctx, input)),
|
||||
|
||||
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)),
|
||||
|
||||
chapters: protectedProcedure.query(({ ctx }) => listResourceChapters(ctx)),
|
||||
|
||||
@@ -18,17 +18,23 @@ import {
|
||||
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(
|
||||
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),
|
||||
}),
|
||||
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 };
|
||||
@@ -107,7 +113,8 @@ export const staffingCapacityReadProcedures = {
|
||||
});
|
||||
const bookedHours = (bookingsByResourceId.get(resource.id) ?? []).reduce(
|
||||
(sum, booking) =>
|
||||
sum + calculateEffectiveBookedHours({
|
||||
sum +
|
||||
calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
@@ -179,15 +186,17 @@ export const staffingCapacityReadProcedures = {
|
||||
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,
|
||||
}],
|
||||
[
|
||||
{
|
||||
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,
|
||||
);
|
||||
@@ -231,9 +240,8 @@ export const staffingCapacityReadProcedures = {
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
const currentChargeability = totalAvailableHours > 0
|
||||
? (totalChargeableHours / totalAvailableHours) * 100
|
||||
: 0;
|
||||
const currentChargeability =
|
||||
totalAvailableHours > 0 ? (totalChargeableHours / totalAvailableHours) * 100 : 0;
|
||||
|
||||
return {
|
||||
resourceId: resource.id,
|
||||
@@ -291,15 +299,17 @@ export const staffingCapacityReadProcedures = {
|
||||
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,
|
||||
}],
|
||||
[
|
||||
{
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { z } from "zod";
|
||||
import { adminProcedure, createTRPCRouter } from "../trpc.js";
|
||||
import {
|
||||
listSystemRoleConfigs,
|
||||
@@ -11,6 +12,10 @@ export const systemRoleConfigRouter = createTRPCRouter({
|
||||
|
||||
/** Update a role's default permissions, label, description, and color */
|
||||
update: adminProcedure
|
||||
.input(systemRoleConfigUpdateInputSchema)
|
||||
.input(
|
||||
systemRoleConfigUpdateInputSchema as z.ZodType<
|
||||
z.infer<typeof systemRoleConfigUpdateInputSchema>
|
||||
>,
|
||||
)
|
||||
.mutation(({ ctx, input }) => updateSystemRoleConfig(ctx, input)),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { PermissionKey, ShiftProjectSchema } from "@capakraken/shared";
|
||||
import type { ShiftProjectInput } from "@capakraken/shared";
|
||||
import type { z } from "zod";
|
||||
import { managerProcedure, requirePermission } from "../trpc.js";
|
||||
import { timelineAllocationMutationProcedures } from "./timeline-allocation-mutations.js";
|
||||
import { applyTimelineProjectShiftMutation } from "./timeline-shift-router-support.js";
|
||||
@@ -7,7 +9,7 @@ export const timelineMutationProcedures = {
|
||||
...timelineAllocationMutationProcedures,
|
||||
|
||||
applyShift: managerProcedure
|
||||
.input(ShiftProjectSchema)
|
||||
.input(ShiftProjectSchema as z.ZodType<ShiftProjectInput>)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
autoLinkUsersByEmail,
|
||||
countActiveUsers,
|
||||
@@ -92,7 +99,9 @@ export const userRouter = createTRPCRouter({
|
||||
.mutation(({ ctx, input }) => toggleFavoriteProject(ctx, input)),
|
||||
|
||||
setPermissions: adminProcedure
|
||||
.input(SetUserPermissionsInputSchema)
|
||||
.input(
|
||||
SetUserPermissionsInputSchema as z.ZodType<z.infer<typeof SetUserPermissionsInputSchema>>,
|
||||
)
|
||||
.mutation(({ ctx, input }) => setUserPermissions(ctx, input)),
|
||||
|
||||
resetPermissions: adminProcedure
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
|
||||
import type { z } from "zod";
|
||||
import {
|
||||
createVacationRequest,
|
||||
CreateVacationRequestSchema,
|
||||
type CreateVacationRequestInput,
|
||||
} from "./vacation-create-support.js";
|
||||
import { vacationManagementProcedures } from "./vacation-management-procedures.js";
|
||||
import { vacationReadProcedures } from "./vacation-read.js";
|
||||
@@ -11,6 +13,6 @@ export const vacationRouter = createTRPCRouter({
|
||||
...vacationManagementProcedures,
|
||||
|
||||
create: protectedProcedure
|
||||
.input(CreateVacationRequestSchema)
|
||||
.input(CreateVacationRequestSchema as z.ZodType<CreateVacationRequestInput>)
|
||||
.mutation(({ ctx, input }) => createVacationRequest(ctx, input)),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user