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 { 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 ? (
|
||||
|
||||
Reference in New Issue
Block a user