"use client"; import React, { useState, useMemo, useCallback } from "react"; import { trpc } from "~/lib/trpc/client.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; // ─── Helpers ───────────────────────────────────────────────────────────────── function formatMonth(key: string): string { const [y, m] = key.split("-"); const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; return `${months[Number(m) - 1]} ${y}`; } function pct(ratio: number): string { return `${Math.round(ratio * 100)}%`; } function chgColor(ratio: number, target: number): string { if (ratio >= target) return "text-green-700 dark:text-green-400"; if (ratio >= target - 0.1) return "text-yellow-700 dark:text-yellow-400"; return "text-red-700 dark:text-red-400"; } function barStyle(ratio: number, color: string): React.CSSProperties { return { background: `linear-gradient(to right, ${color} ${Math.min(ratio * 100, 100)}%, transparent ${Math.min(ratio * 100, 100)}%)`, }; } type GroupByField = "none" | "orgUnit" | "mgmtGroup" | "country"; interface ResourceRow { id: string; eid: string; displayName: string; fte: number; country: string | null; city: string | null; orgUnit: string | null; mgmtGroup: string | null; mgmtLevel: string | null; targetPct: number; months: MonthData[]; } interface MonthData { monthKey: string; sah: number; chg: number; bd: number; mdi: number; mo: number; pdr: number; absence: number; unassigned: number; } interface GroupSummary { label: string; resources: ResourceRow[]; monthTotals: { monthKey: string; chg: number; target: number; gap: number; totalFte: number }[]; } function computeGroupMonthTotals( resources: ResourceRow[], monthKeys: string[], ): GroupSummary["monthTotals"] { const totalFte = resources.reduce((sum, r) => sum + r.fte, 0); return monthKeys.map((key, idx) => { if (totalFte === 0) return { monthKey: key, chg: 0, target: 0, gap: 0, totalFte: 0 }; const chg = resources.reduce((sum, r) => sum + r.fte * r.months[idx]!.chg, 0) / totalFte; const target = resources.reduce((sum, r) => sum + r.fte * r.targetPct, 0) / totalFte; return { monthKey: key, chg, target, gap: chg - target, totalFte }; }); } // ─── Export ────────────────────────────────────────────────────────────────── async function exportToExcel( resources: ResourceRow[], monthKeys: string[], groupTotals: { monthKey: string; chg: number; target: number; gap: number; totalFte: number }[], groups: GroupSummary[], groupBy: GroupByField, ) { const XLSX = await import("xlsx"); const wb = XLSX.utils.book_new(); // Build main data rows const headers = ["Name", "EID", "FTE", "Target", "Country", "City", "Org Unit", "Mgmt Group", "Mgmt Level"]; for (const key of monthKeys) { headers.push(`Chg ${formatMonth(key)}`); headers.push(`BD ${formatMonth(key)}`); headers.push(`MD&I ${formatMonth(key)}`); headers.push(`M&O ${formatMonth(key)}`); headers.push(`PD&R ${formatMonth(key)}`); headers.push(`Abs ${formatMonth(key)}`); headers.push(`Free ${formatMonth(key)}`); headers.push(`SAH ${formatMonth(key)}`); } const rows: (string | number)[][] = [headers]; // Group total row const totalRow: (string | number)[] = [ "GROUP TOTAL", "", groupTotals[0]?.totalFte ?? 0, groupTotals[0]?.target ?? 0, "", "", "", "", "", ]; for (const gt of groupTotals) { totalRow.push(Math.round(gt.chg * 100) / 100, 0, 0, 0, 0, 0, 0, 0); } rows.push(totalRow); // Sub-group totals if grouping if (groupBy !== "none") { for (const g of groups) { const subRow: (string | number)[] = [ ` ${g.label} (${g.resources.length})`, "", g.monthTotals[0]?.totalFte ?? 0, g.monthTotals[0]?.target ?? 0, "", "", "", "", "", ]; for (const mt of g.monthTotals) { subRow.push(Math.round(mt.chg * 100) / 100, 0, 0, 0, 0, 0, 0, 0); } rows.push(subRow); } rows.push([]); // blank separator } // Resource rows for (const r of resources) { const row: (string | number)[] = [ r.displayName, r.eid, r.fte, Math.round(r.targetPct * 100) / 100, r.country ?? "", r.city ?? "", r.orgUnit ?? "", r.mgmtGroup ?? "", r.mgmtLevel ?? "", ]; for (const m of r.months) { row.push( Math.round(m.chg * 1000) / 1000, Math.round(m.bd * 1000) / 1000, Math.round(m.mdi * 1000) / 1000, Math.round(m.mo * 1000) / 1000, Math.round(m.pdr * 1000) / 1000, Math.round(m.absence * 1000) / 1000, Math.round(m.unassigned * 1000) / 1000, Math.round(m.sah * 100) / 100, ); } rows.push(row); } const ws = XLSX.utils.aoa_to_sheet(rows); XLSX.utils.book_append_sheet(wb, ws, "Chargeability"); XLSX.writeFile(wb, `chargeability-report.xlsx`); } function exportToCsv( resources: ResourceRow[], monthKeys: string[], ) { const headers = ["Name", "EID", "FTE", "Target", "Country", "City", "Org Unit", "Mgmt Group", "Mgmt Level"]; for (const key of monthKeys) { headers.push(`Chg_${key}`, `BD_${key}`, `MDI_${key}`, `MO_${key}`, `PDR_${key}`, `Abs_${key}`, `Free_${key}`, `SAH_${key}`); } const lines = [headers.join(",")]; for (const r of resources) { const vals = [ `"${r.displayName}"`, r.eid, r.fte, r.targetPct, r.country ?? "", r.city ?? "", r.orgUnit ?? "", r.mgmtGroup ?? "", r.mgmtLevel ?? "", ]; for (const m of r.months) { vals.push( m.chg as unknown as string, m.bd as unknown as string, m.mdi as unknown as string, m.mo as unknown as string, m.pdr as unknown as string, m.absence as unknown as string, m.unassigned as unknown as string, m.sah as unknown as string, ); } lines.push(vals.join(",")); } const blob = new Blob([lines.join("\n")], { type: "text/csv" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "chargeability-report.csv"; a.click(); URL.revokeObjectURL(url); } // ─── Component ─────────────────────────────────────────────────────────────── const NOW = new Date(); const DEFAULT_START = `${NOW.getFullYear()}-${String(NOW.getMonth() + 1).padStart(2, "0")}`; const DEFAULT_END_DATE = new Date(NOW.getFullYear(), NOW.getMonth() + 5, 1); const DEFAULT_END = `${DEFAULT_END_DATE.getFullYear()}-${String(DEFAULT_END_DATE.getMonth() + 1).padStart(2, "0")}`; export function ChargeabilityReportClient() { const [startMonth, setStartMonth] = useState(DEFAULT_START); const [endMonth, setEndMonth] = useState(DEFAULT_END); const [orgUnitId, setOrgUnitId] = useState(""); const [mgmtGroupId, setMgmtGroupId] = useState(""); const [countryId, setCountryId] = useState(""); const [includeProposed, setIncludeProposed] = useState(false); const [nameSearch, setNameSearch] = useState(""); const [groupBy, setGroupBy] = useState("none"); const [expandedResource, setExpandedResource] = useState(null); const [expandedGroups, setExpandedGroups] = useState>(new Set()); // Filter dropdowns data const orgUnitsQuery = trpc.orgUnit.list.useQuery(); const mgmtGroupsQuery = trpc.managementLevel.listGroups.useQuery(); const countriesQuery = trpc.country.list.useQuery(); const reportQuery = trpc.chargeabilityReport.getReport.useQuery( { startMonth, endMonth, ...(orgUnitId ? { orgUnitId } : {}), ...(mgmtGroupId ? { managementLevelGroupId: mgmtGroupId } : {}), ...(countryId ? { countryId } : {}), includeProposed, }, { placeholderData: (prev) => prev }, ); const data = reportQuery.data; const orgUnits = useMemo(() => { const items = orgUnitsQuery.data ?? []; return items.filter((u: { level: number }) => u.level === 7); }, [orgUnitsQuery.data]); const filteredResources = useMemo(() => { if (!data) return []; const query = nameSearch.trim().toLowerCase(); if (!query) return data.resources; return data.resources.filter((resource) => resource.displayName.toLowerCase().includes(query) || resource.eid.toLowerCase().includes(query), ); }, [data, nameSearch]); const filteredGroupTotals = useMemo(() => { if (!data) return []; return computeGroupMonthTotals(filteredResources, data.monthKeys); }, [data, filteredResources]); const averageTarget = filteredGroupTotals[0]?.target ?? 0; const averageChargeability = filteredGroupTotals[0]?.chg ?? 0; const averageGap = filteredGroupTotals[0]?.gap ?? 0; // Group resources by selected dimension const groups = useMemo((): GroupSummary[] => { if (!data || groupBy === "none") return []; const buckets = new Map(); for (const r of filteredResources) { let key: string; switch (groupBy) { case "orgUnit": key = r.orgUnit ?? "(No Org Unit)"; break; case "mgmtGroup": key = r.mgmtGroup ?? "(No Mgmt Group)"; break; case "country": key = r.country ?? "(No Country)"; break; } const list = buckets.get(key) ?? []; list.push(r); buckets.set(key, list); } return Array.from(buckets.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([label, resources]) => ({ label, resources, monthTotals: computeGroupMonthTotals(resources, data.monthKeys), })); }, [data, filteredResources, groupBy]); const toggleGroup = useCallback((label: string) => { setExpandedGroups((prev) => { const next = new Set(prev); if (next.has(label)) next.delete(label); else next.add(label); return next; }); }, []); const handleExportExcel = useCallback(() => { if (!data) return; exportToExcel(filteredResources, data.monthKeys, filteredGroupTotals, groups, groupBy); }, [data, filteredGroupTotals, filteredResources, groups, groupBy]); const handleExportCsv = useCallback(() => { if (!data) return; exportToCsv(filteredResources, data.monthKeys); }, [data, filteredResources]); // ─── Render helpers ────────────────────────────────────────────────────── function renderResourceRow(r: ResourceRow) { return ( setExpandedResource(expandedResource === r.id ? null : r.id)} >
{r.displayName}
{[r.eid, r.country, r.city, r.orgUnit].filter(Boolean).join(" · ")}
{r.fte.toFixed(2)} {pct(r.targetPct)} {r.months.map((m) => (
= r.targetPct ? "rgba(34,197,94,0.15)" : "rgba(239,68,68,0.1)")} > {pct(m.chg)}
))} ); } function renderExpandedRow(r: ResourceRow) { if (expandedResource !== r.id) return null; return (
Mgmt: {r.mgmtGroup ?? "—"} / {r.mgmtLevel ?? "—"}
Chg BD MD&I M&O PD&R Abs Free
{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)}
))} ); } function renderGroupTotalsRow( label: string, monthTotals: GroupSummary["monthTotals"], count: number, isOverall: boolean, onClick?: () => void, ) { const bg = isOverall ? "bg-brand-50/90 dark:bg-brand-900/25" : "bg-slate-100/90 dark:bg-slate-800/70"; return ( {onClick && {expandedGroups.has(label) ? "▾" : "▸"}} {label} ({count} resources) {monthTotals[0]?.totalFte.toFixed(1)} {monthTotals[0] ? pct(monthTotals[0].target) : "—"} {monthTotals.map((mt) => (
{pct(mt.chg)}
{mt.gap !== 0 && (
{mt.gap > 0 ? "+" : ""}{pct(mt.gap)}
)} ))} ); } // ─── Main render ───────────────────────────────────────────────────────── return (

