refactor(api): extract timeline read procedures
This commit is contained in:
@@ -0,0 +1,985 @@
|
|||||||
|
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<typeof TimelineWindowFiltersSchema>;
|
||||||
|
type TimelineSelfServiceContext = Pick<TRPCContext, "db" | "dbUser">;
|
||||||
|
|
||||||
|
export function getAssignmentResourceIds(
|
||||||
|
readModel: ReturnType<typeof buildSplitAllocationReadModel>,
|
||||||
|
): 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<TimelineEntriesFilters, "startDate" | "endDate"> {
|
||||||
|
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<string | null> {
|
||||||
|
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<TimelineEntriesFilters | null> {
|
||||||
|
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<string>();
|
||||||
|
const resourceIds = new Set<string>();
|
||||||
|
|
||||||
|
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<typeof formatHolidayOverlays>,
|
||||||
|
) {
|
||||||
|
const resourceIds = new Set<string>();
|
||||||
|
const byScope = new Map<string, number>();
|
||||||
|
|
||||||
|
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<string, unknown>[] = [];
|
||||||
|
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<typeof buildSplitAllocationReadModel>,
|
||||||
|
) {
|
||||||
|
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<T extends { resource?: { id: string } | null }>(
|
||||||
|
entry: T,
|
||||||
|
directory: Awaited<ReturnType<typeof getAnonymizationDirectory>>,
|
||||||
|
): 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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
+49
-1045
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user