feat: Sprint 4 — scenario planner, report builder, comments, dashboard widgets
What-If Scenario Planner (G5): - New /projects/[id]/scenario page with side-by-side baseline vs scenario - simulate mutation: pure cost/hours/headcount/utilization computation - apply mutation: creates real PROPOSED assignments from scenario - Impact cards: cost delta, hours delta, headcount, skill coverage % - Per-resource utilization impact table with over-allocation warnings - "What-If" button added to project detail page Custom Report Builder (G7): - New /reports/builder page with full config panel - Entity selector (resource/project/assignment), column picker, filter builder - Dynamic Prisma query with eq/neq/gt/lt/contains/in operators - Sortable results table with pagination (50/page) - CSV export via exportReport mutation - Sidebar nav link under Analytics Collaboration Layer (G8): - Comment model in Prisma (entityType/entityId, replies, @mentions, resolved) - comment router: list, count, create, resolve, delete - @mention parsing with notification creation + SSE delivery - CommentInput with @mention autocomplete (arrow nav, Enter/Tab confirm) - CommentThread with avatar, timestamp, reply, resolve, delete - Integrated as "Comments" tab in estimate workspace with count badge Dashboard Widgets: - BudgetForecastWidget: progress bars per project, burn rate, exhaustion date - SkillGapWidget: supply vs demand per skill, shortage/surplus indicators - ProjectHealthWidget: 3-dimension health circles + composite score - 3 new application use-cases + dashboard router queries - All registered in widget-registry with lazy imports Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||
|
||||
function healthDot(value: number): string {
|
||||
if (value >= 70) return "bg-green-500";
|
||||
if (value >= 40) return "bg-amber-400";
|
||||
return "bg-red-500";
|
||||
}
|
||||
|
||||
function scoreBadge(score: number): string {
|
||||
if (score >= 70) return "bg-green-100 text-green-700";
|
||||
if (score >= 40) return "bg-amber-100 text-amber-700";
|
||||
return "bg-red-100 text-red-700";
|
||||
}
|
||||
|
||||
export function ProjectHealthWidget(_props: WidgetProps) {
|
||||
const { data, isLoading } = trpc.dashboard.getProjectHealth.useQuery(
|
||||
undefined,
|
||||
{ staleTime: 60_000, placeholderData: (prev) => prev },
|
||||
);
|
||||
|
||||
if (isLoading && !data) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 pt-1">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-3 py-2">
|
||||
<div className="h-3 w-16 shimmer-skeleton rounded" />
|
||||
<div className="h-3 flex-1 shimmer-skeleton rounded" />
|
||||
<div className="flex gap-1">
|
||||
<div className="h-4 w-4 shimmer-skeleton rounded-full" />
|
||||
<div className="h-4 w-4 shimmer-skeleton rounded-full" />
|
||||
<div className="h-4 w-4 shimmer-skeleton rounded-full" />
|
||||
</div>
|
||||
<div className="h-5 w-10 shimmer-skeleton rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rows = data ?? [];
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-sm text-gray-400">
|
||||
No active projects found.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-auto h-full">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">Project</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-gray-500" title="Budget / Staffing / Timeline">
|
||||
B / S / T
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500">Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{rows.map((row) => (
|
||||
<tr key={row.shortCode} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-2 font-medium text-gray-900 max-w-[160px] truncate">
|
||||
<span className="font-mono text-gray-500 mr-1">{row.shortCode}</span>
|
||||
{row.projectName}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span
|
||||
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.budgetHealth)}`}
|
||||
title={`Budget: ${row.budgetHealth}%`}
|
||||
/>
|
||||
<span
|
||||
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.staffingHealth)}`}
|
||||
title={`Staffing: ${row.staffingHealth}%`}
|
||||
/>
|
||||
<span
|
||||
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.timelineHealth)}`}
|
||||
title={`Timeline: ${row.timelineHealth}%`}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full font-semibold tabular-nums ${scoreBadge(row.compositeScore)}`}
|
||||
>
|
||||
{row.compositeScore}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user