feat: dashboard overhaul, chargeability reports, dispo import enhancements, UI polish

Dashboard: expanded chargeability widget, resource/project table widgets
with sorting and filters, stat cards with formatMoney integration.

Chargeability: new report client with filtering, chargeability-bookings
use case, updated dashboard overview logic.

Dispo import: TBD project handling, parse-dispo-matrix improvements,
stage-dispo-projects resource value scores, new tests.

Estimates: CommercialTermsEditor component, commercial-terms engine
module, expanded estimate schemas and types.

UI: AppShell navigation updates, timeline filter/toolbar enhancements,
role management improvements, signin page redesign, Tailwind/globals
polish, SystemSettings SMTP section, anonymization support.

Tests: new router tests (anonymization, chargeability, effort-rule,
entitlement, estimate, experience-multiplier, notification, resource,
staffing, vacation).

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-14 23:29:07 +01:00
parent ad0855902b
commit 625a842d89
74 changed files with 11680 additions and 1583 deletions
@@ -18,13 +18,14 @@ interface ResourceRow {
export function ResourceTableWidget({ config, onConfigChange }: WidgetProps) {
const chapter = (config.chapter as string) || "";
const [includeProposed, setIncludeProposed] = useState(false);
const now = new Date();
const startDate = new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
const endDate = new Date(now.getFullYear(), now.getMonth() + 3, 0).toISOString();
const { data: resources, isLoading } = trpc.resource.listWithUtilization.useQuery(
{ chapter: chapter || undefined, startDate, endDate },
{ chapter: chapter || undefined, includeProposed, startDate, endDate },
{ staleTime: 60_000 },
);
@@ -37,7 +38,10 @@ export function ResourceTableWidget({ config, onConfigChange }: WidgetProps) {
function toggleSort(key: SortKey) {
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else { setSortKey(key); setSortDir("asc"); }
else {
setSortKey(key);
setSortDir("asc");
}
}
if (isLoading) {
@@ -46,12 +50,19 @@ export function ResourceTableWidget({ config, onConfigChange }: WidgetProps) {
{/* header row */}
<div className="flex gap-3 px-3 py-2">
{[40, 120, 80, 60, 60].map((w, i) => (
<div key={i} className="h-2.5 bg-gray-200 dark:bg-gray-700 rounded" style={{ width: w }} />
<div
key={i}
className="h-2.5 bg-gray-200 dark:bg-gray-700 rounded"
style={{ width: w }}
/>
))}
</div>
{/* data rows */}
{[...Array(6)].map((_, i) => (
<div key={i} className="flex gap-3 px-3 py-2 border-t border-gray-100 dark:border-gray-800">
<div
key={i}
className="flex gap-3 px-3 py-2 border-t border-gray-100 dark:border-gray-800"
>
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded font-mono" />
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded" />
@@ -68,77 +79,174 @@ export function ResourceTableWidget({ config, onConfigChange }: WidgetProps) {
const sorted = [...list].sort((a, b) => {
const mult = sortDir === "asc" ? 1 : -1;
switch (sortKey) {
case "eid": return mult * a.eid.localeCompare(b.eid);
case "name": return mult * a.displayName.localeCompare(b.displayName);
case "chapter": return mult * (a.chapter ?? "").localeCompare(b.chapter ?? "");
case "bookings": return mult * (a.bookingCount - b.bookingCount);
case "utilization": return mult * (a.utilizationPercent - b.utilizationPercent);
case "target": return mult * (a.chargeabilityTarget - b.chargeabilityTarget);
default: return 0;
case "eid":
return mult * a.eid.localeCompare(b.eid);
case "name":
return mult * a.displayName.localeCompare(b.displayName);
case "chapter":
return mult * (a.chapter ?? "").localeCompare(b.chapter ?? "");
case "bookings":
return mult * (a.bookingCount - b.bookingCount);
case "utilization":
return mult * (a.utilizationPercent - b.utilizationPercent);
case "target":
return mult * (a.chargeabilityTarget - b.chargeabilityTarget);
default:
return 0;
}
});
return (
<div className="flex flex-col h-full gap-3">
{/* Filter */}
{chapters.length > 0 && (
<select
value={chapter}
onChange={(e) => onConfigChange?.({ chapter: e.target.value })}
className="w-40 px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white"
>
<option value="">All Chapters</option>
{chapters.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
)}
<div className="flex flex-wrap items-center gap-3">
{chapters.length > 0 && (
<select
value={chapter}
onChange={(e) => onConfigChange?.({ chapter: e.target.value })}
className="app-select w-44 text-xs"
>
<option value="">All Chapters</option>
{chapters.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
)}
<label className="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300">
<input
type="checkbox"
checked={includeProposed}
onChange={(event) => setIncludeProposed(event.target.checked)}
className="rounded border-gray-300"
/>
Include proposed
</label>
</div>
{/* Table */}
<div className="overflow-auto flex-1">
<div className="app-data-table flex-1 overflow-auto">
<table className="w-full text-xs">
<thead className="bg-gray-50 sticky top-0">
<thead className="sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<span className="inline-flex items-center">
<button type="button" onClick={() => toggleSort("eid")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
<button
type="button"
onClick={() => toggleSort("eid")}
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
>
EID
<span className="text-[10px] ml-0.5">{sortKey === "eid" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300"></span>}</span>
<span className="text-[10px] ml-0.5">
{sortKey === "eid" ? (
sortDir === "asc" ? (
"▲"
) : (
"▼"
)
) : (
<span className="text-gray-300"></span>
)}
</span>
</button>
<InfoTooltip content="Employee ID — unique identifier for each resource." />
</span>
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<button type="button" onClick={() => toggleSort("name")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
<button
type="button"
onClick={() => toggleSort("name")}
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
>
Name
<span className="text-[10px] ml-0.5">{sortKey === "name" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300"></span>}</span>
<span className="text-[10px] ml-0.5">
{sortKey === "name" ? (
sortDir === "asc" ? (
"▲"
) : (
"▼"
)
) : (
<span className="text-gray-300"></span>
)}
</span>
</button>
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<button type="button" onClick={() => toggleSort("chapter")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
<button
type="button"
onClick={() => toggleSort("chapter")}
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
>
Chapter
<span className="text-[10px] ml-0.5">{sortKey === "chapter" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300"></span>}</span>
<span className="text-[10px] ml-0.5">
{sortKey === "chapter" ? (
sortDir === "asc" ? (
"▲"
) : (
"▼"
)
) : (
<span className="text-gray-300"></span>
)}
</span>
</button>
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleSort("bookings")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
<button
type="button"
onClick={() => toggleSort("bookings")}
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
>
Bookings
<span className="text-[10px] ml-0.5">{sortKey === "bookings" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300"></span>}</span>
<span className="text-[10px] ml-0.5">
{sortKey === "bookings" ? (
sortDir === "asc" ? (
"▲"
) : (
"▼"
)
) : (
<span className="text-gray-300"></span>
)}
</span>
</button>
<InfoTooltip content="Number of non-cancelled allocations in the period (current month + next 3 months)." />
<InfoTooltip content="Number of non-cancelled assignments in the period. Proposed rows are only counted when the toggle is enabled." />
</span>
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleSort("utilization")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
<button
type="button"
onClick={() => toggleSort("utilization")}
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
>
Utilization
<span className="text-[10px] ml-0.5">{sortKey === "utilization" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300"></span>}</span>
<span className="text-[10px] ml-0.5">
{sortKey === "utilization" ? (
sortDir === "asc" ? (
"▲"
) : (
"▼"
)
) : (
<span className="text-gray-300"></span>
)}
</span>
</button>
<InfoTooltip
content={
<span>
Booked hours ÷ available hours × 100 for the period.<br />
Available hours = working days × hours from personal schedule.<br />
<span className="text-orange-300">Orange</span> = &gt;85% · <span className="text-red-300">Red</span> = &gt;100%
Booked hours ÷ available hours × 100 for the period.
<br />
Available hours = working days × hours from personal schedule.
<br />
Proposed rows are only counted when the toggle is enabled.
<br />
<span className="text-orange-300">Orange</span> = &gt;85% ·{" "}
<span className="text-red-300">Red</span> = &gt;100%
</span>
}
width="w-72"
@@ -147,34 +255,59 @@ export function ResourceTableWidget({ config, onConfigChange }: WidgetProps) {
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">
<span className="inline-flex items-center justify-end">
<button type="button" onClick={() => toggleSort("target")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
<button
type="button"
onClick={() => toggleSort("target")}
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
>
Target
<span className="text-[10px] ml-0.5">{sortKey === "target" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300"></span>}</span>
<span className="text-[10px] ml-0.5">
{sortKey === "target" ? (
sortDir === "asc" ? (
"▲"
) : (
"▼"
)
) : (
<span className="text-gray-300"></span>
)}
</span>
</button>
<InfoTooltip content="Chargeability target set by management per resource. Not a computed value." />
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{sorted.map((r) => (
<tr key={r.id} className={`hover:bg-gray-50 ${r.isOverbooked ? "bg-amber-50" : ""}`}>
<td className="px-3 py-2 font-mono text-gray-600">{r.eid}</td>
<td className="px-3 py-2 font-medium text-gray-900">{r.displayName}</td>
<td className="px-3 py-2 text-gray-500">{r.chapter ?? "—"}</td>
<td className="px-3 py-2 text-right text-gray-700">{r.bookingCount}</td>
<tr
key={r.id}
className={`transition hover:bg-gray-50 dark:hover:bg-gray-800/60 ${r.isOverbooked ? "bg-amber-50 dark:bg-amber-950/20" : ""}`}
>
<td className="px-3 py-2 font-mono text-gray-600 dark:text-gray-300">{r.eid}</td>
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100">
{r.displayName}
</td>
<td className="px-3 py-2 text-gray-500 dark:text-gray-400">{r.chapter ?? "—"}</td>
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-200">
{r.bookingCount}
</td>
<td className="px-3 py-2 text-right">
<span className={`font-semibold ${r.utilizationPercent > 100 ? "text-red-600" : r.utilizationPercent > 85 ? "text-orange-600" : "text-green-700"}`}>
<span
className={`font-semibold ${r.utilizationPercent > 100 ? "text-red-600" : r.utilizationPercent > 85 ? "text-orange-600" : "text-green-700"}`}
>
{r.utilizationPercent}%
</span>
</td>
<td className="px-3 py-2 text-right text-gray-500">{r.chargeabilityTarget}%</td>
<td className="px-3 py-2 text-right text-gray-500 dark:text-gray-400">
{r.chargeabilityTarget}%
</td>
</tr>
))}
</tbody>
</table>
{list.length === 0 && (
<div className="text-center py-8 text-sm text-gray-400">No resources found.</div>
<div className="py-8 text-center text-sm text-gray-400">No resources found.</div>
)}
</div>
</div>