import { buildSplitAllocationReadModel, createAssignment, findAllocationEntry, loadAllocationEntry, listAssignmentBookings, updateAssignment, updateDemandRequirement, updateAllocationEntry, } from "@planarchy/application"; import type { PrismaClient } from "@planarchy/db"; import { calculateAllocation, computeBudgetStatus, validateShift } from "@planarchy/engine"; import { AllocationStatus, PermissionKey, ShiftProjectSchema, UpdateAllocationHoursSchema } from "@planarchy/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { loadProjectPlanningReadModel, PROJECT_PLANNING_ASSIGNMENT_INCLUDE, PROJECT_PLANNING_DEMAND_INCLUDE, } from "./project-planning-read-model.js"; import { emitAllocationCreated, emitAllocationUpdated, emitProjectShifted, } from "../sse/event-bus.js"; import { buildTimelineShiftPlan } from "./timeline-shift-planning.js"; import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js"; type ShiftDbClient = Pick< PrismaClient, "project" | "demandRequirement" | "assignment" >; type TimelineEntriesDbClient = Pick< PrismaClient, "demandRequirement" | "assignment" >; type TimelineEntriesFilters = { startDate: Date; endDate: Date; resourceIds?: string[] | undefined; projectIds?: string[] | undefined; }; function getAssignmentResourceIds( readModel: ReturnType, ): string[] { return [ ...new Set( readModel.assignments .map((assignment) => assignment.resourceId) .filter((resourceId): resourceId is string => resourceId !== null), ), ]; } async function loadTimelineEntriesReadModel( db: TimelineEntriesDbClient, input: TimelineEntriesFilters, ) { const { startDate, endDate, resourceIds, projectIds } = input; const [demandRequirements, assignments] = await Promise.all([ resourceIds && resourceIds.length > 0 ? Promise.resolve([]) : db.demandRequirement.findMany({ where: { status: { not: "CANCELLED" }, startDate: { lte: endDate }, endDate: { gte: startDate }, ...(projectIds ? { projectId: { in: projectIds } } : {}), }, include: PROJECT_PLANNING_DEMAND_INCLUDE, orderBy: [{ startDate: "asc" }, { projectId: "asc" }], }), db.assignment.findMany({ where: { status: { not: "CANCELLED" }, startDate: { lte: endDate }, endDate: { gte: startDate }, ...(resourceIds ? { resourceId: { in: resourceIds } } : {}), ...(projectIds ? { projectId: { in: projectIds } } : {}), }, include: PROJECT_PLANNING_ASSIGNMENT_INCLUDE, orderBy: [{ startDate: "asc" }, { resourceId: "asc" }], }), ]); return buildSplitAllocationReadModel({ demandRequirements, assignments }); } async function loadProjectShiftContext(db: ShiftDbClient, projectId: string) { const [project, planningRead] = await Promise.all([ db.project.findUnique({ where: { id: projectId }, select: { id: true, budgetCents: true, winProbability: true, startDate: true, endDate: true, }, }), loadProjectPlanningReadModel(db, { projectId, activeOnly: true }), ]); if (!project) { throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" }); } 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 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(), }), ) .query(async ({ ctx, input }) => { const readModel = await loadTimelineEntriesReadModel(ctx.db, input); return readModel.allocations; }), 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(), }), ) .query(async ({ ctx, input }) => loadTimelineEntriesReadModel(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, planningRead] = await Promise.all([ ctx.db.project.findUnique({ where: { id: input.projectId }, select: { id: true, name: true, shortCode: true, orderType: true, budgetCents: true, winProbability: true, status: true, startDate: true, endDate: true, staffingReqs: true, }, }), loadProjectPlanningReadModel(ctx.db, { projectId: input.projectId, activeOnly: true, }), ]); if (!project) { throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" }); } const resourceIds = getAssignmentResourceIds(planningRead.readModel); const allResourceAllocations = resourceIds.length === 0 ? [] : await listAssignmentBookings(ctx.db, { resourceIds, }); return { project, allocations: planningRead.readModel.allocations, demands: planningRead.readModel.demands, assignments: planningRead.readModel.assignments, allResourceAllocations, 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("@planarchy/shared").WeekdayAvailability; // Load recurrence from merged metadata const recurrence = (newMeta.recurrence as import("@planarchy/shared").RecurrencePattern | undefined); // Load approved vacations for recalculation (graceful fallback if table not yet migrated) const vacationDates: Date[] = []; try { const vacations = await ctx.db.vacation.findMany({ where: { resourceId: resolved.resourceId, status: "APPROVED", startDate: { lte: newEndDate }, endDate: { gte: newStartDate }, }, select: { startDate: true, endDate: 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); while (cur <= vEnd) { vacationDates.push(new Date(cur)); cur.setDate(cur.getDate() + 1); } } } catch { // vacation table may not exist yet — proceed without vacation adjustment } newDailyCostCents = calculateAllocation({ lcrCents: existingResource.lcrCents, hoursPerDay: newHoursPerDay, startDate: newStartDate, endDate: newEndDate, availability, includeSaturday, ...(recurrence ? { recurrence } : {}), vacationDates, }).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 }) => { const { projectId, newStartDate, newEndDate } = input; const { project, shiftPlan } = await loadProjectShiftContext(ctx.db, projectId); return validateShift({ project: { id: project.id, budgetCents: project.budgetCents, winProbability: project.winProbability, startDate: project.startDate, endDate: project.endDate, }, newStartDate, newEndDate, allocations: shiftPlan.validationAllocations, }); }), /** * 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(", ")}`, }); } // 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 newDailyCost = calculateAllocation({ lcrCents: assignment.resource!.lcrCents, hoursPerDay: assignment.hoursPerDay, startDate: newStartDate, endDate: newEndDate, availability: assignment.resource!.availability as unknown as import("@planarchy/shared").WeekdayAvailability, includeSaturday, }).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("@planarchy/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; }), /** * Get budget status for a project. */ getBudgetStatus: protectedProcedure .input(z.object({ projectId: z.string() })) .query(async ({ ctx, input }) => { const project = await ctx.db.project.findUnique({ where: { id: input.projectId }, select: { id: true, budgetCents: true, winProbability: true, startDate: true, endDate: true, }, }); if (!project) { throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" }); } const bookings = await listAssignmentBookings(ctx.db, { startDate: project.startDate, endDate: project.endDate, 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, ); }), });