Files
CapaKraken/apps/web/src/components/reports/ChargeabilityReportClient.tsx
T
Hartmut 093e13b88f feat: project cover art with AI generation, branding rename, RBAC fix, computation graph
- Add DALL-E cover art generation for projects (Azure OpenAI + standard OpenAI)
- CoverArtSection component with generate/upload/remove/focus-point controls
- Client-side image compression (10MB input → WebP/JPEG, max 1920px)
- DALL-E settings in admin panel (deployment, endpoint, API key)
- MCP assistant tools for cover art (generate_project_cover, remove_project_cover)
- Rename "Planarchy" → "plANARCHY" across all UI-facing text (13 files)
- Fix hardcoded canEdit={true} on project detail page — now checks user role
- Computation graph visualization (2D/3D) for calculation rules
- OG image and OpenGraph metadata

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-18 11:31:56 +01:00

629 lines
27 KiB
TypeScript

"use client";
import React, { useState, useMemo, useCallback } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
// ─── Helpers ─────────────────────────────────────────────────────────────────
function formatMonth(key: string): string {
const [y, m] = key.split("-");
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
return `${months[Number(m) - 1]} ${y}`;
}
function pct(ratio: number): string {
return `${Math.round(ratio * 100)}%`;
}
function chgColor(ratio: number, target: number): string {
if (ratio >= target) return "text-green-700 dark:text-green-400";
if (ratio >= target - 0.1) return "text-yellow-700 dark:text-yellow-400";
return "text-red-700 dark:text-red-400";
}
function barStyle(ratio: number, color: string): React.CSSProperties {
return {
background: `linear-gradient(to right, ${color} ${Math.min(ratio * 100, 100)}%, transparent ${Math.min(ratio * 100, 100)}%)`,
};
}
type GroupByField = "none" | "orgUnit" | "mgmtGroup" | "country";
interface ResourceRow {
id: string;
eid: string;
displayName: string;
fte: number;
country: string | null;
city: string | null;
orgUnit: string | null;
mgmtGroup: string | null;
mgmtLevel: string | null;
targetPct: number;
months: MonthData[];
}
interface MonthData {
monthKey: string;
sah: number;
chg: number;
bd: number;
mdi: number;
mo: number;
pdr: number;
absence: number;
unassigned: number;
}
interface GroupSummary {
label: string;
resources: ResourceRow[];
monthTotals: { monthKey: string; chg: number; target: number; gap: number; totalFte: number }[];
}
function computeGroupMonthTotals(
resources: ResourceRow[],
monthKeys: string[],
): GroupSummary["monthTotals"] {
const totalFte = resources.reduce((sum, r) => sum + r.fte, 0);
return monthKeys.map((key, idx) => {
if (totalFte === 0) return { monthKey: key, chg: 0, target: 0, gap: 0, totalFte: 0 };
const chg = resources.reduce((sum, r) => sum + r.fte * r.months[idx]!.chg, 0) / totalFte;
const target = resources.reduce((sum, r) => sum + r.fte * r.targetPct, 0) / totalFte;
return { monthKey: key, chg, target, gap: chg - target, totalFte };
});
}
// ─── Export ──────────────────────────────────────────────────────────────────
async function exportToExcel(
resources: ResourceRow[],
monthKeys: string[],
groupTotals: { monthKey: string; chg: number; target: number; gap: number; totalFte: number }[],
groups: GroupSummary[],
groupBy: GroupByField,
) {
const XLSX = await import("xlsx");
const wb = XLSX.utils.book_new();
// Build main data rows
const headers = ["Name", "EID", "FTE", "Target", "Country", "City", "Org Unit", "Mgmt Group", "Mgmt Level"];
for (const key of monthKeys) {
headers.push(`Chg ${formatMonth(key)}`);
headers.push(`BD ${formatMonth(key)}`);
headers.push(`MD&I ${formatMonth(key)}`);
headers.push(`M&O ${formatMonth(key)}`);
headers.push(`PD&R ${formatMonth(key)}`);
headers.push(`Abs ${formatMonth(key)}`);
headers.push(`Free ${formatMonth(key)}`);
headers.push(`SAH ${formatMonth(key)}`);
}
const rows: (string | number)[][] = [headers];
// Group total row
const totalRow: (string | number)[] = [
"GROUP TOTAL", "", groupTotals[0]?.totalFte ?? 0, groupTotals[0]?.target ?? 0,
"", "", "", "", "",
];
for (const gt of groupTotals) {
totalRow.push(Math.round(gt.chg * 100) / 100, 0, 0, 0, 0, 0, 0, 0);
}
rows.push(totalRow);
// Sub-group totals if grouping
if (groupBy !== "none") {
for (const g of groups) {
const subRow: (string | number)[] = [
` ${g.label} (${g.resources.length})`, "", g.monthTotals[0]?.totalFte ?? 0,
g.monthTotals[0]?.target ?? 0, "", "", "", "", "",
];
for (const mt of g.monthTotals) {
subRow.push(Math.round(mt.chg * 100) / 100, 0, 0, 0, 0, 0, 0, 0);
}
rows.push(subRow);
}
rows.push([]); // blank separator
}
// Resource rows
for (const r of resources) {
const row: (string | number)[] = [
r.displayName, r.eid, r.fte, Math.round(r.targetPct * 100) / 100,
r.country ?? "", r.city ?? "", r.orgUnit ?? "", r.mgmtGroup ?? "", r.mgmtLevel ?? "",
];
for (const m of r.months) {
row.push(
Math.round(m.chg * 1000) / 1000,
Math.round(m.bd * 1000) / 1000,
Math.round(m.mdi * 1000) / 1000,
Math.round(m.mo * 1000) / 1000,
Math.round(m.pdr * 1000) / 1000,
Math.round(m.absence * 1000) / 1000,
Math.round(m.unassigned * 1000) / 1000,
Math.round(m.sah * 100) / 100,
);
}
rows.push(row);
}
const ws = XLSX.utils.aoa_to_sheet(rows);
XLSX.utils.book_append_sheet(wb, ws, "Chargeability");
XLSX.writeFile(wb, `chargeability-report.xlsx`);
}
function exportToCsv(
resources: ResourceRow[],
monthKeys: string[],
) {
const headers = ["Name", "EID", "FTE", "Target", "Country", "City", "Org Unit", "Mgmt Group", "Mgmt Level"];
for (const key of monthKeys) {
headers.push(`Chg_${key}`, `BD_${key}`, `MDI_${key}`, `MO_${key}`, `PDR_${key}`, `Abs_${key}`, `Free_${key}`, `SAH_${key}`);
}
const lines = [headers.join(",")];
for (const r of resources) {
const vals = [
`"${r.displayName}"`, r.eid, r.fte, r.targetPct,
r.country ?? "", r.city ?? "", r.orgUnit ?? "", r.mgmtGroup ?? "", r.mgmtLevel ?? "",
];
for (const m of r.months) {
vals.push(
m.chg as unknown as string, m.bd as unknown as string, m.mdi as unknown as string,
m.mo as unknown as string, m.pdr as unknown as string, m.absence as unknown as string,
m.unassigned as unknown as string, m.sah as unknown as string,
);
}
lines.push(vals.join(","));
}
const blob = new Blob([lines.join("\n")], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "chargeability-report.csv";
a.click();
URL.revokeObjectURL(url);
}
// ─── Component ───────────────────────────────────────────────────────────────
const NOW = new Date();
const DEFAULT_START = `${NOW.getFullYear()}-${String(NOW.getMonth() + 1).padStart(2, "0")}`;
const DEFAULT_END_DATE = new Date(NOW.getFullYear(), NOW.getMonth() + 5, 1);
const DEFAULT_END = `${DEFAULT_END_DATE.getFullYear()}-${String(DEFAULT_END_DATE.getMonth() + 1).padStart(2, "0")}`;
export function ChargeabilityReportClient() {
const [startMonth, setStartMonth] = useState(DEFAULT_START);
const [endMonth, setEndMonth] = useState(DEFAULT_END);
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());
// Filter dropdowns data
const orgUnitsQuery = trpc.orgUnit.list.useQuery();
const mgmtGroupsQuery = trpc.managementLevel.listGroups.useQuery();
const countriesQuery = trpc.country.list.useQuery();
const reportQuery = trpc.chargeabilityReport.getReport.useQuery(
{
startMonth,
endMonth,
...(orgUnitId ? { orgUnitId } : {}),
...(mgmtGroupId ? { managementLevelGroupId: mgmtGroupId } : {}),
...(countryId ? { countryId } : {}),
includeProposed,
},
{ placeholderData: (prev) => prev },
);
const data = reportQuery.data;
const orgUnits = useMemo(() => {
const items = orgUnitsQuery.data ?? [];
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 filteredResources) {
let key: string;
switch (groupBy) {
case "orgUnit": key = r.orgUnit ?? "(No Org Unit)"; break;
case "mgmtGroup": key = r.mgmtGroup ?? "(No Mgmt Group)"; break;
case "country": key = r.country ?? "(No Country)"; break;
}
const list = buckets.get(key) ?? [];
list.push(r);
buckets.set(key, list);
}
return Array.from(buckets.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([label, resources]) => ({
label,
resources,
monthTotals: computeGroupMonthTotals(resources, data.monthKeys),
}));
}, [data, filteredResources, groupBy]);
const toggleGroup = useCallback((label: string) => {
setExpandedGroups((prev) => {
const next = new Set(prev);
if (next.has(label)) next.delete(label);
else next.add(label);
return next;
});
}, []);
const handleExportExcel = useCallback(() => {
if (!data) return;
exportToExcel(filteredResources, data.monthKeys, filteredGroupTotals, groups, groupBy);
}, [data, filteredGroupTotals, filteredResources, groups, groupBy]);
const handleExportCsv = useCallback(() => {
if (!data) return;
exportToCsv(filteredResources, data.monthKeys);
}, [data, filteredResources]);
// ─── Render helpers ──────────────────────────────────────────────────────
function renderResourceRow(r: ResourceRow) {
return (
<tr
key={r.id}
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/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-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-3 py-3 text-center">
<div
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)}
</div>
</td>
))}
</tr>
);
}
function renderExpandedRow(r: ResourceRow) {
if (expandedResource !== r.id) return null;
return (
<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-2 grid grid-cols-7 gap-1 text-[10px] uppercase tracking-[0.16em] text-gray-400 dark:text-gray-500">
<span className="font-medium inline-flex items-center gap-0.5">Chg<InfoTooltip content="Chargeability: % of available hours on chargeable work." /></span>
<span className="font-medium inline-flex items-center gap-0.5">BD<InfoTooltip content="Business Development hours as % of available time." /></span>
<span className="font-medium inline-flex items-center gap-0.5">MD&I<InfoTooltip content="Market Development & Innovation hours %." /></span>
<span className="font-medium inline-flex items-center gap-0.5">M&O<InfoTooltip content="Management & Overhead hours %." /></span>
<span className="font-medium inline-flex items-center gap-0.5">PD&R<InfoTooltip content="People Development & Recruiting hours %." /></span>
<span className="font-medium inline-flex items-center gap-0.5">Abs<InfoTooltip content="Absence (vacation, sick) as % of working time." /></span>
<span className="font-medium inline-flex items-center gap-0.5">Free<InfoTooltip content="Unassigned / free capacity as % of available hours." /></span>
</div>
</div>
</td>
{r.months.map((m) => (
<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 dark:text-orange-300">{pct(m.absence)}</span>
<span className="text-gray-400 dark:text-gray-500">{pct(m.unassigned)}</span>
</div>
</td>
))}
</tr>
);
}
function renderGroupTotalsRow(
label: string,
monthTotals: GroupSummary["monthTotals"],
count: number,
isOverall: boolean,
onClick?: () => void,
) {
const bg = isOverall
? "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-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-3 py-3 text-center text-gray-700 dark:text-gray-300">
{monthTotals[0]?.totalFte.toFixed(1)}
</td>
<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-3 py-3 text-center">
<div className={`font-semibold ${chgColor(mt.chg, mt.target)}`}>{pct(mt.chg)}</div>
{mt.gap !== 0 && (
<div className="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
{mt.gap > 0 ? "+" : ""}{pct(mt.gap)}
</div>
)}
</td>
))}
</tr>
);
}
// ─── Main render ─────────────────────────────────────────────────────────
return (
<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 flex-wrap gap-2">
<button
onClick={handleExportExcel}
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="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>
</div>
)}
</div>
{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 inline-flex items-center gap-0.5">Resources<InfoTooltip content="Number of active resources matching the current filters." /></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 inline-flex items-center gap-0.5">Average Chargeability<InfoTooltip content="FTE-weighted average chargeability across all visible resources. Formula: sum(FTE x Chg%) / sum(FTE)." /></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 inline-flex items-center gap-0.5">Average Target<InfoTooltip content="FTE-weighted average chargeability target set per resource. The benchmark for actual performance." /></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 inline-flex items-center gap-0.5">Average Gap<InfoTooltip content="Chargeability minus target. Green = above target, red = below target." /></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>
) : 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 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>
{reportQuery.isLoading && !data ? (
<div className="app-surface-strong py-16 text-center text-sm text-gray-500">Loading report...</div>
) : reportQuery.error ? (
<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="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="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>
<th className="w-20 px-3 py-3 text-center"><span className="inline-flex items-center justify-center gap-0.5">FTE<InfoTooltip content="Full-Time Equivalent. 1.0 = full-time, 0.5 = half-time. Used to weight chargeability averages." /></span></th>
<th className="w-24 px-3 py-3 text-center"><span className="inline-flex items-center justify-center gap-0.5">Target<InfoTooltip content="Chargeability target for this resource. Cells are green/yellow/red relative to this target." /></span></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)}
{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>
);
}