import { buildSplitAllocationReadModel, listAssignmentBookings, } from "@capakraken/application"; import { Prisma, VacationType } from "@capakraken/db"; import type { PrismaClient } from "@capakraken/db"; import { computeBudgetStatus, validateShift } from "@capakraken/engine"; import { ShiftProjectSchema } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js"; import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js"; import type { TRPCContext } from "../trpc.js"; import { controllerProcedure, protectedProcedure } from "../trpc.js"; import { loadProjectPlanningReadModel, PROJECT_PLANNING_DEMAND_INCLUDE, TIMELINE_ASSIGNMENT_INCLUDE, } from "./project-planning-read-model.js"; import { buildTimelineShiftPlan } from "./timeline-shift-planning.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 const TimelineWindowFiltersSchema = 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(), }); type TimelineWindowFiltersInput = z.infer; type TimelineSelfServiceContext = Pick; export function getAssignmentResourceIds( readModel: ReturnType, ): string[] { return [ ...new Set( readModel.assignments .map((assignment) => assignment.resourceId) .filter((resourceId): resourceId is string => resourceId !== null), ), ]; } function fmtDate(value: Date | null | undefined): string | null { if (!value) { return null; } return value.toISOString().slice(0, 10); } function createUtcDate(year: number, month: number, day: number): Date { return new Date(Date.UTC(year, month, day, 0, 0, 0, 0)); } function createTimelineDateRange(input: { startDate?: string | undefined; endDate?: string | undefined; durationDays?: number | undefined; }): { startDate: Date; endDate: Date } { const now = new Date(); const startDate = input.startDate ? new Date(`${input.startDate}T00:00:00.000Z`) : createUtcDate(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); if (Number.isNaN(startDate.getTime())) { throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid startDate: ${input.startDate}`, }); } const endDate = input.endDate ? new Date(`${input.endDate}T00:00:00.000Z`) : createUtcDate( startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate() + Math.max((input.durationDays ?? 21) - 1, 0), ); if (Number.isNaN(endDate.getTime())) { throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid endDate: ${input.endDate}`, }); } if (endDate < startDate) { throw new TRPCError({ code: "BAD_REQUEST", message: "endDate must be on or after startDate.", }); } return { startDate, endDate }; } function normalizeStringList(values?: string[] | undefined): string[] | undefined { const normalized = values ?.map((value) => value.trim()) .filter((value) => value.length > 0); return normalized && normalized.length > 0 ? normalized : undefined; } function createTimelineFilters(input: { resourceIds?: string[] | undefined; projectIds?: string[] | undefined; clientIds?: string[] | undefined; chapters?: string[] | undefined; eids?: string[] | undefined; countryCodes?: string[] | undefined; }): Omit { return { resourceIds: normalizeStringList(input.resourceIds), projectIds: normalizeStringList(input.projectIds), clientIds: normalizeStringList(input.clientIds), chapters: normalizeStringList(input.chapters), eids: normalizeStringList(input.eids), countryCodes: normalizeStringList(input.countryCodes), }; } function createEmptyTimelineEntriesView() { return buildSplitAllocationReadModel({ demandRequirements: [], assignments: [], }); } async function findOwnedTimelineResourceId( ctx: TimelineSelfServiceContext, ): Promise { if (!ctx.dbUser?.id) { return null; } if (!ctx.db.resource || typeof ctx.db.resource.findFirst !== "function") { return null; } const resource = await ctx.db.resource.findFirst({ where: { userId: ctx.dbUser.id }, select: { id: true }, }); return resource?.id ?? null; } async function buildSelfServiceTimelineInput( ctx: TimelineSelfServiceContext, input: TimelineWindowFiltersInput, ): Promise { const ownedResourceId = await findOwnedTimelineResourceId(ctx); if (!ownedResourceId) { return null; } return { startDate: input.startDate, endDate: input.endDate, resourceIds: [ownedResourceId], projectIds: normalizeStringList(input.projectIds), clientIds: normalizeStringList(input.clientIds), }; } function summarizeTimelineEntries(readModel: { allocations: Array<{ projectId: string | null; resourceId: string | null }>; demands: Array<{ projectId: string | null }>; assignments: Array<{ projectId: string | null; resourceId: string | null }>; }) { const projectIds = new Set(); const resourceIds = new Set(); for (const entry of [...readModel.allocations, ...readModel.demands, ...readModel.assignments]) { if (entry.projectId) { projectIds.add(entry.projectId); } } for (const assignment of [...readModel.allocations, ...readModel.assignments]) { if (assignment.resourceId) { resourceIds.add(assignment.resourceId); } } return { allocationCount: readModel.allocations.length, demandCount: readModel.demands.length, assignmentCount: readModel.assignments.length, projectCount: projectIds.size, resourceCount: resourceIds.size, }; } function formatHolidayOverlays( overlays: Array<{ id: string; resourceId: string; startDate: Date; endDate: Date; note?: string | null; scope?: string | null; calendarName?: string | null; sourceType?: string | null; countryCode?: string | null; countryName?: string | null; federalState?: string | null; metroCityName?: string | null; }>, ) { return overlays.map((overlay) => ({ id: overlay.id, resourceId: overlay.resourceId, startDate: fmtDate(overlay.startDate), endDate: fmtDate(overlay.endDate), note: overlay.note ?? null, scope: overlay.scope ?? null, calendarName: overlay.calendarName ?? null, sourceType: overlay.sourceType ?? null, countryCode: overlay.countryCode ?? null, countryName: overlay.countryName ?? null, federalState: overlay.federalState ?? null, metroCityName: overlay.metroCityName ?? null, })); } function summarizeHolidayOverlays( overlays: ReturnType, ) { const resourceIds = new Set(); const byScope = new Map(); for (const overlay of overlays) { resourceIds.add(overlay.resourceId); const scope = overlay.scope ?? "UNKNOWN"; byScope.set(scope, (byScope.get(scope) ?? 0) + 1); } return { overlayCount: overlays.length, holidayResourceCount: resourceIds.size, byScope: [...byScope.entries()] .sort(([left], [right]) => left.localeCompare(right)) .map(([scope, count]) => ({ scope, count })), }; } function rangesOverlap( leftStart: Date, leftEnd: Date, rightStart: Date, rightEnd: Date, ): boolean { return leftStart <= rightEnd && rightStart <= leftEnd; } function toDate(value: Date | string): Date { return value instanceof Date ? value : new Date(value); } export async function loadTimelineEntriesReadModel( db: TimelineEntriesDbClient, input: TimelineEntriesFilters, ) { const { startDate, endDate, resourceIds, projectIds, clientIds, chapters, eids, countryCodes } = input; 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({ where: (andConditions.length === 1 ? andConditions[0]! : { AND: andConditions }) as Prisma.ResourceWhereInput, select: { id: true }, }); return matching.map((resource) => resource.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)); })(); 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); return loadTimelineHolidayOverlaysForReadModel(db, input, readModel); } async function loadTimelineHolidayOverlaysForReadModel( db: TimelineEntriesDbClient, input: TimelineEntriesFilters, readModel: ReturnType, ) { 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: Prisma.ResourceWhereInput[] = []; 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({ where: andConditions.length === 1 ? andConditions[0]! : { AND: andConditions }, 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, name: 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, countryCode: resource.country?.code ?? null, countryName: resource.country?.name ?? null, federalState: resource.federalState ?? null, metroCityName: resource.metroCity?.name ?? null, }; }); }), ); return overlays.flat().sort((left, right) => { if (left.resourceId !== right.resourceId) { return left.resourceId.localeCompare(right.resourceId); } return left.startDate.getTime() - right.startDate.getTime(); }); } export 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), }; } export const timelineReadProcedures = { getEntries: controllerProcedure .input(TimelineWindowFiltersSchema) .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: controllerProcedure .input(TimelineWindowFiltersSchema) .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)), }; }), getMyEntriesView: protectedProcedure .input(TimelineWindowFiltersSchema) .query(async ({ ctx, input }) => { const selfServiceInput = await buildSelfServiceTimelineInput(ctx, input); if (!selfServiceInput) { return createEmptyTimelineEntriesView(); } const [readModel, directory] = await Promise.all([ loadTimelineEntriesReadModel(ctx.db, selfServiceInput), getAnonymizationDirectory(ctx.db), ]); return { ...readModel, allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)), assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)), }; }), getHolidayOverlays: controllerProcedure .input(TimelineWindowFiltersSchema) .query(async ({ ctx, input }) => loadTimelineHolidayOverlays(ctx.db, input)), getMyHolidayOverlays: protectedProcedure .input(TimelineWindowFiltersSchema) .query(async ({ ctx, input }) => { const selfServiceInput = await buildSelfServiceTimelineInput(ctx, input); if (!selfServiceInput) { return []; } return loadTimelineHolidayOverlays(ctx.db, selfServiceInput); }), getEntriesDetail: controllerProcedure .input( z.object({ startDate: z.string().optional(), endDate: z.string().optional(), durationDays: z.number().int().min(1).max(366).optional(), 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 { startDate, endDate } = createTimelineDateRange(input); const filters = createTimelineFilters(input); const timelineInput = { ...filters, startDate, endDate }; const [readModel, directory] = await Promise.all([ loadTimelineEntriesReadModel(ctx.db, timelineInput), getAnonymizationDirectory(ctx.db), ]); const holidayOverlays = await loadTimelineHolidayOverlaysForReadModel( ctx.db, timelineInput, readModel, ); const formattedHolidayOverlays = formatHolidayOverlays(holidayOverlays); return { period: { startDate: fmtDate(startDate), endDate: fmtDate(endDate), }, filters, summary: { ...summarizeTimelineEntries(readModel), ...summarizeHolidayOverlays(formattedHolidayOverlays), }, allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)), demands: readModel.demands, assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)), holidayOverlays: formattedHolidayOverlays, }; }), getHolidayOverlayDetail: controllerProcedure .input( z.object({ startDate: z.string().optional(), endDate: z.string().optional(), durationDays: z.number().int().min(1).max(366).optional(), 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 { startDate, endDate } = createTimelineDateRange(input); const filters = createTimelineFilters(input); const holidayOverlays = await loadTimelineHolidayOverlays(ctx.db, { ...filters, startDate, endDate, }); const formattedOverlays = formatHolidayOverlays(holidayOverlays); return { period: { startDate: fmtDate(startDate), endDate: fmtDate(endDate), }, filters, summary: summarizeHolidayOverlays(formattedOverlays), overlays: formattedOverlays, }; }), getProjectContext: controllerProcedure .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, }; }), getProjectContextDetail: controllerProcedure .input( z.object({ projectId: z.string(), startDate: z.string().optional(), endDate: z.string().optional(), durationDays: z.number().int().min(1).max(366).optional(), }), ) .query(async ({ ctx, input }) => { const projectContext = await loadTimelineProjectContext(ctx.db, input.projectId); const directory = await getAnonymizationDirectory(ctx.db); const derivedStartDate = input.startDate ? createTimelineDateRange({ startDate: input.startDate, durationDays: 1 }).startDate : projectContext.project.startDate ?? projectContext.assignments[0]?.startDate ?? projectContext.demands[0]?.startDate ?? createTimelineDateRange({ durationDays: 1 }).startDate; const derivedEndDate = input.endDate ? createTimelineDateRange({ startDate: fmtDate(derivedStartDate) ?? undefined, endDate: input.endDate }).endDate : projectContext.project.endDate ?? createTimelineDateRange({ startDate: fmtDate(derivedStartDate) ?? undefined, durationDays: input.durationDays ?? 21, }).endDate; if (derivedEndDate < derivedStartDate) { throw new TRPCError({ code: "BAD_REQUEST", message: "endDate must be on or after startDate.", }); } const holidayOverlays = projectContext.resourceIds.length > 0 ? await loadTimelineHolidayOverlays(ctx.db, { startDate: derivedStartDate, endDate: derivedEndDate, resourceIds: projectContext.resourceIds, projectIds: [input.projectId], }) : []; const formattedHolidayOverlays = formatHolidayOverlays(holidayOverlays); const assignmentConflicts = projectContext.assignments .filter((assignment) => assignment.resourceId && assignment.resource) .map((assignment) => { const overlaps = projectContext.allResourceAllocations .filter((booking) => ( booking.resourceId === assignment.resourceId && booking.id !== assignment.id && rangesOverlap( toDate(booking.startDate), toDate(booking.endDate), toDate(assignment.startDate), toDate(assignment.endDate), ) )) .map((booking) => ({ id: booking.id, projectId: booking.projectId, projectName: booking.project?.name ?? null, projectShortCode: booking.project?.shortCode ?? null, startDate: fmtDate(toDate(booking.startDate)), endDate: fmtDate(toDate(booking.endDate)), hoursPerDay: booking.hoursPerDay, status: booking.status, sameProject: booking.projectId === input.projectId, })); return { assignmentId: assignment.id, resourceId: assignment.resourceId!, resourceName: assignment.resource?.displayName ?? null, startDate: fmtDate(toDate(assignment.startDate)), endDate: fmtDate(toDate(assignment.endDate)), hoursPerDay: assignment.hoursPerDay, overlapCount: overlaps.length, crossProjectOverlapCount: overlaps.filter((booking) => !booking.sameProject).length, overlaps, }; }); return { project: projectContext.project, period: { startDate: fmtDate(derivedStartDate), endDate: fmtDate(derivedEndDate), }, summary: { ...summarizeTimelineEntries({ allocations: projectContext.allocations, demands: projectContext.demands, assignments: projectContext.assignments, }), resourceIds: projectContext.resourceIds.length, allResourceAllocationCount: projectContext.allResourceAllocations.length, conflictedAssignmentCount: assignmentConflicts.filter((item) => item.crossProjectOverlapCount > 0).length, ...summarizeHolidayOverlays(formattedHolidayOverlays), }, allocations: projectContext.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory), ), demands: projectContext.demands, assignments: projectContext.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory), ), allResourceAllocations: projectContext.allResourceAllocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory), ), assignmentConflicts, holidayOverlays: formattedHolidayOverlays, resourceIds: projectContext.resourceIds, }; }), previewShift: controllerProcedure .input(ShiftProjectSchema) .query(async ({ ctx, input }) => previewTimelineProjectShift(ctx.db, input)), getShiftPreviewDetail: controllerProcedure .input(ShiftProjectSchema) .query(async ({ ctx, input }) => { const [project, preview] = await Promise.all([ findUniqueOrThrow( ctx.db.project.findUnique({ where: { id: input.projectId }, select: { id: true, name: true, shortCode: true, status: true, responsiblePerson: true, startDate: true, endDate: true, }, }), "Project", ), previewTimelineProjectShift(ctx.db, input), ]); return { project: { id: project.id, name: project.name, shortCode: project.shortCode, status: project.status, responsiblePerson: project.responsiblePerson, startDate: fmtDate(project.startDate), endDate: fmtDate(project.endDate), }, requestedShift: { newStartDate: fmtDate(input.newStartDate), newEndDate: fmtDate(input.newEndDate), }, preview, }; }), getBudgetStatus: controllerProcedure .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, name: true, shortCode: true, budgetCents: true, winProbability: true, startDate: true, endDate: true, }, }), "Project", ); const bookings = await listAssignmentBookings(ctx.db, { projectIds: [project.id], }); const budgetStatus = 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< import("@capakraken/shared").Allocation, "status" | "dailyCostCents" | "startDate" | "endDate" | "hoursPerDay" >[], project.startDate, project.endDate, ); return { ...budgetStatus, projectName: project.name, projectCode: project.shortCode, totalAllocations: bookings.length, budgetCents: project.budgetCents, }; }), };