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
@@ -27,6 +27,7 @@ import { useViewPrefs } from "~/hooks/useViewPrefs.js";
import { useRowOrder } from "~/hooks/useRowOrder.js";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js";
import { ShoringBadge } from "~/components/projects/ShoringIndicator.js";
import { PROJECT_STATUS_BADGE as STATUS_COLORS, ORDER_TYPE_BADGE as ORDER_TYPE_COLORS } from "~/lib/status-styles.js";
@@ -453,6 +454,12 @@ export function ProjectsClient() {
)}
</td>
);
case "shoring":
return (
<td key={col.key} className="px-4 py-3 text-center">
<ShoringBadge projectId={project.id} />
</td>
);
case "responsible":
return <td key={col.key} className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400"></td>;
default:
@@ -10,6 +10,7 @@ import { ProjectAssignmentsTable } from "~/components/projects/ProjectAssignment
import { PROJECT_STATUS_BADGE as STATUS_COLORS, ORDER_TYPE_BADGE as ORDER_TYPE_COLORS } from "~/lib/status-styles.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { CoverArtSection } from "~/components/projects/CoverArtSection.js";
import { ShoringIndicator } from "~/components/projects/ShoringIndicator.js";
const EDIT_ROLES = new Set(["ADMIN", "MANAGER"]);
@@ -133,6 +134,9 @@ export default async function ProjectDetailPage({ params }: ProjectDetailPagePro
{/* Budget status card (client component) */}
<BudgetStatusCard projectId={project.id} />
{/* Nearshore ratio indicator (client component) */}
<ShoringIndicator projectId={project.id} />
{/* Assignments table (client component with delete action) */}
<ProjectAssignmentsTable assignments={project.assignments as never} />
@@ -50,6 +50,7 @@ interface FormState {
color: string;
utilizationCategoryId: string;
clientId: string;
shoringThreshold: string;
}
function getDefaultForm(): FormState {
@@ -68,6 +69,7 @@ function getDefaultForm(): FormState {
color: "",
utilizationCategoryId: "",
clientId: "",
shoringThreshold: "55",
};
}
@@ -86,6 +88,7 @@ function projectToForm(project: Project): FormState {
color: (project as unknown as { color?: string | null }).color ?? "",
utilizationCategoryId: (project as unknown as { utilizationCategoryId?: string | null }).utilizationCategoryId ?? "",
clientId: (project as unknown as { clientId?: string | null }).clientId ?? "",
shoringThreshold: String((project as unknown as { shoringThreshold?: number | null }).shoringThreshold ?? 55),
};
}
@@ -208,6 +211,7 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
...(form.color ? { color: form.color } : {}),
...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}),
...(form.clientId ? { clientId: form.clientId } : {}),
shoringThreshold: Number(form.shoringThreshold),
},
});
} else {
@@ -227,6 +231,7 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
...(form.color ? { color: form.color } : {}),
...(form.utilizationCategoryId ? { utilizationCategoryId: form.utilizationCategoryId } : {}),
...(form.clientId ? { clientId: form.clientId } : {}),
shoringThreshold: Number(form.shoringThreshold),
});
}
}
@@ -441,7 +446,7 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
<legend className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Timeline &amp; Budget
</legend>
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-4 gap-4">
<div>
<label className={labelClass} htmlFor="startDate">
Start Date
@@ -492,6 +497,22 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{errors.budgetEur}</p>
)}
</div>
<div>
<label className={labelClass} htmlFor="shoringThreshold">
Max Offshore %
<InfoTooltip content="Maximum allowed offshore staffing percentage (0-100). Triggers a warning when exceeded. Default: 55%." />
</label>
<input
id="shoringThreshold"
type="number"
min={0}
max={100}
value={form.shoringThreshold}
onChange={(e) => setField("shoringThreshold", e.target.value)}
placeholder="55"
className={inputClass}
/>
</div>
</div>
</fieldset>
@@ -0,0 +1,199 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
// Stable country colors — deterministic from code
const COUNTRY_COLORS: Record<string, string> = {
DE: "#3b82f6",
ES: "#f59e0b",
IN: "#10b981",
PL: "#ef4444",
PT: "#8b5cf6",
RO: "#ec4899",
CZ: "#06b6d4",
HU: "#f97316",
BG: "#14b8a6",
US: "#6366f1",
UK: "#a855f7",
FR: "#84cc16",
IT: "#e11d48",
UNKNOWN: "#9ca3af",
};
function getCountryColor(code: string): string {
if (COUNTRY_COLORS[code]) return COUNTRY_COLORS[code];
// Deterministic fallback based on char codes
let hash = 0;
for (let i = 0; i < code.length; i++) {
hash = code.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = Math.abs(hash) % 360;
return `hsl(${hue}, 65%, 50%)`;
}
function getSeverity(offshoreRatio: number, threshold: number): "green" | "yellow" | "red" {
if (offshoreRatio >= threshold) return "red";
if (offshoreRatio >= threshold - 10) return "yellow";
return "green";
}
const SEVERITY_BADGE: Record<string, string> = {
green: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300",
yellow: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300",
red: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
};
const SEVERITY_DOT: Record<string, string> = {
green: "bg-green-500",
yellow: "bg-yellow-500",
red: "bg-red-500",
};
// ─── Mini badge for list views ────────────────────────────────────────────────
export function ShoringBadge({ projectId }: { projectId: string }) {
const { data, isLoading } = trpc.project.getShoringRatio.useQuery(
{ projectId },
{ staleTime: 60_000 },
);
if (isLoading) {
return <span className="inline-block h-4 w-12 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />;
}
if (!data || data.totalHours === 0) {
return <span className="text-xs text-gray-400 dark:text-gray-500">--</span>;
}
const severity = getSeverity(data.offshoreRatio, data.threshold);
return (
<span className="inline-flex items-center gap-1.5">
<span className={`h-2 w-2 rounded-full ${SEVERITY_DOT[severity]}`} />
<span className="text-xs text-gray-700 dark:text-gray-300">{data.offshoreRatio}%</span>
</span>
);
}
// ─── Full indicator for detail views ──────────────────────────────────────────
export function ShoringIndicator({ projectId }: { projectId: string }) {
const [tooltipOpen, setTooltipOpen] = useState(false);
const { data, isLoading } = trpc.project.getShoringRatio.useQuery(
{ projectId },
{ staleTime: 30_000 },
);
if (isLoading) {
return (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-5">
<div className="space-y-3 animate-pulse">
<div className="h-4 w-32 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-6 w-full rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-48 rounded bg-gray-200 dark:bg-gray-700" />
</div>
</div>
);
}
if (!data || data.totalHours === 0) {
return (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-5">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">
Nearshore Ratio
</h3>
<p className="text-sm text-gray-400 dark:text-gray-500">No assignments</p>
</div>
);
}
const severity = getSeverity(data.offshoreRatio, data.threshold);
// Build sorted country segments for the bar
const segments = Object.entries(data.byCountry)
.filter(([code]) => code !== "UNKNOWN")
.sort((a, b) => b[1].pct - a[1].pct);
if (data.byCountry["UNKNOWN"]) {
segments.push(["UNKNOWN", data.byCountry["UNKNOWN"]]);
}
return (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Nearshore Ratio
</h3>
<span className={`inline-block rounded-full px-2.5 py-0.5 text-xs font-medium ${SEVERITY_BADGE[severity]}`}>
{data.offshoreRatio}% offshore
{severity === "red" ? ` — Above ${data.threshold}% limit` : ""}
</span>
</div>
{/* Stacked horizontal bar */}
<div
className="relative h-7 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800 cursor-pointer"
onMouseEnter={() => setTooltipOpen(true)}
onMouseLeave={() => setTooltipOpen(false)}
>
<div className="absolute inset-0 flex">
{segments.map(([code, info]) => (
<div
key={code}
className="h-full flex items-center justify-center text-[10px] font-semibold text-white transition-all duration-500 first:rounded-l-full last:rounded-r-full"
style={{
width: `${info.pct}%`,
backgroundColor: getCountryColor(code),
minWidth: info.pct > 0 ? "2px" : "0",
}}
>
{info.pct > 10 ? `${code} ${info.pct}%` : ""}
</div>
))}
</div>
{/* Tooltip overlay */}
{tooltipOpen && (
<div className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 z-10 min-w-[200px] rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-3 shadow-xl text-xs">
<div className="font-semibold text-gray-900 dark:text-gray-100 mb-2">
Country Breakdown
</div>
<div className="space-y-1.5">
{segments.map(([code, info]) => (
<div key={code} className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span
className="inline-block h-2.5 w-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: getCountryColor(code) }}
/>
<span className="text-gray-700 dark:text-gray-300">
{code === "UNKNOWN" ? "Unknown" : code}
</span>
</div>
<div className="text-gray-500 dark:text-gray-400 tabular-nums">
{info.pct}% ({info.resourceCount} {info.resourceCount === 1 ? "person" : "people"})
</div>
</div>
))}
</div>
{data.unknownCount > 0 && (
<div className="mt-2 pt-2 border-t border-gray-100 dark:border-gray-800 text-gray-400 dark:text-gray-500">
{data.unknownCount} resource{data.unknownCount !== 1 ? "s" : ""} without country
</div>
)}
</div>
)}
</div>
{/* Summary text */}
<div className="mt-2 flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
<span>{data.onshoreRatio}% onshore ({data.onshoreCountryCode})</span>
<span className="text-gray-300 dark:text-gray-600">|</span>
<span>{data.offshoreRatio}% offshore</span>
<span className="text-gray-300 dark:text-gray-600">|</span>
<span>Threshold: {data.threshold}%</span>
</div>
</div>
);
}
@@ -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 ───────────────────────────────────────────────────────────────
+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"],
});
+3 -1
View File
@@ -812,7 +812,9 @@ model Project {
startDate DateTime @db.Date
endDate DateTime @db.Date
status ProjectStatus @default(DRAFT)
responsiblePerson String?
responsiblePerson String?
shoringThreshold Int? @default(55) // Max offshore % before alert (0-100)
onshoreCountryCode String? @default("DE") // Country code considered "onshore"
color String? // Hex color for timeline display, e.g. "#3b82f6"
coverImageUrl String? @db.Text // Base64 data-URL for project cover art
coverFocusY Int @default(50) // Vertical focus point 0-100 (% from top)
@@ -0,0 +1,118 @@
import { describe, it, expect } from "vitest";
import { calculateShoringRatio, type ShoringAssignment } from "../allocation/shoring-ratio.js";
function make(resourceId: string, countryCode: string | null, hoursPerDay = 8, workingDays = 20): ShoringAssignment {
return { resourceId, countryCode, hoursPerDay, workingDays };
}
describe("calculateShoringRatio", () => {
it("returns zeros for empty assignments", () => {
const r = calculateShoringRatio([]);
expect(r.totalHours).toBe(0);
expect(r.offshoreRatio).toBe(0);
expect(r.isAboveThreshold).toBe(false);
});
it("100% German → 0% offshore", () => {
const r = calculateShoringRatio([make("r1", "DE"), make("r2", "DE")]);
expect(r.onshoreRatio).toBe(100);
expect(r.offshoreRatio).toBe(0);
expect(r.isAboveThreshold).toBe(false);
});
it("100% offshore → 100% offshore, above threshold", () => {
const r = calculateShoringRatio([make("r1", "ES"), make("r2", "IN")]);
expect(r.offshoreRatio).toBe(100);
expect(r.isAboveThreshold).toBe(true);
});
it("50/50 mix → 50% offshore, below 55% threshold", () => {
const r = calculateShoringRatio([make("r1", "DE"), make("r2", "ES")]);
expect(r.offshoreRatio).toBe(50);
expect(r.isAboveThreshold).toBe(false);
});
it("40% onshore / 60% offshore → above 55% threshold", () => {
const assignments = [
make("r1", "DE", 8, 20), // 160h
make("r2", "DE", 8, 20), // 160h → 320h onshore
make("r3", "ES", 8, 20), // 160h
make("r4", "IN", 8, 20), // 160h
make("r5", "IN", 8, 20), // 160h → 480h offshore
];
const r = calculateShoringRatio(assignments);
expect(r.offshoreRatio).toBe(60);
expect(r.isAboveThreshold).toBe(true);
});
it("custom threshold: 30% → 40% offshore is above", () => {
const assignments = [
make("r1", "DE", 8, 20), // 160h onshore
make("r2", "DE", 8, 20), // 160h onshore → 320h
make("r3", "ES", 4, 20), // 80h offshore
make("r4", "IN", 4, 20), // 80h offshore → 160h
];
// Total 480h, offshore 160h = 33%
const r = calculateShoringRatio(assignments, 30);
expect(r.offshoreRatio).toBe(33);
expect(r.isAboveThreshold).toBe(true);
});
it("resource without country counts as offshore", () => {
const r = calculateShoringRatio([make("r1", "DE"), make("r2", null)]);
expect(r.offshoreRatio).toBe(50);
expect(r.unknownCount).toBe(1);
expect(r.byCountry["UNKNOWN"]).toBeDefined();
});
it("country code is case-insensitive", () => {
const r = calculateShoringRatio([make("r1", "de"), make("r2", "De")]);
expect(r.onshoreRatio).toBe(100);
expect(r.offshoreRatio).toBe(0);
});
it("custom onshore country code", () => {
const r = calculateShoringRatio([make("r1", "ES"), make("r2", "DE")], 55, "ES");
expect(r.onshoreCountryCode).toBe("ES");
expect(r.onshoreHours).toBe(160); // r1 is onshore
expect(r.offshoreHours).toBe(160); // r2 is offshore (DE is not onshore here)
});
it("byCountry breakdown is correct", () => {
const r = calculateShoringRatio([
make("r1", "DE", 8, 10),
make("r2", "ES", 8, 10),
make("r3", "ES", 4, 10),
make("r4", "IN", 8, 10),
]);
expect(r.byCountry["DE"].resourceCount).toBe(1);
expect(r.byCountry["ES"].resourceCount).toBe(2);
expect(r.byCountry["IN"].resourceCount).toBe(1);
expect(r.byCountry["DE"].hours).toBe(80);
expect(r.byCountry["ES"].hours).toBe(120);
expect(r.byCountry["IN"].hours).toBe(80);
});
it("exactly at threshold is above", () => {
// 55% offshore = at threshold = isAboveThreshold true (>= not >)
// Need 55/100 = 11/20 ratio
const assignments = [
make("r1", "DE", 9, 20), // 180h onshore
make("r2", "ES", 11, 20), // 220h offshore
];
// Total 400h, offshore 220h = 55%
const r = calculateShoringRatio(assignments);
expect(r.offshoreRatio).toBe(55);
expect(r.isAboveThreshold).toBe(true);
});
it("weighted by hours — FTE matters", () => {
const r = calculateShoringRatio([
make("r1", "DE", 8, 20), // 160h full-time onshore
make("r2", "ES", 2, 20), // 40h part-time offshore
]);
// Total 200h, offshore 40h = 20%
expect(r.offshoreRatio).toBe(20);
expect(r.isAboveThreshold).toBe(false);
});
});
+1
View File
@@ -3,3 +3,4 @@ export * from "./availability-validator.js";
export * from "./recurrence.js";
export * from "./chargeability.js";
export * from "./duplicate-check.js";
export * from "./shoring-ratio.js";
@@ -0,0 +1,115 @@
/**
* Calculate the onshore/offshore staffing ratio for a project.
* "Onshore" = resources in the configured country (default: DE).
* "Offshore" = everything else (including resources without a country).
*/
export interface ShoringAssignment {
resourceId: string;
countryCode: string | null;
hoursPerDay: number;
workingDays: number;
}
export interface ShoringCountryBreakdown {
hours: number;
pct: number;
resourceCount: number;
}
export interface ShoringResult {
totalHours: number;
onshoreHours: number;
offshoreHours: number;
onshoreRatio: number; // 0-100
offshoreRatio: number; // 0-100
threshold: number;
isAboveThreshold: boolean;
onshoreCountryCode: string;
/** Breakdown per country code ("DE" → { hours, pct, resourceCount }) */
byCountry: Record<string, ShoringCountryBreakdown>;
/** Resources with no country assigned */
unknownCount: number;
}
/**
* @param assignments - All active assignments for a project with their resource's country
* @param threshold - Max offshore percentage before alert (default 55)
* @param onshoreCountryCode - Country code considered "onshore" (default "DE")
*/
export function calculateShoringRatio(
assignments: ShoringAssignment[],
threshold = 55,
onshoreCountryCode = "DE",
): ShoringResult {
if (assignments.length === 0) {
return {
totalHours: 0,
onshoreHours: 0,
offshoreHours: 0,
onshoreRatio: 0,
offshoreRatio: 0,
threshold,
isAboveThreshold: false,
onshoreCountryCode,
byCountry: {},
unknownCount: 0,
};
}
const byCountry: Record<string, { hours: number; resources: Set<string> }> = {};
let totalHours = 0;
let onshoreHours = 0;
let unknownCount = 0;
const unknownResources = new Set<string>();
for (const a of assignments) {
const hours = a.hoursPerDay * a.workingDays;
totalHours += hours;
const code = a.countryCode?.toUpperCase() ?? "UNKNOWN";
if (code === "UNKNOWN") {
if (!unknownResources.has(a.resourceId)) {
unknownCount++;
unknownResources.add(a.resourceId);
}
}
if (!byCountry[code]) {
byCountry[code] = { hours: 0, resources: new Set() };
}
byCountry[code].hours += hours;
byCountry[code].resources.add(a.resourceId);
if (code === onshoreCountryCode.toUpperCase()) {
onshoreHours += hours;
}
}
const offshoreHours = totalHours - onshoreHours;
const offshoreRatio = totalHours > 0 ? Math.round((offshoreHours / totalHours) * 100) : 0;
const onshoreRatio = totalHours > 0 ? 100 - offshoreRatio : 0;
const result: Record<string, ShoringCountryBreakdown> = {};
for (const [code, data] of Object.entries(byCountry)) {
result[code] = {
hours: data.hours,
pct: totalHours > 0 ? Math.round((data.hours / totalHours) * 100) : 0,
resourceCount: data.resources.size,
};
}
return {
totalHours,
onshoreHours,
offshoreHours,
onshoreRatio,
offshoreRatio,
threshold,
isAboveThreshold: offshoreRatio >= threshold,
onshoreCountryCode: onshoreCountryCode.toUpperCase(),
byCountry: result,
unknownCount,
};
}
+1
View File
@@ -21,6 +21,7 @@ export const PROJECT_COLUMNS: ColumnDef[] = [
{ key: "dates", label: "Dates", defaultVisible: true, hideable: true },
{ key: "budget", label: "Budget", defaultVisible: false, hideable: true },
{ key: "allocations", label: "Allocations", defaultVisible: true, hideable: true },
{ key: "shoring", label: "Shoring", defaultVisible: false, hideable: true },
{ key: "responsible", label: "Responsible", defaultVisible: false, hideable: true },
];
@@ -33,6 +33,8 @@ export const CreateProjectBaseSchema = z.object({
utilizationCategoryId: z.string().optional(),
clientId: z.string().optional(),
coverImageUrl: z.string().optional(),
shoringThreshold: z.number().int().min(0).max(100).optional(),
onshoreCountryCode: z.string().min(2).max(3).optional(),
});
// Full schema with date-range validation
+143 -40
View File
@@ -1,19 +1,17 @@
# Admin Set Password — Plan
# Nearshore-Ratio Indikator — Plan
## Anforderungsanalyse
**Was:** Admins sollen im User-Management das Passwort fuer beliebige User setzen/zuruecksetzen koennen.
**Was:** Ein Nearshore/Offshore-Ratio Indikator pro Projekt, der zeigt wieviel % der gebuchten Ressourcen Standort Deutschland vs. andere Standorte haben.
**Ist-Zustand:**
- `user.create` Mutation hat Passwort-Support (Argon2 Hashing via `@node-rs/argon2`)
- Kein `setPassword` oder `resetPassword` Mutation fuer bestehende User
- UsersClient hat Passwort nur im Create-Formular, nicht im Edit-Bereich
**Kernkonzept:**
- **Onshore** = Resource hat `countryId` → Country mit Code "DE" (Deutschland)
- **Offshore/Nearshore** = Resource hat `countryId` → Country mit Code != "DE" (oder kein Country gesetzt)
- **Shoring-Ratio** = (Offshore-Stunden / Gesamt-Stunden) * 100
- **Threshold** = Default 55%, pro Projekt aenderbar
- **Anzeige** = Farbiger Indikator (gruen < Threshold, rot >= Threshold)
**Soll-Zustand:**
- Neuer `setPassword` adminProcedure im user Router
- "Set Password" Button pro User im Admin-UI
- Passwort-Modal mit Eingabe + Bestaetigung
- Audit-Log Eintrag bei Passwort-Aenderung (ohne Passwort-Wert!)
**Datenquelle:** Assignments eines Projekts → Resource.countryId → Country.code
---
@@ -21,54 +19,159 @@
| Paket | Dateien | Art der Aenderung |
|-------|---------|------------------|
| `packages/api` | `src/router/user.ts` | **edit**`setPassword` Mutation hinzufuegen |
| `apps/web` | `src/components/admin/UsersClient.tsx` | **edit**"Set Password" Button + Modal |
| `packages/db` | `prisma/schema.prisma` | **edit**`shoringThreshold Int?` auf Project |
| `packages/engine` | `src/allocation/shoring-ratio.ts` | **create**Pure Berechnung |
| `packages/engine` | `src/__tests__/shoring-ratio.test.ts` | **create** — Unit Tests |
| `packages/api` | `src/router/project.ts` | **edit**`getShoringRatio` Query + Threshold im Update |
| `packages/api` | `src/router/assistant-tools.ts` | **edit**`get_shoring_ratio` AI Tool |
| `apps/web` | `src/components/projects/ShoringIndicator.tsx` | **create** — Visueller Indikator |
| `apps/web` | `src/app/(app)/projects/[id]/page.tsx` | **edit** — Indikator auf Detail-Seite |
| `apps/web` | `src/app/(app)/projects/ProjectsClient.tsx` | **edit** — Indikator-Spalte in Projektliste |
| `apps/web` | `src/components/projects/ProjectModal.tsx` | **edit** — Threshold-Feld im Edit |
| `apps/web` | `src/components/dashboard/widgets/ProjectHealthWidget.tsx` | **edit** — Optional: Shoring in Health-Score |
---
## Task-Liste
- [ ] **Task 1:** `setPassword` Mutation → `packages/api/src/router/user.ts`
- Input: `{ userId: string, password: string }` (min 8 Zeichen)
- adminProcedure (nur Admins duerfen Passwoerter setzen)
- Hash mit `@node-rs/argon2` (gleiches Pattern wie `create`)
- `db.user.update({ where: { id }, data: { passwordHash } })`
- Audit-Log: `createAuditEntry({ entityType: "User", action: "UPDATE", summary: "Password reset by admin" })`
- KEIN Passwort-Wert im Audit-Log (Sicherheit!)
### Phase 1: Schema + Engine (sequenziell)
- [ ] **Task 2:** UI — "Set Password" Button + Modal → `UsersClient.tsx`
- Pro User-Zeile: "Set Password" Button (Schloss-Icon)
- Klick oeffnet AnimatedModal mit:
- User-Name als Titel
- Neues Passwort Input (min 8 Zeichen)
- Passwort bestaetigen Input
- Validierung: Passwoerter muessen uebereinstimmen
- Submit-Button (disabled wenn <8 Zeichen oder nicht matching)
- Success: Toast "Password updated", Modal schliessen
- Error: Fehlermeldung anzeigen
- [ ] **Task 1:** Schema erweitern → `packages/db/prisma/schema.prisma`
- `shoringThreshold Int? @default(55)` auf dem Project Model
- Repraesntiert den kritischen Shoring-Prozentsatz (Default 55%)
- `prisma db push`
- [ ] **Task 2:** Pure Shoring-Ratio Berechnung → `packages/engine/src/allocation/shoring-ratio.ts`
```typescript
interface ShoringInput {
resourceId: string;
countryCode: string | null; // "DE", "ES", "IN", etc.
hoursPerDay: number;
workingDays: number; // oder totalHours direkt
}
interface ShoringResult {
totalHours: number;
onshoreHours: number; // countryCode === "DE"
offshoreHours: number; // countryCode !== "DE"
offshoreRatio: number; // 0-100 (%)
onshoreRatio: number; // 0-100 (%)
threshold: number; // Default 55
isAboveThreshold: boolean;
byCountry: Record<string, { hours: number; pct: number; resourceCount: number }>;
}
export function calculateShoringRatio(
assignments: ShoringInput[],
threshold?: number, // Default 55
onshoreCountryCode?: string, // Default "DE"
): ShoringResult
```
- [ ] **Task 3:** Unit Tests → `packages/engine/src/__tests__/shoring-ratio.test.ts`
- 100% Deutsche Ressourcen → offshoreRatio = 0, isAboveThreshold = false
- 100% Offshore → offshoreRatio = 100, isAboveThreshold = true
- 50/50 Mix → offshoreRatio = 50, isAboveThreshold = false (< 55)
- 60/40 Mix → offshoreRatio = 60, isAboveThreshold = true (> 55)
- Custom Threshold (30%) → 40% Offshore = above threshold
- Leere Assignments → offshoreRatio = 0
- Resource ohne Country → zaehlt als Offshore
- [ ] **Task 4:** Export → `packages/engine/src/allocation/index.ts`
### Phase 2: API + UI (parallel)
- [ ] **Task 5:** API Query → `packages/api/src/router/project.ts`
- `getShoringRatio` Query (protectedProcedure):
- Input: `{ projectId: string }`
- Laedt Assignments mit Resource.country
- Ruft `calculateShoringRatio()` auf
- Returns: `ShoringResult`
- `update` Mutation erweitern: `shoringThreshold` im Input akzeptieren
- `getById` erweitern: `shoringThreshold` im Response zurueckgeben
- [ ] **Task 6:** Visueller Indikator → `ShoringIndicator.tsx`
- Kompakte Anzeige:
```
[====DE 45%====|==ES 30%==|IN 25%] ⚠ 55% offshore (Limit: 55%)
```
- Stacked horizontal bar: Laender-Segmente farbig
- Gruen/Gelb/Rot Badge je nach Threshold
- Gruen: < Threshold - 10pp
- Gelb: Threshold - 10pp bis Threshold
- Rot: >= Threshold
- Tooltip: Aufschluesselung nach Land
- Props: `projectId: string` (laedt Daten selbst via tRPC)
- [ ] **Task 7:** Threshold im ProjectModal → `ProjectModal.tsx`
- Neues Feld: "Max Offshore %" (Number Input, 0-100, Default 55)
- Unter "Budget" oder als eigene Section "Shoring"
- Label: "Maximum Offshore/Nearshore Ratio (%)"
- Hinweis: "Alert when offshore staffing exceeds this percentage"
- [ ] **Task 8:** Indikator auf Projekt-Detail-Seite → `projects/[id]/page.tsx`
- `<ShoringIndicator projectId={id} />` neben dem Budget-Status
- Oder als eigene Karte unter den Budget-Karten
- [ ] **Task 9:** Indikator-Spalte in Projektliste → `ProjectsClient.tsx`
- Neue Spalte "Shoring" mit Mini-Indikator (nur Badge gruen/gelb/rot + %)
- InfoTooltip: "Offshore staffing ratio vs project threshold"
### Phase 3: AI + Dashboard (parallel)
- [ ] **Task 10:** AI Assistant Tool → `assistant-tools.ts`
- `get_shoring_ratio` Tool:
- Input: `{ projectId: string }`
- Returns: menschenlesbare Aufschluesselung
- "Project X: 45% onshore (DE), 55% offshore (ES 30%, IN 25%). ⚠ Above 55% threshold."
- [ ] **Task 11:** Optional: Dashboard Widget oder ProjectHealth Integration
- Shoring-Warnung in ProjectHealthWidget einbauen
- Oder neues Mini-Widget "Shoring Alerts" (Projekte ueber Threshold)
---
## Abhaengigkeiten
- Task 1 muss vor Task 2 (API benoetigt fuer UI)
- Beide Tasks koennen in einer Sequenz implementiert werden (gleicher Agent)
```
Task 1 (Schema) → Task 2 (Engine) → Task 3 (Tests) → Task 4 (Export)
Task 4 → Task 5 (API)
Task 5 → Task 6 + Task 7 + Task 8 + Task 9 (alle parallel)
Task 5 → Task 10 (AI Tool)
Task 5 → Task 11 (Dashboard)
```
- Tasks 6-9 **vollstaendig parallel** (verschiedene Dateien)
- Tasks 10-11 parallel zu Tasks 6-9
---
## Akzeptanzkriterien
- [ ] `pnpm test:unit` laeuft gruen (inkl. neue shoring-ratio Tests)
- [ ] `pnpm --filter @planarchy/web exec tsc --noEmit` — keine neuen Errors
- [ ] Admin kann Passwort fuer beliebigen User setzen
- [ ] Passwort wird mit Argon2 gehasht (nicht plaintext gespeichert)
- [ ] Audit-Log Eintrag wird erstellt (ohne Passwort-Wert)
- [ ] Min 8 Zeichen Validierung im UI und API
- [ ] Passwort-Bestaetigung muss uebereinstimmen
- [ ] **Projekt-Detail:** Shoring-Indikator sichtbar mit Laender-Aufschluesselung
- [ ] **Projektliste:** Shoring-Badge-Spalte (gruen/gelb/rot)
- [ ] **Projekt-Edit:** Threshold aenderbar (Default 55%)
- [ ] **AI Assistant:** "Wie ist der Shoring-Mix bei Projekt X?" liefert Antwort
- [ ] **Farben:** Gruen < 45%, Gelb 45-55%, Rot >= 55% (bei Default-Threshold)
- [ ] **Resource ohne Country:** Zaehlt als Offshore
- [ ] **Projekt ohne Assignments:** Zeigt "No data" statt Fehler
---
## Risiken & offene Fragen
- **Sicherheit:** Nur ADMIN-Rolle darf Passwoerter setzen (adminProcedure)
- **Audit:** Passwort-Wert DARF NICHT im Audit-Log erscheinen
- **UX:** Soll der User benachrichtigt werden? → Vorschlag: Nein, Admin setzt manuell und teilt dem User das Passwort separat mit
### Risiken
- **Country-Daten unvollstaendig:** Viele Resources haben moeglicherweise kein `countryId` gesetzt
→ Mitigation: Zaehlt als Offshore + Warning "X resources have no country assigned"
- **Stunden vs Headcount:** Soll nach Stunden oder Koepfen gemessen werden?
→ Vorschlag: Stunden (gewichtet), da ein 50%-FTE weniger zaehlt als ein 100%-FTE
### Offene Fragen
1. **Nur "DE" als Onshore?** Oder soll der Onshore-Laendercode konfigurierbar sein?
→ Vorschlag: Default "DE", aber konfigurierbar pro Projekt
2. **Welche Assignment-Status zaehlen?** Nur CONFIRMED? Auch PROPOSED?
→ Vorschlag: CONFIRMED + PROPOSED (beide relevant fuer Planung)
3. **Soll der Indikator auch in der Timeline sichtbar sein?**
→ Vorschlag: Erst mal nur Projektliste + Detail-Seite