feat: Nearshore-Ratio indicator per project

Engine (packages/engine):
- calculateShoringRatio() pure function: onshore/offshore hours,
  country breakdown, threshold check, weighted by hours not headcount
- 12 unit tests: empty, 100% onshore/offshore, mixed ratios,
  custom threshold, case-insensitive, unknown country, FTE weighting

Schema:
- Project.shoringThreshold (default 55%) — per-project configurable
- Project.onshoreCountryCode (default "DE") — configurable onshore country

API (project router):
- getShoringRatio query: loads assignments with resource.country,
  computes ratio, returns full breakdown
- update mutation: accepts shoringThreshold + onshoreCountryCode

UI:
- ShoringIndicator: stacked horizontal bar with country segments,
  severity badge (green/yellow/red), hover tooltip, dark theme
- ShoringBadge: mini colored dot + % for project list column
- ProjectModal: "Max Offshore %" number input
- Project detail: indicator after budget status card
- Project list: "Shoring" column (default hidden, toggleable)

AI Assistant:
- get_shoring_ratio tool: human-readable breakdown with threshold alert

Colors: green (<threshold-10), yellow (threshold-10 to threshold), red (>=threshold)
Default: 55% offshore threshold, "DE" as onshore country

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-26 11:45:50 +01:00
parent a9107add7b
commit 92a982b151
13 changed files with 721 additions and 42 deletions
+46
View File
@@ -5,6 +5,7 @@ import {
import { BlueprintTarget, CreateProjectSchema, FieldType, PermissionKey, ProjectStatus, UpdateProjectSchema } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { calculateShoringRatio, type ShoringAssignment } from "@planarchy/engine/allocation";
import { findUniqueOrThrow } from "../db/helpers.js";
import { paginate, paginateCursor, PaginationInputSchema, CursorInputSchema } from "../db/pagination.js";
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
@@ -106,6 +107,49 @@ export const projectRouter = createTRPCRouter({
};
}),
getShoringRatio: 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,
name: true,
shoringThreshold: true,
onshoreCountryCode: true,
},
});
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
const assignments = await ctx.db.assignment.findMany({
where: { projectId: input.projectId, status: { not: "CANCELLED" } },
include: { resource: { include: { country: { select: { code: true } } } } },
});
const mapped: ShoringAssignment[] = assignments.map((a) => {
const start = new Date(a.startDate);
const end = new Date(a.endDate);
const diffMs = end.getTime() - start.getTime();
const diffDays = Math.max(1, Math.round(diffMs / (1000 * 60 * 60 * 24)) + 1);
const workingDays = Math.round(diffDays / 7 * 5);
return {
resourceId: a.resourceId,
countryCode: a.resource.country?.code ?? null,
hoursPerDay: a.hoursPerDay,
workingDays: Math.max(1, workingDays),
};
});
return calculateShoringRatio(
mapped,
project.shoringThreshold ?? 55,
project.onshoreCountryCode ?? "DE",
);
}),
create: managerProcedure
.input(CreateProjectSchema)
.mutation(async ({ ctx, input }) => {
@@ -205,6 +249,8 @@ export const projectRouter = createTRPCRouter({
...(input.data.blueprintId !== undefined ? { blueprintId: input.data.blueprintId } : {}),
...(input.data.utilizationCategoryId !== undefined ? { utilizationCategoryId: input.data.utilizationCategoryId || null } : {}),
...(input.data.clientId !== undefined ? { clientId: input.data.clientId || null } : {}),
...(input.data.shoringThreshold !== undefined ? { shoringThreshold: input.data.shoringThreshold } : {}),
...(input.data.onshoreCountryCode !== undefined ? { onshoreCountryCode: input.data.onshoreCountryCode } : {}),
} as unknown as Parameters<typeof ctx.db.project.update>[0]["data"],
});