chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
@@ -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>
);
}