e7b74f13bd
- Extract shared render helpers (vacation blocks, range overlay, overbooking blink) into renderHelpers.tsx - Centralize status badge styles and vacation color maps into status-styles.ts - Extract dragMath.ts utility from useTimelineDrag for reuse - Split useInvalidatePlanningViews into useInvalidateTimeline (4 queries) + useInvalidatePlanningViews (8 queries) - Adopt findUniqueOrThrow() and Prisma select constants across API routers - Add shared fmtEur() helper for API-side money formatting - Wrap TimelineResourcePanel and TimelineProjectPanel with React.memo - Fix pre-existing TS2589 deep type errors in TeamCalendar and VacationModal - 38 files changed, reducing ~400 lines of duplicated code Co-Authored-By: claude-flow <ruv@ruv.net>
938 lines
30 KiB
TypeScript
938 lines
30 KiB
TypeScript
import {
|
|
buildSplitAllocationReadModel,
|
|
createAssignment,
|
|
findAllocationEntry,
|
|
loadAllocationEntry,
|
|
listAssignmentBookings,
|
|
updateAssignment,
|
|
updateDemandRequirement,
|
|
updateAllocationEntry,
|
|
} from "@planarchy/application";
|
|
import type { PrismaClient } from "@planarchy/db";
|
|
import { calculateAllocation, computeBudgetStatus, validateShift, DEFAULT_CALCULATION_RULES } from "@planarchy/engine";
|
|
import type { CalculationRule, AbsenceDay } from "@planarchy/shared";
|
|
import { VacationType } from "@planarchy/db";
|
|
import { AllocationStatus, PermissionKey, ShiftProjectSchema, UpdateAllocationHoursSchema } from "@planarchy/shared";
|
|
import { TRPCError } from "@trpc/server";
|
|
import { z } from "zod";
|
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
|
import {
|
|
loadProjectPlanningReadModel,
|
|
TIMELINE_ASSIGNMENT_INCLUDE,
|
|
PROJECT_PLANNING_DEMAND_INCLUDE,
|
|
} from "./project-planning-read-model.js";
|
|
import {
|
|
emitAllocationCreated,
|
|
emitAllocationUpdated,
|
|
emitProjectShifted,
|
|
} from "../sse/event-bus.js";
|
|
import { buildTimelineShiftPlan } from "./timeline-shift-planning.js";
|
|
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
|
import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js";
|
|
|
|
type ShiftDbClient = Pick<
|
|
PrismaClient,
|
|
"project" | "demandRequirement" | "assignment"
|
|
>;
|
|
|
|
type TimelineEntriesDbClient = Pick<
|
|
PrismaClient,
|
|
"demandRequirement" | "assignment" | "resource" | "project"
|
|
>;
|
|
|
|
type TimelineEntriesFilters = {
|
|
startDate: Date;
|
|
endDate: Date;
|
|
resourceIds?: string[] | undefined;
|
|
projectIds?: string[] | undefined;
|
|
clientIds?: string[] | undefined;
|
|
chapters?: string[] | undefined;
|
|
eids?: string[] | undefined;
|
|
countryCodes?: string[] | undefined;
|
|
};
|
|
|
|
function getAssignmentResourceIds(
|
|
readModel: ReturnType<typeof buildSplitAllocationReadModel>,
|
|
): string[] {
|
|
return [
|
|
...new Set(
|
|
readModel.assignments
|
|
.map((assignment) => assignment.resourceId)
|
|
.filter((resourceId): resourceId is string => resourceId !== null),
|
|
),
|
|
];
|
|
}
|
|
|
|
async function loadTimelineEntriesReadModel(
|
|
db: TimelineEntriesDbClient,
|
|
input: TimelineEntriesFilters,
|
|
) {
|
|
const { startDate, endDate, resourceIds, projectIds, clientIds, chapters, eids, countryCodes } = input;
|
|
|
|
// When resource-level filters are active (resourceIds, chapters, eids, or countryCodes),
|
|
// resolve matching resource IDs so we can push the filter to the DB query.
|
|
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({
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
where: (andConditions.length === 1 ? andConditions[0]! : { AND: andConditions }) as any,
|
|
select: { id: true },
|
|
});
|
|
return matching.map((r) => r.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));
|
|
})();
|
|
|
|
// When filtering by resource (either explicit resourceIds or derived from chapters),
|
|
// demands without a resource are excluded.
|
|
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 });
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
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),
|
|
};
|
|
}
|
|
|
|
/** Load active calculation rules from DB, falling back to defaults if none configured. */
|
|
async function loadCalculationRules(db: PrismaClient): Promise<CalculationRule[]> {
|
|
try {
|
|
const rules = await db.calculationRule.findMany({
|
|
where: { isActive: true },
|
|
orderBy: [{ priority: "desc" }],
|
|
});
|
|
if (rules.length > 0) {
|
|
return rules as unknown as CalculationRule[];
|
|
}
|
|
} catch {
|
|
// table may not exist yet
|
|
}
|
|
return DEFAULT_CALCULATION_RULES;
|
|
}
|
|
|
|
/** Build typed absence days from vacations for a resource in a date range. */
|
|
async function buildAbsenceDays(
|
|
db: PrismaClient,
|
|
resourceId: string,
|
|
startDate: Date,
|
|
endDate: Date,
|
|
): Promise<{ absenceDays: AbsenceDay[]; legacyVacationDates: Date[] }> {
|
|
const absenceDays: AbsenceDay[] = [];
|
|
const legacyVacationDates: Date[] = [];
|
|
|
|
try {
|
|
const vacations = await db.vacation.findMany({
|
|
where: {
|
|
resourceId,
|
|
status: "APPROVED",
|
|
startDate: { lte: endDate },
|
|
endDate: { gte: startDate },
|
|
},
|
|
select: { startDate: true, endDate: true, type: true, isHalfDay: true },
|
|
});
|
|
|
|
for (const v of vacations) {
|
|
const cur = new Date(v.startDate);
|
|
cur.setHours(0, 0, 0, 0);
|
|
const vEnd = new Date(v.endDate);
|
|
vEnd.setHours(0, 0, 0, 0);
|
|
|
|
// Map Prisma VacationType to AbsenceTrigger
|
|
const triggerType = v.type === VacationType.SICK ? "SICK" as const
|
|
: v.type === VacationType.PUBLIC_HOLIDAY ? "PUBLIC_HOLIDAY" as const
|
|
: "VACATION" as const;
|
|
|
|
while (cur <= vEnd) {
|
|
absenceDays.push({
|
|
date: new Date(cur),
|
|
type: triggerType,
|
|
...(v.isHalfDay ? { isHalfDay: true } : {}),
|
|
});
|
|
// Also populate legacy vacation dates for backward compat
|
|
if (triggerType === "VACATION") {
|
|
legacyVacationDates.push(new Date(cur));
|
|
}
|
|
cur.setDate(cur.getDate() + 1);
|
|
}
|
|
}
|
|
} catch {
|
|
// vacation table may not exist yet
|
|
}
|
|
|
|
return { absenceDays, legacyVacationDates };
|
|
}
|
|
|
|
export const timelineRouter = createTRPCRouter({
|
|
/**
|
|
* Get all timeline entries (projects + allocations) for a date range.
|
|
* Includes project startDate, endDate, staffingReqs for demand overlay.
|
|
*/
|
|
getEntries: protectedProcedure
|
|
.input(
|
|
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(),
|
|
}),
|
|
)
|
|
.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: protectedProcedure
|
|
.input(
|
|
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(),
|
|
}),
|
|
)
|
|
.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)),
|
|
};
|
|
}),
|
|
|
|
/**
|
|
* Get full project context for a project:
|
|
* - project with staffingReqs and budget
|
|
* - all active planning entries on this project
|
|
* - all assignment bookings for the same resources (for cross-project overlap display)
|
|
* Used when: drag starts or project panel opens.
|
|
*/
|
|
getProjectContext: protectedProcedure
|
|
.input(z.object({ projectId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const [project, planningRead] = await Promise.all([
|
|
findUniqueOrThrow(
|
|
ctx.db.project.findUnique({
|
|
where: { id: input.projectId },
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
shortCode: true,
|
|
orderType: true,
|
|
budgetCents: true,
|
|
winProbability: true,
|
|
status: true,
|
|
startDate: true,
|
|
endDate: true,
|
|
staffingReqs: true,
|
|
},
|
|
}),
|
|
"Project",
|
|
),
|
|
loadProjectPlanningReadModel(ctx.db, {
|
|
projectId: input.projectId,
|
|
activeOnly: true,
|
|
}),
|
|
]);
|
|
|
|
const resourceIds = getAssignmentResourceIds(planningRead.readModel);
|
|
const allResourceAllocations =
|
|
resourceIds.length === 0
|
|
? []
|
|
: await listAssignmentBookings(ctx.db, {
|
|
resourceIds,
|
|
});
|
|
|
|
const directory = await getAnonymizationDirectory(ctx.db);
|
|
|
|
return {
|
|
project,
|
|
allocations: planningRead.readModel.allocations.map((allocation) =>
|
|
anonymizeResourceOnEntry(allocation, directory),
|
|
),
|
|
demands: planningRead.readModel.demands,
|
|
assignments: planningRead.readModel.assignments.map((assignment) =>
|
|
anonymizeResourceOnEntry(assignment, directory),
|
|
),
|
|
allResourceAllocations: allResourceAllocations.map((allocation) =>
|
|
anonymizeResourceOnEntry(allocation, directory),
|
|
),
|
|
resourceIds,
|
|
};
|
|
}),
|
|
|
|
/**
|
|
* Inline update of an allocation's hours, dates, includeSaturday, or role.
|
|
* Recalculates dailyCostCents and emits SSE.
|
|
*/
|
|
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",
|
|
});
|
|
}
|
|
|
|
// Merge includeSaturday into metadata
|
|
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;
|
|
|
|
// For placeholder allocations (no resource), dailyCostCents stays 0
|
|
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("@planarchy/shared").WeekdayAvailability;
|
|
|
|
// Load recurrence from merged metadata
|
|
const recurrence = (newMeta.recurrence as import("@planarchy/shared").RecurrencePattern | undefined);
|
|
|
|
// Load typed absences + calculation rules for rules-aware cost computation
|
|
const [absenceData, calculationRules] = await Promise.all([
|
|
buildAbsenceDays(ctx.db as PrismaClient, resolved.resourceId, newStartDate, newEndDate),
|
|
loadCalculationRules(ctx.db as PrismaClient),
|
|
]);
|
|
|
|
newDailyCostCents = calculateAllocation({
|
|
lcrCents: existingResource.lcrCents,
|
|
hoursPerDay: newHoursPerDay,
|
|
startDate: newStartDate,
|
|
endDate: newEndDate,
|
|
availability,
|
|
includeSaturday,
|
|
...(recurrence ? { recurrence } : {}),
|
|
vacationDates: absenceData.legacyVacationDates,
|
|
absenceDays: absenceData.absenceDays,
|
|
calculationRules,
|
|
}).dailyCostCents;
|
|
}
|
|
|
|
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;
|
|
}),
|
|
|
|
/**
|
|
* Preview a project shift — validate without committing.
|
|
* Returns cost impact, conflicts, warnings.
|
|
*/
|
|
previewShift: protectedProcedure
|
|
.input(ShiftProjectSchema)
|
|
.query(async ({ ctx, input }) => {
|
|
const { projectId, newStartDate, newEndDate } = input;
|
|
const { project, shiftPlan } = await loadProjectShiftContext(ctx.db, projectId);
|
|
|
|
return validateShift({
|
|
project: {
|
|
id: project.id,
|
|
budgetCents: project.budgetCents,
|
|
winProbability: project.winProbability,
|
|
startDate: project.startDate,
|
|
endDate: project.endDate,
|
|
},
|
|
newStartDate,
|
|
newEndDate,
|
|
allocations: shiftPlan.validationAllocations,
|
|
});
|
|
}),
|
|
|
|
/**
|
|
* Apply a project shift — validate, then commit all allocation date changes.
|
|
* Reads includeSaturday from each allocation's metadata.
|
|
*/
|
|
applyShift: managerProcedure
|
|
.input(ShiftProjectSchema)
|
|
.mutation(async ({ ctx, input }) => {
|
|
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
|
const { projectId, newStartDate, newEndDate } = input;
|
|
const { project, demandRequirements, assignments, shiftPlan } = await loadProjectShiftContext(
|
|
ctx.db,
|
|
projectId,
|
|
);
|
|
|
|
// Re-validate before committing
|
|
const validation = validateShift({
|
|
project: {
|
|
id: project.id,
|
|
budgetCents: project.budgetCents,
|
|
winProbability: project.winProbability,
|
|
startDate: project.startDate,
|
|
endDate: project.endDate,
|
|
},
|
|
newStartDate,
|
|
newEndDate,
|
|
allocations: shiftPlan.validationAllocations,
|
|
});
|
|
|
|
if (!validation.valid) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: `Shift validation failed: ${validation.errors.map((e) => e.message).join(", ")}`,
|
|
});
|
|
}
|
|
|
|
// Pre-load calculation rules for cost recalculation
|
|
const shiftRules = await loadCalculationRules(ctx.db as PrismaClient);
|
|
|
|
// Apply shift in a transaction
|
|
const updatedProject = await ctx.db.$transaction(async (tx) => {
|
|
// Update project dates
|
|
const proj = await tx.project.update({
|
|
where: { id: projectId },
|
|
data: { startDate: newStartDate, endDate: newEndDate },
|
|
});
|
|
|
|
for (const demandRequirement of demandRequirements) {
|
|
await updateDemandRequirement(
|
|
tx as unknown as Parameters<typeof updateDemandRequirement>[0],
|
|
demandRequirement.id,
|
|
{
|
|
startDate: newStartDate,
|
|
endDate: newEndDate,
|
|
},
|
|
);
|
|
}
|
|
|
|
for (const assignment of assignments) {
|
|
const metadata = (assignment.metadata as Record<string, unknown> | null | undefined) ?? {};
|
|
const includeSaturday = (metadata.includeSaturday as boolean | undefined) ?? false;
|
|
|
|
const shiftAbsenceData = await buildAbsenceDays(
|
|
ctx.db as PrismaClient,
|
|
assignment.resourceId!,
|
|
newStartDate,
|
|
newEndDate,
|
|
);
|
|
|
|
const newDailyCost = calculateAllocation({
|
|
lcrCents: assignment.resource!.lcrCents,
|
|
hoursPerDay: assignment.hoursPerDay,
|
|
startDate: newStartDate,
|
|
endDate: newEndDate,
|
|
availability:
|
|
assignment.resource!.availability as unknown as import("@planarchy/shared").WeekdayAvailability,
|
|
includeSaturday,
|
|
vacationDates: shiftAbsenceData.legacyVacationDates,
|
|
absenceDays: shiftAbsenceData.absenceDays,
|
|
calculationRules: shiftRules,
|
|
}).dailyCostCents;
|
|
|
|
await updateAssignment(
|
|
tx as unknown as Parameters<typeof updateAssignment>[0],
|
|
assignment.id,
|
|
{
|
|
startDate: newStartDate,
|
|
endDate: newEndDate,
|
|
dailyCostCents: newDailyCost,
|
|
},
|
|
);
|
|
}
|
|
|
|
// Write audit log
|
|
await tx.auditLog.create({
|
|
data: {
|
|
entityType: "Project",
|
|
entityId: projectId,
|
|
action: "SHIFT",
|
|
changes: {
|
|
before: { startDate: project.startDate, endDate: project.endDate },
|
|
after: { startDate: newStartDate, endDate: newEndDate },
|
|
costImpact: validation.costImpact,
|
|
} as unknown as import("@planarchy/db").Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
|
|
return proj;
|
|
});
|
|
|
|
// Emit SSE event for live updates
|
|
emitProjectShifted({
|
|
projectId,
|
|
newStartDate: newStartDate.toISOString(),
|
|
newEndDate: newEndDate.toISOString(),
|
|
costDeltaCents: validation.costImpact.deltaCents,
|
|
});
|
|
|
|
return { project: updatedProject, validation };
|
|
}),
|
|
|
|
/**
|
|
* Quick-assign a resource to a project for a date range.
|
|
* Overbooking is intentionally allowed — no availability throw.
|
|
* For use from the timeline drag-to-assign UI.
|
|
*/
|
|
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;
|
|
}),
|
|
|
|
/**
|
|
* Batch quick-assign multiple resources to a project for a date range.
|
|
* Used by the multi-selection floating action bar.
|
|
*/
|
|
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);
|
|
|
|
// Validate all date ranges
|
|
for (const a of input.assignments) {
|
|
if (a.endDate < a.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 a of input.assignments) {
|
|
const percentage = Math.min(
|
|
100,
|
|
Math.round((a.hoursPerDay / 8) * 100),
|
|
);
|
|
const metadata = {
|
|
source: "batchQuickAssign",
|
|
} satisfies Record<string, unknown>;
|
|
|
|
const assignment = await createAssignment(
|
|
tx as unknown as Parameters<typeof createAssignment>[0],
|
|
{
|
|
resourceId: a.resourceId,
|
|
projectId: a.projectId,
|
|
startDate: a.startDate,
|
|
endDate: a.endDate,
|
|
hoursPerDay: a.hoursPerDay,
|
|
percentage,
|
|
role: a.role,
|
|
status: a.status,
|
|
metadata,
|
|
},
|
|
);
|
|
created.push(assignment);
|
|
}
|
|
return created;
|
|
});
|
|
|
|
// Fire SSE events
|
|
for (const assignment of results) {
|
|
emitAllocationCreated({
|
|
id: assignment.id,
|
|
projectId: assignment.projectId,
|
|
resourceId: assignment.resourceId,
|
|
});
|
|
}
|
|
|
|
return { count: results.length };
|
|
}),
|
|
|
|
/**
|
|
* Batch-shift multiple allocations by the same number of days.
|
|
* Used by multi-select drag on the timeline.
|
|
*/
|
|
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 };
|
|
|
|
// Load all allocations
|
|
const entries = await Promise.all(
|
|
input.allocationIds.map((id) => findAllocationEntry(ctx.db, id)),
|
|
);
|
|
const resolved = entries.filter(
|
|
(e): e is NonNullable<typeof e> => e !== 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);
|
|
// Clamp: start must not exceed end
|
|
if (newStart > newEnd) newStart.setTime(newEnd.getTime());
|
|
} else {
|
|
// resize-end
|
|
newEnd.setDate(newEnd.getDate() + input.daysDelta);
|
|
// Clamp: end must not precede start
|
|
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("@planarchy/db").Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
|
|
return updated;
|
|
});
|
|
|
|
// Fire SSE events
|
|
for (const alloc of results) {
|
|
emitAllocationUpdated({
|
|
id: alloc.id,
|
|
projectId: alloc.projectId,
|
|
resourceId: alloc.resourceId,
|
|
});
|
|
}
|
|
|
|
return { count: results.length };
|
|
}),
|
|
|
|
/**
|
|
* Get budget status for a project.
|
|
*/
|
|
getBudgetStatus: protectedProcedure
|
|
.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,
|
|
budgetCents: true,
|
|
winProbability: true,
|
|
startDate: true,
|
|
endDate: true,
|
|
},
|
|
}),
|
|
"Project",
|
|
);
|
|
|
|
// Use wide date range to catch all assignments (including those extending beyond project dates)
|
|
const bookings = await listAssignmentBookings(ctx.db, {
|
|
projectIds: [project.id],
|
|
});
|
|
|
|
return 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("@planarchy/shared").Allocation, "status" | "dailyCostCents" | "startDate" | "endDate" | "hoursPerDay">[],
|
|
project.startDate,
|
|
project.endDate,
|
|
);
|
|
}),
|
|
});
|