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,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) ·{" "}
|
||||
{formatMoney(baseline.totalCostCents)} total ·{" "}
|
||||
{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)} ·{" "}
|
||||
{a.hoursPerDay}h/d · {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"} · {d.headcount}x ·{" "}
|
||||
{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"} — 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user