chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
+200
View File
@@ -0,0 +1,200 @@
import { analyzeUtilization, findCapacityWindows, rankResources } from "@planarchy/staffing";
import { listAssignmentBookings } from "@planarchy/application";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
export const staffingRouter = createTRPCRouter({
/**
* Get ranked resource suggestions for a staffing requirement.
*/
getSuggestions: protectedProcedure
.input(
z.object({
requiredSkills: z.array(z.string()),
preferredSkills: z.array(z.string()).optional(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
hoursPerDay: z.number().min(0).max(24),
budgetLcrCentsPerHour: z.number().optional(),
chapter: z.string().optional(),
skillCategory: z.string().optional(),
mainSkillsOnly: z.boolean().optional(),
minProficiency: z.number().min(1).max(5).optional(),
}),
)
.query(async ({ ctx, input }) => {
const { requiredSkills, preferredSkills, startDate, endDate, hoursPerDay, budgetLcrCentsPerHour, chapter, skillCategory, mainSkillsOnly, minProficiency } = input;
const resources = await ctx.db.resource.findMany({
where: {
isActive: true,
...(chapter ? { chapter } : {}),
},
});
const bookings = await listAssignmentBookings(ctx.db, {
startDate,
endDate,
resourceIds: resources.map((resource) => resource.id),
});
// Compute utilization percent for each resource in the requested period
const enrichedResources = resources.map((resource) => {
const totalAvailableHours =
(resource.availability as { monday?: number; tuesday?: number; wednesday?: number; thursday?: number; friday?: number }).monday ?? 8;
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
const allocatedHoursPerDay = resourceBookings.reduce(
(sum, a) => sum + a.hoursPerDay,
0,
);
const utilizationPercent =
totalAvailableHours > 0
? Math.min(100, (allocatedHoursPerDay / totalAvailableHours) * 100)
: 0;
const wouldExceedCapacity = allocatedHoursPerDay + hoursPerDay > totalAvailableHours;
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
let skills = resource.skills as unknown as SkillRow[];
// Apply skill filters before matching
if (mainSkillsOnly) skills = skills.filter((s) => s.isMainSkill);
if (skillCategory) skills = skills.filter((s) => s.category === skillCategory);
if (minProficiency) skills = skills.filter((s) => s.proficiency >= minProficiency);
return {
id: resource.id,
displayName: resource.displayName,
eid: resource.eid,
skills: skills as unknown as import("@planarchy/shared").SkillEntry[],
lcrCents: resource.lcrCents,
chargeabilityTarget: resource.chargeabilityTarget,
currentUtilizationPercent: utilizationPercent,
hasAvailabilityConflicts: wouldExceedCapacity,
conflictDays: wouldExceedCapacity ? ["(multiple days)"] : [],
valueScore: resource.valueScore ?? 0,
};
});
const ranked = rankResources({
requiredSkills,
preferredSkills: preferredSkills,
resources: enrichedResources,
budgetLcrCentsPerHour,
} as unknown as Parameters<typeof rankResources>[0]);
// Value-score tiebreaker: within 2 points, prefer higher valueScore
return ranked.sort((a, b) => {
if (Math.abs(a.score - b.score) <= 2) {
const aVal = (enrichedResources.find((r) => r.id === a.resourceId)?.valueScore ?? 0);
const bVal = (enrichedResources.find((r) => r.id === b.resourceId)?.valueScore ?? 0);
return bVal - aVal;
}
return 0;
});
}),
/**
* Analyze utilization for a specific resource over a date range.
*/
analyzeUtilization: protectedProcedure
.input(
z.object({
resourceId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
}),
)
.query(async ({ ctx, input }) => {
const resource = await ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: {
id: true,
displayName: true,
chargeabilityTarget: true,
availability: true,
},
});
if (!resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
const resourceBookings = await listAssignmentBookings(ctx.db, {
startDate: input.startDate,
endDate: input.endDate,
resourceIds: [resource.id],
});
return analyzeUtilization({
resource: {
id: resource.id,
displayName: resource.displayName,
chargeabilityTarget: resource.chargeabilityTarget,
availability: resource.availability as unknown as import("@planarchy/shared").WeekdayAvailability,
},
allocations: resourceBookings.map((booking) => ({
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
status: booking.status,
projectName: booking.project.name,
isChargeable: booking.project.orderType === "CHARGEABLE",
})) as unknown as Parameters<typeof analyzeUtilization>[0]["allocations"],
analysisStart: input.startDate,
analysisEnd: input.endDate,
});
}),
/**
* Find capacity windows for a resource.
*/
findCapacity: protectedProcedure
.input(
z.object({
resourceId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
minAvailableHoursPerDay: z.number().optional().default(4),
}),
)
.query(async ({ ctx, input }) => {
const resource = await ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: {
id: true,
displayName: true,
availability: true,
},
});
if (!resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
const resourceBookings = await listAssignmentBookings(ctx.db, {
startDate: input.startDate,
endDate: input.endDate,
resourceIds: [resource.id],
});
return findCapacityWindows(
{
id: resource.id,
displayName: resource.displayName,
availability: resource.availability as unknown as import("@planarchy/shared").WeekdayAvailability,
},
resourceBookings.map((booking) => ({
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
status: booking.status,
})) as Pick<import("@planarchy/shared").Allocation, "startDate" | "endDate" | "hoursPerDay" | "status">[],
input.startDate,
input.endDate,
input.minAvailableHoursPerDay,
);
}),
});