Remodel/packages/web/components/ScenarioWizard.tsx
Manohar Gupta 206b9a0f99
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
Fix: DC capacity now updates when AC capacity changes
2026-05-22 13:39:09 +05:30

562 lines
14 KiB
TypeScript

"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import type { ScenarioInputPayload } from "@/lib/api";
const LOCATION_OPTIONS = [
{ value: "RJ", label: "Rajasthan (High Solar)" },
{ value: "GJ", label: "Gujarat (High Solar)" },
{ value: "AP", label: "Andhra Pradesh" },
{ value: "TN", label: "Tamil Nadu" },
{ value: "MP", label: "Madhya Pradesh" },
{ value: "KA", label: "Karnataka" },
];
const STEP_LABELS = [
"Project Info",
"Solar",
"Wind",
"BESS",
"Solver",
"Review",
];
interface WizardState {
name: string;
cod_date: string;
// Solar
solar_enabled: boolean;
solar_location: string;
solar_ac_mw: number;
solar_dc_ac_ratio: number;
solar_dc_mwp: number;
// Wind
wind_enabled: boolean;
wind_location: string;
wind_mw: number;
// BESS
bess_enabled: boolean;
bess_mwh: number;
bess_mw: number;
// Solver
solver_mode: "solve_tariff" | "fixed_tariff";
target_irr: number;
fixed_tariff: number;
}
const DEFAULT_STATE: WizardState = {
name: "",
cod_date: "2027-04-01",
solar_enabled: true,
solar_location: "RJ",
solar_ac_mw: 100,
solar_dc_ac_ratio: 1.4,
solar_dc_mwp: 140,
wind_enabled: false,
wind_location: "RJ",
wind_mw: 50,
bess_enabled: false,
bess_mwh: 200,
bess_mw: 50,
solver_mode: "solve_tariff",
target_irr: 0.18,
fixed_tariff: 3.5,
};
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-foreground">{label}</label>
{children}
</div>
);
}
function Input({
value,
onChange,
type = "text",
step,
min,
max,
readOnly,
className = "",
}: {
value: string | number;
onChange: (v: string) => void;
type?: string;
step?: number;
min?: number;
max?: number;
readOnly?: boolean;
className?: string;
}) {
return (
<input
type={type}
step={step}
min={min}
max={max}
readOnly={readOnly}
value={value}
onChange={(e) => onChange(e.target.value)}
className={`border rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary bg-background ${readOnly ? "bg-muted cursor-default" : ""} ${className}`}
/>
);
}
function Select({
value,
onChange,
options,
}: {
value: string;
onChange: (v: string) => void;
options: { value: string; label: string }[];
}) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="border rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary bg-background"
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
);
}
function Toggle({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="w-4 h-4 accent-primary"
/>
<span className="text-sm font-medium">{label}</span>
</label>
);
}
// ---------------------------------------------------------------------------
// Step components
// ---------------------------------------------------------------------------
function StepProjectInfo({
state,
set,
}: {
state: WizardState;
set: (k: keyof WizardState, v: WizardState[keyof WizardState]) => void;
}) {
return (
<div className="flex flex-col gap-4">
<h2 className="text-lg font-semibold">Project Information</h2>
<Field label="Scenario name *">
<Input
value={state.name}
onChange={(v) => set("name", v)}
/>
</Field>
<Field label="Commercial Operation Date (COD)">
<Input
type="date"
value={state.cod_date}
onChange={(v) => set("cod_date", v)}
/>
</Field>
</div>
);
}
function StepSolar({
state,
set,
}: {
state: WizardState;
set: (k: keyof WizardState, v: WizardState[keyof WizardState]) => void;
}) {
return (
<div className="flex flex-col gap-4">
<h2 className="text-lg font-semibold">Solar Generation</h2>
<Toggle
label="Include Solar"
checked={state.solar_enabled}
onChange={(v) => set("solar_enabled", v)}
/>
{state.solar_enabled && (
<>
<Field label="Location">
<Select
value={state.solar_location}
onChange={(v) => set("solar_location", v)}
options={LOCATION_OPTIONS}
/>
</Field>
<Field label="AC Capacity (MW)">
<Input
type="number"
value={state.solar_ac_mw}
step={5}
min={1}
onChange={(v) => {
const ac = Number(v);
const ratio = state.solar_dc_ac_ratio;
set("solar_ac_mw", ac);
set("solar_dc_mwp", parseFloat((ac * ratio).toFixed(3)));
}}
/>
</Field>
<Field label="DC:AC Ratio">
<Input
type="number"
value={state.solar_dc_ac_ratio}
step={0.05}
min={1.0}
max={1.8}
onChange={(v) => {
const ratio = Number(v);
set("solar_dc_ac_ratio", ratio);
set("solar_dc_mwp", parseFloat((state.solar_ac_mw * ratio).toFixed(3)));
}}
/>
</Field>
<Field label="DC Capacity (MWp)">
<Input
type="number"
value={state.solar_dc_mwp}
step={5}
min={1}
readOnly
className="bg-muted cursor-default"
onChange={() => {}}
/>
</Field>
</>
)}
</div>
);
}
function StepWind({
state,
set,
}: {
state: WizardState;
set: (k: keyof WizardState, v: WizardState[keyof WizardState]) => void;
}) {
return (
<div className="flex flex-col gap-4">
<h2 className="text-lg font-semibold">Wind Generation</h2>
<Toggle
label="Include Wind"
checked={state.wind_enabled}
onChange={(v) => set("wind_enabled", v)}
/>
{state.wind_enabled && (
<>
<Field label="Location">
<Select
value={state.wind_location}
onChange={(v) => set("wind_location", v)}
options={LOCATION_OPTIONS}
/>
</Field>
<Field label="Capacity (MW)">
<Input
type="number"
value={state.wind_mw}
step={5}
min={1}
onChange={(v) => set("wind_mw", Number(v))}
/>
</Field>
</>
)}
</div>
);
}
function StepBess({
state,
set,
}: {
state: WizardState;
set: (k: keyof WizardState, v: WizardState[keyof WizardState]) => void;
}) {
return (
<div className="flex flex-col gap-4">
<h2 className="text-lg font-semibold">BESS (Battery Storage)</h2>
<Toggle
label="Include BESS"
checked={state.bess_enabled}
onChange={(v) => set("bess_enabled", v)}
/>
{state.bess_enabled && (
<>
<Field label="Energy Capacity (MWh)">
<Input
type="number"
value={state.bess_mwh}
step={10}
min={10}
onChange={(v) => set("bess_mwh", Number(v))}
/>
</Field>
<Field label="Power Capacity (MW)">
<Input
type="number"
value={state.bess_mw}
step={5}
min={5}
onChange={(v) => set("bess_mw", Number(v))}
/>
</Field>
</>
)}
</div>
);
}
function StepSolver({
state,
set,
}: {
state: WizardState;
set: (k: keyof WizardState, v: WizardState[keyof WizardState]) => void;
}) {
return (
<div className="flex flex-col gap-4">
<h2 className="text-lg font-semibold">Solver & Tariff</h2>
<Field label="Mode">
<Select
value={state.solver_mode}
onChange={(v) => set("solver_mode", v as "solve_tariff" | "fixed_tariff")}
options={[
{ value: "solve_tariff", label: "Solve tariff for target IRR" },
{ value: "fixed_tariff", label: "Fixed tariff" },
]}
/>
</Field>
{state.solver_mode === "solve_tariff" ? (
<Field label="Target Equity IRR (e.g. 0.18 = 18%)">
<Input
type="number"
value={state.target_irr}
step={0.01}
min={0.05}
onChange={(v) => set("target_irr", Number(v))}
/>
</Field>
) : (
<Field label="Fixed Tariff (INR/kWh)">
<Input
type="number"
value={state.fixed_tariff}
step={0.1}
min={1}
onChange={(v) => set("fixed_tariff", Number(v))}
/>
</Field>
)}
</div>
);
}
function StepReview({ state }: { state: WizardState }) {
return (
<div className="flex flex-col gap-4">
<h2 className="text-lg font-semibold">Review & Submit</h2>
<dl className="grid grid-cols-2 gap-2 text-sm">
<dt className="text-muted-foreground">Name</dt>
<dd>{state.name || "—"}</dd>
<dt className="text-muted-foreground">COD</dt>
<dd>{state.cod_date}</dd>
{state.solar_enabled && (
<>
<dt className="text-muted-foreground">Solar</dt>
<dd>
{state.solar_ac_mw} MW AC / {state.solar_dc_mwp} MWp DC ({state.solar_location}, {state.solar_dc_ac_ratio}x)
</dd>
</>
)}
{state.wind_enabled && (
<>
<dt className="text-muted-foreground">Wind</dt>
<dd>
{state.wind_mw} MW ({state.wind_location})
</dd>
</>
)}
{state.bess_enabled && (
<>
<dt className="text-muted-foreground">BESS</dt>
<dd>
{state.bess_mwh} MWh / {state.bess_mw} MW
</dd>
</>
)}
<dt className="text-muted-foreground">Solver</dt>
<dd>
{state.solver_mode === "solve_tariff"
? `Solve tariff @ IRR ${(state.target_irr * 100).toFixed(0)}%`
: `Fixed ₹${state.fixed_tariff}/kWh`}
</dd>
</dl>
</div>
);
}
// ---------------------------------------------------------------------------
// Main wizard
// ---------------------------------------------------------------------------
interface ScenarioWizardProps {
onSubmit: (name: string, inputs: ScenarioInputPayload) => Promise<void>;
onCancel: () => void;
}
export function ScenarioWizard({ onSubmit, onCancel }: ScenarioWizardProps) {
const [step, setStep] = useState(0);
const [state, setState] = useState<WizardState>(DEFAULT_STATE);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
function set<K extends keyof WizardState>(k: K, v: WizardState[K]) {
setState((prev) => ({ ...prev, [k]: v }));
}
const steps = [
<StepProjectInfo key="0" state={state} set={set} />,
<StepSolar key="1" state={state} set={set} />,
<StepWind key="2" state={state} set={set} />,
<StepBess key="3" state={state} set={set} />,
<StepSolver key="4" state={state} set={set} />,
<StepReview key="5" state={state} />,
];
function buildInputs(): ScenarioInputPayload {
return {
project: {
name: state.name,
capacity_solar_mwp: state.solar_enabled ? state.solar_dc_mwp : 0,
capacity_wind_mw: state.wind_enabled ? state.wind_mw : 0,
capacity_bess_mwh: state.bess_enabled ? state.bess_mwh : 0,
capacity_bess_mw: state.bess_enabled ? state.bess_mw : 0,
cod_date: state.cod_date,
},
solar: state.solar_enabled
? {
location_id: state.solar_location,
capacity_ac_mw: state.solar_ac_mw,
dc_ac_ratio: state.solar_dc_ac_ratio,
capacity_dc_mwp: state.solar_dc_mwp,
}
: null,
wind: state.wind_enabled
? { location_id: state.wind_location, capacity_mw: state.wind_mw }
: null,
solver:
state.solver_mode === "fixed_tariff"
? {
mode: "fixed_tariff",
fixed_tariff: state.fixed_tariff,
}
: {
mode: "solve_tariff",
target_equity_irr: state.target_irr,
},
};
}
async function handleSubmit() {
if (!state.name.trim()) {
setError("Please enter a scenario name.");
return;
}
setSubmitting(true);
setError(null);
try {
await onSubmit(state.name, buildInputs());
} catch (e) {
setError(e instanceof Error ? e.message : "Unknown error");
setSubmitting(false);
}
}
const isLast = step === steps.length - 1;
return (
<div className="flex flex-col gap-6">
{/* Progress bar */}
<div className="flex gap-1">
{STEP_LABELS.map((label, i) => (
<div key={label} className="flex-1 flex flex-col items-center gap-1">
<div
className={`h-1.5 w-full rounded-full ${i <= step ? "bg-primary" : "bg-muted"}`}
/>
<span
className={`text-xs ${i === step ? "text-primary font-medium" : "text-muted-foreground"}`}
>
{label}
</span>
</div>
))}
</div>
{/* Step content */}
<div className="min-h-[280px]">{steps[step]}</div>
{error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded">
{error}
</p>
)}
{/* Navigation */}
<div className="flex justify-between">
<div className="flex gap-2">
<Button variant="ghost" onClick={onCancel} disabled={submitting}>
Cancel
</Button>
{step > 0 && (
<Button
variant="outline"
onClick={() => setStep((s) => s - 1)}
disabled={submitting}
>
Back
</Button>
)}
</div>
{isLast ? (
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? "Submitting…" : "Run Scenario"}
</Button>
) : (
<Button onClick={() => setStep((s) => s + 1)}>Next</Button>
)}
</div>
</div>
);
}