feat(web): expand chargeability export explainability

This commit is contained in:
2026-03-31 23:06:39 +02:00
parent dfa289213c
commit 8cb34a1c9b
@@ -4,6 +4,7 @@ import React, { useState, useMemo, useCallback } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { ProgressRing } from "~/components/ui/ProgressRing.js";
import { downloadWorkbookSheets } from "~/lib/workbook-export.js";
// ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -37,6 +38,7 @@ interface ResourceRow {
displayName: string;
fte: number;
country: string | null;
federalState: string | null;
city: string | null;
orgUnit: string | null;
mgmtGroup: string | null;
@@ -48,13 +50,85 @@ interface ResourceRow {
interface MonthData {
monthKey: string;
sah: number;
chg: number;
bd: number;
mdi: number;
mo: number;
pdr: number;
absence: number;
unassigned: number;
sahHours?: number;
chg?: number;
bd?: number;
mdi?: number;
mo?: number;
pdr?: number;
absence?: number;
unassigned?: number;
chargeabilityRatio?: number;
businessDevelopmentRatio?: number;
marketDevelopmentInnovationRatio?: number;
managementOverheadRatio?: number;
peopleDevelopmentRecruitingRatio?: number;
plannedAbsenceRatio?: number;
unassignedRatio?: number;
chargeabilityHours?: number;
businessDevelopmentHours?: number;
marketDevelopmentInnovationHours?: number;
managementOverheadHours?: number;
peopleDevelopmentRecruitingHours?: number;
plannedAbsenceHours?: number;
unassignedHours?: number;
targetRatio?: number;
targetHours?: number;
gapRatio?: number;
gapHours?: number;
derivation?: {
baseAvailableHours: number;
publicHolidayCount: number;
publicHolidayWorkdayCount: number;
publicHolidayHoursDeduction: number;
absenceDayEquivalent: number;
absenceHoursDeduction: number;
effectiveAvailableHours: number;
};
}
function formatLocation(resource: Pick<ResourceRow, "country" | "federalState" | "city">): string {
return [resource.country, resource.federalState, resource.city].filter(Boolean).join(" / ") || "No calendar context";
}
function monthChargeabilityRatio(month: MonthData): number {
return month.chargeabilityRatio ?? month.chg ?? 0;
}
function monthBusinessDevelopmentRatio(month: MonthData): number {
return month.businessDevelopmentRatio ?? month.bd ?? 0;
}
function monthMarketDevelopmentInnovationRatio(month: MonthData): number {
return month.marketDevelopmentInnovationRatio ?? month.mdi ?? 0;
}
function monthManagementOverheadRatio(month: MonthData): number {
return month.managementOverheadRatio ?? month.mo ?? 0;
}
function monthPeopleDevelopmentRecruitingRatio(month: MonthData): number {
return month.peopleDevelopmentRecruitingRatio ?? month.pdr ?? 0;
}
function monthPlannedAbsenceRatio(month: MonthData): number {
return month.plannedAbsenceRatio ?? month.absence ?? 0;
}
function monthUnassignedRatio(month: MonthData): number {
return month.unassignedRatio ?? month.unassigned ?? 0;
}
function monthSahHours(month: MonthData): number {
return month.sahHours ?? month.sah;
}
function monthTargetRatio(month: MonthData, resourceTargetRatio: number): number {
return month.targetRatio ?? resourceTargetRatio;
}
function monthGapRatio(month: MonthData, resourceTargetRatio: number): number {
return month.gapRatio ?? (monthChargeabilityRatio(month) - monthTargetRatio(month, resourceTargetRatio));
}
interface GroupSummary {
@@ -63,6 +137,26 @@ interface GroupSummary {
monthTotals: { monthKey: string; chg: number; target: number; gap: number; totalFte: number }[];
}
interface ReportExplainability {
locationFields: string[];
monthDerivationFields: string[];
activeFilters: string[];
formulas: {
sah: string;
chargeabilityPct: string;
targetHours: string;
gapHours: string;
};
notes: string[];
}
interface ReportFilterSummary {
startMonth: string;
endMonth: string;
includeProposed: boolean;
groupBy: GroupByField;
}
function computeGroupMonthTotals(
resources: ResourceRow[],
monthKeys: string[],
@@ -70,8 +164,8 @@ function computeGroupMonthTotals(
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;
const chg = resources.reduce((sum, r) => sum + r.fte * monthChargeabilityRatio(r.months[idx]!), 0) / totalFte;
const target = resources.reduce((sum, r) => sum + r.fte * monthTargetRatio(r.months[idx]!, r.targetPct), 0) / totalFte;
return { monthKey: key, chg, target, gap: chg - target, totalFte };
});
}
@@ -84,20 +178,26 @@ async function exportToExcel(
groupTotals: { monthKey: string; chg: number; target: number; gap: number; totalFte: number }[],
groups: GroupSummary[],
groupBy: GroupByField,
explainability: ReportExplainability | undefined,
filterSummary: ReportFilterSummary,
) {
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"];
const headers = ["Name", "EID", "FTE", "Target", "Country", "State", "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(`Chg % ${formatMonth(key)}`);
headers.push(`Chg H ${formatMonth(key)}`);
headers.push(`Target % ${formatMonth(key)}`);
headers.push(`Target H ${formatMonth(key)}`);
headers.push(`Gap % ${formatMonth(key)}`);
headers.push(`Gap H ${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(`Planned Abs % ${formatMonth(key)}`);
headers.push(`Free % ${formatMonth(key)}`);
headers.push(`Base ${formatMonth(key)}`);
headers.push(`Hol ${formatMonth(key)}`);
headers.push(`AbsH ${formatMonth(key)}`);
headers.push(`SAH ${formatMonth(key)}`);
}
@@ -106,10 +206,27 @@ async function exportToExcel(
// 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);
totalRow.push(
Math.round(gt.chg * 1000) / 1000,
0,
Math.round(gt.target * 1000) / 1000,
0,
Math.round(gt.gap * 1000) / 1000,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
);
}
rows.push(totalRow);
@@ -118,10 +235,27 @@ async function exportToExcel(
for (const g of groups) {
const subRow: (string | number)[] = [
` ${g.label} (${g.resources.length})`, "", g.monthTotals[0]?.totalFte ?? 0,
g.monthTotals[0]?.target ?? 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);
subRow.push(
Math.round(mt.chg * 1000) / 1000,
0,
Math.round(mt.target * 1000) / 1000,
0,
Math.round(mt.gap * 1000) / 1000,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
);
}
rows.push(subRow);
}
@@ -132,48 +266,125 @@ async function exportToExcel(
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 ?? "",
r.country ?? "", r.federalState ?? "", r.city ?? "", r.orgUnit ?? "", r.mgmtGroup ?? "", r.mgmtLevel ?? "",
];
for (const m of r.months) {
const chargeabilityRatio = monthChargeabilityRatio(m);
const businessDevelopmentRatio = monthBusinessDevelopmentRatio(m);
const marketDevelopmentInnovationRatio = monthMarketDevelopmentInnovationRatio(m);
const managementOverheadRatio = monthManagementOverheadRatio(m);
const peopleDevelopmentRecruitingRatio = monthPeopleDevelopmentRecruitingRatio(m);
const plannedAbsenceRatio = monthPlannedAbsenceRatio(m);
const unassignedRatio = monthUnassignedRatio(m);
const targetRatio = monthTargetRatio(m, r.targetPct);
const gapRatio = monthGapRatio(m, r.targetPct);
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,
Math.round(chargeabilityRatio * 1000) / 1000,
Math.round((m.chargeabilityHours ?? (monthSahHours(m) * chargeabilityRatio)) * 10) / 10,
Math.round(targetRatio * 1000) / 1000,
Math.round((m.targetHours ?? (monthSahHours(m) * targetRatio)) * 10) / 10,
Math.round(gapRatio * 1000) / 1000,
Math.round((m.gapHours ?? (monthSahHours(m) * gapRatio)) * 10) / 10,
Math.round(businessDevelopmentRatio * 1000) / 1000,
Math.round(marketDevelopmentInnovationRatio * 1000) / 1000,
Math.round(managementOverheadRatio * 1000) / 1000,
Math.round(peopleDevelopmentRecruitingRatio * 1000) / 1000,
Math.round(plannedAbsenceRatio * 1000) / 1000,
Math.round(unassignedRatio * 1000) / 1000,
Math.round((m.derivation?.baseAvailableHours ?? 0) * 10) / 10,
Math.round((m.derivation?.publicHolidayHoursDeduction ?? 0) * 10) / 10,
Math.round((m.derivation?.absenceHoursDeduction ?? 0) * 10) / 10,
Math.round(monthSahHours(m) * 10) / 10,
);
}
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`);
const explainabilityRows: (string | number)[][] = [
["Chargeability Forecast Explainability"],
[],
["Report range", `${formatMonth(filterSummary.startMonth)} to ${formatMonth(filterSummary.endMonth)}`],
["Include proposed work", filterSummary.includeProposed ? "Yes" : "No"],
["Grouping", filterSummary.groupBy === "none" ? "Flat resource view" : filterSummary.groupBy],
[],
["Location basis fields", ...(explainability?.locationFields ?? [])],
["Month derivation fields", ...(explainability?.monthDerivationFields ?? [])],
["Active filters", ...((explainability?.activeFilters.length ?? 0) > 0 ? explainability!.activeFilters : ["none"])],
[],
["Formula", "Definition"],
["SAH", explainability?.formulas.sah ?? ""],
["Chargeability %", explainability?.formulas.chargeabilityPct ?? ""],
["Target hours", explainability?.formulas.targetHours ?? ""],
["Gap hours", explainability?.formulas.gapHours ?? ""],
[],
["Notes"],
...(explainability?.notes.map((note) => [note]) ?? []),
[],
["Export coverage"],
["Resource rows already include Country, State, City, Org Unit, Mgmt Group, Mgmt Level."],
["Each month exports Base, Holiday, Absence, and SAH hours alongside chargeability, target, and gap."],
];
await downloadWorkbookSheets("chargeability-report.xlsx", [
{ name: "Chargeability", rows },
{ name: "Explainability", rows: explainabilityRows },
]);
}
function exportToCsv(
resources: ResourceRow[],
monthKeys: string[],
) {
const headers = ["Name", "EID", "FTE", "Target", "Country", "City", "Org Unit", "Mgmt Group", "Mgmt Level"];
const headers = ["Name", "EID", "FTE", "Target", "Country", "State", "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}`);
headers.push(
`ChgPct_${key}`,
`ChgHours_${key}`,
`TargetPct_${key}`,
`TargetHours_${key}`,
`GapPct_${key}`,
`GapHours_${key}`,
`BDPct_${key}`,
`MDIPct_${key}`,
`MOPct_${key}`,
`PDRPct_${key}`,
`PlannedAbsPct_${key}`,
`FreePct_${key}`,
`BaseH_${key}`,
`HolidayH_${key}`,
`AbsenceH_${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 ?? "",
r.country ?? "", r.federalState ?? "", r.city ?? "", r.orgUnit ?? "", r.mgmtGroup ?? "", r.mgmtLevel ?? "",
];
for (const m of r.months) {
const chargeabilityRatio = monthChargeabilityRatio(m);
const targetRatio = monthTargetRatio(m, r.targetPct);
const gapRatio = monthGapRatio(m, r.targetPct);
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,
chargeabilityRatio as unknown as string,
(m.chargeabilityHours ?? (monthSahHours(m) * chargeabilityRatio)) as unknown as string,
targetRatio as unknown as string,
(m.targetHours ?? (monthSahHours(m) * targetRatio)) as unknown as string,
gapRatio as unknown as string,
(m.gapHours ?? (monthSahHours(m) * gapRatio)) as unknown as string,
monthBusinessDevelopmentRatio(m) as unknown as string,
monthMarketDevelopmentInnovationRatio(m) as unknown as string,
monthManagementOverheadRatio(m) as unknown as string,
monthPeopleDevelopmentRecruitingRatio(m) as unknown as string,
monthPlannedAbsenceRatio(m) as unknown as string,
monthUnassignedRatio(m) as unknown as string,
(m.derivation?.baseAvailableHours ?? 0) as unknown as string,
(m.derivation?.publicHolidayHoursDeduction ?? 0) as unknown as string,
(m.derivation?.absenceHoursDeduction ?? 0) as unknown as string,
monthSahHours(m) as unknown as string,
);
}
lines.push(vals.join(","));
@@ -188,6 +399,19 @@ function exportToCsv(
URL.revokeObjectURL(url);
}
function toTitleLabel(value: string): string {
return value
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/Id$/g, "")
.replace(/^mdi$/i, "MD&I")
.replace(/^pdr$/i, "PD&R")
.replace(/^mgmt$/i, "Mgmt")
.replace(/^orgUnit$/i, "Org Unit")
.replace(/^federalState$/i, "Federal State")
.replace(/^country$/i, "Country")
.replace(/^city$/i, "City");
}
// ─── Component ───────────────────────────────────────────────────────────────
const NOW = new Date();
@@ -288,8 +512,21 @@ export function ChargeabilityReportClient() {
const handleExportExcel = useCallback(() => {
if (!data) return;
exportToExcel(filteredResources, data.monthKeys, filteredGroupTotals, groups, groupBy);
}, [data, filteredGroupTotals, filteredResources, groups, groupBy]);
exportToExcel(
filteredResources,
data.monthKeys,
filteredGroupTotals,
groups,
groupBy,
data.explainability,
{
startMonth,
endMonth,
includeProposed,
groupBy,
},
);
}, [data, endMonth, filteredGroupTotals, filteredResources, groupBy, groups, includeProposed, startMonth]);
const handleExportCsv = useCallback(() => {
if (!data) return;
@@ -308,19 +545,26 @@ export function ChargeabilityReportClient() {
<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(" · ")}
{[r.eid, formatLocation(r), 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">
{(() => {
const chargeabilityRatio = monthChargeabilityRatio(m);
const targetRatio = monthTargetRatio(m, r.targetPct);
return (
<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)")}
className={`rounded-xl border border-transparent px-2 py-1 font-semibold ${chgColor(chargeabilityRatio, targetRatio)}`}
style={barStyle(chargeabilityRatio, chargeabilityRatio >= targetRatio ? "rgba(34,197,94,0.15)" : "rgba(239,68,68,0.1)")}
>
{pct(m.chg)}
{pct(chargeabilityRatio)}
</div>
);
})()}
</td>
))}
</tr>
@@ -334,6 +578,7 @@ export function ChargeabilityReportClient() {
<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>Calendar basis: {formatLocation(r)}</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>
@@ -348,13 +593,26 @@ export function ChargeabilityReportClient() {
{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>
<span className="font-semibold text-green-600 dark:text-green-400">{pct(monthChargeabilityRatio(m))}</span>
<span>{pct(monthBusinessDevelopmentRatio(m))}</span>
<span>{pct(monthMarketDevelopmentInnovationRatio(m))}</span>
<span>{pct(monthManagementOverheadRatio(m))}</span>
<span>{pct(monthPeopleDevelopmentRecruitingRatio(m))}</span>
<span className="text-orange-500 dark:text-orange-300">{pct(monthPlannedAbsenceRatio(m))}</span>
<span className="text-gray-400 dark:text-gray-500">{pct(monthUnassignedRatio(m))}</span>
{m.derivation ? (
<span className="pt-1 text-[9px] leading-4 text-gray-400 dark:text-gray-500">
{m.derivation.baseAvailableHours}h - {m.derivation.publicHolidayHoursDeduction}h hol - {m.derivation.absenceHoursDeduction}h abs = {m.derivation.effectiveAvailableHours}h SAH
</span>
) : null}
{m.derivation ? (
<span className="text-[9px] leading-4 text-gray-400 dark:text-gray-500">
{m.derivation.publicHolidayWorkdayCount}/{m.derivation.publicHolidayCount} holiday days · {m.derivation.absenceDayEquivalent} absence days
</span>
) : null}
<span className="text-[9px] leading-4 text-gray-400 dark:text-gray-500">
{Math.round((m.chargeabilityHours ?? (monthSahHours(m) * monthChargeabilityRatio(m))) * 10) / 10}h chg vs {Math.round((m.targetHours ?? (monthSahHours(m) * monthTargetRatio(m, r.targetPct))) * 10) / 10}h target
</span>
</div>
</td>
))}
@@ -573,6 +831,69 @@ export function ChargeabilityReportClient() {
</div>
</div>
{data?.explainability ? (
<div className="app-surface p-4">
<div className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div className="space-y-2">
<div className="inline-flex items-center gap-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">
Calculation Basis
<InfoTooltip content="The same holiday, absence, and SAH logic used by the API and assistant." />
</div>
<div className="text-sm text-gray-600 dark:text-gray-300">
SAH = base available hours minus public-holiday deduction minus absence deduction. Exported rows already include Country, State, City, and monthly Base/Holiday/Absence/SAH values.
</div>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Excel adds an Explainability sheet with formulas, notes, and active filters.
</div>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-3">
<div className="rounded-2xl border border-gray-200/80 bg-gray-50/70 p-3 dark:border-gray-800 dark:bg-slate-900/60">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">Location basis</div>
<div className="mt-2 flex flex-wrap gap-2">
{data.explainability.locationFields.map((field) => (
<span key={field} className="rounded-full border border-gray-200 bg-white px-2 py-1 text-xs text-gray-600 dark:border-gray-700 dark:bg-slate-950 dark:text-gray-300">
{toTitleLabel(field)}
</span>
))}
</div>
</div>
<div className="rounded-2xl border border-gray-200/80 bg-gray-50/70 p-3 dark:border-gray-800 dark:bg-slate-900/60">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">Month derivation</div>
<div className="mt-2 flex flex-wrap gap-2">
{data.explainability.monthDerivationFields.map((field) => (
<span key={field} className="rounded-full border border-gray-200 bg-white px-2 py-1 text-xs text-gray-600 dark:border-gray-700 dark:bg-slate-950 dark:text-gray-300">
{toTitleLabel(field)}
</span>
))}
</div>
</div>
<div className="rounded-2xl border border-gray-200/80 bg-gray-50/70 p-3 dark:border-gray-800 dark:bg-slate-900/60">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">Active filters</div>
<div className="mt-2 flex flex-wrap gap-2">
{data.explainability.activeFilters.length > 0 ? data.explainability.activeFilters.map((field) => (
<span key={field} className="rounded-full border border-brand-200 bg-brand-50 px-2 py-1 text-xs text-brand-700 dark:border-brand-800 dark:bg-brand-900/30 dark:text-brand-200">
{toTitleLabel(field)}
</span>
)) : (
<span className="rounded-full border border-gray-200 bg-white px-2 py-1 text-xs text-gray-500 dark:border-gray-700 dark:bg-slate-950 dark:text-gray-400">
none
</span>
)}
</div>
<div className="mt-3 space-y-1 text-xs text-gray-500 dark:text-gray-400">
<div>Chargeability %: {data.explainability.formulas.chargeabilityPct}</div>
<div>Target hours: {data.explainability.formulas.targetHours}</div>
<div>Gap hours: {data.explainability.formulas.gapHours}</div>
</div>
</div>
</div>
</div>
) : null}
{reportQuery.isLoading && !data ? (
<div className="app-surface-strong py-16 text-center text-sm text-gray-500">Loading report...</div>
) : reportQuery.error ? (