feat: dashboard overhaul, chargeability reports, dispo import enhancements, UI polish

Dashboard: expanded chargeability widget, resource/project table widgets
with sorting and filters, stat cards with formatMoney integration.

Chargeability: new report client with filtering, chargeability-bookings
use case, updated dashboard overview logic.

Dispo import: TBD project handling, parse-dispo-matrix improvements,
stage-dispo-projects resource value scores, new tests.

Estimates: CommercialTermsEditor component, commercial-terms engine
module, expanded estimate schemas and types.

UI: AppShell navigation updates, timeline filter/toolbar enhancements,
role management improvements, signin page redesign, Tailwind/globals
polish, SystemSettings SMTP section, anonymization support.

Tests: new router tests (anonymization, chargeability, effort-rule,
entitlement, estimate, experience-multiplier, notification, resource,
staffing, vacation).

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-14 23:29:07 +01:00
parent ad0855902b
commit 625a842d89
74 changed files with 11680 additions and 1583 deletions
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
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";
@@ -8,27 +8,116 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
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<HTMLDivElement | null>(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 (
<div ref={dropdownRef} className="relative">
<button
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 ? (
<div className="absolute right-0 z-20 mt-2 w-72 rounded-2xl border border-gray-200 bg-white p-3 shadow-lg dark:border-gray-700 dark:bg-gray-900">
{children}
</div>
) : null}
</div>
);
}
export function ChargeabilityWidget({ config: _config }: WidgetProps) {
const config = _config as { topN?: number; watchlistThreshold?: number };
const [includeProposed, setIncludeProposed] = useState(false);
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 { 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"); }
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"); }
else {
setWatchSort(key);
setWatchDir(key === "name" ? "asc" : "asc");
}
}
const { data, isLoading } = trpc.dashboard.getChargeabilityOverview.useQuery(
{ topN: config.topN ?? 10, watchlistThreshold: config.watchlistThreshold ?? 15 },
{
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]);
if (isLoading) {
return (
<div className="animate-pulse flex flex-col gap-3 pt-1">
@@ -59,53 +148,158 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
const rawWatch = data?.watchlist ?? [];
const month = data?.month ?? "";
const top = [...rawTop].sort((a, b) => {
const top = ([...rawTop] as ChargeabilityRow[]).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;
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 = [...rawWatch].sort((a, b) => {
const watchlist = ([...rawWatch] as ChargeabilityRow[]).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;
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>;
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>;
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">
{month && (
<p className="text-xs text-gray-400 px-1 flex-shrink-0 flex items-center gap-1">
Period: {month}
<InfoTooltip
content="Chargeability is calculated for the current calendar month. Available hours are based on each person's weekly schedule (WeekdayAvailability). Watchlist threshold: 15 percentage points below target."
width="w-72"
/>
</p>
<div className="px-1 flex-shrink-0 flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<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 based on each person's weekly schedule (WeekdayAvailability). Watchlist threshold: 15 percentage points below target."
width="w-72"
/>
</p>
<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={includeProposed}
onChange={(event) => setIncludeProposed(event.target.checked)}
className="rounded border-gray-300"
/>
Include proposed
</label>
</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
</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">
<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">
Top Chargeability
<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>
@@ -115,28 +309,43 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
<tr className="text-gray-400 border-b border-gray-100">
<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
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
type="button"
onClick={() => toggleTop("actual")}
className="inline-flex items-center hover:text-gray-600 cursor-pointer"
>
Actual
<TopInd k="actual" />
</button>
<InfoTooltip
content="CONFIRMED + ACTIVE allocations on non-DRAFT projects ÷ available working hours this month × 100."
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
type="button"
onClick={() => toggleTop("expected")}
className="inline-flex items-center hover:text-gray-600 cursor-pointer"
>
Expected
<TopInd k="expected" />
</button>
<InfoTooltip
content="All non-CANCELLED allocations (including DRAFT projects and PROPOSED status) ÷ available working hours this month × 100."
content="Expected includes all non-CANCELLED bookings this month, including draft projects and proposed planning rows."
width="w-72"
/>
</span>
@@ -144,7 +353,7 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{top.map((r, i) => (
{visibleTop.map((r, i) => (
<tr key={r.id} className="hover:bg-gray-50">
<td className="px-2 py-1 text-gray-400">{i + 1}</td>
<td className="px-2 py-1 text-gray-800 truncate max-w-[120px]">
@@ -154,9 +363,7 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
<td className="px-2 py-1 text-right font-semibold text-green-700">
{r.actualChargeability}%
</td>
<td className="px-2 py-1 text-right text-gray-400">
{r.expectedChargeability}%
</td>
<td className="px-2 py-1 text-right text-gray-400">{r.expectedChargeability}%</td>
</tr>
))}
</tbody>
@@ -167,9 +374,17 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
<div className="border-t border-gray-100 flex-shrink-0" />
{/* Watchlist */}
<section className="flex-1 min-h-0 overflow-auto">
<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">
Watchlist <span className="font-normal text-gray-400">(below target)</span>
<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>
@@ -178,22 +393,37 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
<thead>
<tr className="text-gray-400 border-b border-gray-100">
<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
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
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: CONFIRMED + ACTIVE allocations on non-DRAFT projects ÷ available hours." />
<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
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>
@@ -201,7 +431,7 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{watchlist.map((r) => (
{visibleWatchlist.map((r) => (
<tr key={r.id} className="hover:bg-gray-50">
<td className="px-2 py-1 text-gray-800 truncate max-w-[140px]">
<span title={r.displayName}>{r.displayName}</span>
@@ -210,9 +440,7 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
<td className="px-2 py-1 text-right font-semibold text-red-600">
{r.actualChargeability}%
</td>
<td className="px-2 py-1 text-right text-gray-400">
{r.chargeabilityTarget}%
</td>
<td className="px-2 py-1 text-right text-gray-400">{r.chargeabilityTarget}%</td>
</tr>
))}
</tbody>