chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
// @react-pdf/renderer runs server-side only — no "use client" directive
|
||||
import { Document, Page, StyleSheet, Text, View } from "@react-pdf/renderer";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: { padding: 30, fontFamily: "Helvetica", fontSize: 10 },
|
||||
title: { fontSize: 18, marginBottom: 4, fontFamily: "Helvetica-Bold" },
|
||||
subtitle: { fontSize: 11, color: "#6b7280", marginBottom: 20 },
|
||||
table: { marginTop: 10 },
|
||||
tableHeader: { flexDirection: "row", backgroundColor: "#f3f4f6", padding: "6 8", borderBottom: "1 solid #e5e7eb" },
|
||||
tableRow: { flexDirection: "row", padding: "5 8", borderBottom: "1 solid #f3f4f6" },
|
||||
col1: { width: "25%" },
|
||||
col2: { width: "20%" },
|
||||
col3: { width: "15%" },
|
||||
col4: { width: "15%" },
|
||||
col5: { width: "15%" },
|
||||
col6: { width: "10%" },
|
||||
headerText: { fontFamily: "Helvetica-Bold", color: "#374151", fontSize: 9 },
|
||||
cellText: { color: "#4b5563", fontSize: 9 },
|
||||
footer: { position: "absolute", bottom: 20, left: 30, right: 30, textAlign: "center", color: "#9ca3af", fontSize: 8 },
|
||||
});
|
||||
|
||||
interface AllocationRow {
|
||||
resourceName: string;
|
||||
projectName: string;
|
||||
role?: string | null;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
hoursPerDay: number;
|
||||
dailyCostCents: number;
|
||||
}
|
||||
|
||||
interface AllocationReportProps {
|
||||
title: string;
|
||||
generatedAt: string;
|
||||
rows: AllocationRow[];
|
||||
}
|
||||
|
||||
export function AllocationReport({ title, generatedAt, rows }: AllocationReportProps) {
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" orientation="landscape" style={styles.page}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Text style={styles.subtitle}>Generated: {generatedAt}</Text>
|
||||
|
||||
<View style={styles.table}>
|
||||
<View style={styles.tableHeader}>
|
||||
<Text style={[styles.col1, styles.headerText]}>Resource</Text>
|
||||
<Text style={[styles.col2, styles.headerText]}>Project</Text>
|
||||
<Text style={[styles.col3, styles.headerText]}>Role</Text>
|
||||
<Text style={[styles.col4, styles.headerText]}>Start</Text>
|
||||
<Text style={[styles.col5, styles.headerText]}>End</Text>
|
||||
<Text style={[styles.col6, styles.headerText]}>h/day</Text>
|
||||
</View>
|
||||
{rows.map((row, i) => (
|
||||
<View key={i} style={[styles.tableRow, i % 2 === 1 ? { backgroundColor: "#f9fafb" } : {}]}>
|
||||
<Text style={[styles.col1, styles.cellText]}>{row.resourceName}</Text>
|
||||
<Text style={[styles.col2, styles.cellText]}>{row.projectName}</Text>
|
||||
<Text style={[styles.col3, styles.cellText]}>{row.role ?? "—"}</Text>
|
||||
<Text style={[styles.col4, styles.cellText]}>{row.startDate}</Text>
|
||||
<Text style={[styles.col5, styles.cellText]}>{row.endDate}</Text>
|
||||
<Text style={[styles.col6, styles.cellText]}>{row.hoursPerDay}h</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Text style={styles.footer}>Planarchy · Confidential · {rows.length} allocations</Text>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo, useCallback } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.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 [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 } : {}),
|
||||
},
|
||||
{ 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]);
|
||||
|
||||
// Group resources by selected dimension
|
||||
const groups = useMemo((): GroupSummary[] => {
|
||||
if (!data || groupBy === "none") return [];
|
||||
|
||||
const buckets = new Map<string, ResourceRow[]>();
|
||||
for (const r of data.resources) {
|
||||
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, 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(data.resources, data.monthKeys, data.groupTotals, groups, groupBy);
|
||||
}, [data, groups, groupBy]);
|
||||
|
||||
const handleExportCsv = useCallback(() => {
|
||||
if (!data) return;
|
||||
exportToCsv(data.resources, data.monthKeys);
|
||||
}, [data]);
|
||||
|
||||
// ─── Render helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function renderResourceRow(r: ResourceRow) {
|
||||
return (
|
||||
<tr
|
||||
key={r.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer"
|
||||
onClick={() => setExpandedResource(expandedResource === r.id ? null : r.id)}
|
||||
>
|
||||
<td className="sticky left-0 z-10 bg-white dark:bg-gray-900 px-3 py-2">
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">{r.displayName}</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{[r.eid, r.country, r.city, r.orgUnit].filter(Boolean).join(" | ")}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center text-gray-600 dark:text-gray-400">{r.fte.toFixed(2)}</td>
|
||||
<td className="px-2 py-2 text-center text-gray-600 dark:text-gray-400">{pct(r.targetPct)}</td>
|
||||
{r.months.map((m) => (
|
||||
<td key={m.monthKey} className="px-2 py-2 text-center">
|
||||
<div
|
||||
className={`rounded px-1 ${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 dark:bg-gray-800/30">
|
||||
<td className="sticky left-0 z-10 bg-gray-50 dark:bg-gray-800/30 px-3 py-2" colSpan={3}>
|
||||
<div className="text-xs space-y-0.5 text-gray-500 dark:text-gray-400">
|
||||
<div>Mgmt: {r.mgmtGroup ?? "—"} / {r.mgmtLevel ?? "—"}</div>
|
||||
<div className="mt-1 grid grid-cols-7 gap-1 text-[10px]">
|
||||
<span className="font-medium">Chg</span>
|
||||
<span className="font-medium">BD</span>
|
||||
<span className="font-medium">MD&I</span>
|
||||
<span className="font-medium">M&O</span>
|
||||
<span className="font-medium">PD&R</span>
|
||||
<span className="font-medium">Abs</span>
|
||||
<span className="font-medium">Free</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{r.months.map((m) => (
|
||||
<td key={m.monthKey} className="px-2 py-2 text-center">
|
||||
<div className="grid grid-cols-1 gap-0.5 text-[10px] text-gray-500 dark:text-gray-400">
|
||||
<span className="text-green-600">{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">{pct(m.absence)}</span>
|
||||
<span className="text-gray-400">{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 dark:bg-brand-900/20"
|
||||
: "bg-indigo-50 dark:bg-indigo-900/20";
|
||||
return (
|
||||
<tr
|
||||
className={`${bg} font-semibold ${onClick ? "cursor-pointer" : ""}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<td className={`sticky left-0 z-10 ${bg} px-3 py-2 text-gray-900 dark:text-gray-100`}>
|
||||
{onClick && <span className="mr-1 text-xs">{expandedGroups.has(label) ? "▾" : "▸"}</span>}
|
||||
{label} ({count} resources)
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">
|
||||
{monthTotals[0]?.totalFte.toFixed(1)}
|
||||
</td>
|
||||
<td className="px-2 py-2 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-2 py-2 text-center">
|
||||
<div className={chgColor(mt.chg, mt.target)}>{pct(mt.chg)}</div>
|
||||
{mt.gap !== 0 && (
|
||||
<div className="text-xs text-gray-400">
|
||||
{mt.gap > 0 ? "+" : ""}{pct(mt.gap)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main render ─────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">
|
||||
Chargeability Forecast
|
||||
</h1>
|
||||
{/* Export buttons */}
|
||||
{data && data.resources.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleExportExcel}
|
||||
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Export Excel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportCsv}
|
||||
className="px-3 py-1.5 text-sm bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-4 items-end bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">From</label>
|
||||
<input
|
||||
type="month"
|
||||
value={startMonth}
|
||||
onChange={(e) => setStartMonth(e.target.value)}
|
||||
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">To</label>
|
||||
<input
|
||||
type="month"
|
||||
value={endMonth}
|
||||
onChange={(e) => setEndMonth(e.target.value)}
|
||||
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Country</label>
|
||||
<select
|
||||
value={countryId}
|
||||
onChange={(e) => setCountryId(e.target.value)}
|
||||
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="">All</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="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Org Unit</label>
|
||||
<select
|
||||
value={orgUnitId}
|
||||
onChange={(e) => setOrgUnitId(e.target.value)}
|
||||
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="">All</option>
|
||||
{orgUnits.map((u: { id: string; name: string }) => (
|
||||
<option key={u.id} value={u.id}>{u.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Mgmt Level Group</label>
|
||||
<select
|
||||
value={mgmtGroupId}
|
||||
onChange={(e) => setMgmtGroupId(e.target.value)}
|
||||
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="">All</option>
|
||||
{(mgmtGroupsQuery.data ?? []).map((g: { id: string; name: string }) => (
|
||||
<option key={g.id} value={g.id}>{g.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Group By</label>
|
||||
<select
|
||||
value={groupBy}
|
||||
onChange={(e) => { setGroupBy(e.target.value as GroupByField); setExpandedGroups(new Set()); }}
|
||||
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Report table */}
|
||||
{reportQuery.isLoading && !data ? (
|
||||
<div className="text-center py-12 text-gray-500">Loading report...</div>
|
||||
) : reportQuery.error ? (
|
||||
<div className="text-center py-12 text-red-600">
|
||||
Error: {reportQuery.error.message}
|
||||
</div>
|
||||
) : data && data.resources.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
No resources match the current filters.
|
||||
</div>
|
||||
) : data ? (
|
||||
<div className="overflow-x-auto border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-gray-800">
|
||||
<th className="sticky left-0 z-10 bg-gray-50 dark:bg-gray-800 px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400 min-w-[200px]">
|
||||
Resource
|
||||
</th>
|
||||
<th className="px-2 py-2 text-center font-medium text-gray-500 dark:text-gray-400 w-16">FTE</th>
|
||||
<th className="px-2 py-2 text-center font-medium text-gray-500 dark:text-gray-400 w-16">Target</th>
|
||||
{data.monthKeys.map((key) => (
|
||||
<th key={key} className="px-2 py-2 text-center font-medium text-gray-500 dark:text-gray-400 min-w-[80px]">
|
||||
{formatMonth(key)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{/* Overall group total */}
|
||||
{renderGroupTotalsRow("Group Total", data.groupTotals, data.resources.length, true)}
|
||||
|
||||
{/* Grouped view */}
|
||||
{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>
|
||||
))
|
||||
) : (
|
||||
data.resources.map((r) => (
|
||||
<React.Fragment key={r.id}>
|
||||
{renderResourceRow(r)}
|
||||
{renderExpandedRow(r)}
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user