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:
@@ -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 & 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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user