import { analyzeUtilization, findCapacityWindows, rankResources } from "@planarchy/staffing"; import { listAssignmentBookings } from "@planarchy/application"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "../trpc.js"; export const staffingRouter = createTRPCRouter({ /** * Get ranked resource suggestions for a staffing requirement. */ getSuggestions: protectedProcedure .input( z.object({ requiredSkills: z.array(z.string()), preferredSkills: z.array(z.string()).optional(), startDate: z.coerce.date(), endDate: z.coerce.date(), hoursPerDay: z.number().min(0).max(24), budgetLcrCentsPerHour: z.number().optional(), chapter: z.string().optional(), skillCategory: z.string().optional(), mainSkillsOnly: z.boolean().optional(), minProficiency: z.number().min(1).max(5).optional(), }), ) .query(async ({ ctx, input }) => { const { requiredSkills, preferredSkills, startDate, endDate, hoursPerDay, budgetLcrCentsPerHour, chapter, skillCategory, mainSkillsOnly, minProficiency } = input; const resources = await ctx.db.resource.findMany({ where: { isActive: true, ...(chapter ? { chapter } : {}), }, }); const bookings = await listAssignmentBookings(ctx.db, { startDate, endDate, resourceIds: resources.map((resource) => resource.id), }); // Compute utilization percent for each resource in the requested period const enrichedResources = resources.map((resource) => { const totalAvailableHours = (resource.availability as { monday?: number; tuesday?: number; wednesday?: number; thursday?: number; friday?: number }).monday ?? 8; const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id); const allocatedHoursPerDay = resourceBookings.reduce( (sum, a) => sum + a.hoursPerDay, 0, ); const utilizationPercent = totalAvailableHours > 0 ? Math.min(100, (allocatedHoursPerDay / totalAvailableHours) * 100) : 0; const wouldExceedCapacity = allocatedHoursPerDay + hoursPerDay > totalAvailableHours; type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean }; let skills = resource.skills as unknown as SkillRow[]; // Apply skill filters before matching if (mainSkillsOnly) skills = skills.filter((s) => s.isMainSkill); if (skillCategory) skills = skills.filter((s) => s.category === skillCategory); if (minProficiency) skills = skills.filter((s) => s.proficiency >= minProficiency); return { id: resource.id, displayName: resource.displayName, eid: resource.eid, skills: skills as unknown as import("@planarchy/shared").SkillEntry[], lcrCents: resource.lcrCents, chargeabilityTarget: resource.chargeabilityTarget, currentUtilizationPercent: utilizationPercent, hasAvailabilityConflicts: wouldExceedCapacity, conflictDays: wouldExceedCapacity ? ["(multiple days)"] : [], valueScore: resource.valueScore ?? 0, }; }); const ranked = rankResources({ requiredSkills, preferredSkills: preferredSkills, resources: enrichedResources, budgetLcrCentsPerHour, } as unknown as Parameters[0]); // Value-score tiebreaker: within 2 points, prefer higher valueScore return ranked.sort((a, b) => { if (Math.abs(a.score - b.score) <= 2) { const aVal = (enrichedResources.find((r) => r.id === a.resourceId)?.valueScore ?? 0); const bVal = (enrichedResources.find((r) => r.id === b.resourceId)?.valueScore ?? 0); return bVal - aVal; } return 0; }); }), /** * Analyze utilization for a specific resource over a date range. */ analyzeUtilization: protectedProcedure .input( z.object({ resourceId: z.string(), startDate: z.coerce.date(), endDate: z.coerce.date(), }), ) .query(async ({ ctx, input }) => { const resource = await ctx.db.resource.findUnique({ where: { id: input.resourceId }, select: { id: true, displayName: true, chargeabilityTarget: true, availability: true, }, }); if (!resource) { throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); } const resourceBookings = await listAssignmentBookings(ctx.db, { startDate: input.startDate, endDate: input.endDate, resourceIds: [resource.id], }); return analyzeUtilization({ resource: { id: resource.id, displayName: resource.displayName, chargeabilityTarget: resource.chargeabilityTarget, availability: resource.availability as unknown as import("@planarchy/shared").WeekdayAvailability, }, allocations: resourceBookings.map((booking) => ({ startDate: booking.startDate, endDate: booking.endDate, hoursPerDay: booking.hoursPerDay, status: booking.status, projectName: booking.project.name, isChargeable: booking.project.orderType === "CHARGEABLE", })) as unknown as Parameters[0]["allocations"], analysisStart: input.startDate, analysisEnd: input.endDate, }); }), /** * Find capacity windows for a resource. */ findCapacity: protectedProcedure .input( z.object({ resourceId: z.string(), startDate: z.coerce.date(), endDate: z.coerce.date(), minAvailableHoursPerDay: z.number().optional().default(4), }), ) .query(async ({ ctx, input }) => { const resource = await ctx.db.resource.findUnique({ where: { id: input.resourceId }, select: { id: true, displayName: true, availability: true, }, }); if (!resource) { throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); } const resourceBookings = await listAssignmentBookings(ctx.db, { startDate: input.startDate, endDate: input.endDate, resourceIds: [resource.id], }); return findCapacityWindows( { id: resource.id, displayName: resource.displayName, availability: resource.availability as unknown as import("@planarchy/shared").WeekdayAvailability, }, resourceBookings.map((booking) => ({ startDate: booking.startDate, endDate: booking.endDate, hoursPerDay: booking.hoursPerDay, status: booking.status, })) as Pick[], input.startDate, input.endDate, input.minAvailableHoursPerDay, ); }), });