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,224 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
type TopSortKey = "name" | "actual" | "expected";
type WatchSortKey = "name" | "actual" | "target";
export function ChargeabilityWidget({ config: _config }: WidgetProps) {
const config = _config as { topN?: number; watchlistThreshold?: number };
const [topSort, setTopSort] = useState<TopSortKey>("actual");
const [topDir, setTopDir] = useState<"asc" | "desc">("desc");
const [watchSort, setWatchSort] = useState<WatchSortKey>("actual");
const [watchDir, setWatchDir] = useState<"asc" | "desc">("asc");
function toggleTop(key: TopSortKey) {
if (topSort === key) setTopDir((d) => (d === "asc" ? "desc" : "asc"));
else { setTopSort(key); setTopDir(key === "name" ? "asc" : "desc"); }
}
function toggleWatch(key: WatchSortKey) {
if (watchSort === key) setWatchDir((d) => (d === "asc" ? "desc" : "asc"));
else { setWatchSort(key); setWatchDir(key === "name" ? "asc" : "asc"); }
}
const { data, isLoading } = trpc.dashboard.getChargeabilityOverview.useQuery(
{ topN: config.topN ?? 10, watchlistThreshold: config.watchlistThreshold ?? 15 },
{ staleTime: 60_000, placeholderData: (prev) => prev },
);
if (isLoading) {
return (
<div className="animate-pulse flex flex-col gap-3 pt-1">
<div className="h-2 w-32 bg-gray-200 dark:bg-gray-700 rounded" />
{[...Array(4)].map((_, i) => (
<div key={i} className="flex gap-3 px-2 py-1">
<div className="h-3 w-4 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
))}
<div className="border-t border-gray-100 dark:border-gray-800 mt-1 pt-2">
<div className="h-2 w-20 bg-gray-200 dark:bg-gray-700 rounded mb-2" />
{[...Array(3)].map((_, i) => (
<div key={i} className="flex gap-3 px-2 py-1">
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-10 bg-gray-100 dark:bg-gray-800 rounded" />
<div className="h-3 w-10 bg-gray-100 dark:bg-gray-800 rounded" />
</div>
))}
</div>
</div>
);
}
const rawTop = data?.top ?? [];
const rawWatch = data?.watchlist ?? [];
const month = data?.month ?? "";
const top = [...rawTop].sort((a, b) => {
const mult = topDir === "asc" ? 1 : -1;
switch (topSort) {
case "name": return mult * a.displayName.localeCompare(b.displayName);
case "actual": return mult * (a.actualChargeability - b.actualChargeability);
case "expected": return mult * (a.expectedChargeability - b.expectedChargeability);
default: return 0;
}
});
const watchlist = [...rawWatch].sort((a, b) => {
const mult = watchDir === "asc" ? 1 : -1;
switch (watchSort) {
case "name": return mult * a.displayName.localeCompare(b.displayName);
case "actual": return mult * (a.actualChargeability - b.actualChargeability);
case "target": return mult * (a.chargeabilityTarget - b.chargeabilityTarget);
default: return 0;
}
});
function TopInd({ k }: { k: TopSortKey }) {
return topSort === k
? <span className="text-[10px] ml-0.5">{topDir === "asc" ? "▲" : "▼"}</span>
: <span className="text-[10px] ml-0.5 text-gray-300"></span>;
}
function WatchInd({ k }: { k: WatchSortKey }) {
return watchSort === k
? <span className="text-[10px] ml-0.5">{watchDir === "asc" ? "▲" : "▼"}</span>
: <span className="text-[10px] ml-0.5 text-gray-300"></span>;
}
return (
<div className="h-full flex flex-col gap-2 overflow-hidden">
{month && (
<p className="text-xs text-gray-400 px-1 flex-shrink-0 flex items-center gap-1">
Period: {month}
<InfoTooltip
content="Chargeability is calculated for the current calendar month. Available hours are based on each person's weekly schedule (WeekdayAvailability). Watchlist threshold: 15 percentage points below target."
width="w-72"
/>
</p>
)}
{/* Top list */}
<section className="flex-1 min-h-0 overflow-auto">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider px-1 mb-1 sticky top-0 bg-white">
Top Chargeability
</h3>
{top.length === 0 ? (
<p className="text-xs text-gray-400 px-1">No data available.</p>
) : (
<table className="w-full text-xs">
<thead>
<tr className="text-gray-400 border-b border-gray-100">
<th className="px-2 py-1 text-left font-medium w-6">#</th>
<th className="px-2 py-1 text-left font-medium">
<button type="button" onClick={() => toggleTop("name")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
Name<TopInd k="name" />
</button>
</th>
<th className="px-2 py-1 text-right font-medium">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleTop("actual")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
Actual<TopInd k="actual" />
</button>
<InfoTooltip
content="CONFIRMED + ACTIVE allocations on non-DRAFT projects ÷ available working hours this month × 100."
width="w-72"
/>
</span>
</th>
<th className="px-2 py-1 text-right font-medium">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleTop("expected")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
Expected<TopInd k="expected" />
</button>
<InfoTooltip
content="All non-CANCELLED allocations (including DRAFT projects and PROPOSED status) ÷ available working hours this month × 100."
width="w-72"
/>
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{top.map((r, i) => (
<tr key={r.id} className="hover:bg-gray-50">
<td className="px-2 py-1 text-gray-400">{i + 1}</td>
<td className="px-2 py-1 text-gray-800 truncate max-w-[120px]">
<span title={r.displayName}>{r.displayName}</span>
{r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>}
</td>
<td className="px-2 py-1 text-right font-semibold text-green-700">
{r.actualChargeability}%
</td>
<td className="px-2 py-1 text-right text-gray-400">
{r.expectedChargeability}%
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<div className="border-t border-gray-100 flex-shrink-0" />
{/* Watchlist */}
<section className="flex-1 min-h-0 overflow-auto">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider px-1 mb-1 sticky top-0 bg-white">
Watchlist <span className="font-normal text-gray-400">(below target)</span>
</h3>
{watchlist.length === 0 ? (
<p className="text-xs text-gray-400 px-1">All resources at or near target.</p>
) : (
<table className="w-full text-xs">
<thead>
<tr className="text-gray-400 border-b border-gray-100">
<th className="px-2 py-1 text-left font-medium">
<button type="button" onClick={() => toggleWatch("name")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
Name<WatchInd k="name" />
</button>
</th>
<th className="px-2 py-1 text-right font-medium">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleWatch("actual")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
Actual<WatchInd k="actual" />
</button>
<InfoTooltip content="Actual chargeability this month: CONFIRMED + ACTIVE allocations on non-DRAFT projects ÷ available hours." />
</span>
</th>
<th className="px-2 py-1 text-right font-medium">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleWatch("target")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
Target<WatchInd k="target" />
</button>
<InfoTooltip content="Chargeability target set by management. Watchlist shows resources more than 15 percentage points below their target." />
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{watchlist.map((r) => (
<tr key={r.id} className="hover:bg-gray-50">
<td className="px-2 py-1 text-gray-800 truncate max-w-[140px]">
<span title={r.displayName}>{r.displayName}</span>
{r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>}
</td>
<td className="px-2 py-1 text-right font-semibold text-red-600">
{r.actualChargeability}%
</td>
<td className="px-2 py-1 text-right text-gray-400">
{r.chargeabilityTarget}%
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</div>
);
}