Files
CapaKraken/apps/web/src/components/projects/ShoringIndicator.tsx
T
Hartmut bf3751f667 fix: invert shoring ratio logic — higher offshore = better
The shoring indicator logic was backwards. In the business context,
higher offshore = more cost-efficient = GOOD.

Inverted logic:
- Green: offshore >= threshold (target met, e.g. >= 55%)
- Yellow: offshore close to threshold (threshold-10 to threshold)
- Red: offshore below threshold (too little offshore, too expensive)

Updated:
- ShoringIndicator: getSeverity() inverted, badge text updated
- ProjectModal: "Max Offshore" renamed to "Min Offshore" with new tooltip
- AI Tool: status text reflects "target met" vs "below target"
- Tool description: "higher offshore is better, threshold is minimum"

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-26 13:07:36 +01:00

201 lines
7.6 KiB
TypeScript

"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" {
// Higher offshore = better (cost-efficient). Threshold is the MINIMUM target.
if (offshoreRatio >= threshold) return "green"; // Target met
if (offshoreRatio >= threshold - 10) return "yellow"; // Close to target
return "red"; // Too little offshore
}
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 === "green" ? " — Target met" : severity === "red" ? ` — Below ${data.threshold}% target` : ""}
</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>
);
}