From 8cb34a1c9b3fdb41d7d542d6ab8fc80c0516f11a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 23:06:39 +0200 Subject: [PATCH] feat(web): expand chargeability export explainability --- .../reports/ChargeabilityReportClient.tsx | 433 +++++++++++++++--- 1 file changed, 377 insertions(+), 56 deletions(-) diff --git a/apps/web/src/components/reports/ChargeabilityReportClient.tsx b/apps/web/src/components/reports/ChargeabilityReportClient.tsx index d839460..5a48ccf 100644 --- a/apps/web/src/components/reports/ChargeabilityReportClient.tsx +++ b/apps/web/src/components/reports/ChargeabilityReportClient.tsx @@ -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): 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() {
{r.displayName}
- {[r.eid, r.country, r.city, r.orgUnit].filter(Boolean).join(" · ")} + {[r.eid, formatLocation(r), r.orgUnit].filter(Boolean).join(" · ")}
{r.fte.toFixed(2)} {pct(r.targetPct)} {r.months.map((m) => ( + {(() => { + const chargeabilityRatio = monthChargeabilityRatio(m); + const targetRatio = monthTargetRatio(m, r.targetPct); + + return (
= 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)}
+ ); + })()} ))} @@ -334,6 +578,7 @@ export function ChargeabilityReportClient() {
Mgmt: {r.mgmtGroup ?? "—"} / {r.mgmtLevel ?? "—"}
+
Calendar basis: {formatLocation(r)}
Chg BD @@ -348,13 +593,26 @@ export function ChargeabilityReportClient() { {r.months.map((m) => (
- {pct(m.chg)} - {pct(m.bd)} - {pct(m.mdi)} - {pct(m.mo)} - {pct(m.pdr)} - {pct(m.absence)} - {pct(m.unassigned)} + {pct(monthChargeabilityRatio(m))} + {pct(monthBusinessDevelopmentRatio(m))} + {pct(monthMarketDevelopmentInnovationRatio(m))} + {pct(monthManagementOverheadRatio(m))} + {pct(monthPeopleDevelopmentRecruitingRatio(m))} + {pct(monthPlannedAbsenceRatio(m))} + {pct(monthUnassignedRatio(m))} + {m.derivation ? ( + + {m.derivation.baseAvailableHours}h - {m.derivation.publicHolidayHoursDeduction}h hol - {m.derivation.absenceHoursDeduction}h abs = {m.derivation.effectiveAvailableHours}h SAH + + ) : null} + {m.derivation ? ( + + {m.derivation.publicHolidayWorkdayCount}/{m.derivation.publicHolidayCount} holiday days · {m.derivation.absenceDayEquivalent} absence days + + ) : null} + + {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 +
))} @@ -573,6 +831,69 @@ export function ChargeabilityReportClient() {
+ {data?.explainability ? ( +
+
+
+
+ Calculation Basis + +
+
+ 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. +
+
+
+ Excel adds an Explainability sheet with formulas, notes, and active filters. +
+
+ +
+
+
Location basis
+
+ {data.explainability.locationFields.map((field) => ( + + {toTitleLabel(field)} + + ))} +
+
+ +
+
Month derivation
+
+ {data.explainability.monthDerivationFields.map((field) => ( + + {toTitleLabel(field)} + + ))} +
+
+ +
+
Active filters
+
+ {data.explainability.activeFilters.length > 0 ? data.explainability.activeFilters.map((field) => ( + + {toTitleLabel(field)} + + )) : ( + + none + + )} +
+
+
Chargeability %: {data.explainability.formulas.chargeabilityPct}
+
Target hours: {data.explainability.formulas.targetHours}
+
Gap hours: {data.explainability.formulas.gapHours}
+
+
+
+
+ ) : null} + {reportQuery.isLoading && !data ? (
Loading report...
) : reportQuery.error ? (