- Added pct() helper function - Target Equity IRR now shows 18% instead of 0.18 - Input accepts percentage and converts back to decimal internally
571 lines
15 KiB
TypeScript
571 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import type { ScenarioInputPayload } from "@/lib/api";
|
|
|
|
// Helper to convert decimal to percentage display
|
|
function pct(raw: number): number {
|
|
return parseFloat((raw * 100).toFixed(2));
|
|
}
|
|
|
|
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">
|
|
<div className="flex items-center gap-1.5">
|
|
<Input
|
|
type="number"
|
|
value={pct(state.target_irr)}
|
|
step={1}
|
|
min={5}
|
|
max={40}
|
|
onChange={(v) => set("target_irr", Number(v) / 100)}
|
|
/>
|
|
<span className="text-sm text-muted-foreground">%</span>
|
|
</div>
|
|
</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>
|
|
);
|
|
}
|