d1a21a79b2
Phase 1 — globals.css: add ~45 new dark-mode override rules covering 250+ component instances at once: - bg-*-50 (red/green/blue/yellow/amber/purple/indigo/orange/brand/emerald) - border-*-200 (colored alert/badge borders) - hover:bg-*-50/100 (colored hover states) - text-amber-700/orange-600/green-600/emerald-700/brand-700 (missing overrides) - divide-gray-50 (ChargeabilityWidget sticky section dividers) Phase 2 — targeted component fixes: - Button.tsx: add dark variants to secondary (bg-gray-800) and ghost variants - DynamicFieldEditor.tsx: add dark variants to INPUT_NORMAL and INPUT_ERROR constants - WidgetContainer.tsx: replace slate-900 (blue-tinted) gradient with neutral surface-card values (rgb 22,23,26 / 16,17,19) - status-styles.ts: add explicit dark variants to PROJECT_STATUS_BADGE and ORDER_TYPE_BADGE (consistent with other badge maps in same file) Phase 3 — dashboard widget tables: - TopValueWidget: dark thead, tbody divider, row hover - DemandWidget: dark thead, tbody divider, row hover - ChargeabilityWidget: dark sticky h3 headers (bg-white→surface-card), border-gray-100 thead rows, divide-gray-50 tbodys Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
613 lines
26 KiB
TypeScript
613 lines
26 KiB
TypeScript
"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 (
|
|
<div className="h-1 w-full rounded-full bg-gray-100 dark:bg-gray-800 mt-0.5">
|
|
<div
|
|
className={`h-full rounded-full transition-all duration-500 ${barColor}`}
|
|
style={{ width: `${Math.min(percent, 100)}%` }}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<span className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2 py-0.5 text-[10px] font-medium text-gray-600 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300">
|
|
<span className="text-gray-400 dark:text-gray-500">{label}</span>
|
|
<span className="text-gray-700 dark:text-gray-200">{value}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="mt-1.5 space-y-1.5 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
|
|
<div className="flex flex-wrap gap-1">
|
|
<MetricPill label="Loc" value={formatLocation(row)} />
|
|
<MetricPill label="Week" value={formatHours(derivation.weeklyAvailabilityHours)} />
|
|
<MetricPill label="Target" value={formatHours(derivation.targetBookedHours)} />
|
|
</div>
|
|
<div className="grid gap-x-3 gap-y-0.5 sm:grid-cols-2">
|
|
<div>
|
|
Days {formatDayEquivalent(derivation.baseWorkingDays)} {"->"} {formatDayEquivalent(derivation.effectiveWorkingDayEquivalent)}
|
|
</div>
|
|
<div>
|
|
Holidays {derivation.publicHolidayWorkdayCount}/{derivation.publicHolidayCount} ({formatHours(derivation.publicHolidayHoursDeduction)})
|
|
</div>
|
|
<div>
|
|
Base {formatHours(derivation.baseAvailableHours)} {"->"} Effective {formatHours(derivation.effectiveAvailableHours)}
|
|
</div>
|
|
<div>
|
|
Absence {formatDayEquivalent(derivation.absenceDayEquivalent)} ({formatHours(derivation.absenceHoursDeduction)})
|
|
</div>
|
|
<div>
|
|
Actual {formatHours(derivation.actualBookedHours)} · Expected {formatHours(derivation.expectedBookedHours)}
|
|
</div>
|
|
<div>
|
|
Free {formatHours(derivation.unassignedHours)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FilterDropdown({ label, children }: { label: string; children: ReactNode }) {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
const { panelRef, position } = useAnchoredOverlay<HTMLButtonElement>({
|
|
open: isOpen,
|
|
onClose: () => setIsOpen(false),
|
|
align: "end",
|
|
triggerRef,
|
|
});
|
|
|
|
return (
|
|
<div className="relative">
|
|
<button
|
|
ref={triggerRef}
|
|
type="button"
|
|
onClick={() => setIsOpen((current) => !current)}
|
|
className="inline-flex min-w-44 items-center justify-between gap-3 rounded-xl border border-gray-300 bg-white px-3 py-2 text-xs text-gray-700 shadow-sm transition hover:border-gray-400 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200"
|
|
>
|
|
<span className="truncate">{label}</span>
|
|
<span className="text-[10px] text-gray-400">{isOpen ? "▲" : "▼"}</span>
|
|
</button>
|
|
{isOpen && typeof document !== "undefined"
|
|
? createPortal(
|
|
<div
|
|
ref={panelRef}
|
|
className="fixed z-[9998] w-72 rounded-2xl border border-gray-200 bg-white p-3 shadow-lg dark:border-gray-700 dark:bg-gray-900"
|
|
style={{
|
|
top: position.top,
|
|
left: position.left,
|
|
}}
|
|
>
|
|
{children}
|
|
</div>,
|
|
document.body,
|
|
)
|
|
: null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<WidgetFilter[]>(
|
|
() => [
|
|
{ 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<string[]>([]);
|
|
const [topSort, setTopSort] = useState<TopSortKey>("actual");
|
|
const [topDir, setTopDir] = useState<"asc" | "desc">("desc");
|
|
const [watchSort, setWatchSort] = useState<WatchSortKey>("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 (
|
|
<div className="flex flex-col gap-3 pt-1">
|
|
<div className="h-2 w-32 shimmer-skeleton rounded" />
|
|
{[...Array(4)].map((_, i) => (
|
|
<div key={i} className="flex gap-3 px-2 py-1">
|
|
<div className="h-3 w-4 shimmer-skeleton rounded" />
|
|
<div className="h-3 flex-1 shimmer-skeleton rounded" />
|
|
<div className="h-3 w-10 shimmer-skeleton rounded" />
|
|
<div className="h-3 w-10 shimmer-skeleton rounded" />
|
|
</div>
|
|
))}
|
|
<div className="border-t border-gray-100 dark:border-gray-800 mt-1 pt-2">
|
|
<div className="h-2 w-20 shimmer-skeleton rounded mb-2" />
|
|
{[...Array(3)].map((_, i) => (
|
|
<div key={i} className="flex gap-3 px-2 py-1">
|
|
<div className="h-3 flex-1 shimmer-skeleton rounded" />
|
|
<div className="h-3 w-10 bg-gray-100 dark:bg-gray-800 rounded" />
|
|
<div className="h-3 w-10 bg-gray-100 dark:bg-gray-800 rounded" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 ? (
|
|
<span className="text-[10px] ml-0.5">{topDir === "asc" ? "▲" : "▼"}</span>
|
|
) : (
|
|
<span className="text-[10px] ml-0.5 text-gray-300">⇅</span>
|
|
);
|
|
}
|
|
function WatchInd({ k }: { k: WatchSortKey }) {
|
|
return watchSort === k ? (
|
|
<span className="text-[10px] ml-0.5">{watchDir === "asc" ? "▲" : "▼"}</span>
|
|
) : (
|
|
<span className="text-[10px] ml-0.5 text-gray-300">⇅</span>
|
|
);
|
|
}
|
|
|
|
const visibleTop = top.slice(0, topVisibleCount);
|
|
const visibleWatchlist = watchlist.slice(0, watchVisibleCount);
|
|
|
|
function handleSectionScroll(
|
|
event: UIEvent<HTMLElement>,
|
|
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 (
|
|
<div className="h-full flex flex-col gap-2 overflow-hidden">
|
|
<div className="px-1 flex-shrink-0 flex flex-col gap-2">
|
|
<WidgetFilterBar filters={widgetFilters} values={_config} onChange={onConfigChange ?? (() => {})} />
|
|
<div className="flex items-center justify-between gap-3">
|
|
{month && (
|
|
<p className="text-xs text-gray-400 flex items-center gap-1">
|
|
Period: {month}
|
|
<InfoTooltip
|
|
content="Chargeability is calculated for the current calendar month. Available hours are derived from each person's weekly schedule and reduced by regional public holidays plus approved absences. Watchlist threshold: 15 percentage points below target."
|
|
width="w-72"
|
|
/>
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<label className="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300">
|
|
<input
|
|
type="checkbox"
|
|
checked={showDeparted}
|
|
onChange={(event) => setShowDeparted(event.target.checked)}
|
|
className="rounded border-gray-300"
|
|
/>
|
|
Show departed
|
|
<InfoTooltip content="When enabled, resources who have left the company are included in the lists." />
|
|
</label>
|
|
<FilterDropdown label={selectedCountryLabel}>
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-xs font-medium text-gray-600">Countries</p>
|
|
{selectedCountryIds.length > 0 ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelectedCountryIds([])}
|
|
className="text-[11px] text-brand-600 hover:text-brand-700"
|
|
>
|
|
Clear
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
<p className="text-[11px] text-gray-400">
|
|
Empty selection means all countries are included.
|
|
</p>
|
|
<div className="max-h-48 space-y-1 overflow-y-auto pr-1">
|
|
{countries.map((country) => (
|
|
<label
|
|
key={country.id}
|
|
className="flex items-center gap-2 text-xs text-gray-700"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedCountryIds.includes(country.id)}
|
|
onChange={(event) => toggleCountry(country.id, event.target.checked)}
|
|
className="rounded border-gray-300"
|
|
/>
|
|
<span>{country.name}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</FilterDropdown>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Top list */}
|
|
<section
|
|
className="flex-1 min-h-0 overflow-auto"
|
|
onScroll={(event) =>
|
|
handleSectionScroll(event, topVisibleCount, top.length, setTopVisibleCount)
|
|
}
|
|
>
|
|
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider px-1 mb-1 sticky top-0 bg-white dark:bg-[rgb(var(--surface-card))] flex items-center">
|
|
Top Chargeability
|
|
<InfoTooltip content="Resources ranked by highest actual chargeability this month. Chargeability = chargeable booked hours divided by holiday- and absence-adjusted available hours." />
|
|
<span className="ml-1 font-normal normal-case text-gray-400">
|
|
{visibleTop.length}/{top.length}
|
|
</span>
|
|
</h3>
|
|
{top.length === 0 ? (
|
|
<p className="text-xs text-gray-400 px-1">No data available.</p>
|
|
) : (
|
|
<table className="w-full text-xs">
|
|
<thead>
|
|
<tr className="text-gray-400 border-b border-gray-100 dark:border-gray-800">
|
|
<th className="px-2 py-1 text-left font-medium w-6">#</th>
|
|
<th className="px-2 py-1 text-left font-medium">
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleTop("name")}
|
|
className="inline-flex items-center hover:text-gray-600 cursor-pointer"
|
|
>
|
|
Name
|
|
<TopInd k="name" />
|
|
</button>
|
|
</th>
|
|
<th className="px-2 py-1 text-right font-medium">
|
|
<span className="inline-flex items-center justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleTop("actual")}
|
|
className="inline-flex items-center hover:text-gray-600 cursor-pointer"
|
|
>
|
|
Actual
|
|
<TopInd k="actual" />
|
|
</button>
|
|
<InfoTooltip
|
|
content="Actual uses CONFIRMED and ACTIVE bookings on active projects. Turn on 'Include proposed' to also count proposed work and imported TBD planning."
|
|
width="w-72"
|
|
/>
|
|
</span>
|
|
</th>
|
|
<th className="px-2 py-1 text-right font-medium">
|
|
<span className="inline-flex items-center justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleTop("expected")}
|
|
className="inline-flex items-center hover:text-gray-600 cursor-pointer"
|
|
>
|
|
Expected
|
|
<TopInd k="expected" />
|
|
</button>
|
|
<InfoTooltip
|
|
content="Expected includes all non-CANCELLED bookings this month, including draft projects and proposed planning rows."
|
|
width="w-72"
|
|
/>
|
|
</span>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-50 dark:divide-gray-800">
|
|
{visibleTop.map((r, i) => (
|
|
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/40">
|
|
<td className="px-2 py-1 text-gray-400">{i + 1}</td>
|
|
<td className="px-2 py-1 text-gray-800 dark:text-gray-200 max-w-[240px] align-top">
|
|
<div className="truncate">
|
|
<span title={r.displayName}>{r.displayName}</span>
|
|
{r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>}
|
|
</div>
|
|
{showDetails ? <ChargeabilityContextLine row={r} /> : null}
|
|
<UtilizationBar percent={r.actualChargeability} />
|
|
</td>
|
|
<td className="px-2 py-1 text-right font-semibold text-green-700 dark:text-green-400 align-top">
|
|
<div>
|
|
<AnimatedNumber value={r.actualChargeability} suffix="%" />
|
|
</div>
|
|
{showDetails ? (
|
|
<div className="mt-1 text-[10px] font-normal leading-4 text-gray-500 dark:text-gray-400">
|
|
{formatHours(r.derivation?.actualBookedHours)} / {formatHours(r.derivation?.effectiveAvailableHours)}
|
|
</div>
|
|
) : null}
|
|
</td>
|
|
<td className="px-2 py-1 text-right text-gray-400 align-top">
|
|
<div>
|
|
<AnimatedNumber value={r.expectedChargeability} suffix="%" />
|
|
</div>
|
|
{showDetails ? (
|
|
<div className="mt-1 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
|
|
{formatHours(r.derivation?.expectedBookedHours)} / {formatHours(r.derivation?.effectiveAvailableHours)}
|
|
</div>
|
|
) : null}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</section>
|
|
|
|
<div className="border-t border-gray-100 flex-shrink-0" />
|
|
|
|
{/* Watchlist */}
|
|
<section
|
|
className="flex-1 min-h-0 overflow-auto"
|
|
onScroll={(event) =>
|
|
handleSectionScroll(event, watchVisibleCount, watchlist.length, setWatchVisibleCount)
|
|
}
|
|
>
|
|
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider px-1 mb-1 sticky top-0 bg-white dark:bg-[rgb(var(--surface-card))] flex items-center">
|
|
Watchlist <span className="font-normal text-gray-400">(below target)</span>
|
|
<InfoTooltip content="Resources whose actual chargeability is more than 15 percentage points below their individual target. These may need more project assignments." />
|
|
<span className="ml-1 font-normal normal-case text-gray-400">
|
|
{visibleWatchlist.length}/{watchlist.length}
|
|
</span>
|
|
</h3>
|
|
{watchlist.length === 0 ? (
|
|
<p className="text-xs text-gray-400 px-1">All resources at or near target.</p>
|
|
) : (
|
|
<table className="w-full text-xs">
|
|
<thead>
|
|
<tr className="text-gray-400 border-b border-gray-100 dark:border-gray-800">
|
|
<th className="px-2 py-1 text-left font-medium">
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleWatch("name")}
|
|
className="inline-flex items-center hover:text-gray-600 cursor-pointer"
|
|
>
|
|
Name
|
|
<WatchInd k="name" />
|
|
</button>
|
|
</th>
|
|
<th className="px-2 py-1 text-right font-medium">
|
|
<span className="inline-flex items-center justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleWatch("actual")}
|
|
className="inline-flex items-center hover:text-gray-600 cursor-pointer"
|
|
>
|
|
Actual
|
|
<WatchInd k="actual" />
|
|
</button>
|
|
<InfoTooltip content="Actual chargeability this month. By default this counts CONFIRMED and ACTIVE bookings on ACTIVE projects; the toggle can also include PROPOSED work." />
|
|
</span>
|
|
</th>
|
|
<th className="px-2 py-1 text-right font-medium">
|
|
<span className="inline-flex items-center justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleWatch("target")}
|
|
className="inline-flex items-center hover:text-gray-600 cursor-pointer"
|
|
>
|
|
Target
|
|
<WatchInd k="target" />
|
|
</button>
|
|
<InfoTooltip content="Chargeability target set by management. Watchlist shows resources more than 15 percentage points below their target." />
|
|
</span>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-50 dark:divide-gray-800">
|
|
{visibleWatchlist.map((r) => (
|
|
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/40">
|
|
<td className="px-2 py-1 text-gray-800 dark:text-gray-200 max-w-[240px] align-top">
|
|
<div className="truncate">
|
|
<span title={r.displayName}>{r.displayName}</span>
|
|
{r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>}
|
|
</div>
|
|
{showDetails ? <ChargeabilityContextLine row={r} /> : null}
|
|
<UtilizationBar percent={r.actualChargeability} />
|
|
</td>
|
|
<td className="px-2 py-1 text-right font-semibold text-red-600 dark:text-red-400 align-top">
|
|
<div>
|
|
<AnimatedNumber value={r.actualChargeability} suffix="%" />
|
|
</div>
|
|
{showDetails ? (
|
|
<div className="mt-1 text-[10px] font-normal leading-4 text-gray-500 dark:text-gray-400">
|
|
{formatHours(r.derivation?.actualBookedHours)} / {formatHours(r.derivation?.effectiveAvailableHours)}
|
|
</div>
|
|
) : null}
|
|
</td>
|
|
<td className="px-2 py-1 text-right text-gray-400 align-top">
|
|
<div>
|
|
<AnimatedNumber value={r.chargeabilityTarget} suffix="%" />
|
|
</div>
|
|
{showDetails ? (
|
|
<div className="mt-1 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
|
|
Target {formatHours(r.derivation?.targetBookedHours)} · Free {formatHours(r.derivation?.unassignedHours)}
|
|
</div>
|
|
) : null}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|