"use client";
import { useEffect, useMemo, useRef, useState, type ReactNode, type UIEvent } from "react";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { AnimatedNumber } from "~/components/ui/AnimatedNumber.js";
import { WidgetFilterBar, type WidgetFilter } from "~/components/dashboard/WidgetFilterBar.js";
import { useWidgetFilterOptions } from "~/hooks/useWidgetFilterOptions.js";
function UtilizationBar({ percent }: { percent: number }) {
const barColor =
percent >= 80 ? "bg-green-500" : percent >= 50 ? "bg-amber-500" : "bg-red-500";
return (
);
}
type TopSortKey = "name" | "actual" | "expected";
type WatchSortKey = "name" | "actual" | "target";
type CountryOption = {
id: string;
name: string;
};
type ChargeabilityRow = {
id: string;
displayName: string;
chapter: string | null;
chargeabilityTarget: number;
actualChargeability: number;
expectedChargeability: number;
};
function FilterDropdown({ label, children }: { label: string; children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef(null);
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
const target = event.target as Node;
if (dropdownRef.current && !dropdownRef.current.contains(target)) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handlePointerDown);
return () => document.removeEventListener("mousedown", handlePointerDown);
}, []);
return (
{isOpen ? (
{children}
) : null}
);
}
export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetProps) {
const config = _config as { topN?: number; watchlistThreshold?: number; chapter?: string; includeProposed?: boolean };
const { chapters } = useWidgetFilterOptions();
const widgetFilters = useMemo(
() => [
{ type: "select", key: "chapter", label: "Chapter", options: chapters },
{ type: "toggle", key: "includeProposed", label: "Include Proposed" },
],
[chapters],
);
const includeProposed = !!config.includeProposed;
const chapterFilter = (config.chapter as string) ?? "";
const [showDeparted, setShowDeparted] = useState(false);
const [selectedCountryIds, setSelectedCountryIds] = useState([]);
const [topSort, setTopSort] = useState("actual");
const [topDir, setTopDir] = useState<"asc" | "desc">("desc");
const [watchSort, setWatchSort] = useState("actual");
const [watchDir, setWatchDir] = useState<"asc" | "desc">("asc");
const batchSize = Math.max(config.topN ?? 10, 10);
const [topVisibleCount, setTopVisibleCount] = useState(batchSize);
const [watchVisibleCount, setWatchVisibleCount] = useState(batchSize);
const { data: countriesData } = trpc.country.list.useQuery(undefined, { staleTime: 60_000 });
const countries = useMemo(
() =>
((countriesData ?? []) as Array<{ id: string; name: string }>).map((country) => ({
id: country.id,
name: country.name,
})),
[countriesData],
) as CountryOption[];
const selectedCountryLabel = useMemo(() => {
if (selectedCountryIds.length === 0) return "Countries: All";
if (selectedCountryIds.length === 1) {
return `Country: ${countries.find((country) => country.id === selectedCountryIds[0])?.name ?? "1 selected"}`;
}
return `Countries: ${selectedCountryIds.length} selected`;
}, [countries, selectedCountryIds]);
function toggleTop(key: TopSortKey) {
if (topSort === key) setTopDir((d) => (d === "asc" ? "desc" : "asc"));
else {
setTopSort(key);
setTopDir(key === "name" ? "asc" : "desc");
}
}
function toggleWatch(key: WatchSortKey) {
if (watchSort === key) setWatchDir((d) => (d === "asc" ? "desc" : "asc"));
else {
setWatchSort(key);
setWatchDir(key === "name" ? "asc" : "asc");
}
}
const { data, isLoading } = trpc.dashboard.getChargeabilityOverview.useQuery(
{
includeProposed,
topN: config.topN ?? 10,
watchlistThreshold: config.watchlistThreshold ?? 15,
...(selectedCountryIds.length > 0 ? { countryIds: selectedCountryIds } : {}),
...(!showDeparted ? { departed: false } : {}),
},
{ staleTime: 60_000, placeholderData: (prev) => prev },
);
useEffect(() => {
setTopVisibleCount(batchSize);
setWatchVisibleCount(batchSize);
}, [batchSize, includeProposed, selectedCountryIds, showDeparted]);
// These useMemo hooks MUST be before any early return to respect Rules of Hooks
const rawTop = data?.top ?? [];
const rawWatch = data?.watchlist ?? [];
const month = (data?.month as string) ?? "";
const filteredTop = useMemo(() => {
const arr = rawTop as ChargeabilityRow[];
if (!chapterFilter) return arr;
return arr.filter((r) => r.chapter === chapterFilter);
}, [rawTop, chapterFilter]);
const filteredWatch = useMemo(() => {
const arr = rawWatch as ChargeabilityRow[];
if (!chapterFilter) return arr;
return arr.filter((r) => r.chapter === chapterFilter);
}, [rawWatch, chapterFilter]);
if (isLoading) {
return (
{[...Array(4)].map((_, i) => (
))}
{[...Array(3)].map((_, i) => (
))}
);
}
const top = ([...filteredTop]).sort((a, b) => {
const mult = topDir === "asc" ? 1 : -1;
switch (topSort) {
case "name":
return mult * a.displayName.localeCompare(b.displayName);
case "actual":
return mult * (a.actualChargeability - b.actualChargeability);
case "expected":
return mult * (a.expectedChargeability - b.expectedChargeability);
default:
return 0;
}
});
const watchlist = ([...filteredWatch]).sort((a, b) => {
const mult = watchDir === "asc" ? 1 : -1;
switch (watchSort) {
case "name":
return mult * a.displayName.localeCompare(b.displayName);
case "actual":
return mult * (a.actualChargeability - b.actualChargeability);
case "target":
return mult * (a.chargeabilityTarget - b.chargeabilityTarget);
default:
return 0;
}
});
function TopInd({ k }: { k: TopSortKey }) {
return topSort === k ? (
{topDir === "asc" ? "▲" : "▼"}
) : (
⇅
);
}
function WatchInd({ k }: { k: WatchSortKey }) {
return watchSort === k ? (
{watchDir === "asc" ? "▲" : "▼"}
) : (
⇅
);
}
const visibleTop = top.slice(0, topVisibleCount);
const visibleWatchlist = watchlist.slice(0, watchVisibleCount);
function handleSectionScroll(
event: UIEvent,
visibleCount: number,
totalCount: number,
setVisibleCount: (value: number | ((current: number) => number)) => void,
) {
const element = event.currentTarget;
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
if (distanceFromBottom > 48 || visibleCount >= totalCount) {
return;
}
setVisibleCount((current) => Math.min(current + batchSize, totalCount));
}
function toggleCountry(countryId: string, checked: boolean) {
setSelectedCountryIds((current) => {
if (checked) {
return current.includes(countryId) ? current : [...current, countryId];
}
return current.filter((id) => id !== countryId);
});
}
return (
{})} />
{month && (
Period: {month}
)}
{/* Top list */}
handleSectionScroll(event, topVisibleCount, top.length, setTopVisibleCount)
}
>
Top Chargeability
{visibleTop.length}/{top.length}
{top.length === 0 ? (
No data available.
) : (
| # |
|
|
|
{visibleTop.map((r, i) => (
| {i + 1} |
{r.displayName}
{r.chapter && · {r.chapter}}
|
|
|
))}
)}
{/* Watchlist */}
handleSectionScroll(event, watchVisibleCount, watchlist.length, setWatchVisibleCount)
}
>
Watchlist (below target)
{visibleWatchlist.length}/{watchlist.length}
{watchlist.length === 0 ? (
All resources at or near target.
) : (
|
|
|
|
{visibleWatchlist.map((r) => (
|
{r.displayName}
{r.chapter && · {r.chapter}}
|
|
|
))}
)}
);
}