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:
@@ -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> = >85% · <span className="text-red-300">Red</span> = >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> = >85% ·{" "}
|
||||
<span className="text-red-300">Red</span> = >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>
|
||||
|
||||
Reference in New Issue
Block a user