From 92a982b1515b5291589e6b2a14c3d2ec9ae563c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Thu, 26 Mar 2026 11:45:50 +0100 Subject: [PATCH] feat: Nearshore-Ratio indicator per project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Default: 55% offshore threshold, "DE" as onshore country Co-Authored-By: claude-flow --- .../src/app/(app)/projects/ProjectsClient.tsx | 7 + apps/web/src/app/(app)/projects/[id]/page.tsx | 4 + .../src/components/projects/ProjectModal.tsx | 23 +- .../components/projects/ShoringIndicator.tsx | 199 ++++++++++++++++++ packages/api/src/router/assistant-tools.ts | 60 ++++++ packages/api/src/router/project.ts | 46 ++++ packages/db/prisma/schema.prisma | 4 +- .../src/__tests__/shoring-ratio.test.ts | 118 +++++++++++ packages/engine/src/allocation/index.ts | 1 + .../engine/src/allocation/shoring-ratio.ts | 115 ++++++++++ packages/shared/src/constants/columns.ts | 1 + packages/shared/src/schemas/project.schema.ts | 2 + plan.md | 183 ++++++++++++---- 13 files changed, 721 insertions(+), 42 deletions(-) create mode 100644 apps/web/src/components/projects/ShoringIndicator.tsx create mode 100644 packages/engine/src/__tests__/shoring-ratio.test.ts create mode 100644 packages/engine/src/allocation/shoring-ratio.ts diff --git a/apps/web/src/app/(app)/projects/ProjectsClient.tsx b/apps/web/src/app/(app)/projects/ProjectsClient.tsx index 6f99a20..f05d6e3 100644 --- a/apps/web/src/app/(app)/projects/ProjectsClient.tsx +++ b/apps/web/src/app/(app)/projects/ProjectsClient.tsx @@ -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() { )} ); + case "shoring": + return ( + + + + ); case "responsible": return —; default: diff --git a/apps/web/src/app/(app)/projects/[id]/page.tsx b/apps/web/src/app/(app)/projects/[id]/page.tsx index 38175f1..e1be42d 100644 --- a/apps/web/src/app/(app)/projects/[id]/page.tsx +++ b/apps/web/src/app/(app)/projects/[id]/page.tsx @@ -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) */} + {/* Nearshore ratio indicator (client component) */} + + {/* Assignments table (client component with delete action) */} diff --git a/apps/web/src/components/projects/ProjectModal.tsx b/apps/web/src/components/projects/ProjectModal.tsx index 5f7e891..49c0a41 100644 --- a/apps/web/src/components/projects/ProjectModal.tsx +++ b/apps/web/src/components/projects/ProjectModal.tsx @@ -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) { Timeline & Budget -
+
+
+ + setField("shoringThreshold", e.target.value)} + placeholder="55" + className={inputClass} + /> +
diff --git a/apps/web/src/components/projects/ShoringIndicator.tsx b/apps/web/src/components/projects/ShoringIndicator.tsx new file mode 100644 index 0000000..2579cfe --- /dev/null +++ b/apps/web/src/components/projects/ShoringIndicator.tsx @@ -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 = { + 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 = { + 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 = { + 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 ; + } + + if (!data || data.totalHours === 0) { + return --; + } + + const severity = getSeverity(data.offshoreRatio, data.threshold); + + return ( + + + {data.offshoreRatio}% + + ); +} + +// ─── 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 ( +
+
+
+
+
+
+
+ ); + } + + if (!data || data.totalHours === 0) { + return ( +
+

+ Nearshore Ratio +

+

No assignments

+
+ ); + } + + 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 ( +
+
+

+ 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[0]["data"], }); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index ed25f65..c2c8278 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -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) diff --git a/packages/engine/src/__tests__/shoring-ratio.test.ts b/packages/engine/src/__tests__/shoring-ratio.test.ts new file mode 100644 index 0000000..71a158e --- /dev/null +++ b/packages/engine/src/__tests__/shoring-ratio.test.ts @@ -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); + }); +}); diff --git a/packages/engine/src/allocation/index.ts b/packages/engine/src/allocation/index.ts index efb44dc..94f39e2 100644 --- a/packages/engine/src/allocation/index.ts +++ b/packages/engine/src/allocation/index.ts @@ -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"; diff --git a/packages/engine/src/allocation/shoring-ratio.ts b/packages/engine/src/allocation/shoring-ratio.ts new file mode 100644 index 0000000..b910f6a --- /dev/null +++ b/packages/engine/src/allocation/shoring-ratio.ts @@ -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; + /** 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 }> = {}; + let totalHours = 0; + let onshoreHours = 0; + let unknownCount = 0; + const unknownResources = new Set(); + + 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 = {}; + 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, + }; +} diff --git a/packages/shared/src/constants/columns.ts b/packages/shared/src/constants/columns.ts index 15ee15e..c1226d8 100644 --- a/packages/shared/src/constants/columns.ts +++ b/packages/shared/src/constants/columns.ts @@ -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 }, ]; diff --git a/packages/shared/src/schemas/project.schema.ts b/packages/shared/src/schemas/project.schema.ts index 743024a..0863f3b 100644 --- a/packages/shared/src/schemas/project.schema.ts +++ b/packages/shared/src/schemas/project.schema.ts @@ -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 diff --git a/plan.md b/plan.md index 0074778..a7e7013 100644 --- a/plan.md +++ b/plan.md @@ -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; + } + + 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` + - `` 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