import { buildSplitAllocationReadModel, createAssignment, findAllocationEntry, loadAllocationEntry, listAssignmentBookings, updateAssignment, updateDemandRequirement, updateAllocationEntry, } from "@capakraken/application"; import type { PrismaClient } from "@capakraken/db"; import { calculateAllocation, computeBudgetStatus, validateShift, DEFAULT_CALCULATION_RULES } from "@capakraken/engine"; import type { CalculationRule, AbsenceDay } from "@capakraken/shared"; import { VacationType } from "@capakraken/db"; import { AllocationStatus, PermissionKey, ShiftProjectSchema, UpdateAllocationHoursSchema } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { loadProjectPlanningReadModel, TIMELINE_ASSIGNMENT_INCLUDE, PROJECT_PLANNING_DEMAND_INCLUDE, } from "./project-planning-read-model.js"; import { emitAllocationCreated, emitAllocationUpdated, emitProjectShifted, } from "../sse/event-bus.js"; import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js"; import { buildTimelineShiftPlan } from "./timeline-shift-planning.js"; import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js"; import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js"; type ShiftDbClient = Pick< PrismaClient, "project" | "demandRequirement" | "assignment" >; export type TimelineEntriesDbClient = Pick< PrismaClient, "demandRequirement" | "assignment" | "resource" | "project" | "holidayCalendar" | "country" | "metroCity" >; export type TimelineEntriesFilters = { startDate: Date; endDate: Date; resourceIds?: string[] | undefined; projectIds?: string[] | undefined; clientIds?: string[] | undefined; chapters?: string[] | undefined; eids?: string[] | undefined; countryCodes?: string[] | undefined; }; export function getAssignmentResourceIds( readModel: ReturnType, ): string[] { return [ ...new Set( readModel.assignments .map((assignment) => assignment.resourceId) .filter((resourceId): resourceId is string => resourceId !== null), ), ]; } export async function loadTimelineEntriesReadModel( db: TimelineEntriesDbClient, input: TimelineEntriesFilters, ) { const { startDate, endDate, resourceIds, projectIds, clientIds, chapters, eids, countryCodes } = input; // When resource-level filters are active (resourceIds, chapters, eids, or countryCodes), // resolve matching resource IDs so we can push the filter to the DB query. const effectiveResourceIds = await (async () => { if (resourceIds && resourceIds.length > 0) return resourceIds; const hasChapters = chapters && chapters.length > 0; const hasEids = eids && eids.length > 0; const hasCountry = countryCodes && countryCodes.length > 0; if (!hasChapters && !hasEids && !hasCountry) return undefined; const andConditions: Record[] = []; if (hasChapters) andConditions.push({ chapter: { in: chapters } }); if (hasEids) andConditions.push({ eid: { in: eids } }); if (hasCountry) andConditions.push({ country: { code: { in: countryCodes } } }); const matching = await db.resource.findMany({ // eslint-disable-next-line @typescript-eslint/no-explicit-any where: (andConditions.length === 1 ? andConditions[0]! : { AND: andConditions }) as any, select: { id: true }, }); return matching.map((r) => r.id); })(); const effectiveProjectIds = await (async () => { if (!clientIds || clientIds.length === 0) return projectIds; const matchingProjects = await db.project.findMany({ where: { clientId: { in: clientIds } }, select: { id: true }, }); const clientProjectIds = matchingProjects.map((project) => project.id); if (!projectIds || projectIds.length === 0) { return clientProjectIds; } const allowedIds = new Set(clientProjectIds); return projectIds.filter((projectId) => allowedIds.has(projectId)); })(); // When filtering by resource (either explicit resourceIds or derived from chapters), // demands without a resource are excluded. const excludeDemands = effectiveResourceIds !== undefined; const [demandRequirements, assignments] = await Promise.all([ excludeDemands ? Promise.resolve([]) : db.demandRequirement.findMany({ where: { status: { not: "CANCELLED" }, startDate: { lte: endDate }, endDate: { gte: startDate }, ...(effectiveProjectIds ? { projectId: { in: effectiveProjectIds } } : {}), }, include: PROJECT_PLANNING_DEMAND_INCLUDE, orderBy: [{ startDate: "asc" }, { projectId: "asc" }], }), db.assignment.findMany({ where: { status: { not: "CANCELLED" }, startDate: { lte: endDate }, endDate: { gte: startDate }, ...(effectiveResourceIds ? { resourceId: { in: effectiveResourceIds } } : {}), ...(effectiveProjectIds ? { projectId: { in: effectiveProjectIds } } : {}), }, include: TIMELINE_ASSIGNMENT_INCLUDE, orderBy: [{ startDate: "asc" }, { resourceId: "asc" }], }), ]); return buildSplitAllocationReadModel({ demandRequirements, assignments }); } export async function loadTimelineHolidayOverlays( db: TimelineEntriesDbClient, input: TimelineEntriesFilters, ) { const readModel = await loadTimelineEntriesReadModel(db, input); const resourceIds = [...new Set( readModel.assignments .map((assignment) => assignment.resourceId) .filter((resourceId): resourceId is string => typeof resourceId === "string" && resourceId.length > 0), )]; if (input.resourceIds && input.resourceIds.length > 0) { for (const resourceId of input.resourceIds) { if (resourceId && !resourceIds.includes(resourceId)) { resourceIds.push(resourceId); } } } const hasResourceFilters = (input.chapters?.length ?? 0) > 0 || (input.eids?.length ?? 0) > 0 || (input.countryCodes?.length ?? 0) > 0; if (hasResourceFilters) { const andConditions: Record[] = []; if (input.chapters && input.chapters.length > 0) { andConditions.push({ chapter: { in: input.chapters } }); } if (input.eids && input.eids.length > 0) { andConditions.push({ eid: { in: input.eids } }); } if (input.countryCodes && input.countryCodes.length > 0) { andConditions.push({ country: { code: { in: input.countryCodes } } }); } const matchingResources = await db.resource.findMany({ // eslint-disable-next-line @typescript-eslint/no-explicit-any where: (andConditions.length === 1 ? andConditions[0]! : { AND: andConditions }) as any, select: { id: true }, }); for (const resource of matchingResources) { if (!resourceIds.includes(resource.id)) { resourceIds.push(resource.id); } } } if (resourceIds.length === 0) { return []; } const resources = await db.resource.findMany({ where: { id: { in: resourceIds } }, select: { id: true, countryId: true, federalState: true, metroCityId: true, country: { select: { code: true } }, metroCity: { select: { name: true } }, }, }); const overlays = await Promise.all( resources.map(async (resource) => { const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(db), { periodStart: input.startDate, periodEnd: input.endDate, countryId: resource.countryId, countryCode: resource.country?.code ?? null, federalState: resource.federalState, metroCityId: resource.metroCityId, metroCityName: resource.metroCity?.name ?? null, }); return holidays.map((holiday) => { const holidayDate = new Date(`${holiday.date}T00:00:00.000Z`); return { id: `calendar-holiday:${resource.id}:${holiday.date}`, resourceId: resource.id, type: VacationType.PUBLIC_HOLIDAY, status: "APPROVED" as const, startDate: holidayDate, endDate: holidayDate, note: holiday.name, scope: holiday.scope, calendarName: holiday.calendarName, sourceType: holiday.sourceType, }; }); }), ); return overlays.flat().sort((left, right) => { if (left.resourceId !== right.resourceId) { return left.resourceId.localeCompare(right.resourceId); } return left.startDate.getTime() - right.startDate.getTime(); }); } async function loadProjectShiftContext(db: ShiftDbClient, projectId: string) { const [project, planningRead] = await Promise.all([ findUniqueOrThrow( db.project.findUnique({ where: { id: projectId }, select: { id: true, budgetCents: true, winProbability: true, startDate: true, endDate: true, }, }), "Project", ), loadProjectPlanningReadModel(db, { projectId, activeOnly: true }), ]); const { demandRequirements, assignments, readModel: projectReadModel } = planningRead; const resourceIds = getAssignmentResourceIds(projectReadModel); const allAssignmentWindows = resourceIds.length === 0 ? [] : ( await listAssignmentBookings(db, { resourceIds, }) ).map((booking) => ({ id: booking.id, resourceId: booking.resourceId!, projectId: booking.projectId, startDate: booking.startDate, endDate: booking.endDate, hoursPerDay: booking.hoursPerDay, status: booking.status, })); const shiftPlan = buildTimelineShiftPlan({ demandRequirements, assignments, allAssignmentWindows, }); return { project, demandRequirements, assignments, shiftPlan, }; } export async function loadTimelineProjectContext(db: ShiftDbClient, projectId: string) { const [project, planningRead] = await Promise.all([ findUniqueOrThrow( db.project.findUnique({ where: { id: projectId }, select: { id: true, name: true, shortCode: true, orderType: true, budgetCents: true, winProbability: true, status: true, startDate: true, endDate: true, staffingReqs: true, }, }), "Project", ), loadProjectPlanningReadModel(db, { projectId, activeOnly: true, }), ]); const resourceIds = getAssignmentResourceIds(planningRead.readModel); const allResourceAllocations = resourceIds.length === 0 ? [] : await listAssignmentBookings(db, { resourceIds, }); return { project, allocations: planningRead.readModel.allocations, demands: planningRead.readModel.demands, assignments: planningRead.readModel.assignments, allResourceAllocations, resourceIds, }; } export async function previewTimelineProjectShift( db: ShiftDbClient, input: { projectId: string; newStartDate: Date; newEndDate: Date; }, ) { const { project, shiftPlan } = await loadProjectShiftContext(db, input.projectId); return validateShift({ project: { id: project.id, budgetCents: project.budgetCents, winProbability: project.winProbability, startDate: project.startDate, endDate: project.endDate, }, newStartDate: input.newStartDate, newEndDate: input.newEndDate, allocations: shiftPlan.validationAllocations, }); } function anonymizeResourceOnEntry( entry: T, directory: Awaited>, ): T { if (!entry.resource) { return entry; } return { ...entry, resource: anonymizeResource(entry.resource, directory), }; } /** Load active calculation rules from DB, falling back to defaults if none configured. */ async function loadCalculationRules(db: PrismaClient): Promise { try { const rules = await db.calculationRule.findMany({ where: { isActive: true }, orderBy: [{ priority: "desc" }], }); if (rules.length > 0) { return rules as unknown as CalculationRule[]; } } catch { // table may not exist yet } return DEFAULT_CALCULATION_RULES; } /** Build typed absence days from vacations for a resource in a date range. */ async function buildAbsenceDays( db: PrismaClient, resourceId: string, startDate: Date, endDate: Date, ): Promise<{ absenceDays: AbsenceDay[]; legacyVacationDates: Date[] }> { const absenceDays: AbsenceDay[] = []; const legacyVacationDates: Date[] = []; try { const vacations = await db.vacation.findMany({ where: { resourceId, status: "APPROVED", startDate: { lte: endDate }, endDate: { gte: startDate }, }, select: { startDate: true, endDate: true, type: true, isHalfDay: true }, }); for (const v of vacations) { const cur = new Date(v.startDate); cur.setHours(0, 0, 0, 0); const vEnd = new Date(v.endDate); vEnd.setHours(0, 0, 0, 0); // Map Prisma VacationType to AbsenceTrigger const triggerType = v.type === VacationType.SICK ? "SICK" as const : v.type === VacationType.PUBLIC_HOLIDAY ? "PUBLIC_HOLIDAY" as const : "VACATION" as const; while (cur <= vEnd) { absenceDays.push({ date: new Date(cur), type: triggerType, ...(v.isHalfDay ? { isHalfDay: true } : {}), }); // Also populate legacy vacation dates for backward compat if (triggerType === "VACATION") { legacyVacationDates.push(new Date(cur)); } cur.setDate(cur.getDate() + 1); } } } catch { // vacation table may not exist yet } return { absenceDays, legacyVacationDates }; } export const timelineRouter = createTRPCRouter({ /** * Get all timeline entries (projects + allocations) for a date range. * Includes project startDate, endDate, staffingReqs for demand overlay. */ getEntries: protectedProcedure .input( z.object({ startDate: z.coerce.date(), endDate: z.coerce.date(), resourceIds: z.array(z.string()).optional(), projectIds: z.array(z.string()).optional(), clientIds: z.array(z.string()).optional(), chapters: z.array(z.string()).optional(), eids: z.array(z.string()).optional(), countryCodes: z.array(z.string()).optional(), }), ) .query(async ({ ctx, input }) => { const readModel = await loadTimelineEntriesReadModel(ctx.db, input); const directory = await getAnonymizationDirectory(ctx.db); return readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)); }), getEntriesView: protectedProcedure .input( z.object({ startDate: z.coerce.date(), endDate: z.coerce.date(), resourceIds: z.array(z.string()).optional(), projectIds: z.array(z.string()).optional(), clientIds: z.array(z.string()).optional(), chapters: z.array(z.string()).optional(), eids: z.array(z.string()).optional(), countryCodes: z.array(z.string()).optional(), }), ) .query(async ({ ctx, input }) => { const [readModel, directory] = await Promise.all([ loadTimelineEntriesReadModel(ctx.db, input), getAnonymizationDirectory(ctx.db), ]); return { ...readModel, allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)), assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)), }; }), getHolidayOverlays: protectedProcedure .input( z.object({ startDate: z.coerce.date(), endDate: z.coerce.date(), resourceIds: z.array(z.string()).optional(), projectIds: z.array(z.string()).optional(), clientIds: z.array(z.string()).optional(), chapters: z.array(z.string()).optional(), eids: z.array(z.string()).optional(), countryCodes: z.array(z.string()).optional(), }), ) .query(async ({ ctx, input }) => loadTimelineHolidayOverlays(ctx.db, input)), /** * Get full project context for a project: * - project with staffingReqs and budget * - all active planning entries on this project * - all assignment bookings for the same resources (for cross-project overlap display) * Used when: drag starts or project panel opens. */ getProjectContext: protectedProcedure .input(z.object({ projectId: z.string() })) .query(async ({ ctx, input }) => { const { project, allocations, demands, assignments, allResourceAllocations, resourceIds, } = await loadTimelineProjectContext(ctx.db, input.projectId); const directory = await getAnonymizationDirectory(ctx.db); return { project, allocations: allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory), ), demands, assignments: assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory), ), allResourceAllocations: allResourceAllocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory), ), resourceIds, }; }), /** * Inline update of an allocation's hours, dates, includeSaturday, or role. * Recalculates dailyCostCents and emits SSE. */ updateAllocationInline: managerProcedure .input(UpdateAllocationHoursSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); const resolved = await loadAllocationEntry(ctx.db, input.allocationId); const existing = resolved.entry; const existingResource = resolved.resourceId ? await ctx.db.resource.findUnique({ where: { id: resolved.resourceId }, select: { id: true, lcrCents: true, availability: true }, }) : null; const newHoursPerDay = input.hoursPerDay ?? existing.hoursPerDay; const newStartDate = input.startDate ?? existing.startDate; const newEndDate = input.endDate ?? existing.endDate; if (newEndDate < newStartDate) { throw new TRPCError({ code: "BAD_REQUEST", message: "End date must be after start date", }); } // Merge includeSaturday into metadata const existingMeta = (existing.metadata as Record) ?? {}; const newMeta: Record = { ...existingMeta, ...(input.includeSaturday !== undefined ? { includeSaturday: input.includeSaturday } : {}), }; const includeSaturday = input.includeSaturday ?? (existingMeta.includeSaturday as boolean | undefined) ?? false; // For placeholder allocations (no resource), dailyCostCents stays 0 let newDailyCostCents = 0; if (resolved.resourceId) { if (!existingResource) { throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); } const availability = existingResource.availability as unknown as import("@capakraken/shared").WeekdayAvailability; // Load recurrence from merged metadata const recurrence = (newMeta.recurrence as import("@capakraken/shared").RecurrencePattern | undefined); // Load typed absences + calculation rules for rules-aware cost computation const [absenceData, calculationRules] = await Promise.all([ buildAbsenceDays(ctx.db as PrismaClient, resolved.resourceId, newStartDate, newEndDate), loadCalculationRules(ctx.db as PrismaClient), ]); newDailyCostCents = calculateAllocation({ lcrCents: existingResource.lcrCents, hoursPerDay: newHoursPerDay, startDate: newStartDate, endDate: newEndDate, availability, includeSaturday, ...(recurrence ? { recurrence } : {}), vacationDates: absenceData.legacyVacationDates, absenceDays: absenceData.absenceDays, calculationRules, }).dailyCostCents; } const updated = await ctx.db.$transaction(async (tx) => { const { allocation: updatedAllocation } = await updateAllocationEntry( tx as unknown as Parameters[0], { id: input.allocationId, demandRequirementUpdate: { hoursPerDay: newHoursPerDay, startDate: newStartDate, endDate: newEndDate, metadata: newMeta, ...(input.role !== undefined ? { role: input.role } : {}), }, assignmentUpdate: { hoursPerDay: newHoursPerDay, startDate: newStartDate, endDate: newEndDate, dailyCostCents: newDailyCostCents, metadata: newMeta, ...(input.role !== undefined ? { role: input.role } : {}), }, }, ); await tx.auditLog.create({ data: { entityType: "Allocation", entityId: input.allocationId, action: "UPDATE", changes: { before: { id: resolved.entry.id, hoursPerDay: existing.hoursPerDay, startDate: existing.startDate, endDate: existing.endDate, }, after: { id: updatedAllocation.id, hoursPerDay: newHoursPerDay, startDate: newStartDate, endDate: newEndDate, includeSaturday, }, }, }, }); return updatedAllocation; }); emitAllocationUpdated({ id: updated.id, projectId: updated.projectId, resourceId: updated.resourceId, }); return updated; }), /** * Preview a project shift — validate without committing. * Returns cost impact, conflicts, warnings. */ previewShift: protectedProcedure .input(ShiftProjectSchema) .query(async ({ ctx, input }) => previewTimelineProjectShift(ctx.db, input)), /** * Apply a project shift — validate, then commit all allocation date changes. * Reads includeSaturday from each allocation's metadata. */ applyShift: managerProcedure .input(ShiftProjectSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); const { projectId, newStartDate, newEndDate } = input; const { project, demandRequirements, assignments, shiftPlan } = await loadProjectShiftContext( ctx.db, projectId, ); // Re-validate before committing const validation = validateShift({ project: { id: project.id, budgetCents: project.budgetCents, winProbability: project.winProbability, startDate: project.startDate, endDate: project.endDate, }, newStartDate, newEndDate, allocations: shiftPlan.validationAllocations, }); if (!validation.valid) { throw new TRPCError({ code: "BAD_REQUEST", message: `Shift validation failed: ${validation.errors.map((e) => e.message).join(", ")}`, }); } // Pre-load calculation rules for cost recalculation const shiftRules = await loadCalculationRules(ctx.db as PrismaClient); // Apply shift in a transaction const updatedProject = await ctx.db.$transaction(async (tx) => { // Update project dates const proj = await tx.project.update({ where: { id: projectId }, data: { startDate: newStartDate, endDate: newEndDate }, }); for (const demandRequirement of demandRequirements) { await updateDemandRequirement( tx as unknown as Parameters[0], demandRequirement.id, { startDate: newStartDate, endDate: newEndDate, }, ); } for (const assignment of assignments) { const metadata = (assignment.metadata as Record | null | undefined) ?? {}; const includeSaturday = (metadata.includeSaturday as boolean | undefined) ?? false; const shiftAbsenceData = await buildAbsenceDays( ctx.db as PrismaClient, assignment.resourceId!, newStartDate, newEndDate, ); const newDailyCost = calculateAllocation({ lcrCents: assignment.resource!.lcrCents, hoursPerDay: assignment.hoursPerDay, startDate: newStartDate, endDate: newEndDate, availability: assignment.resource!.availability as unknown as import("@capakraken/shared").WeekdayAvailability, includeSaturday, vacationDates: shiftAbsenceData.legacyVacationDates, absenceDays: shiftAbsenceData.absenceDays, calculationRules: shiftRules, }).dailyCostCents; await updateAssignment( tx as unknown as Parameters[0], assignment.id, { startDate: newStartDate, endDate: newEndDate, dailyCostCents: newDailyCost, }, ); } // Write audit log await tx.auditLog.create({ data: { entityType: "Project", entityId: projectId, action: "SHIFT", changes: { before: { startDate: project.startDate, endDate: project.endDate }, after: { startDate: newStartDate, endDate: newEndDate }, costImpact: validation.costImpact, } as unknown as import("@capakraken/db").Prisma.InputJsonValue, }, }); return proj; }); // Emit SSE event for live updates emitProjectShifted({ projectId, newStartDate: newStartDate.toISOString(), newEndDate: newEndDate.toISOString(), costDeltaCents: validation.costImpact.deltaCents, }); return { project: updatedProject, validation }; }), /** * Quick-assign a resource to a project for a date range. * Overbooking is intentionally allowed — no availability throw. * For use from the timeline drag-to-assign UI. */ quickAssign: 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).default(8), role: z.string().min(1).max(200).default("Team Member"), roleId: z.string().optional(), status: z.nativeEnum(AllocationStatus).default(AllocationStatus.PROPOSED), }), ) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); if (input.endDate < input.startDate) { throw new TRPCError({ code: "BAD_REQUEST", message: "End date must be after start date" }); } const percentage = Math.min(100, Math.round((input.hoursPerDay / 8) * 100)); const metadata = { source: "quickAssign" } satisfies Record; const allocation = await ctx.db.$transaction(async (tx) => { const assignment = await createAssignment( tx as unknown as Parameters[0], { resourceId: input.resourceId, projectId: input.projectId, startDate: input.startDate, endDate: input.endDate, hoursPerDay: input.hoursPerDay, percentage, role: input.role, roleId: input.roleId ?? undefined, status: input.status, metadata, }, ); return buildSplitAllocationReadModel({ demandRequirements: [], assignments: [assignment], }).allocations[0]!; }); emitAllocationCreated({ id: allocation.id, projectId: allocation.projectId, resourceId: allocation.resourceId, }); return allocation; }), /** * Batch quick-assign multiple resources to a project for a date range. * Used by the multi-selection floating action bar. */ batchQuickAssign: managerProcedure .input( z.object({ assignments: z .array( z.object({ resourceId: z.string(), projectId: z.string(), startDate: z.coerce.date(), endDate: z.coerce.date(), hoursPerDay: z.number().min(0.5).max(24).default(8), role: z.string().min(1).max(200).default("Team Member"), status: z .nativeEnum(AllocationStatus) .default(AllocationStatus.PROPOSED), }), ) .min(1) .max(50), }), ) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); // Validate all date ranges for (const a of input.assignments) { if (a.endDate < a.startDate) { throw new TRPCError({ code: "BAD_REQUEST", message: "End date must be after start date", }); } } const results = await ctx.db.$transaction(async (tx) => { const created = []; for (const a of input.assignments) { const percentage = Math.min( 100, Math.round((a.hoursPerDay / 8) * 100), ); const metadata = { source: "batchQuickAssign", } satisfies Record; const assignment = await createAssignment( tx as unknown as Parameters[0], { resourceId: a.resourceId, projectId: a.projectId, startDate: a.startDate, endDate: a.endDate, hoursPerDay: a.hoursPerDay, percentage, role: a.role, status: a.status, metadata, }, ); created.push(assignment); } return created; }); // Fire SSE events for (const assignment of results) { emitAllocationCreated({ id: assignment.id, projectId: assignment.projectId, resourceId: assignment.resourceId, }); } return { count: results.length }; }), /** * Batch-shift multiple allocations by the same number of days. * Used by multi-select drag on the timeline. */ batchShiftAllocations: managerProcedure .input( z.object({ allocationIds: z.array(z.string()).min(1).max(100), daysDelta: z.number().int().min(-3650).max(3650), mode: z.enum(["move", "resize-start", "resize-end"]).default("move"), }), ) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); if (input.daysDelta === 0) return { count: 0 }; // Load all allocations const entries = await Promise.all( input.allocationIds.map((id) => findAllocationEntry(ctx.db, id)), ); const resolved = entries.filter( (e): e is NonNullable => e !== null, ); if (resolved.length === 0) { throw new TRPCError({ code: "NOT_FOUND", message: "No allocations found" }); } const results = await ctx.db.$transaction(async (tx) => { const updated = []; for (const entry of resolved) { const existing = entry.entry; const newStart = new Date(existing.startDate); const newEnd = new Date(existing.endDate); if (input.mode === "move") { newStart.setDate(newStart.getDate() + input.daysDelta); newEnd.setDate(newEnd.getDate() + input.daysDelta); } else if (input.mode === "resize-start") { newStart.setDate(newStart.getDate() + input.daysDelta); // Clamp: start must not exceed end if (newStart > newEnd) newStart.setTime(newEnd.getTime()); } else { // resize-end newEnd.setDate(newEnd.getDate() + input.daysDelta); // Clamp: end must not precede start if (newEnd < newStart) newEnd.setTime(newStart.getTime()); } const result = await updateAllocationEntry( tx as unknown as Parameters[0], { id: existing.id, demandRequirementUpdate: { startDate: newStart, endDate: newEnd, }, assignmentUpdate: { startDate: newStart, endDate: newEnd, }, }, ); updated.push(result.allocation); } await tx.auditLog.create({ data: { entityType: "Allocation", entityId: input.allocationIds.join(","), action: "UPDATE", changes: { operation: "batchShift", mode: input.mode, daysDelta: input.daysDelta, count: resolved.length, } as unknown as import("@capakraken/db").Prisma.InputJsonValue, }, }); return updated; }); // Fire SSE events for (const alloc of results) { emitAllocationUpdated({ id: alloc.id, projectId: alloc.projectId, resourceId: alloc.resourceId, }); } return { count: results.length }; }), /** * Get budget status for a project. */ getBudgetStatus: protectedProcedure .input(z.object({ projectId: z.string() })) .query(async ({ ctx, input }) => { const project = await findUniqueOrThrow( ctx.db.project.findUnique({ where: { id: input.projectId }, select: { id: true, budgetCents: true, winProbability: true, startDate: true, endDate: true, }, }), "Project", ); // Use wide date range to catch all assignments (including those extending beyond project dates) const bookings = await listAssignmentBookings(ctx.db, { projectIds: [project.id], }); return computeBudgetStatus( project.budgetCents, project.winProbability, bookings.map((booking) => ({ status: booking.status, dailyCostCents: booking.dailyCostCents, startDate: booking.startDate, endDate: booking.endDate, hoursPerDay: booking.hoursPerDay, })) as unknown as Pick[], project.startDate, project.endDate, ); }), });