Files
CapaKraken/packages/api/src/router/timeline-project-context-support.ts
T

420 lines
13 KiB
TypeScript

import { TRPCError } from "@trpc/server";
import type { Allocation } from "@capakraken/shared";
import {
formatHolidayOverlays,
loadTimelineHolidayOverlays,
summarizeHolidayOverlays,
} from "./timeline-holiday-read.js";
import {
anonymizeResourceOnEntry,
createTimelineDateRange,
fmtDate,
rangesOverlap,
summarizeTimelineEntries,
toDate,
} from "./timeline-read-shared.js";
type TimelineAnonymizationDirectory = Parameters<typeof anonymizeResourceOnEntry>[1];
export interface ResolveTimelineProjectContextPeriodInput {
requestedStartDate?: string | undefined;
requestedEndDate?: string | undefined;
durationDays?: number | undefined;
projectStartDate?: Date | null | undefined;
projectEndDate?: Date | null | undefined;
firstAssignmentStartDate?: Date | string | null | undefined;
firstDemandStartDate?: Date | string | null | undefined;
}
export function resolveTimelineProjectContextPeriod(
input: ResolveTimelineProjectContextPeriodInput,
) {
const startDate = input.requestedStartDate
? createTimelineDateRange({
startDate: input.requestedStartDate,
durationDays: 1,
}).startDate
: input.projectStartDate
?? (input.firstAssignmentStartDate ? toDate(input.firstAssignmentStartDate) : null)
?? (input.firstDemandStartDate ? toDate(input.firstDemandStartDate) : null)
?? createTimelineDateRange({ durationDays: 1 }).startDate;
const endDate = input.requestedEndDate
? createTimelineDateRange({
startDate: fmtDate(startDate) ?? undefined,
endDate: input.requestedEndDate,
}).endDate
: input.projectEndDate
?? createTimelineDateRange({
startDate: fmtDate(startDate) ?? undefined,
durationDays: input.durationDays ?? 21,
}).endDate;
if (endDate < startDate) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "endDate must be on or after startDate.",
});
}
return { startDate, endDate };
}
export interface TimelineProjectAssignmentConflict {
assignmentId: string;
resourceId: string;
resourceName: string | null;
startDate: string | null;
endDate: string | null;
hoursPerDay: number;
overlapCount: number;
crossProjectOverlapCount: number;
overlaps: Array<{
id: string;
projectId: string | null;
projectName: string | null;
projectShortCode: string | null;
startDate: string | null;
endDate: string | null;
hoursPerDay: number;
status: string;
sameProject: boolean;
}>;
}
export function buildTimelineProjectAssignmentConflicts(input: {
projectId: string;
assignments: Array<{
id: string;
resourceId: string | null;
resource?: { displayName?: string | null } | null;
startDate: Date | string;
endDate: Date | string;
hoursPerDay: number;
}>;
allResourceAllocations: Array<{
id: string;
resourceId: string | null;
projectId: string | null;
project?: { name?: string | null; shortCode?: string | null } | null;
startDate: Date | string;
endDate: Date | string;
hoursPerDay: number;
status: string;
}>;
}): TimelineProjectAssignmentConflict[] {
return input.assignments
.filter((assignment) => assignment.resourceId && assignment.resource)
.map((assignment) => {
const overlaps = input.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,
};
});
}
export function buildTimelineProjectContextSummary(input: {
allocations: Array<{ projectId: string | null; resourceId: string | null }>;
demands: Array<{ projectId: string | null }>;
assignments: Array<{ projectId: string | null; resourceId: string | null }>;
resourceIds: string[];
allResourceAllocations: unknown[];
assignmentConflicts: Array<{ crossProjectOverlapCount: number }>;
holidayOverlays: ReturnType<typeof formatHolidayOverlays>;
}) {
return {
...summarizeTimelineEntries({
allocations: input.allocations,
demands: input.demands,
assignments: input.assignments,
}),
resourceIds: input.resourceIds.length,
allResourceAllocationCount: input.allResourceAllocations.length,
conflictedAssignmentCount: input.assignmentConflicts.filter(
(item) => item.crossProjectOverlapCount > 0,
).length,
...summarizeHolidayOverlays(input.holidayOverlays),
};
}
export function buildTimelineShiftValidationBookings(
bookings: Array<{
id: string;
resourceId: string | null;
projectId: string | null;
startDate: Date;
endDate: Date;
hoursPerDay: number;
status: string;
}>,
) {
return bookings
.filter(
(
booking,
): booking is typeof booking & { resourceId: string; projectId: string } =>
booking.resourceId !== null && booking.projectId !== null,
)
.map((booking) => ({
id: booking.id,
resourceId: booking.resourceId,
projectId: booking.projectId,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
status: booking.status,
}));
}
export function buildTimelineBudgetStatusAllocations(
bookings: Array<{
status: string;
dailyCostCents: number;
startDate: Date;
endDate: Date;
hoursPerDay: number;
}>,
): Pick<
Allocation,
"status" | "dailyCostCents" | "startDate" | "endDate" | "hoursPerDay"
>[] {
return bookings.map((booking) => ({
status: booking.status as Allocation["status"],
dailyCostCents: booking.dailyCostCents,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
}));
}
export function buildTimelineBudgetStatusResponse<TBudgetStatus extends object>(input: {
project: {
name: string;
shortCode: string | null;
budgetCents: number;
};
budgetStatus: TBudgetStatus;
totalAllocations: number;
}): TBudgetStatus & {
projectName: string;
projectCode: string;
totalAllocations: number;
budgetCents: number;
} {
return {
...input.budgetStatus,
projectName: input.project.name,
projectCode: input.project.shortCode ?? "",
totalAllocations: input.totalAllocations,
budgetCents: input.project.budgetCents,
};
}
export function buildTimelineProjectContextResponse<
TProject,
TAllocation extends { resource?: { id: string } | null },
TDemand,
TAssignment extends { resource?: { id: string } | null },
TBooking extends { resource?: { id: string } | null },
>(input: {
project: TProject;
allocations: TAllocation[];
demands: TDemand[];
assignments: TAssignment[];
allResourceAllocations: TBooking[];
resourceIds: string[];
directory: TimelineAnonymizationDirectory;
}) {
return {
project: input.project,
allocations: input.allocations.map((allocation) =>
anonymizeResourceOnEntry(allocation, input.directory),
),
demands: input.demands,
assignments: input.assignments.map((assignment) =>
anonymizeResourceOnEntry(assignment, input.directory),
),
allResourceAllocations: input.allResourceAllocations.map((allocation) =>
anonymizeResourceOnEntry(allocation, input.directory),
),
resourceIds: input.resourceIds,
};
}
export function buildTimelineProjectContextDetailResponse<
TProject,
TAllocation extends { projectId: string | null; resourceId: string | null; resource?: { id: string } | null },
TDemand extends { projectId: string | null },
TAssignment extends { projectId: string | null; resourceId: string | null; resource?: { id: string } | null },
TBooking extends { resource?: { id: string } | null },
TConflict extends { crossProjectOverlapCount: number },
>(input: {
project: TProject;
period: { startDate: Date; endDate: Date };
allocations: TAllocation[];
demands: TDemand[];
assignments: TAssignment[];
allResourceAllocations: TBooking[];
resourceIds: string[];
assignmentConflicts: TConflict[];
holidayOverlays: ReturnType<typeof formatHolidayOverlays>;
directory: TimelineAnonymizationDirectory;
}) {
const base = buildTimelineProjectContextResponse({
project: input.project,
allocations: input.allocations,
demands: input.demands,
assignments: input.assignments,
allResourceAllocations: input.allResourceAllocations,
resourceIds: input.resourceIds,
directory: input.directory,
});
return {
...base,
period: {
startDate: fmtDate(input.period.startDate),
endDate: fmtDate(input.period.endDate),
},
summary: buildTimelineProjectContextSummary({
allocations: input.allocations,
demands: input.demands,
assignments: input.assignments,
resourceIds: input.resourceIds,
allResourceAllocations: input.allResourceAllocations,
assignmentConflicts: input.assignmentConflicts,
holidayOverlays: input.holidayOverlays,
}),
assignmentConflicts: input.assignmentConflicts,
holidayOverlays: input.holidayOverlays,
};
}
export async function loadTimelineProjectContextDetailArtifacts(
db: Parameters<typeof loadTimelineHolidayOverlays>[0],
input: {
projectId: string;
requestedStartDate?: string | undefined;
requestedEndDate?: string | undefined;
durationDays?: number | undefined;
projectStartDate?: Date | null | undefined;
projectEndDate?: Date | null | undefined;
firstAssignmentStartDate?: Date | string | null | undefined;
firstDemandStartDate?: Date | string | null | undefined;
assignments: Array<{
id: string;
resourceId: string | null;
resource?: { displayName?: string | null } | null;
startDate: Date | string;
endDate: Date | string;
hoursPerDay: number;
}>;
allResourceAllocations: Array<{
id: string;
resourceId: string | null;
projectId: string | null;
project?: { name?: string | null; shortCode?: string | null } | null;
startDate: Date | string;
endDate: Date | string;
hoursPerDay: number;
status: string;
}>;
resourceIds: string[];
},
) {
const period = resolveTimelineProjectContextPeriod({
requestedStartDate: input.requestedStartDate,
requestedEndDate: input.requestedEndDate,
durationDays: input.durationDays,
projectStartDate: input.projectStartDate,
projectEndDate: input.projectEndDate,
firstAssignmentStartDate: input.firstAssignmentStartDate,
firstDemandStartDate: input.firstDemandStartDate,
});
const holidayOverlays = input.resourceIds.length > 0
? formatHolidayOverlays(await loadTimelineHolidayOverlays(db, {
startDate: period.startDate,
endDate: period.endDate,
resourceIds: input.resourceIds,
projectIds: [input.projectId],
}))
: [];
const assignmentConflicts = buildTimelineProjectAssignmentConflicts({
projectId: input.projectId,
assignments: input.assignments,
allResourceAllocations: input.allResourceAllocations,
});
return {
period,
holidayOverlays,
assignmentConflicts,
};
}
export function buildTimelineShiftPreviewDetailResponse<TPreview>(input: {
project: {
id: string;
name: string;
shortCode: string | null;
status: string;
responsiblePerson: string | null;
startDate: Date;
endDate: Date;
};
requestedShift: {
newStartDate: Date;
newEndDate: Date;
};
preview: TPreview;
}) {
return {
project: {
id: input.project.id,
name: input.project.name,
shortCode: input.project.shortCode,
status: input.project.status,
responsiblePerson: input.project.responsiblePerson,
startDate: fmtDate(input.project.startDate),
endDate: fmtDate(input.project.endDate),
},
requestedShift: {
newStartDate: fmtDate(input.requestedShift.newStartDate),
newEndDate: fmtDate(input.requestedShift.newEndDate),
},
preview: input.preview,
};
}