bf3751f667
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>
201 lines
7.6 KiB
TypeScript
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>
|
|
);
|
|
}
|