+
+
+ Nearshore Ratio
+
+
+ {data.offshoreRatio}% offshore
+ {severity === "red" ? ` — Above ${data.threshold}% limit` : ""}
+
+
+
+ {/* Stacked horizontal bar */}
+
setTooltipOpen(true)}
+ onMouseLeave={() => setTooltipOpen(false)}
+ >
+
+ {segments.map(([code, info]) => (
+
0 ? "2px" : "0",
+ }}
+ >
+ {info.pct > 10 ? `${code} ${info.pct}%` : ""}
+
+ ))}
+
+
+ {/* Tooltip overlay */}
+ {tooltipOpen && (
+
+
+ Country Breakdown
+
+
+ {segments.map(([code, info]) => (
+
+
+
+
+ {code === "UNKNOWN" ? "Unknown" : code}
+
+
+
+ {info.pct}% ({info.resourceCount} {info.resourceCount === 1 ? "person" : "people"})
+
+
+ ))}
+
+ {data.unknownCount > 0 && (
+
+ {data.unknownCount} resource{data.unknownCount !== 1 ? "s" : ""} without country
+
+ )}
+
+ )}
+
+
+ {/* Summary text */}
+
+ {data.onshoreRatio}% onshore ({data.onshoreCountryCode})
+ |
+ {data.offshoreRatio}% offshore
+ |
+ Threshold: {data.threshold}%
+
+
+ );
+}
diff --git a/packages/api/src/router/assistant-tools.ts b/packages/api/src/router/assistant-tools.ts
index 8c22452..dd7c233 100644
--- a/packages/api/src/router/assistant-tools.ts
+++ b/packages/api/src/router/assistant-tools.ts
@@ -1385,6 +1385,20 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
},
},
},
+ {
+ type: "function",
+ function: {
+ name: "get_shoring_ratio",
+ description: "Get the onshore/offshore staffing ratio for a project. Shows the percentage of work hours allocated to each country, whether the project exceeds its nearshore threshold, and a full country breakdown.",
+ parameters: {
+ type: "object",
+ properties: {
+ projectId: { type: "string", description: "Project ID or short code" },
+ },
+ required: ["projectId"],
+ },
+ },
+ },
];
// ─── Helpers ────────────────────────────────────────────────────────────────
@@ -5520,6 +5534,52 @@ const executors = {
return `Change history for ${params.entityType} "${entityName}" (${entries.length} entries):\n\n${lines.join("\n")}`;
},
+
+ async get_shoring_ratio(params: { projectId: string }, ctx: ToolContext) {
+ const sel = { id: true, name: true, shortCode: true, shoringThreshold: true, onshoreCountryCode: true } as const;
+ let project = await ctx.db.project.findUnique({ where: { id: params.projectId }, select: sel });
+ if (!project) {
+ project = await ctx.db.project.findUnique({ where: { shortCode: params.projectId }, select: sel });
+ }
+ if (!project) return { error: `Project not found: ${params.projectId}` };
+
+ const assignments = await ctx.db.assignment.findMany({
+ where: { projectId: project.id, status: { not: "CANCELLED" } },
+ include: { resource: { include: { country: { select: { code: true } } } } },
+ });
+
+ if (assignments.length === 0) {
+ return `Project "${project.name}" (${project.shortCode}): No active assignments — shoring ratio not available.`;
+ }
+
+ const { calculateShoringRatio: calcShoring } = await import("@planarchy/engine/allocation");
+
+ const mapped = assignments.map((a) => {
+ const start = new Date(a.startDate);
+ const end = new Date(a.endDate);
+ const diffDays = Math.max(1, Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1);
+ const workingDays = Math.max(1, Math.round(diffDays / 7 * 5));
+ return {
+ resourceId: a.resourceId,
+ countryCode: a.resource.country?.code ?? null,
+ hoursPerDay: a.hoursPerDay,
+ workingDays,
+ };
+ });
+
+ const threshold = project.shoringThreshold ?? 55;
+ const onshoreCode = project.onshoreCountryCode ?? "DE";
+ const result = calcShoring(mapped, threshold, onshoreCode);
+
+ const countryParts = Object.entries(result.byCountry)
+ .sort((a, b) => b[1].pct - a[1].pct)
+ .map(([code, info]) => `${code} ${info.pct}% (${info.resourceCount} people)`)
+ .join(", ");
+
+ const warning = result.isAboveThreshold ? ` -- Above ${threshold}% offshore threshold!` : "";
+
+ return `Project "${project.name}" (${project.shortCode}): ${result.onshoreRatio}% onshore (${onshoreCode}), ${result.offshoreRatio}% offshore. Breakdown: ${countryParts}.${warning}${result.unknownCount > 0 ? ` (${result.unknownCount} resource(s) without country)` : ""}`;
+ },
};
// ─── Executor ───────────────────────────────────────────────────────────────
diff --git a/packages/api/src/router/project.ts b/packages/api/src/router/project.ts
index ec03c2a..c5de384 100644
--- a/packages/api/src/router/project.ts
+++ b/packages/api/src/router/project.ts
@@ -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