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
@@ -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>