chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,610 @@
|
||||
import {
|
||||
buildSplitAllocationReadModel,
|
||||
createAssignment,
|
||||
findAllocationEntry,
|
||||
loadAllocationEntry,
|
||||
listAssignmentBookings,
|
||||
updateAssignment,
|
||||
updateDemandRequirement,
|
||||
updateAllocationEntry,
|
||||
} from "@planarchy/application";
|
||||
import type { PrismaClient } from "@planarchy/db";
|
||||
import { calculateAllocation, computeBudgetStatus, validateShift } from "@planarchy/engine";
|
||||
import { AllocationStatus, PermissionKey, ShiftProjectSchema, UpdateAllocationHoursSchema } from "@planarchy/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
loadProjectPlanningReadModel,
|
||||
PROJECT_PLANNING_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";
|
||||
|
||||
type ShiftDbClient = Pick<
|
||||
PrismaClient,
|
||||
"project" | "demandRequirement" | "assignment"
|
||||
>;
|
||||
|
||||
type TimelineEntriesDbClient = Pick<
|
||||
PrismaClient,
|
||||
"demandRequirement" | "assignment"
|
||||
>;
|
||||
|
||||
type TimelineEntriesFilters = {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
resourceIds?: string[] | undefined;
|
||||
projectIds?: 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 } = input;
|
||||
|
||||
const [demandRequirements, assignments] = await Promise.all([
|
||||
resourceIds && resourceIds.length > 0
|
||||
? Promise.resolve([])
|
||||
: db.demandRequirement.findMany({
|
||||
where: {
|
||||
status: { not: "CANCELLED" },
|
||||
startDate: { lte: endDate },
|
||||
endDate: { gte: startDate },
|
||||
...(projectIds ? { projectId: { in: projectIds } } : {}),
|
||||
},
|
||||
include: PROJECT_PLANNING_DEMAND_INCLUDE,
|
||||
orderBy: [{ startDate: "asc" }, { projectId: "asc" }],
|
||||
}),
|
||||
db.assignment.findMany({
|
||||
where: {
|
||||
status: { not: "CANCELLED" },
|
||||
startDate: { lte: endDate },
|
||||
endDate: { gte: startDate },
|
||||
...(resourceIds ? { resourceId: { in: resourceIds } } : {}),
|
||||
...(projectIds ? { projectId: { in: projectIds } } : {}),
|
||||
},
|
||||
include: PROJECT_PLANNING_ASSIGNMENT_INCLUDE,
|
||||
orderBy: [{ startDate: "asc" }, { resourceId: "asc" }],
|
||||
}),
|
||||
]);
|
||||
|
||||
return buildSplitAllocationReadModel({ demandRequirements, assignments });
|
||||
}
|
||||
|
||||
async function loadProjectShiftContext(db: ShiftDbClient, projectId: string) {
|
||||
const [project, planningRead] = await Promise.all([
|
||||
db.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: {
|
||||
id: true,
|
||||
budgetCents: true,
|
||||
winProbability: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
},
|
||||
}),
|
||||
loadProjectPlanningReadModel(db, { projectId, activeOnly: true }),
|
||||
]);
|
||||
|
||||
if (!project) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
||||
}
|
||||
|
||||
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 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(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const readModel = await loadTimelineEntriesReadModel(ctx.db, input);
|
||||
return readModel.allocations;
|
||||
}),
|
||||
|
||||
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(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => loadTimelineEntriesReadModel(ctx.db, input)),
|
||||
|
||||
/**
|
||||
* 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([
|
||||
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,
|
||||
},
|
||||
}),
|
||||
loadProjectPlanningReadModel(ctx.db, {
|
||||
projectId: input.projectId,
|
||||
activeOnly: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!project) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
||||
}
|
||||
|
||||
const resourceIds = getAssignmentResourceIds(planningRead.readModel);
|
||||
const allResourceAllocations =
|
||||
resourceIds.length === 0
|
||||
? []
|
||||
: await listAssignmentBookings(ctx.db, {
|
||||
resourceIds,
|
||||
});
|
||||
|
||||
return {
|
||||
project,
|
||||
allocations: planningRead.readModel.allocations,
|
||||
demands: planningRead.readModel.demands,
|
||||
assignments: planningRead.readModel.assignments,
|
||||
allResourceAllocations,
|
||||
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 approved vacations for recalculation (graceful fallback if table not yet migrated)
|
||||
const vacationDates: Date[] = [];
|
||||
try {
|
||||
const vacations = await ctx.db.vacation.findMany({
|
||||
where: {
|
||||
resourceId: resolved.resourceId,
|
||||
status: "APPROVED",
|
||||
startDate: { lte: newEndDate },
|
||||
endDate: { gte: newStartDate },
|
||||
},
|
||||
select: { startDate: true, endDate: 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);
|
||||
while (cur <= vEnd) {
|
||||
vacationDates.push(new Date(cur));
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// vacation table may not exist yet — proceed without vacation adjustment
|
||||
}
|
||||
|
||||
newDailyCostCents = calculateAllocation({
|
||||
lcrCents: existingResource.lcrCents,
|
||||
hoursPerDay: newHoursPerDay,
|
||||
startDate: newStartDate,
|
||||
endDate: newEndDate,
|
||||
availability,
|
||||
includeSaturday,
|
||||
...(recurrence ? { recurrence } : {}),
|
||||
vacationDates,
|
||||
}).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(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
// 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 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,
|
||||
}).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;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get budget status for a project.
|
||||
*/
|
||||
getBudgetStatus: protectedProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const project = await ctx.db.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: {
|
||||
id: true,
|
||||
budgetCents: true,
|
||||
winProbability: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
||||
}
|
||||
|
||||
const bookings = await listAssignmentBookings(ctx.db, {
|
||||
startDate: project.startDate,
|
||||
endDate: project.endDate,
|
||||
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,
|
||||
);
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user