420 lines
13 KiB
TypeScript
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,
|
|
};
|
|
}
|