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:
@@ -199,6 +199,8 @@ export function ChargeabilityReportClient() {
|
||||
const [orgUnitId, setOrgUnitId] = useState<string>("");
|
||||
const [mgmtGroupId, setMgmtGroupId] = useState<string>("");
|
||||
const [countryId, setCountryId] = useState<string>("");
|
||||
const [includeProposed, setIncludeProposed] = useState(false);
|
||||
const [nameSearch, setNameSearch] = useState("");
|
||||
const [groupBy, setGroupBy] = useState<GroupByField>("none");
|
||||
const [expandedResource, setExpandedResource] = useState<string | null>(null);
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
@@ -215,6 +217,7 @@ export function ChargeabilityReportClient() {
|
||||
...(orgUnitId ? { orgUnitId } : {}),
|
||||
...(mgmtGroupId ? { managementLevelGroupId: mgmtGroupId } : {}),
|
||||
...(countryId ? { countryId } : {}),
|
||||
includeProposed,
|
||||
},
|
||||
{ placeholderData: (prev) => prev },
|
||||
);
|
||||
@@ -226,12 +229,32 @@ export function ChargeabilityReportClient() {
|
||||
return items.filter((u: { level: number }) => u.level === 7);
|
||||
}, [orgUnitsQuery.data]);
|
||||
|
||||
const filteredResources = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const query = nameSearch.trim().toLowerCase();
|
||||
if (!query) return data.resources;
|
||||
|
||||
return data.resources.filter((resource) =>
|
||||
resource.displayName.toLowerCase().includes(query) ||
|
||||
resource.eid.toLowerCase().includes(query),
|
||||
);
|
||||
}, [data, nameSearch]);
|
||||
|
||||
const filteredGroupTotals = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return computeGroupMonthTotals(filteredResources, data.monthKeys);
|
||||
}, [data, filteredResources]);
|
||||
|
||||
const averageTarget = filteredGroupTotals[0]?.target ?? 0;
|
||||
const averageChargeability = filteredGroupTotals[0]?.chg ?? 0;
|
||||
const averageGap = filteredGroupTotals[0]?.gap ?? 0;
|
||||
|
||||
// Group resources by selected dimension
|
||||
const groups = useMemo((): GroupSummary[] => {
|
||||
if (!data || groupBy === "none") return [];
|
||||
|
||||
const buckets = new Map<string, ResourceRow[]>();
|
||||
for (const r of data.resources) {
|
||||
for (const r of filteredResources) {
|
||||
let key: string;
|
||||
switch (groupBy) {
|
||||
case "orgUnit": key = r.orgUnit ?? "(No Org Unit)"; break;
|
||||
@@ -250,7 +273,7 @@ export function ChargeabilityReportClient() {
|
||||
resources,
|
||||
monthTotals: computeGroupMonthTotals(resources, data.monthKeys),
|
||||
}));
|
||||
}, [data, groupBy]);
|
||||
}, [data, filteredResources, groupBy]);
|
||||
|
||||
const toggleGroup = useCallback((label: string) => {
|
||||
setExpandedGroups((prev) => {
|
||||
@@ -263,13 +286,13 @@ export function ChargeabilityReportClient() {
|
||||
|
||||
const handleExportExcel = useCallback(() => {
|
||||
if (!data) return;
|
||||
exportToExcel(data.resources, data.monthKeys, data.groupTotals, groups, groupBy);
|
||||
}, [data, groups, groupBy]);
|
||||
exportToExcel(filteredResources, data.monthKeys, filteredGroupTotals, groups, groupBy);
|
||||
}, [data, filteredGroupTotals, filteredResources, groups, groupBy]);
|
||||
|
||||
const handleExportCsv = useCallback(() => {
|
||||
if (!data) return;
|
||||
exportToCsv(data.resources, data.monthKeys);
|
||||
}, [data]);
|
||||
exportToCsv(filteredResources, data.monthKeys);
|
||||
}, [data, filteredResources]);
|
||||
|
||||
// ─── Render helpers ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -277,21 +300,21 @@ export function ChargeabilityReportClient() {
|
||||
return (
|
||||
<tr
|
||||
key={r.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer"
|
||||
className="cursor-pointer transition-colors hover:bg-gray-50/90 dark:hover:bg-gray-800/50"
|
||||
onClick={() => setExpandedResource(expandedResource === r.id ? null : r.id)}
|
||||
>
|
||||
<td className="sticky left-0 z-10 bg-white dark:bg-gray-900 px-3 py-2">
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">{r.displayName}</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{[r.eid, r.country, r.city, r.orgUnit].filter(Boolean).join(" | ")}
|
||||
<td className="sticky left-0 z-10 bg-white/95 px-4 py-3 backdrop-blur dark:bg-slate-950/95">
|
||||
<div className="font-semibold text-gray-900 dark:text-gray-100">{r.displayName}</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{[r.eid, r.country, r.city, r.orgUnit].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center text-gray-600 dark:text-gray-400">{r.fte.toFixed(2)}</td>
|
||||
<td className="px-2 py-2 text-center text-gray-600 dark:text-gray-400">{pct(r.targetPct)}</td>
|
||||
<td className="px-3 py-3 text-center font-medium text-gray-600 dark:text-gray-300">{r.fte.toFixed(2)}</td>
|
||||
<td className="px-3 py-3 text-center font-medium text-gray-600 dark:text-gray-300">{pct(r.targetPct)}</td>
|
||||
{r.months.map((m) => (
|
||||
<td key={m.monthKey} className="px-2 py-2 text-center">
|
||||
<td key={m.monthKey} className="px-3 py-3 text-center">
|
||||
<div
|
||||
className={`rounded px-1 ${chgColor(m.chg, r.targetPct)}`}
|
||||
className={`rounded-xl border border-transparent px-2 py-1 font-semibold ${chgColor(m.chg, r.targetPct)}`}
|
||||
style={barStyle(m.chg, m.chg >= r.targetPct ? "rgba(34,197,94,0.15)" : "rgba(239,68,68,0.1)")}
|
||||
>
|
||||
{pct(m.chg)}
|
||||
@@ -305,11 +328,11 @@ export function ChargeabilityReportClient() {
|
||||
function renderExpandedRow(r: ResourceRow) {
|
||||
if (expandedResource !== r.id) return null;
|
||||
return (
|
||||
<tr key={`${r.id}-detail`} className="bg-gray-50 dark:bg-gray-800/30">
|
||||
<td className="sticky left-0 z-10 bg-gray-50 dark:bg-gray-800/30 px-3 py-2" colSpan={3}>
|
||||
<div className="text-xs space-y-0.5 text-gray-500 dark:text-gray-400">
|
||||
<tr key={`${r.id}-detail`} className="bg-gray-50/80 dark:bg-slate-900/70">
|
||||
<td className="sticky left-0 z-10 bg-gray-50/95 px-4 py-3 backdrop-blur dark:bg-slate-900/95" colSpan={3}>
|
||||
<div className="space-y-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div>Mgmt: {r.mgmtGroup ?? "—"} / {r.mgmtLevel ?? "—"}</div>
|
||||
<div className="mt-1 grid grid-cols-7 gap-1 text-[10px]">
|
||||
<div className="mt-2 grid grid-cols-7 gap-1 text-[10px] uppercase tracking-[0.16em] text-gray-400 dark:text-gray-500">
|
||||
<span className="font-medium">Chg</span>
|
||||
<span className="font-medium">BD</span>
|
||||
<span className="font-medium">MD&I</span>
|
||||
@@ -321,15 +344,15 @@ export function ChargeabilityReportClient() {
|
||||
</div>
|
||||
</td>
|
||||
{r.months.map((m) => (
|
||||
<td key={m.monthKey} className="px-2 py-2 text-center">
|
||||
<div className="grid grid-cols-1 gap-0.5 text-[10px] text-gray-500 dark:text-gray-400">
|
||||
<span className="text-green-600">{pct(m.chg)}</span>
|
||||
<td key={m.monthKey} className="px-3 py-3 text-center">
|
||||
<div className="grid grid-cols-1 gap-1 rounded-xl bg-white/70 px-2 py-2 text-[10px] text-gray-500 shadow-sm dark:bg-slate-950/40 dark:text-gray-400">
|
||||
<span className="font-semibold text-green-600 dark:text-green-400">{pct(m.chg)}</span>
|
||||
<span>{pct(m.bd)}</span>
|
||||
<span>{pct(m.mdi)}</span>
|
||||
<span>{pct(m.mo)}</span>
|
||||
<span>{pct(m.pdr)}</span>
|
||||
<span className="text-orange-500">{pct(m.absence)}</span>
|
||||
<span className="text-gray-400">{pct(m.unassigned)}</span>
|
||||
<span className="text-orange-500 dark:text-orange-300">{pct(m.absence)}</span>
|
||||
<span className="text-gray-400 dark:text-gray-500">{pct(m.unassigned)}</span>
|
||||
</div>
|
||||
</td>
|
||||
))}
|
||||
@@ -345,28 +368,28 @@ export function ChargeabilityReportClient() {
|
||||
onClick?: () => void,
|
||||
) {
|
||||
const bg = isOverall
|
||||
? "bg-brand-50 dark:bg-brand-900/20"
|
||||
: "bg-indigo-50 dark:bg-indigo-900/20";
|
||||
? "bg-brand-50/90 dark:bg-brand-900/25"
|
||||
: "bg-slate-100/90 dark:bg-slate-800/70";
|
||||
return (
|
||||
<tr
|
||||
className={`${bg} font-semibold ${onClick ? "cursor-pointer" : ""}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<td className={`sticky left-0 z-10 ${bg} px-3 py-2 text-gray-900 dark:text-gray-100`}>
|
||||
{onClick && <span className="mr-1 text-xs">{expandedGroups.has(label) ? "▾" : "▸"}</span>}
|
||||
<td className={`sticky left-0 z-10 ${bg} px-4 py-3 text-gray-900 dark:text-gray-100`}>
|
||||
{onClick && <span className="mr-2 text-xs text-gray-500 dark:text-gray-400">{expandedGroups.has(label) ? "▾" : "▸"}</span>}
|
||||
{label} ({count} resources)
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">
|
||||
<td className="px-3 py-3 text-center text-gray-700 dark:text-gray-300">
|
||||
{monthTotals[0]?.totalFte.toFixed(1)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">
|
||||
<td className="px-3 py-3 text-center text-gray-700 dark:text-gray-300">
|
||||
{monthTotals[0] ? pct(monthTotals[0].target) : "—"}
|
||||
</td>
|
||||
{monthTotals.map((mt) => (
|
||||
<td key={mt.monthKey} className="px-2 py-2 text-center">
|
||||
<div className={chgColor(mt.chg, mt.target)}>{pct(mt.chg)}</div>
|
||||
<td key={mt.monthKey} className="px-3 py-3 text-center">
|
||||
<div className={`font-semibold ${chgColor(mt.chg, mt.target)}`}>{pct(mt.chg)}</div>
|
||||
{mt.gap !== 0 && (
|
||||
<div className="text-xs text-gray-400">
|
||||
<div className="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
|
||||
{mt.gap > 0 ? "+" : ""}{pct(mt.gap)}
|
||||
</div>
|
||||
)}
|
||||
@@ -379,23 +402,32 @@ export function ChargeabilityReportClient() {
|
||||
// ─── Main render ─────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">
|
||||
Chargeability Forecast
|
||||
</h1>
|
||||
{/* Export buttons */}
|
||||
<div className="app-page space-y-6">
|
||||
<div className="app-page-header gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="inline-flex items-center rounded-full border border-brand-200 bg-brand-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:border-brand-800/80 dark:bg-brand-900/30 dark:text-brand-200">
|
||||
Forecast Report
|
||||
</p>
|
||||
<div>
|
||||
<h1 className="app-page-title" data-page-title="true">
|
||||
Chargeability Forecast
|
||||
</h1>
|
||||
<p className="app-page-subtitle">
|
||||
Review expected utilization, search specific people quickly, and compare monthly chargeability against target.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{data && data.resources.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={handleExportExcel}
|
||||
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
|
||||
className="inline-flex items-center justify-center rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-emerald-700"
|
||||
>
|
||||
Export Excel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportCsv}
|
||||
className="px-3 py-1.5 text-sm bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
|
||||
className="inline-flex items-center justify-center rounded-xl border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
@@ -403,135 +435,191 @@ export function ChargeabilityReportClient() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-4 items-end bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">From</label>
|
||||
<input
|
||||
type="month"
|
||||
value={startMonth}
|
||||
onChange={(e) => setStartMonth(e.target.value)}
|
||||
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
{data ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="app-surface p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Resources</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-gray-900 dark:text-gray-100">{filteredResources.length}</div>
|
||||
<div className="mt-1 text-sm text-gray-500">People in the current filter scope</div>
|
||||
</div>
|
||||
<div className="app-surface p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Average Chargeability</div>
|
||||
<div className={`mt-2 text-3xl font-semibold ${chgColor(averageChargeability, averageTarget)}`}>{pct(averageChargeability)}</div>
|
||||
<div className="mt-1 text-sm text-gray-500">Weighted across visible resources</div>
|
||||
</div>
|
||||
<div className="app-surface p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Average Target</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-gray-900 dark:text-gray-100">{pct(averageTarget)}</div>
|
||||
<div className="mt-1 text-sm text-gray-500">Planning target for the same population</div>
|
||||
</div>
|
||||
<div className="app-surface p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Average Gap</div>
|
||||
<div className={`mt-2 text-3xl font-semibold ${averageGap >= 0 ? "text-green-700 dark:text-green-400" : "text-red-700 dark:text-red-400"}`}>
|
||||
{averageGap > 0 ? "+" : ""}{pct(averageGap)}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-500">Difference between chargeability and target</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">To</label>
|
||||
<input
|
||||
type="month"
|
||||
value={endMonth}
|
||||
onChange={(e) => setEndMonth(e.target.value)}
|
||||
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="app-toolbar">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-7">
|
||||
<div>
|
||||
<label className="app-label">From</label>
|
||||
<input
|
||||
type="month"
|
||||
value={startMonth}
|
||||
onChange={(e) => setStartMonth(e.target.value)}
|
||||
className="app-input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="app-label">To</label>
|
||||
<input
|
||||
type="month"
|
||||
value={endMonth}
|
||||
onChange={(e) => setEndMonth(e.target.value)}
|
||||
className="app-input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="app-label">Country</label>
|
||||
<select
|
||||
value={countryId}
|
||||
onChange={(e) => setCountryId(e.target.value)}
|
||||
className="app-select w-full"
|
||||
>
|
||||
<option value="">All countries</option>
|
||||
{(countriesQuery.data ?? []).map((c: { id: string; code: string; name: string }) => (
|
||||
<option key={c.id} value={c.id}>{c.code} - {c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="app-label">Org Unit</label>
|
||||
<select
|
||||
value={orgUnitId}
|
||||
onChange={(e) => setOrgUnitId(e.target.value)}
|
||||
className="app-select w-full"
|
||||
>
|
||||
<option value="">All org units</option>
|
||||
{orgUnits.map((u: { id: string; name: string }) => (
|
||||
<option key={u.id} value={u.id}>{u.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="app-label">Mgmt Level Group</label>
|
||||
<select
|
||||
value={mgmtGroupId}
|
||||
onChange={(e) => setMgmtGroupId(e.target.value)}
|
||||
className="app-select w-full"
|
||||
>
|
||||
<option value="">All groups</option>
|
||||
{(mgmtGroupsQuery.data ?? []).map((g: { id: string; name: string }) => (
|
||||
<option key={g.id} value={g.id}>{g.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="app-label">Group By</label>
|
||||
<select
|
||||
value={groupBy}
|
||||
onChange={(e) => { setGroupBy(e.target.value as GroupByField); setExpandedGroups(new Set()); }}
|
||||
className="app-select w-full"
|
||||
>
|
||||
<option value="none">No grouping</option>
|
||||
<option value="orgUnit">Org unit</option>
|
||||
<option value="mgmtGroup">Mgmt level group</option>
|
||||
<option value="country">Country</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="md:col-span-2 xl:col-span-1 2xl:col-span-1">
|
||||
<label className="app-label">Name Search</label>
|
||||
<input
|
||||
type="search"
|
||||
value={nameSearch}
|
||||
onChange={(e) => setNameSearch(e.target.value)}
|
||||
placeholder="Search by name or EID"
|
||||
className="app-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Country</label>
|
||||
<select
|
||||
value={countryId}
|
||||
onChange={(e) => setCountryId(e.target.value)}
|
||||
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="">All</option>
|
||||
{(countriesQuery.data ?? []).map((c: { id: string; code: string; name: string }) => (
|
||||
<option key={c.id} value={c.id}>{c.code} — {c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Org Unit</label>
|
||||
<select
|
||||
value={orgUnitId}
|
||||
onChange={(e) => setOrgUnitId(e.target.value)}
|
||||
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="">All</option>
|
||||
{orgUnits.map((u: { id: string; name: string }) => (
|
||||
<option key={u.id} value={u.id}>{u.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Mgmt Level Group</label>
|
||||
<select
|
||||
value={mgmtGroupId}
|
||||
onChange={(e) => setMgmtGroupId(e.target.value)}
|
||||
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="">All</option>
|
||||
{(mgmtGroupsQuery.data ?? []).map((g: { id: string; name: string }) => (
|
||||
<option key={g.id} value={g.id}>{g.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Group By</label>
|
||||
<select
|
||||
value={groupBy}
|
||||
onChange={(e) => { setGroupBy(e.target.value as GroupByField); setExpandedGroups(new Set()); }}
|
||||
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="none">No Grouping</option>
|
||||
<option value="orgUnit">Org Unit</option>
|
||||
<option value="mgmtGroup">Mgmt Level Group</option>
|
||||
<option value="country">Country</option>
|
||||
</select>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
|
||||
<label className="inline-flex items-center gap-3 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeProposed}
|
||||
onChange={(e) => setIncludeProposed(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<span>Include proposed work in utilization calculations</span>
|
||||
</label>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
|
||||
{groupBy === "none" ? "Flat resource view" : `Grouped by ${groupBy}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Report table */}
|
||||
{reportQuery.isLoading && !data ? (
|
||||
<div className="text-center py-12 text-gray-500">Loading report...</div>
|
||||
<div className="app-surface-strong py-16 text-center text-sm text-gray-500">Loading report...</div>
|
||||
) : reportQuery.error ? (
|
||||
<div className="text-center py-12 text-red-600">
|
||||
<div className="app-surface-strong py-16 text-center text-sm text-red-600 dark:text-red-400">
|
||||
Error: {reportQuery.error.message}
|
||||
</div>
|
||||
) : data && data.resources.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<div className="app-surface-strong py-16 text-center text-sm text-gray-500">
|
||||
No resources match the current filters.
|
||||
</div>
|
||||
) : data && filteredResources.length === 0 ? (
|
||||
<div className="app-surface-strong py-16 text-center text-sm text-gray-500">
|
||||
No resources match the current search.
|
||||
</div>
|
||||
) : data ? (
|
||||
<div className="overflow-x-auto border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-gray-800">
|
||||
<th className="sticky left-0 z-10 bg-gray-50 dark:bg-gray-800 px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400 min-w-[200px]">
|
||||
Resource
|
||||
</th>
|
||||
<th className="px-2 py-2 text-center font-medium text-gray-500 dark:text-gray-400 w-16">FTE</th>
|
||||
<th className="px-2 py-2 text-center font-medium text-gray-500 dark:text-gray-400 w-16">Target</th>
|
||||
{data.monthKeys.map((key) => (
|
||||
<th key={key} className="px-2 py-2 text-center font-medium text-gray-500 dark:text-gray-400 min-w-[80px]">
|
||||
{formatMonth(key)}
|
||||
<div className="app-data-table">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky left-0 z-10 min-w-[240px] bg-gray-50/95 px-4 py-3 text-left backdrop-blur dark:bg-gray-800/95">
|
||||
Resource
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{/* Overall group total */}
|
||||
{renderGroupTotalsRow("Group Total", data.groupTotals, data.resources.length, true)}
|
||||
<th className="w-20 px-3 py-3 text-center">FTE</th>
|
||||
<th className="w-24 px-3 py-3 text-center">Target</th>
|
||||
{data.monthKeys.map((key) => (
|
||||
<th key={key} className="min-w-[96px] px-3 py-3 text-center">
|
||||
{formatMonth(key)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{renderGroupTotalsRow("Group Total", filteredGroupTotals, filteredResources.length, true)}
|
||||
|
||||
{/* Grouped view */}
|
||||
{groupBy !== "none" ? (
|
||||
groups.map((g) => (
|
||||
<React.Fragment key={g.label}>
|
||||
{renderGroupTotalsRow(g.label, g.monthTotals, g.resources.length, false, () => toggleGroup(g.label))}
|
||||
{expandedGroups.has(g.label) && g.resources.map((r) => (
|
||||
<React.Fragment key={r.id}>
|
||||
{renderResourceRow(r)}
|
||||
{renderExpandedRow(r)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))
|
||||
) : (
|
||||
data.resources.map((r) => (
|
||||
<React.Fragment key={r.id}>
|
||||
{renderResourceRow(r)}
|
||||
{renderExpandedRow(r)}
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{groupBy !== "none" ? (
|
||||
groups.map((g) => (
|
||||
<React.Fragment key={g.label}>
|
||||
{renderGroupTotalsRow(g.label, g.monthTotals, g.resources.length, false, () => toggleGroup(g.label))}
|
||||
{expandedGroups.has(g.label) && g.resources.map((r) => (
|
||||
<React.Fragment key={r.id}>
|
||||
{renderResourceRow(r)}
|
||||
{renderExpandedRow(r)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))
|
||||
) : (
|
||||
filteredResources.map((r) => (
|
||||
<React.Fragment key={r.id}>
|
||||
{renderResourceRow(r)}
|
||||
{renderExpandedRow(r)}
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user