093e13b88f
- Add DALL-E cover art generation for projects (Azure OpenAI + standard OpenAI)
- CoverArtSection component with generate/upload/remove/focus-point controls
- Client-side image compression (10MB input → WebP/JPEG, max 1920px)
- DALL-E settings in admin panel (deployment, endpoint, API key)
- MCP assistant tools for cover art (generate_project_cover, remove_project_cover)
- Rename "Planarchy" → "plANARCHY" across all UI-facing text (13 files)
- Fix hardcoded canEdit={true} on project detail page — now checks user role
- Computation graph visualization (2D/3D) for calculation rules
- OG image and OpenGraph metadata
Co-Authored-By: claude-flow <ruv@ruv.net>
629 lines
27 KiB
TypeScript
629 lines
27 KiB
TypeScript
"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<string>("");
|
|
const [mgmtGroupId, setMgmtGroupId] = useState<string>("");
|
|
const [countryId, setCountryId] = useState<string>("");
|
|
const [includeProposed, setIncludeProposed] = useState(false);
|
|
const [nameSearch, setNameSearch] = useState("");
|
|
const [groupBy, setGroupBy] = useState<GroupByField>("none");
|
|
const [expandedResource, setExpandedResource] = useState<string | null>(null);
|
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(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<string, ResourceRow[]>();
|
|
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 (
|
|
<tr
|
|
key={r.id}
|
|
className="cursor-pointer transition-colors hover:bg-gray-50/90 dark:hover:bg-gray-800/50"
|
|
onClick={() => setExpandedResource(expandedResource === r.id ? null : r.id)}
|
|
>
|
|
<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(" · ")}
|
|
</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">
|
|
<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)")}
|
|
>
|
|
{pct(m.chg)}
|
|
</div>
|
|
</td>
|
|
))}
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
function renderExpandedRow(r: ResourceRow) {
|
|
if (expandedResource !== r.id) return null;
|
|
return (
|
|
<tr key={`${r.id}-detail`} className="bg-gray-50/80 dark:bg-slate-900/70">
|
|
<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 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>
|
|
<span className="font-medium inline-flex items-center gap-0.5">MD&I<InfoTooltip content="Market Development & Innovation hours %." /></span>
|
|
<span className="font-medium inline-flex items-center gap-0.5">M&O<InfoTooltip content="Management & Overhead hours %." /></span>
|
|
<span className="font-medium inline-flex items-center gap-0.5">PD&R<InfoTooltip content="People Development & Recruiting hours %." /></span>
|
|
<span className="font-medium inline-flex items-center gap-0.5">Abs<InfoTooltip content="Absence (vacation, sick) as % of working time." /></span>
|
|
<span className="font-medium inline-flex items-center gap-0.5">Free<InfoTooltip content="Unassigned / free capacity as % of available hours." /></span>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
{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>
|
|
</div>
|
|
</td>
|
|
))}
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<tr
|
|
className={`${bg} font-semibold ${onClick ? "cursor-pointer" : ""}`}
|
|
onClick={onClick}
|
|
>
|
|
<td className={`sticky left-0 z-10 ${bg} px-4 py-3 text-gray-900 dark:text-gray-100`}>
|
|
{onClick && <span className="mr-2 text-xs text-gray-500 dark:text-gray-400">{expandedGroups.has(label) ? "▾" : "▸"}</span>}
|
|
{label} ({count} resources)
|
|
</td>
|
|
<td className="px-3 py-3 text-center text-gray-700 dark:text-gray-300">
|
|
{monthTotals[0]?.totalFte.toFixed(1)}
|
|
</td>
|
|
<td className="px-3 py-3 text-center text-gray-700 dark:text-gray-300">
|
|
{monthTotals[0] ? pct(monthTotals[0].target) : "—"}
|
|
</td>
|
|
{monthTotals.map((mt) => (
|
|
<td key={mt.monthKey} className="px-3 py-3 text-center">
|
|
<div className={`font-semibold ${chgColor(mt.chg, mt.target)}`}>{pct(mt.chg)}</div>
|
|
{mt.gap !== 0 && (
|
|
<div className="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
|
|
{mt.gap > 0 ? "+" : ""}{pct(mt.gap)}
|
|
</div>
|
|
)}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
// ─── Main render ─────────────────────────────────────────────────────────
|
|
|
|
return (
|
|
<div className="app-page space-y-6">
|
|
<div className="app-page-header gap-4">
|
|
<div className="space-y-2">
|
|
<p className="inline-flex items-center rounded-full border border-brand-200 bg-brand-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:border-brand-800/80 dark:bg-brand-900/30 dark:text-brand-200">
|
|
Forecast Report
|
|
</p>
|
|
<div>
|
|
<h1 className="app-page-title" data-page-title="true">
|
|
Chargeability Forecast
|
|
</h1>
|
|
<p className="app-page-subtitle">
|
|
Review expected utilization, search specific people quickly, and compare monthly chargeability against target.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{data && data.resources.length > 0 && (
|
|
<div className="flex flex-wrap gap-2">
|
|
<button
|
|
onClick={handleExportExcel}
|
|
className="inline-flex items-center justify-center rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-emerald-700"
|
|
>
|
|
Export Excel
|
|
</button>
|
|
<button
|
|
onClick={handleExportCsv}
|
|
className="inline-flex items-center justify-center rounded-xl border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
|
|
>
|
|
Export CSV
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{data ? (
|
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
<div className="app-surface p-4">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500 inline-flex items-center gap-0.5">Resources<InfoTooltip content="Number of active resources matching the current filters." /></div>
|
|
<div className="mt-2 text-3xl font-semibold text-gray-900 dark:text-gray-100">{filteredResources.length}</div>
|
|
<div className="mt-1 text-sm text-gray-500">People in the current filter scope</div>
|
|
</div>
|
|
<div className="app-surface p-4">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500 inline-flex items-center gap-0.5">Average Chargeability<InfoTooltip content="FTE-weighted average chargeability across all visible resources. Formula: sum(FTE x Chg%) / sum(FTE)." /></div>
|
|
<div className={`mt-2 text-3xl font-semibold ${chgColor(averageChargeability, averageTarget)}`}>{pct(averageChargeability)}</div>
|
|
<div className="mt-1 text-sm text-gray-500">Weighted across visible resources</div>
|
|
</div>
|
|
<div className="app-surface p-4">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500 inline-flex items-center gap-0.5">Average Target<InfoTooltip content="FTE-weighted average chargeability target set per resource. The benchmark for actual performance." /></div>
|
|
<div className="mt-2 text-3xl font-semibold text-gray-900 dark:text-gray-100">{pct(averageTarget)}</div>
|
|
<div className="mt-1 text-sm text-gray-500">Planning target for the same population</div>
|
|
</div>
|
|
<div className="app-surface p-4">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500 inline-flex items-center gap-0.5">Average Gap<InfoTooltip content="Chargeability minus target. Green = above target, red = below target." /></div>
|
|
<div className={`mt-2 text-3xl font-semibold ${averageGap >= 0 ? "text-green-700 dark:text-green-400" : "text-red-700 dark:text-red-400"}`}>
|
|
{averageGap > 0 ? "+" : ""}{pct(averageGap)}
|
|
</div>
|
|
<div className="mt-1 text-sm text-gray-500">Difference between chargeability and target</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="app-toolbar">
|
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-7">
|
|
<div>
|
|
<label className="app-label">From</label>
|
|
<input
|
|
type="month"
|
|
value={startMonth}
|
|
onChange={(e) => setStartMonth(e.target.value)}
|
|
className="app-input"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="app-label">To</label>
|
|
<input
|
|
type="month"
|
|
value={endMonth}
|
|
onChange={(e) => setEndMonth(e.target.value)}
|
|
className="app-input"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="app-label">Country</label>
|
|
<select
|
|
value={countryId}
|
|
onChange={(e) => setCountryId(e.target.value)}
|
|
className="app-select w-full"
|
|
>
|
|
<option value="">All countries</option>
|
|
{(countriesQuery.data ?? []).map((c: { id: string; code: string; name: string }) => (
|
|
<option key={c.id} value={c.id}>{c.code} - {c.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="app-label">Org Unit</label>
|
|
<select
|
|
value={orgUnitId}
|
|
onChange={(e) => setOrgUnitId(e.target.value)}
|
|
className="app-select w-full"
|
|
>
|
|
<option value="">All org units</option>
|
|
{orgUnits.map((u: { id: string; name: string }) => (
|
|
<option key={u.id} value={u.id}>{u.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="app-label">Mgmt Level Group</label>
|
|
<select
|
|
value={mgmtGroupId}
|
|
onChange={(e) => setMgmtGroupId(e.target.value)}
|
|
className="app-select w-full"
|
|
>
|
|
<option value="">All groups</option>
|
|
{(mgmtGroupsQuery.data ?? []).map((g: { id: string; name: string }) => (
|
|
<option key={g.id} value={g.id}>{g.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="app-label">Group By</label>
|
|
<select
|
|
value={groupBy}
|
|
onChange={(e) => { setGroupBy(e.target.value as GroupByField); setExpandedGroups(new Set()); }}
|
|
className="app-select w-full"
|
|
>
|
|
<option value="none">No grouping</option>
|
|
<option value="orgUnit">Org unit</option>
|
|
<option value="mgmtGroup">Mgmt level group</option>
|
|
<option value="country">Country</option>
|
|
</select>
|
|
</div>
|
|
<div className="md:col-span-2 xl:col-span-1 2xl:col-span-1">
|
|
<label className="app-label">Name Search</label>
|
|
<input
|
|
type="search"
|
|
value={nameSearch}
|
|
onChange={(e) => setNameSearch(e.target.value)}
|
|
placeholder="Search by name or EID"
|
|
className="app-input"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
|
|
<label className="inline-flex items-center gap-3 text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
<input
|
|
type="checkbox"
|
|
checked={includeProposed}
|
|
onChange={(e) => setIncludeProposed(e.target.checked)}
|
|
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
|
/>
|
|
<span>Include proposed work in utilization calculations</span>
|
|
</label>
|
|
<div className="text-xs uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
|
|
{groupBy === "none" ? "Flat resource view" : `Grouped by ${groupBy}`}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{reportQuery.isLoading && !data ? (
|
|
<div className="app-surface-strong py-16 text-center text-sm text-gray-500">Loading report...</div>
|
|
) : reportQuery.error ? (
|
|
<div className="app-surface-strong py-16 text-center text-sm text-red-600 dark:text-red-400">
|
|
Error: {reportQuery.error.message}
|
|
</div>
|
|
) : data && data.resources.length === 0 ? (
|
|
<div className="app-surface-strong py-16 text-center text-sm text-gray-500">
|
|
No resources match the current filters.
|
|
</div>
|
|
) : data && filteredResources.length === 0 ? (
|
|
<div className="app-surface-strong py-16 text-center text-sm text-gray-500">
|
|
No resources match the current search.
|
|
</div>
|
|
) : data ? (
|
|
<div className="app-data-table">
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full text-sm">
|
|
<thead>
|
|
<tr>
|
|
<th className="sticky left-0 z-10 min-w-[240px] bg-gray-50/95 px-4 py-3 text-left backdrop-blur dark:bg-gray-800/95">
|
|
Resource
|
|
</th>
|
|
<th className="w-20 px-3 py-3 text-center"><span className="inline-flex items-center justify-center gap-0.5">FTE<InfoTooltip content="Full-Time Equivalent. 1.0 = full-time, 0.5 = half-time. Used to weight chargeability averages." /></span></th>
|
|
<th className="w-24 px-3 py-3 text-center"><span className="inline-flex items-center justify-center gap-0.5">Target<InfoTooltip content="Chargeability target for this resource. Cells are green/yellow/red relative to this target." /></span></th>
|
|
{data.monthKeys.map((key) => (
|
|
<th key={key} className="min-w-[96px] px-3 py-3 text-center">
|
|
{formatMonth(key)}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
|
{renderGroupTotalsRow("Group Total", filteredGroupTotals, filteredResources.length, true)}
|
|
|
|
{groupBy !== "none" ? (
|
|
groups.map((g) => (
|
|
<React.Fragment key={g.label}>
|
|
{renderGroupTotalsRow(g.label, g.monthTotals, g.resources.length, false, () => toggleGroup(g.label))}
|
|
{expandedGroups.has(g.label) && g.resources.map((r) => (
|
|
<React.Fragment key={r.id}>
|
|
{renderResourceRow(r)}
|
|
{renderExpandedRow(r)}
|
|
</React.Fragment>
|
|
))}
|
|
</React.Fragment>
|
|
))
|
|
) : (
|
|
filteredResources.map((r) => (
|
|
<React.Fragment key={r.id}>
|
|
{renderResourceRow(r)}
|
|
{renderExpandedRow(r)}
|
|
</React.Fragment>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|