Remodel/packages/web/app/scenarios/[id]/page.tsx
Mannu fdb387e74c
Some checks are pending
CI / Engine — lint / typecheck / test (push) Waiting to run
CI / API — lint / typecheck / test (push) Waiting to run
CI / Web — typecheck / lint / build (push) Waiting to run
feat: pass solar DC MW and wind capacity from inputs to generation sheet
- Add solarDCMW and windMW props
- Pull from inputs_json in scenario page
- DC MW column now shows actual capacity from input

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 15:02:43 +05:30

326 lines
11 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
getScenario,
getKpis,
scenarioEventsUrl,
scenarioExcelUrl,
type ProgressEvent,
} from "@/lib/api";
import { Button } from "@/components/ui/button";
import { InputsTab } from "@/components/InputsTab";
import { WorkbookView } from "@/components/WorkbookView";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ActiveSheet =
| "inputs"
| "summary"
| "pnl"
| "cfs"
| "bs"
| "debt"
| "irr"
| "generation"
| "idc"
| "opex";
const RESULT_SHEETS: { id: ActiveSheet; label: string }[] = [
{ id: "summary", label: "Summary" },
{ id: "pnl", label: "P&L" },
{ id: "cfs", label: "Cash Flow" },
{ id: "bs", label: "Bal. Sheet" },
{ id: "debt", label: "Debt" },
{ id: "irr", label: "IRR / Returns" },
{ id: "generation", label: "Generation" },
{ id: "idc", label: "IDC / Phasing" },
{ id: "opex", label: "O&M" },
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function ProgressBar({ pct }: { pct: number }) {
return (
<div className="h-0.5 bg-muted overflow-hidden">
<div
className="bg-primary h-0.5 transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const styles: Record<string, string> = {
success: "text-emerald-700 bg-emerald-50 border-emerald-200",
failed: "text-red-600 bg-red-50 border-red-200",
running: "text-primary bg-primary/10 border-primary/30",
queued: "text-amber-700 bg-amber-50 border-amber-200",
};
return (
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium capitalize border ${styles[status] ?? "text-muted-foreground bg-muted border-border"}`}
>
{status}
</span>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export default function ScenarioPage() {
const params = useParams<{ id: string }>();
const router = useRouter();
const queryClient = useQueryClient();
const id = params.id;
const [activeSheet, setActiveSheet] = useState<ActiveSheet>("inputs");
const [progress, setProgress] = useState<ProgressEvent | null>(null);
const [sseOpen, setSseOpen] = useState(true);
const { data: scenario, refetch: refetchScenario } = useQuery({
queryKey: ["scenario", id],
queryFn: () => getScenario(id),
refetchInterval: sseOpen ? false : 3000,
});
const { data: kpis, refetch: refetchKpis } = useQuery({
queryKey: ["kpis", id],
queryFn: () => getKpis(id),
enabled: scenario?.status === "success",
});
useEffect(() => {
if (scenario?.status === "success" && activeSheet === "inputs" && progress !== null) {
setActiveSheet("summary");
}
}, [scenario?.status]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!id) return;
setSseOpen(true);
const es = new EventSource(scenarioEventsUrl(id));
es.onmessage = (event: MessageEvent<string>) => {
const data = JSON.parse(event.data) as ProgressEvent;
setProgress(data);
if (data.stage === "done" || data.stage === "error") {
setSseOpen(false);
es.close();
void refetchScenario();
void refetchKpis();
void queryClient.invalidateQueries({ queryKey: ["statements", id] });
}
};
es.onerror = () => {
setSseOpen(false);
es.close();
};
return () => es.close();
}, [id, refetchScenario, refetchKpis, queryClient]);
const isRunning = scenario?.status === "queued" || scenario?.status === "running";
const hasResults = scenario?.status === "success" && kpis != null;
function handleInputsSaved() {
setProgress(null);
void refetchScenario();
setActiveSheet("summary");
}
function nav(sheet: ActiveSheet) {
if (sheet !== "inputs" && !hasResults) return;
setActiveSheet(sheet);
}
return (
<div className="flex flex-col h-screen bg-background">
{/* ── Top header ─────────────────────────────────────────── */}
<header className="flex items-center gap-3 px-4 py-2.5 border-b bg-card shrink-0">
<button
onClick={() => router.push("/")}
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
>
Back
</button>
<div className="w-px h-4 bg-border" />
<h1 className="font-semibold text-sm truncate flex-1">
{scenario?.name ?? "Loading…"}
</h1>
{scenario && <StatusBadge status={scenario.status} />}
{isRunning && progress && (
<span className="text-xs text-muted-foreground">
{progress.stage} · {progress.pct}%
</span>
)}
{scenario?.runtime_s != null && (
<span className="text-xs text-muted-foreground">
{scenario.runtime_s.toFixed(1)}s
</span>
)}
{hasResults && (
<a
href={scenarioExcelUrl(id)}
download
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs rounded border border-border text-muted-foreground hover:text-foreground hover:border-foreground/40 transition-colors"
>
Excel
</a>
)}
</header>
{/* ── Progress bar ───────────────────────────────────────── */}
{isRunning && <ProgressBar pct={progress?.pct ?? 0} />}
{/* ── Two-column layout ──────────────────────────────────── */}
<div className="flex flex-1 overflow-hidden">
{/* Sidebar */}
<nav className="w-44 shrink-0 border-r bg-sidebar py-3 overflow-y-auto flex flex-col gap-0.5">
<SidebarItem
label="Inputs"
active={activeSheet === "inputs"}
onClick={() => nav("inputs")}
/>
<div className="mx-3 my-2 border-t border-sidebar-border" />
<p className="px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">
Results
</p>
{RESULT_SHEETS.map((s) => (
<SidebarItem
key={s.id}
label={s.label}
active={activeSheet === s.id}
disabled={!hasResults}
onClick={() => nav(s.id)}
/>
))}
</nav>
{/* Content area */}
<div className="flex-1 overflow-y-auto">
{activeSheet === "inputs" ? (
<div className="p-6 max-w-3xl">
{scenario?.inputs_json != null ? (
<InputsTab
key={scenario.id}
scenarioId={id}
inputsJson={scenario.inputs_json}
onSaved={handleInputsSaved}
/>
) : (
<div className="text-sm text-muted-foreground py-8 text-center">
Loading inputs
</div>
)}
</div>
) : (
<div className="p-6">
{hasResults ? (
<WorkbookView
scenarioId={id}
kpis={kpis}
debtScheduleJson={scenario?.debt_schedule_json ?? null}
activeSheet={activeSheet}
codYear={(() => {
try {
const inputs = scenario?.inputs_json ? JSON.parse(scenario.inputs_json) : {};
return inputs?.project?.cod_year || inputs?.project?.cod_date
? new Date(inputs.project.cod_date || inputs.project.cod_year + '-04-01').getFullYear()
: new Date().getFullYear();
} catch { return new Date().getFullYear(); }
})()}
solarDCMW={(() => {
try {
const inputs = scenario?.inputs_json ? JSON.parse(scenario.inputs_json) : {};
return inputs?.solar?.capacity_dc_mwp || inputs?.project?.capacity_solar_mwp || 0;
} catch { return 0; }
})()}
windMW={(() => {
try {
const inputs = scenario?.inputs_json ? JSON.parse(scenario.inputs_json) : {};
return inputs?.wind?.capacity_mw || inputs?.project?.capacity_wind_mw || 0;
} catch { return 0; }
})()}
/>
) : scenario?.status === "failed" ? (
<div className="border border-red-200 rounded-lg p-6 text-sm max-w-lg">
<p className="font-semibold text-red-600 mb-1">Scenario failed</p>
<p className="text-muted-foreground">
{scenario.error_message ?? "Check worker logs for details."}
</p>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => setActiveSheet("inputs")}
>
Edit Inputs
</Button>
</div>
) : (
<div className="flex flex-col items-center justify-center py-24 text-muted-foreground text-sm gap-3">
{isRunning ? (
<>
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<span>
Running {progress?.stage} ({progress?.pct ?? 0}%)
</span>
</>
) : (
<>
<span>No results yet.</span>
<Button
variant="outline"
size="sm"
onClick={() => setActiveSheet("inputs")}
>
Edit Inputs & Run
</Button>
</>
)}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
function SidebarItem({
label,
active,
disabled,
onClick,
}: {
label: string;
active: boolean;
disabled?: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`w-full text-left px-3 py-1.5 text-sm rounded-md mx-1 transition-colors ${
active
? "bg-primary text-primary-foreground font-medium"
: disabled
? "text-muted-foreground/40 cursor-not-allowed"
: "text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
}`}
style={{ width: "calc(100% - 8px)" }}
>
{label}
</button>
);
}