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