import { validateAvailability } from "@capakraken/engine"; import type { AllocationConflictCheckResult, AllocationStatus, WeekdayAvailability, } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { managerProcedure } from "../trpc.js"; import { toIsoDate } from "./allocation/shared.js"; const CheckConflictsInputSchema = z.object({ resourceId: z.string(), startDate: z.coerce.date(), endDate: z.coerce.date(), hoursPerDay: z.number().min(0).max(24), /** Pass the current assignment id when editing so it is excluded from existing load. */ excludeAssignmentId: z.string().optional(), }); export const allocationConflictProcedures = { /** * Pre-flight conflict check for the allocation form. * Returns overbooking details (per-day breakdown) and vacation overlaps. * Read-only — no mutations. */ checkConflicts: managerProcedure .input(CheckConflictsInputSchema as z.ZodType>) .query(async ({ ctx, input }): Promise => { const resource = await ctx.db.resource.findUnique({ where: { id: input.resourceId }, select: { availability: true, fte: true, country: { select: { dailyWorkingHours: true } }, }, }); if (!resource) { throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); } const fallbackDailyHours = (resource.country?.dailyWorkingHours ?? 8) * (resource.fte ?? 1); const availability = (resource.availability as WeekdayAvailability | null) ?? { monday: fallbackDailyHours, tuesday: fallbackDailyHours, wednesday: fallbackDailyHours, thursday: fallbackDailyHours, friday: fallbackDailyHours, saturday: 0, sunday: 0, }; // Load existing active assignments for the resource in the period const existingAssignments = await ctx.db.assignment.findMany({ where: { resourceId: input.resourceId, status: { not: "CANCELLED" }, startDate: { lte: input.endDate }, endDate: { gte: input.startDate }, ...(input.excludeAssignmentId ? { id: { not: input.excludeAssignmentId } } : {}), }, select: { startDate: true, endDate: true, hoursPerDay: true, status: true }, }); // Load approved vacations that overlap the requested period const vacations = await ctx.db.vacation.findMany({ where: { resourceId: input.resourceId, status: "APPROVED", startDate: { lte: input.endDate }, endDate: { gte: input.startDate }, }, select: { startDate: true, endDate: true, type: true, isHalfDay: true }, orderBy: { startDate: "asc" }, }); // Run pure availability check (no DB access). // Cast status to the shared enum — Prisma and shared enums are string-compatible. const availabilityResult = validateAvailability( input.startDate, input.endDate, input.hoursPerDay, availability, existingAssignments as { startDate: Date; endDate: Date; hoursPerDay: number; status: AllocationStatus; }[], ); // Compute max overbook percentage for the worst day let maxOverbookPercent = 0; for (const conflict of availabilityResult.conflicts) { const totalBooked = conflict.existingHours + conflict.requestedHours; const overbookPct = conflict.availableHours > 0 ? (totalBooked / conflict.availableHours - 1) * 100 : 100; if (overbookPct > maxOverbookPercent) maxOverbookPercent = overbookPct; } const isOverbooking = availabilityResult.totalConflictDays > 0; return { isOverbooking, overbooking: isOverbooking ? { conflictDays: availabilityResult.conflicts.map((c) => ({ date: toIsoDate(c.date), availableHours: c.availableHours, existingHours: c.existingHours, requestedHours: c.requestedHours, overageHours: c.overageHours, })), totalConflictDays: availabilityResult.totalConflictDays, maxOverbookPercent: Math.round(maxOverbookPercent), } : null, vacationOverlap: vacations.map((v) => ({ startDate: toIsoDate(v.startDate), endDate: toIsoDate(v.endDate), type: v.type, isHalfDay: v.isHalfDay, })), hasVacationOverlap: vacations.length > 0, }; }), };