355 lines
12 KiB
TypeScript
355 lines
12 KiB
TypeScript
import {
|
|
buildSplitAllocationReadModel,
|
|
createAssignment,
|
|
findAllocationEntry,
|
|
loadAllocationEntry,
|
|
updateAllocationEntry,
|
|
} from "@capakraken/application";
|
|
import type { PrismaClient } from "@capakraken/db";
|
|
import { AllocationStatus, PermissionKey, UpdateAllocationHoursSchema } from "@capakraken/shared";
|
|
import { TRPCError } from "@trpc/server";
|
|
import { z } from "zod";
|
|
import {
|
|
emitAllocationCreated,
|
|
emitAllocationUpdated,
|
|
} from "../sse/event-bus.js";
|
|
import { managerProcedure, requirePermission } from "../trpc.js";
|
|
import { calculateTimelineAllocationDailyCost } from "./timeline-cost-support.js";
|
|
|
|
export const timelineAllocationMutationProcedures = {
|
|
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",
|
|
});
|
|
}
|
|
|
|
const existingMeta = (existing.metadata as Record<string, unknown>) ?? {};
|
|
const newMeta: Record<string, unknown> = {
|
|
...existingMeta,
|
|
...(input.includeSaturday !== undefined
|
|
? { includeSaturday: input.includeSaturday }
|
|
: {}),
|
|
};
|
|
const includeSaturday =
|
|
input.includeSaturday ?? (existingMeta.includeSaturday as boolean | undefined) ?? false;
|
|
|
|
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("@capakraken/shared").WeekdayAvailability;
|
|
const recurrence = newMeta.recurrence as import("@capakraken/shared").RecurrencePattern | undefined;
|
|
newDailyCostCents = await calculateTimelineAllocationDailyCost({
|
|
db: ctx.db as PrismaClient,
|
|
resourceId: resolved.resourceId,
|
|
lcrCents: existingResource.lcrCents,
|
|
hoursPerDay: newHoursPerDay,
|
|
startDate: newStartDate,
|
|
endDate: newEndDate,
|
|
availability,
|
|
includeSaturday,
|
|
...(recurrence ? { recurrence } : {}),
|
|
});
|
|
}
|
|
|
|
const updated = await ctx.db.$transaction(async (tx) => {
|
|
const { allocation: updatedAllocation } = await updateAllocationEntry(
|
|
tx as unknown as Parameters<typeof updateAllocationEntry>[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;
|
|
}),
|
|
|
|
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<string, unknown>;
|
|
|
|
const allocation = await ctx.db.$transaction(async (tx) => {
|
|
const assignment = await createAssignment(
|
|
tx as unknown as Parameters<typeof createAssignment>[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;
|
|
}),
|
|
|
|
batchQuickAssign: managerProcedure
|
|
.input(
|
|
z.object({
|
|
assignments: z
|
|
.array(
|
|
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"),
|
|
status: z
|
|
.nativeEnum(AllocationStatus)
|
|
.default(AllocationStatus.PROPOSED),
|
|
}),
|
|
)
|
|
.min(1)
|
|
.max(50),
|
|
}),
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
|
|
|
for (const assignment of input.assignments) {
|
|
if (assignment.endDate < assignment.startDate) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "End date must be after start date",
|
|
});
|
|
}
|
|
}
|
|
|
|
const results = await ctx.db.$transaction(async (tx) => {
|
|
const created = [];
|
|
for (const assignment of input.assignments) {
|
|
const percentage = Math.min(
|
|
100,
|
|
Math.round((assignment.hoursPerDay / 8) * 100),
|
|
);
|
|
const metadata = {
|
|
source: "batchQuickAssign",
|
|
} satisfies Record<string, unknown>;
|
|
|
|
const createdAssignment = await createAssignment(
|
|
tx as unknown as Parameters<typeof createAssignment>[0],
|
|
{
|
|
resourceId: assignment.resourceId,
|
|
projectId: assignment.projectId,
|
|
startDate: assignment.startDate,
|
|
endDate: assignment.endDate,
|
|
hoursPerDay: assignment.hoursPerDay,
|
|
percentage,
|
|
role: assignment.role,
|
|
status: assignment.status,
|
|
metadata,
|
|
},
|
|
);
|
|
created.push(createdAssignment);
|
|
}
|
|
return created;
|
|
});
|
|
|
|
for (const assignment of results) {
|
|
emitAllocationCreated({
|
|
id: assignment.id,
|
|
projectId: assignment.projectId,
|
|
resourceId: assignment.resourceId,
|
|
});
|
|
}
|
|
|
|
return { count: results.length };
|
|
}),
|
|
|
|
batchShiftAllocations: managerProcedure
|
|
.input(
|
|
z.object({
|
|
allocationIds: z.array(z.string()).min(1).max(100),
|
|
daysDelta: z.number().int().min(-3650).max(3650),
|
|
mode: z.enum(["move", "resize-start", "resize-end"]).default("move"),
|
|
}),
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
|
|
|
if (input.daysDelta === 0) {
|
|
return { count: 0 };
|
|
}
|
|
|
|
const entries = await Promise.all(
|
|
input.allocationIds.map((allocationId) => findAllocationEntry(ctx.db, allocationId)),
|
|
);
|
|
const resolved = entries.filter(
|
|
(entry): entry is NonNullable<typeof entry> => entry !== null,
|
|
);
|
|
|
|
if (resolved.length === 0) {
|
|
throw new TRPCError({ code: "NOT_FOUND", message: "No allocations found" });
|
|
}
|
|
|
|
const results = await ctx.db.$transaction(async (tx) => {
|
|
const updated = [];
|
|
for (const entry of resolved) {
|
|
const existing = entry.entry;
|
|
const newStart = new Date(existing.startDate);
|
|
const newEnd = new Date(existing.endDate);
|
|
|
|
if (input.mode === "move") {
|
|
newStart.setDate(newStart.getDate() + input.daysDelta);
|
|
newEnd.setDate(newEnd.getDate() + input.daysDelta);
|
|
} else if (input.mode === "resize-start") {
|
|
newStart.setDate(newStart.getDate() + input.daysDelta);
|
|
if (newStart > newEnd) {
|
|
newStart.setTime(newEnd.getTime());
|
|
}
|
|
} else {
|
|
newEnd.setDate(newEnd.getDate() + input.daysDelta);
|
|
if (newEnd < newStart) {
|
|
newEnd.setTime(newStart.getTime());
|
|
}
|
|
}
|
|
|
|
const result = await updateAllocationEntry(
|
|
tx as unknown as Parameters<typeof updateAllocationEntry>[0],
|
|
{
|
|
id: existing.id,
|
|
demandRequirementUpdate: {
|
|
startDate: newStart,
|
|
endDate: newEnd,
|
|
},
|
|
assignmentUpdate: {
|
|
startDate: newStart,
|
|
endDate: newEnd,
|
|
},
|
|
},
|
|
);
|
|
updated.push(result.allocation);
|
|
}
|
|
|
|
await tx.auditLog.create({
|
|
data: {
|
|
entityType: "Allocation",
|
|
entityId: input.allocationIds.join(","),
|
|
action: "UPDATE",
|
|
changes: {
|
|
operation: "batchShift",
|
|
mode: input.mode,
|
|
daysDelta: input.daysDelta,
|
|
count: resolved.length,
|
|
} as unknown as import("@capakraken/db").Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
|
|
return updated;
|
|
});
|
|
|
|
for (const allocation of results) {
|
|
emitAllocationUpdated({
|
|
id: allocation.id,
|
|
projectId: allocation.projectId,
|
|
resourceId: allocation.resourceId,
|
|
});
|
|
}
|
|
|
|
return { count: results.length };
|
|
}),
|
|
};
|