feat(web): expand chargeability export explainability
This commit is contained in:
@@ -4,6 +4,7 @@ import React, { useState, useMemo, useCallback } from "react";
|
|||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
import { ProgressRing } from "~/components/ui/ProgressRing.js";
|
import { ProgressRing } from "~/components/ui/ProgressRing.js";
|
||||||
|
import { downloadWorkbookSheets } from "~/lib/workbook-export.js";
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ interface ResourceRow {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
fte: number;
|
fte: number;
|
||||||
country: string | null;
|
country: string | null;
|
||||||
|
federalState: string | null;
|
||||||
city: string | null;
|
city: string | null;
|
||||||
orgUnit: string | null;
|
orgUnit: string | null;
|
||||||
mgmtGroup: string | null;
|
mgmtGroup: string | null;
|
||||||
@@ -48,13 +50,85 @@ interface ResourceRow {
|
|||||||
interface MonthData {
|
interface MonthData {
|
||||||
monthKey: string;
|
monthKey: string;
|
||||||
sah: number;
|
sah: number;
|
||||||
chg: number;
|
sahHours?: number;
|
||||||
bd: number;
|
chg?: number;
|
||||||
mdi: number;
|
bd?: number;
|
||||||
mo: number;
|
mdi?: number;
|
||||||
pdr: number;
|
mo?: number;
|
||||||
absence: number;
|
pdr?: number;
|
||||||
unassigned: 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 {
|
interface GroupSummary {
|
||||||
@@ -63,6 +137,26 @@ interface GroupSummary {
|
|||||||
monthTotals: { monthKey: string; chg: number; target: number; gap: number; totalFte: number }[];
|
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(
|
function computeGroupMonthTotals(
|
||||||
resources: ResourceRow[],
|
resources: ResourceRow[],
|
||||||
monthKeys: string[],
|
monthKeys: string[],
|
||||||
@@ -70,8 +164,8 @@ function computeGroupMonthTotals(
|
|||||||
const totalFte = resources.reduce((sum, r) => sum + r.fte, 0);
|
const totalFte = resources.reduce((sum, r) => sum + r.fte, 0);
|
||||||
return monthKeys.map((key, idx) => {
|
return monthKeys.map((key, idx) => {
|
||||||
if (totalFte === 0) return { monthKey: key, chg: 0, target: 0, gap: 0, totalFte: 0 };
|
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 chg = resources.reduce((sum, r) => sum + r.fte * monthChargeabilityRatio(r.months[idx]!), 0) / totalFte;
|
||||||
const target = resources.reduce((sum, r) => sum + r.fte * r.targetPct, 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 };
|
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 }[],
|
groupTotals: { monthKey: string; chg: number; target: number; gap: number; totalFte: number }[],
|
||||||
groups: GroupSummary[],
|
groups: GroupSummary[],
|
||||||
groupBy: GroupByField,
|
groupBy: GroupByField,
|
||||||
|
explainability: ReportExplainability | undefined,
|
||||||
|
filterSummary: ReportFilterSummary,
|
||||||
) {
|
) {
|
||||||
const XLSX = await import("xlsx");
|
const headers = ["Name", "EID", "FTE", "Target", "Country", "State", "City", "Org Unit", "Mgmt Group", "Mgmt Level"];
|
||||||
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) {
|
for (const key of monthKeys) {
|
||||||
headers.push(`Chg ${formatMonth(key)}`);
|
headers.push(`Chg % ${formatMonth(key)}`);
|
||||||
headers.push(`BD ${formatMonth(key)}`);
|
headers.push(`Chg H ${formatMonth(key)}`);
|
||||||
headers.push(`MD&I ${formatMonth(key)}`);
|
headers.push(`Target % ${formatMonth(key)}`);
|
||||||
headers.push(`M&O ${formatMonth(key)}`);
|
headers.push(`Target H ${formatMonth(key)}`);
|
||||||
headers.push(`PD&R ${formatMonth(key)}`);
|
headers.push(`Gap % ${formatMonth(key)}`);
|
||||||
headers.push(`Abs ${formatMonth(key)}`);
|
headers.push(`Gap H ${formatMonth(key)}`);
|
||||||
headers.push(`Free ${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)}`);
|
headers.push(`SAH ${formatMonth(key)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,10 +206,27 @@ async function exportToExcel(
|
|||||||
// Group total row
|
// Group total row
|
||||||
const totalRow: (string | number)[] = [
|
const totalRow: (string | number)[] = [
|
||||||
"GROUP TOTAL", "", groupTotals[0]?.totalFte ?? 0, groupTotals[0]?.target ?? 0,
|
"GROUP TOTAL", "", groupTotals[0]?.totalFte ?? 0, groupTotals[0]?.target ?? 0,
|
||||||
"", "", "", "", "",
|
"", "", "", "", "", "",
|
||||||
];
|
];
|
||||||
for (const gt of groupTotals) {
|
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);
|
rows.push(totalRow);
|
||||||
|
|
||||||
@@ -118,10 +235,27 @@ async function exportToExcel(
|
|||||||
for (const g of groups) {
|
for (const g of groups) {
|
||||||
const subRow: (string | number)[] = [
|
const subRow: (string | number)[] = [
|
||||||
` ${g.label} (${g.resources.length})`, "", g.monthTotals[0]?.totalFte ?? 0,
|
` ${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) {
|
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);
|
rows.push(subRow);
|
||||||
}
|
}
|
||||||
@@ -132,48 +266,125 @@ async function exportToExcel(
|
|||||||
for (const r of resources) {
|
for (const r of resources) {
|
||||||
const row: (string | number)[] = [
|
const row: (string | number)[] = [
|
||||||
r.displayName, r.eid, r.fte, Math.round(r.targetPct * 100) / 100,
|
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) {
|
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(
|
row.push(
|
||||||
Math.round(m.chg * 1000) / 1000,
|
Math.round(chargeabilityRatio * 1000) / 1000,
|
||||||
Math.round(m.bd * 1000) / 1000,
|
Math.round((m.chargeabilityHours ?? (monthSahHours(m) * chargeabilityRatio)) * 10) / 10,
|
||||||
Math.round(m.mdi * 1000) / 1000,
|
Math.round(targetRatio * 1000) / 1000,
|
||||||
Math.round(m.mo * 1000) / 1000,
|
Math.round((m.targetHours ?? (monthSahHours(m) * targetRatio)) * 10) / 10,
|
||||||
Math.round(m.pdr * 1000) / 1000,
|
Math.round(gapRatio * 1000) / 1000,
|
||||||
Math.round(m.absence * 1000) / 1000,
|
Math.round((m.gapHours ?? (monthSahHours(m) * gapRatio)) * 10) / 10,
|
||||||
Math.round(m.unassigned * 1000) / 1000,
|
Math.round(businessDevelopmentRatio * 1000) / 1000,
|
||||||
Math.round(m.sah * 100) / 100,
|
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);
|
rows.push(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ws = XLSX.utils.aoa_to_sheet(rows);
|
const explainabilityRows: (string | number)[][] = [
|
||||||
XLSX.utils.book_append_sheet(wb, ws, "Chargeability");
|
["Chargeability Forecast Explainability"],
|
||||||
XLSX.writeFile(wb, `chargeability-report.xlsx`);
|
[],
|
||||||
|
["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(
|
function exportToCsv(
|
||||||
resources: ResourceRow[],
|
resources: ResourceRow[],
|
||||||
monthKeys: string[],
|
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) {
|
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(",")];
|
const lines = [headers.join(",")];
|
||||||
for (const r of resources) {
|
for (const r of resources) {
|
||||||
const vals = [
|
const vals = [
|
||||||
`"${r.displayName}"`, r.eid, r.fte, r.targetPct,
|
`"${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) {
|
for (const m of r.months) {
|
||||||
|
const chargeabilityRatio = monthChargeabilityRatio(m);
|
||||||
|
const targetRatio = monthTargetRatio(m, r.targetPct);
|
||||||
|
const gapRatio = monthGapRatio(m, r.targetPct);
|
||||||
vals.push(
|
vals.push(
|
||||||
m.chg as unknown as string, m.bd as unknown as string, m.mdi as unknown as string,
|
chargeabilityRatio as unknown as string,
|
||||||
m.mo as unknown as string, m.pdr as unknown as string, m.absence as unknown as string,
|
(m.chargeabilityHours ?? (monthSahHours(m) * chargeabilityRatio)) as unknown as string,
|
||||||
m.unassigned as unknown as string, m.sah 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(","));
|
lines.push(vals.join(","));
|
||||||
@@ -188,6 +399,19 @@ function exportToCsv(
|
|||||||
URL.revokeObjectURL(url);
|
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 ───────────────────────────────────────────────────────────────
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const NOW = new Date();
|
const NOW = new Date();
|
||||||
@@ -288,8 +512,21 @@ export function ChargeabilityReportClient() {
|
|||||||
|
|
||||||
const handleExportExcel = useCallback(() => {
|
const handleExportExcel = useCallback(() => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
exportToExcel(filteredResources, data.monthKeys, filteredGroupTotals, groups, groupBy);
|
exportToExcel(
|
||||||
}, [data, filteredGroupTotals, filteredResources, groups, groupBy]);
|
filteredResources,
|
||||||
|
data.monthKeys,
|
||||||
|
filteredGroupTotals,
|
||||||
|
groups,
|
||||||
|
groupBy,
|
||||||
|
data.explainability,
|
||||||
|
{
|
||||||
|
startMonth,
|
||||||
|
endMonth,
|
||||||
|
includeProposed,
|
||||||
|
groupBy,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [data, endMonth, filteredGroupTotals, filteredResources, groupBy, groups, includeProposed, startMonth]);
|
||||||
|
|
||||||
const handleExportCsv = useCallback(() => {
|
const handleExportCsv = useCallback(() => {
|
||||||
if (!data) return;
|
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">
|
<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="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">
|
<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>
|
</div>
|
||||||
</td>
|
</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">{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>
|
<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) => (
|
{r.months.map((m) => (
|
||||||
<td key={m.monthKey} className="px-3 py-3 text-center">
|
<td key={m.monthKey} className="px-3 py-3 text-center">
|
||||||
|
{(() => {
|
||||||
|
const chargeabilityRatio = monthChargeabilityRatio(m);
|
||||||
|
const targetRatio = monthTargetRatio(m, r.targetPct);
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
className={`rounded-xl border border-transparent px-2 py-1 font-semibold ${chgColor(m.chg, r.targetPct)}`}
|
className={`rounded-xl border border-transparent px-2 py-1 font-semibold ${chgColor(chargeabilityRatio, targetRatio)}`}
|
||||||
style={barStyle(m.chg, m.chg >= r.targetPct ? "rgba(34,197,94,0.15)" : "rgba(239,68,68,0.1)")}
|
style={barStyle(chargeabilityRatio, chargeabilityRatio >= targetRatio ? "rgba(34,197,94,0.15)" : "rgba(239,68,68,0.1)")}
|
||||||
>
|
>
|
||||||
{pct(m.chg)}
|
{pct(chargeabilityRatio)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</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}>
|
<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 className="space-y-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
<div>Mgmt: {r.mgmtGroup ?? "—"} / {r.mgmtLevel ?? "—"}</div>
|
<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">
|
<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">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">BD<InfoTooltip content="Business Development hours as % of available time." /></span>
|
||||||
@@ -348,13 +593,26 @@ export function ChargeabilityReportClient() {
|
|||||||
{r.months.map((m) => (
|
{r.months.map((m) => (
|
||||||
<td key={m.monthKey} className="px-3 py-3 text-center">
|
<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">
|
<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 className="font-semibold text-green-600 dark:text-green-400">{pct(monthChargeabilityRatio(m))}</span>
|
||||||
<span>{pct(m.bd)}</span>
|
<span>{pct(monthBusinessDevelopmentRatio(m))}</span>
|
||||||
<span>{pct(m.mdi)}</span>
|
<span>{pct(monthMarketDevelopmentInnovationRatio(m))}</span>
|
||||||
<span>{pct(m.mo)}</span>
|
<span>{pct(monthManagementOverheadRatio(m))}</span>
|
||||||
<span>{pct(m.pdr)}</span>
|
<span>{pct(monthPeopleDevelopmentRecruitingRatio(m))}</span>
|
||||||
<span className="text-orange-500 dark:text-orange-300">{pct(m.absence)}</span>
|
<span className="text-orange-500 dark:text-orange-300">{pct(monthPlannedAbsenceRatio(m))}</span>
|
||||||
<span className="text-gray-400 dark:text-gray-500">{pct(m.unassigned)}</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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
@@ -573,6 +831,69 @@ export function ChargeabilityReportClient() {
|
|||||||
</div>
|
</div>
|
||||||
</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 ? (
|
{reportQuery.isLoading && !data ? (
|
||||||
<div className="app-surface-strong py-16 text-center text-sm 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 ? (
|
) : reportQuery.error ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user