"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(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) => { 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 (
{/* Two-panel layout */}
{/* Left: Baseline (read-only) */}

Current Baseline

{baseline.assignments.length} assignment(s) ·{" "} {formatMoney(baseline.totalCostCents)} total ·{" "} {baseline.totalHours.toFixed(0)}h

{baseline.assignments.length === 0 ? (

No assignments yet.

) : (
{baseline.assignments.map((a) => (
{a.resourceName} {a.roleName && ( {a.roleName} )}
{formatDate(a.startDate)} - {formatDate(a.endDate)} ·{" "} {a.hoursPerDay}h/d · {a.workingDays} days
{formatMoney(a.costCents)}
{a.totalHours.toFixed(0)}h
))}
)} {baseline.demands.length > 0 && (

Open Demands

{baseline.demands.map((d) => (
{d.roleName || "Unspecified"} · {d.headcount}x ·{" "} {d.hoursPerDay}h/d
))}
)}
{/* Right: Scenario Editor */}

Scenario Editor

{activeRows.length} allocation(s) {removedRows.length > 0 && ( ({removedRows.length} removed) )}

{isDirty && ( )}
{rows.map((row) => ( ))} {rows.length === 0 && (

Add resources to build your scenario.

)}
{/* Action buttons */}
{result && ( )}
{simulateMut.error && (

{simulateMut.error.message}

)}
{/* Impact Summary */} {result && } {/* Apply confirmation dialog */} {showApplyConfirm && ( setShowApplyConfirm(false)} /> )} {applyMut.isSuccess && (
Scenario applied successfully. Redirecting...
)}
); } // ── ScenarioRowEditor ──────────────────────────────────────────────────────── interface ScenarioRowEditorProps { row: ScenarioRow; resources: ResourceOption[]; roles: RoleOption[]; resourceMap: Map; roleMap: Map; onUpdate: (key: string, updates: Partial) => 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 (
{isRemoved ? (
{resource?.displayName ?? "Unknown"} — removed
) : (
{/* Top row: resource + role + remove */}
{/* Bottom row: dates + hours + LCR info */}
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" />
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" />
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" />
{lcrDisplay && ( {lcrDisplay} )} {!row.assignmentId && ( NEW )}
)}
); } // ── 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 (
{/* Delta cards */}
= 100 ? "text-green-600" : "text-amber-600"} />
{/* Budget progress */} {budgetUsedPct !== null && (
Budget Usage 100 ? "text-red-600" : "text-gray-900 dark:text-gray-100"}`}> {budgetUsedPct}% of {formatMoney(budgetCents)}
100 ? "bg-red-500" : budgetUsedPct > 80 ? "bg-amber-500" : "bg-green-500" }`} style={{ width: `${Math.min(budgetUsedPct, 100)}%` }} />
)} {/* Warnings */} {warnings.length > 0 && (

Warnings

    {warnings.map((w, i) => (
  • {w}
  • ))}
)} {/* Resource utilization impacts */} {resourceImpacts.length > 0 && (

Resource Utilization Impact

{resourceImpacts.map((ri) => ( ))}
Resource Current Scenario Delta Target
{ri.resourceName} {ri.isOverallocated && ( over-allocated )} {ri.currentUtilization.toFixed(1)}% {ri.scenarioUtilization.toFixed(1)}% 0 ? "text-amber-600" : ri.utilizationDelta < 0 ? "text-blue-600" : "text-gray-500" }`}> {ri.utilizationDelta > 0 ? "+" : ""}{ri.utilizationDelta.toFixed(1)}% {ri.chargeabilityTarget}%
)}
); } // ── DeltaCard ──────────────────────────────────────────────────────────────── function DeltaCard({ label, value, subtitle, color, }: { label: string; value: string; subtitle: string; color: string; }) { return (

{label}

{value}

{subtitle}

); }