Forecast Report

Chargeability Forecast

Review expected utilization, search specific people quickly, and compare monthly chargeability against target.

{data && data.resources.length > 0 && (
)}
{data ? (
Resources
{filteredResources.length}
People in the current filter scope
Average Chargeability
{pct(averageChargeability)}
Weighted across visible resources
Average Target
{pct(averageTarget)}
Planning target for the same population
Average Gap
= 0 ? "text-green-700 dark:text-green-400" : "text-red-700 dark:text-red-400"}`}> {averageGap > 0 ? "+" : ""}{pct(averageGap)}
Difference between chargeability and target
) : null}
setStartMonth(e.target.value)} className="app-input" />
setEndMonth(e.target.value)} className="app-input" />
setNameSearch(e.target.value)} placeholder="Search by name or EID" className="app-input" />
{groupBy === "none" ? "Flat resource view" : `Grouped by ${groupBy}`}
{reportQuery.isLoading && !data ? (
Loading report...
) : reportQuery.error ? (
Error: {reportQuery.error.message}
) : data && data.resources.length === 0 ? (
No resources match the current filters.
) : data && filteredResources.length === 0 ? (
No resources match the current search.
) : data ? (
{data.monthKeys.map((key) => ( ))} {renderGroupTotalsRow("Group Total", filteredGroupTotals, filteredResources.length, true)} {groupBy !== "none" ? ( groups.map((g) => ( {renderGroupTotalsRow(g.label, g.monthTotals, g.resources.length, false, () => toggleGroup(g.label))} {expandedGroups.has(g.label) && g.resources.map((r) => ( {renderResourceRow(r)} {renderExpandedRow(r)} ))} )) ) : ( filteredResources.map((r) => ( {renderResourceRow(r)} {renderExpandedRow(r)} )) )}
Resource FTE Target {formatMonth(key)}
) : null}
); }