"use client"; import { createPortal } from "react-dom"; 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"; import { useReferenceData } from "~/hooks/useReferenceData.js"; import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.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 ChargeabilityRow = { id: string; displayName: string; chapter: string | null; chargeabilityTarget: number; actualChargeability: number; expectedChargeability: number; countryCode?: string | null; countryName?: string | null; federalState?: string | null; metroCityName?: string | null; derivation?: { weeklyAvailabilityHours: number; baseWorkingDays: number; effectiveWorkingDayEquivalent: number; baseAvailableHours: number; effectiveAvailableHours: number; publicHolidayCount: number; publicHolidayWorkdayCount: number; publicHolidayHoursDeduction: number; absenceDayEquivalent: number; absenceHoursDeduction: number; actualBookedHours: number; expectedBookedHours: number; targetBookedHours: number; unassignedHours: number; }; }; function formatHours(value: number | undefined): string { if (value === undefined) return "—"; return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}h`; } function formatDayEquivalent(value: number | undefined): string { if (value === undefined) return "—"; return Number.isInteger(value) ? `${value}` : value.toFixed(1); } function MetricPill({ label, value }: { label: string; value: string }) { return ( {label} {value} ); } function formatLocation(row: ChargeabilityRow): string { const parts = [row.countryCode ?? row.countryName ?? null, row.federalState ?? null, row.metroCityName ?? null] .filter((part): part is string => Boolean(part)); return parts.length > 0 ? parts.join(" / ") : "No calendar context"; } function ChargeabilityContextLine({ row }: { row: ChargeabilityRow }) { const derivation = row.derivation; if (!derivation) { return null; } return (
Days {formatDayEquivalent(derivation.baseWorkingDays)} {"->"} {formatDayEquivalent(derivation.effectiveWorkingDayEquivalent)}
Holidays {derivation.publicHolidayWorkdayCount}/{derivation.publicHolidayCount} ({formatHours(derivation.publicHolidayHoursDeduction)})
Base {formatHours(derivation.baseAvailableHours)} {"->"} Effective {formatHours(derivation.effectiveAvailableHours)}
Absence {formatDayEquivalent(derivation.absenceDayEquivalent)} ({formatHours(derivation.absenceHoursDeduction)})
Actual {formatHours(derivation.actualBookedHours)} · Expected {formatHours(derivation.expectedBookedHours)}
Free {formatHours(derivation.unassignedHours)}
); } function FilterDropdown({ label, children }: { label: string; children: ReactNode }) { const [isOpen, setIsOpen] = useState(false); const triggerRef = useRef(null); const { panelRef, position } = useAnchoredOverlay({ open: isOpen, onClose: () => setIsOpen(false), align: "end", triggerRef, }); return (
{isOpen && typeof document !== "undefined" ? createPortal(
{children}
, document.body, ) : null}
); } export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetProps) { const config = _config as { topN?: number; watchlistThreshold?: number; chapter?: string; includeProposed?: boolean; showDetails?: boolean; }; const { chapters } = useWidgetFilterOptions({ chapters: true }); const { countries } = useReferenceData({ countries: true }); const widgetFilters = useMemo( () => [ { type: "select", key: "chapter", label: "Chapter", options: chapters }, { type: "toggle", key: "includeProposed", label: "Include Proposed" }, ], [chapters], ); const includeProposed = !!config.includeProposed; const showDetails = !!config.showDetails; 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 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}

)}

Countries

{selectedCountryIds.length > 0 ? ( ) : null}

Empty selection means all countries are included.

{countries.map((country) => ( ))}
{/* 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}}
{showDetails ? : null}
{showDetails ? (
{formatHours(r.derivation?.actualBookedHours)} / {formatHours(r.derivation?.effectiveAvailableHours)}
) : null}
{showDetails ? (
{formatHours(r.derivation?.expectedBookedHours)} / {formatHours(r.derivation?.effectiveAvailableHours)}
) : null}
)}
{/* 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}}
{showDetails ? : null}
{showDetails ? (
{formatHours(r.derivation?.actualBookedHours)} / {formatHours(r.derivation?.effectiveAvailableHours)}
) : null}
{showDetails ? (
Target {formatHours(r.derivation?.targetBookedHours)} · Free {formatHours(r.derivation?.unassignedHours)}
) : null}
)}
); }