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:
2026-03-19 21:47:47 +01:00
parent 6f34659587
commit e1368c7ef7
27 changed files with 3889 additions and 1 deletions
@@ -0,0 +1,772 @@
"use client";
import { useState, useCallback, useMemo } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { DateInput } from "~/components/ui/DateInput.js";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { formatMoney, formatDate } from "~/lib/format.js";
// ── Types ────────────────────────────────────────────────────────────────────
interface BaselineAssignment {
id: string;
resourceId: string | null;
resourceName: string;
resourceEid: string;
lcrCents: number;
roleId: string | null;
roleName: string;
roleColor: string | null;
startDate: string;
endDate: string;
hoursPerDay: number;
status: string;
costCents: number;
totalHours: number;
workingDays: number;
}
interface BaselineDemand {
id: string;
roleId: string | null;
roleName: string;
roleColor: string | null;
startDate: string;
endDate: string;
hoursPerDay: number;
headcount: number;
status: string;
}
interface Baseline {
project: {
id: string;
name: string;
shortCode: string;
startDate: Date | string;
endDate: Date | string;
budgetCents: number | null;
};
assignments: BaselineAssignment[];
demands: BaselineDemand[];
totalCostCents: number;
totalHours: number;
budgetCents: number | null;
}
interface ResourceOption {
id: string;
displayName: string;
eid: string;
lcrCents: number;
}
interface RoleOption {
id: string;
name: string;
color: string | null;
}
interface ScenarioRow {
key: string;
assignmentId?: string;
resourceId: string;
roleId: string;
startDate: string;
endDate: string;
hoursPerDay: number;
remove?: boolean;
}
interface ScenarioPlannerProps {
projectId: string;
baseline: Baseline;
resources: ResourceOption[];
roles: RoleOption[];
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function toISODate(d: Date | string): string {
if (typeof d === "string") return d.split("T")[0] ?? d;
return d.toISOString().split("T")[0] ?? "";
}
let nextKey = 1;
function genKey(): string {
return `new-${nextKey++}`;
}
// ── Component ────────────────────────────────────────────────────────────────
export function ScenarioPlanner({ projectId, baseline, resources, roles }: ScenarioPlannerProps) {
const projectStart = toISODate(baseline.project.startDate);
const projectEnd = toISODate(baseline.project.endDate);
// Initialize scenario rows from baseline assignments
const initialRows: ScenarioRow[] = baseline.assignments.map((a) => ({
key: a.id,
assignmentId: a.id,
resourceId: a.resourceId ?? "",
roleId: a.roleId ?? "",
startDate: toISODate(a.startDate),
endDate: toISODate(a.endDate),
hoursPerDay: a.hoursPerDay,
}));
const [rows, setRows] = useState<ScenarioRow[]>(initialRows);
const [showApplyConfirm, setShowApplyConfirm] = useState(false);
// Simulation mutation
const simulateMut = trpc.scenario.simulate.useMutation();
const applyMut = trpc.scenario.apply.useMutation();
// Derived: has the scenario diverged from baseline?
const isDirty = useMemo(() => {
if (rows.length !== initialRows.length) return true;
return rows.some((r, i) => {
const init = initialRows[i];
if (!init) return true;
return (
r.resourceId !== init.resourceId ||
r.roleId !== init.roleId ||
r.startDate !== init.startDate ||
r.endDate !== init.endDate ||
r.hoursPerDay !== init.hoursPerDay ||
r.remove
);
});
}, [rows, initialRows]);
// Resource lookup map
const resourceMap = useMemo(() => new Map(resources.map((r) => [r.id, r])), [resources]);
const roleMap = useMemo(() => new Map(roles.map((r) => [r.id, r])), [roles]);
// ── Row Handlers ─────────────────────────────────────────────────────────
const updateRow = useCallback((key: string, updates: Partial<ScenarioRow>) => {
setRows((prev) => prev.map((r) => (r.key === key ? { ...r, ...updates } : r)));
}, []);
const removeRow = useCallback((key: string) => {
setRows((prev) => {
const row = prev.find((r) => r.key === key);
if (!row) return prev;
// If it's an existing assignment, mark for removal instead of deleting
if (row.assignmentId) {
return prev.map((r) => (r.key === key ? { ...r, remove: true } : r));
}
// New row — just remove it
return prev.filter((r) => r.key !== key);
});
}, []);
const restoreRow = useCallback((key: string) => {
setRows((prev) => prev.map((r) => (r.key === key ? { ...r, remove: false } : r)));
}, []);
const addRow = useCallback(() => {
setRows((prev) => [
...prev,
{
key: genKey(),
resourceId: "",
roleId: "",
startDate: projectStart,
endDate: projectEnd,
hoursPerDay: 8,
},
]);
}, [projectStart, projectEnd]);
const resetScenario = useCallback(() => {
setRows(initialRows);
simulateMut.reset();
}, [initialRows, simulateMut]);
// ── Simulate ─────────────────────────────────────────────────────────────
const runSimulation = useCallback(() => {
// Build changes array: only rows that differ from baseline or are new
const changes = rows
.filter((r) => {
if (r.remove) return true;
if (!r.assignmentId) return true; // new row
// Check if modified
const orig = initialRows.find((ir) => ir.key === r.key);
if (!orig) return true;
return (
r.resourceId !== orig.resourceId ||
r.roleId !== orig.roleId ||
r.startDate !== orig.startDate ||
r.endDate !== orig.endDate ||
r.hoursPerDay !== orig.hoursPerDay
);
})
.map((r) => ({
assignmentId: r.assignmentId,
resourceId: r.resourceId || undefined,
roleId: r.roleId || undefined,
startDate: new Date(r.startDate),
endDate: new Date(r.endDate),
hoursPerDay: r.hoursPerDay,
remove: r.remove,
}));
if (changes.length === 0) return;
simulateMut.mutate({ projectId, changes });
}, [rows, initialRows, projectId, simulateMut]);
// ── Apply ────────────────────────────────────────────────────────────────
const applyScenario = useCallback(() => {
const changes = rows
.filter((r) => {
if (r.remove) return true;
if (!r.assignmentId) return true;
const orig = initialRows.find((ir) => ir.key === r.key);
if (!orig) return true;
return (
r.resourceId !== orig.resourceId ||
r.roleId !== orig.roleId ||
r.startDate !== orig.startDate ||
r.endDate !== orig.endDate ||
r.hoursPerDay !== orig.hoursPerDay
);
})
.map((r) => ({
assignmentId: r.assignmentId,
resourceId: r.resourceId || undefined,
roleId: r.roleId || undefined,
startDate: new Date(r.startDate),
endDate: new Date(r.endDate),
hoursPerDay: r.hoursPerDay,
remove: r.remove,
}));
applyMut.mutate(
{ projectId, changes },
{
onSuccess: () => {
setShowApplyConfirm(false);
// Reload page to see updated state
window.location.href = `/projects/${projectId}`;
},
},
);
}, [rows, initialRows, projectId, applyMut]);
const result = simulateMut.data;
const activeRows = rows.filter((r) => !r.remove);
const removedRows = rows.filter((r) => r.remove);
return (
<div className="space-y-6">
{/* Two-panel layout */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left: Baseline (read-only) */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">
Current Baseline
</h2>
<p className="text-xs text-gray-500 mb-4">
{baseline.assignments.length} assignment(s) &middot;{" "}
{formatMoney(baseline.totalCostCents)} total &middot;{" "}
{baseline.totalHours.toFixed(0)}h
</p>
{baseline.assignments.length === 0 ? (
<p className="text-sm text-gray-400 italic">No assignments yet.</p>
) : (
<div className="space-y-2 max-h-[500px] overflow-y-auto">
{baseline.assignments.map((a) => (
<div
key={a.id}
className="flex items-center justify-between rounded-lg border border-gray-100 dark:border-gray-700 px-3 py-2 text-sm"
>
<div className="min-w-0">
<div className="font-medium text-gray-900 dark:text-gray-100 truncate">
{a.resourceName}
{a.roleName && (
<span
className="ml-2 inline-block px-1.5 py-0.5 text-xs rounded"
style={{
backgroundColor: a.roleColor ? `${a.roleColor}20` : "#f3f4f6",
color: a.roleColor ?? "#6b7280",
}}
>
{a.roleName}
</span>
)}
</div>
<div className="text-xs text-gray-500 mt-0.5">
{formatDate(a.startDate)} - {formatDate(a.endDate)} &middot;{" "}
{a.hoursPerDay}h/d &middot; {a.workingDays} days
</div>
</div>
<div className="text-right flex-shrink-0 ml-3">
<div className="font-mono text-sm text-gray-900 dark:text-gray-100">
{formatMoney(a.costCents)}
</div>
<div className="text-xs text-gray-500">{a.totalHours.toFixed(0)}h</div>
</div>
</div>
))}
</div>
)}
{baseline.demands.length > 0 && (
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-gray-700">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Open Demands
</h3>
<div className="space-y-1">
{baseline.demands.map((d) => (
<div key={d.id} className="text-xs text-gray-500 flex items-center gap-2">
<span
className="inline-block w-2 h-2 rounded-full"
style={{ backgroundColor: d.roleColor ?? "#9ca3af" }}
/>
{d.roleName || "Unspecified"} &middot; {d.headcount}x &middot;{" "}
{d.hoursPerDay}h/d
</div>
))}
</div>
</div>
)}
</div>
{/* Right: Scenario Editor */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Scenario Editor
</h2>
<p className="text-xs text-gray-500">
{activeRows.length} allocation(s)
{removedRows.length > 0 && (
<span className="text-red-500 ml-1">
({removedRows.length} removed)
</span>
)}
</p>
</div>
<div className="flex items-center gap-2">
{isDirty && (
<button
type="button"
onClick={resetScenario}
className="text-xs text-gray-500 hover:text-gray-700 underline"
>
Reset
</button>
)}
<button
type="button"
onClick={addRow}
className="inline-flex items-center gap-1 rounded-lg bg-brand-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-brand-700 transition"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add Resource
</button>
</div>
</div>
<div className="space-y-3 max-h-[500px] overflow-y-auto">
{rows.map((row) => (
<ScenarioRowEditor
key={row.key}
row={row}
resources={resources}
roles={roles}
resourceMap={resourceMap}
roleMap={roleMap}
onUpdate={updateRow}
onRemove={removeRow}
onRestore={restoreRow}
/>
))}
{rows.length === 0 && (
<p className="text-sm text-gray-400 italic text-center py-6">
Add resources to build your scenario.
</p>
)}
</div>
{/* Action buttons */}
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-gray-700 flex items-center gap-3">
<button
type="button"
onClick={runSimulation}
disabled={!isDirty || simulateMut.isPending}
className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{simulateMut.isPending ? (
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
)}
Simulate
</button>
{result && (
<button
type="button"
onClick={() => setShowApplyConfirm(true)}
disabled={applyMut.isPending}
className="inline-flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
Apply Scenario
</button>
)}
</div>
{simulateMut.error && (
<p className="mt-2 text-sm text-red-600">{simulateMut.error.message}</p>
)}
</div>
</div>
{/* Impact Summary */}
{result && <ImpactSummary result={result} budgetCents={baseline.budgetCents ?? 0} />}
{/* Apply confirmation dialog */}
{showApplyConfirm && (
<ConfirmDialog
title="Apply Scenario"
message="This will create/modify real assignments based on your scenario. Existing assignments may be changed or cancelled. This action cannot be undone."
confirmLabel={applyMut.isPending ? "Applying..." : "Apply"}
onConfirm={applyScenario}
onCancel={() => setShowApplyConfirm(false)}
/>
)}
{applyMut.isSuccess && (
<div className="rounded-lg bg-green-50 border border-green-200 p-4 text-sm text-green-800">
Scenario applied successfully. Redirecting...
</div>
)}
</div>
);
}
// ── ScenarioRowEditor ────────────────────────────────────────────────────────
interface ScenarioRowEditorProps {
row: ScenarioRow;
resources: ResourceOption[];
roles: RoleOption[];
resourceMap: Map<string, ResourceOption>;
roleMap: Map<string, RoleOption>;
onUpdate: (key: string, updates: Partial<ScenarioRow>) => void;
onRemove: (key: string) => void;
onRestore: (key: string) => void;
}
function ScenarioRowEditor({
row,
resources,
roles,
resourceMap,
roleMap,
onUpdate,
onRemove,
onRestore,
}: ScenarioRowEditorProps) {
const isRemoved = row.remove;
const resource = row.resourceId ? resourceMap.get(row.resourceId) : null;
const lcrDisplay = resource ? `${formatMoney(resource.lcrCents)}/h` : "";
return (
<div
className={`rounded-lg border p-3 transition ${
isRemoved
? "border-red-200 bg-red-50/50 dark:border-red-800 dark:bg-red-900/10 opacity-60"
: row.assignmentId
? "border-gray-200 dark:border-gray-700"
: "border-blue-200 bg-blue-50/30 dark:border-blue-800 dark:bg-blue-900/10"
}`}
>
{isRemoved ? (
<div className="flex items-center justify-between">
<span className="text-sm text-red-600 line-through">
{resource?.displayName ?? "Unknown"} &mdash; removed
</span>
<button
type="button"
onClick={() => onRestore(row.key)}
className="text-xs text-blue-600 hover:text-blue-800 underline"
>
Restore
</button>
</div>
) : (
<div className="space-y-2">
{/* Top row: resource + role + remove */}
<div className="flex items-center gap-2">
<select
value={row.resourceId}
onChange={(e) => onUpdate(row.key, { resourceId: e.target.value })}
className="flex-1 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1.5 text-sm text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-brand-500 focus:border-transparent"
>
<option value="">Select resource...</option>
{resources.map((r) => (
<option key={r.id} value={r.id}>
{r.displayName} ({r.eid})
</option>
))}
</select>
<select
value={row.roleId}
onChange={(e) => onUpdate(row.key, { roleId: e.target.value })}
className="w-40 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1.5 text-sm text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-brand-500 focus:border-transparent"
>
<option value="">Role...</option>
{roles.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
<button
type="button"
onClick={() => onRemove(row.key)}
className="p-1.5 text-gray-400 hover:text-red-500 transition rounded"
title="Remove from scenario"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Bottom row: dates + hours + LCR info */}
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1">
<label className="text-xs text-gray-500 whitespace-nowrap">From</label>
<DateInput
value={row.startDate}
onChange={(v) => onUpdate(row.key, { startDate: v })}
className="w-28 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-xs text-gray-900 dark:text-gray-100"
/>
</div>
<div className="flex items-center gap-1">
<label className="text-xs text-gray-500 whitespace-nowrap">To</label>
<DateInput
value={row.endDate}
onChange={(v) => onUpdate(row.key, { endDate: v })}
className="w-28 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-xs text-gray-900 dark:text-gray-100"
/>
</div>
<div className="flex items-center gap-1">
<label className="text-xs text-gray-500 whitespace-nowrap">h/day</label>
<input
type="number"
min={0}
max={24}
step={0.5}
value={row.hoursPerDay}
onChange={(e) => onUpdate(row.key, { hoursPerDay: parseFloat(e.target.value) || 0 })}
className="w-16 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-xs text-gray-900 dark:text-gray-100 text-center"
/>
</div>
{lcrDisplay && (
<span className="text-xs text-gray-400 ml-auto">{lcrDisplay}</span>
)}
{!row.assignmentId && (
<span className="text-xs text-blue-500 font-medium">NEW</span>
)}
</div>
</div>
)}
</div>
);
}
// ── ImpactSummary ────────────────────────────────────────────────────────────
interface SimulationResult {
baseline: { totalCostCents: number; totalHours: number; headcount: number; skillCount: number };
scenario: { totalCostCents: number; totalHours: number; headcount: number; skillCount: number };
delta: { costCents: number; hours: number; headcount: number; skillCoveragePct: number };
resourceImpacts: Array<{
resourceId: string;
resourceName: string;
chargeabilityTarget: number;
currentUtilization: number;
scenarioUtilization: number;
utilizationDelta: number;
isOverallocated: boolean;
}>;
warnings: string[];
budgetCents: number;
}
function ImpactSummary({ result, budgetCents }: { result: SimulationResult; budgetCents: number }) {
const { baseline, scenario, delta, resourceImpacts, warnings } = result;
const costSign = delta.costCents > 0 ? "+" : "";
const hoursSign = delta.hours > 0 ? "+" : "";
const headcountSign = delta.headcount > 0 ? "+" : "";
const costColor = delta.costCents > 0 ? "text-red-600" : delta.costCents < 0 ? "text-green-600" : "text-gray-500";
const hoursColor = delta.hours > 0 ? "text-amber-600" : delta.hours < 0 ? "text-blue-600" : "text-gray-500";
const budgetUsedPct = budgetCents > 0 ? Math.round((scenario.totalCostCents / budgetCents) * 100) : null;
return (
<div className="space-y-4">
{/* Delta cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<DeltaCard
label="Cost Impact"
value={`${costSign}${formatMoney(delta.costCents)}`}
subtitle={`${formatMoney(scenario.totalCostCents)} total`}
color={costColor}
/>
<DeltaCard
label="Hours Impact"
value={`${hoursSign}${delta.hours.toFixed(0)}h`}
subtitle={`${scenario.totalHours.toFixed(0)}h total`}
color={hoursColor}
/>
<DeltaCard
label="Headcount"
value={`${headcountSign}${delta.headcount}`}
subtitle={`${scenario.headcount} total`}
color={delta.headcount !== 0 ? "text-indigo-600" : "text-gray-500"}
/>
<DeltaCard
label="Skill Coverage"
value={`${delta.skillCoveragePct}%`}
subtitle={`${scenario.skillCount} unique skills`}
color={delta.skillCoveragePct >= 100 ? "text-green-600" : "text-amber-600"}
/>
</div>
{/* Budget progress */}
{budgetUsedPct !== null && (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-gray-600 dark:text-gray-400">Budget Usage</span>
<span className={`font-medium ${budgetUsedPct > 100 ? "text-red-600" : "text-gray-900 dark:text-gray-100"}`}>
{budgetUsedPct}% of {formatMoney(budgetCents)}
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all ${
budgetUsedPct > 100 ? "bg-red-500" : budgetUsedPct > 80 ? "bg-amber-500" : "bg-green-500"
}`}
style={{ width: `${Math.min(budgetUsedPct, 100)}%` }}
/>
</div>
</div>
)}
{/* Warnings */}
{warnings.length > 0 && (
<div className="rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-900/20 p-4">
<h3 className="text-sm font-medium text-amber-800 dark:text-amber-200 mb-2">Warnings</h3>
<ul className="space-y-1">
{warnings.map((w, i) => (
<li key={i} className="text-sm text-amber-700 dark:text-amber-300 flex items-start gap-2">
<svg className="w-4 h-4 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
{w}
</li>
))}
</ul>
</div>
)}
{/* Resource utilization impacts */}
{resourceImpacts.length > 0 && (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">
Resource Utilization Impact
</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 border-b border-gray-100 dark:border-gray-700">
<th className="text-left py-2 pr-4 font-medium">Resource</th>
<th className="text-right py-2 px-3 font-medium">Current</th>
<th className="text-right py-2 px-3 font-medium">Scenario</th>
<th className="text-right py-2 px-3 font-medium">Delta</th>
<th className="text-right py-2 pl-3 font-medium">Target</th>
</tr>
</thead>
<tbody>
{resourceImpacts.map((ri) => (
<tr
key={ri.resourceId}
className={`border-b border-gray-50 dark:border-gray-800 ${
ri.isOverallocated ? "bg-red-50/50 dark:bg-red-900/10" : ""
}`}
>
<td className="py-2 pr-4 font-medium text-gray-900 dark:text-gray-100">
{ri.resourceName}
{ri.isOverallocated && (
<span className="ml-2 text-xs text-red-500 font-normal">over-allocated</span>
)}
</td>
<td className="text-right py-2 px-3 text-gray-600 dark:text-gray-400">
{ri.currentUtilization.toFixed(1)}%
</td>
<td className={`text-right py-2 px-3 font-medium ${ri.isOverallocated ? "text-red-600" : "text-gray-900 dark:text-gray-100"}`}>
{ri.scenarioUtilization.toFixed(1)}%
</td>
<td className={`text-right py-2 px-3 ${
ri.utilizationDelta > 0 ? "text-amber-600" : ri.utilizationDelta < 0 ? "text-blue-600" : "text-gray-500"
}`}>
{ri.utilizationDelta > 0 ? "+" : ""}{ri.utilizationDelta.toFixed(1)}%
</td>
<td className="text-right py-2 pl-3 text-gray-500">
{ri.chargeabilityTarget}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
// ── DeltaCard ────────────────────────────────────────────────────────────────
function DeltaCard({
label,
value,
subtitle,
color,
}: {
label: string;
value: string;
subtitle: string;
color: string;
}) {
return (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<p className="text-xs text-gray-500 mb-1">{label}</p>
<p className={`text-xl font-bold ${color}`}>{value}</p>
<p className="text-xs text-gray-400 mt-1">{subtitle}</p>
</div>
);
}