- Added onClick prop to KpiCard component - SummarySheet now accepts onNavigate callback - KPI cards now link to appropriate sheets: - Solved Tariff, Solar Y1 CUF, Wind Y1 PLF -> Generation - Equity IRR, Project IRR, LCOE, Payback -> IRR / Returns - Min DSCR, Avg DSCR, Total Capex, Debt -> Debt - IDC -> IDC / Phasing - WorkbookView accepts optional onNavigate prop - Scenario page passes onNavigate to switch active sheet
327 lines
11 KiB
TypeScript
327 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; }
|
|
})()}
|
|
onNavigate={(sheet) => setActiveSheet(sheet as ActiveSheet)}
|
|
/>
|
|
) : 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>
|
|
);
|
|
}
|