Remodel/packages/web/components/InputsTab.tsx
Manohar Gupta 39d01299e7
Some checks failed
CI / Engine — lint / typecheck / test (push) Has been cancelled
CI / API — lint / typecheck / test (push) Has been cancelled
CI / Web — typecheck / lint / build (push) Has been cancelled
fix: include Common attribution items in EPC margin calculation
- EPC Margin WTG now includes items with attribution 'WindOnly' OR 'Common' when windEnabled
- EPC Margin Solar now includes items with attribution 'SolarOnly' OR 'Common'
2026-05-29 18:27:37 +05:30

1675 lines
65 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { updateScenarioInputs, type ScenarioInputPayload, type CostItem } from "@/lib/api";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
type Raw = Record<string, unknown>;
// Location config with expected CUF
const LOCATION_CUF: Record<string, number> = {
RJ: 27.5, // Rajasthan - high irradiance
GJ: 26.0, // Gujarat
KA: 24.5, // Karnataka
AP: 25.0, // Andhra Pradesh
TN: 24.0, // Tamil Nadu
MP: 25.5, // Madhya Pradesh
MH: 24.0, // Maharashtra
HR: 23.5, // Haryana
};
const LOCATIONS = [
{ value: "RJ", label: "Rajasthan" },
{ value: "GJ", label: "Gujarat" },
{ value: "KA", label: "Karnataka" },
{ value: "AP", label: "Andhra Pradesh" },
{ value: "TN", label: "Tamil Nadu" },
{ value: "MP", label: "Madhya Pradesh" },
{ value: "MH", label: "Maharashtra" },
{ value: "HR", label: "Haryana" },
{ value: "custom", label: "📤 Upload Custom Profile" },
];
const INDIAN_STATES = [
{ value: "RJ", label: "Rajasthan" },
{ value: "GJ", label: "Gujarat" },
{ value: "KA", label: "Karnataka" },
{ value: "AP", label: "Andhra Pradesh" },
{ value: "TN", label: "Tamil Nadu" },
{ value: "MP", label: "Madhya Pradesh" },
{ value: "MH", label: "Maharashtra" },
{ value: "HR", label: "Haryana" },
{ value: "UP", label: "Uttar Pradesh" },
{ value: "PB", label: "Punjab" },
{ value: "TS", label: "Telangana" },
{ value: "OR", label: "Odisha" },
];
const DEBT_SHAPES = [
{ value: "equal_principal", label: "Equal Principal" },
{ value: "equal_installment", label: "Equal Installment (EMI)" },
{ value: "dscr_sculpted", label: "DSCR Sculpted" },
{ value: "balloon", label: "Balloon" },
];
// Default cost items for each technology
const SOLAR_DEFAULT_ITEMS: CostItem[] = [
{ id: "solar_module", name: "Solar PV Module", category: "HardCost", basis: "PER_WP_DC", value: 18.0, depr_class: "Plant", attribution: "SolarOnly", phasing_id: "default" },
{ id: "solar_inverter", name: "Inverter", category: "HardCost", basis: "PER_WP_DC", value: 4.0, depr_class: "Plant", attribution: "SolarOnly", phasing_id: "default" },
{ id: "solar_dc_bos", name: "DC BOS (cables, mounting, earthing)", category: "HardCost", basis: "PER_WP_DC", value: 3.5, depr_class: "Plant", attribution: "SolarOnly", phasing_id: "default" },
{ id: "solar_ac_bos", name: "AC BOS (transformer, switchgear)", category: "HardCost", basis: "PER_WP_DC", value: 2.5, depr_class: "Plant", attribution: "SolarOnly", phasing_id: "default" },
{ id: "solar_land", name: "Land (per MW)", category: "HardCost", basis: "PER_WP_DC", value: 2.0, depr_class: "Land_NoDepr", attribution: "SolarOnly", phasing_id: "default" },
{ id: "solar_land_upfront", name: "Land Lease (upfront 5yr)", category: "HardCost", basis: "PER_ACRE", value: 30, depr_class: "LandLease_Amortized", attribution: "SolarOnly", phasing_id: "default" },
{ id: "solar_hr", name: "HR & Admin (pre-COD)", category: "SoftCost", basis: "PER_WP_DC", value: 0.5, depr_class: "Intangible", attribution: "SolarOnly", phasing_id: "default" },
{ id: "solar_epc_oh", name: "EPC Overhead", category: "EPCOverhead", basis: "PCT_OF_HARDCOST", value: 0.03, depr_class: "Plant", attribution: "SolarOnly", phasing_id: "default" },
{ id: "solar_contingency", name: "Contingency", category: "Contingency", basis: "PCT_OF_HARDCOST", value: 0.02, depr_class: "Plant", attribution: "SolarOnly", phasing_id: "default" },
];
const WIND_DEFAULT_ITEMS: CostItem[] = [
{ id: "wind_wtg", name: "WTG Supply", category: "HardCost", basis: "PER_MW_WIND", value: 5.50, depr_class: "Plant", attribution: "WindOnly", phasing_id: "default" },
{ id: "wind_tower", name: "Tower", category: "HardCost", basis: "PER_MW_WIND", value: 1.20, depr_class: "Plant", attribution: "WindOnly", phasing_id: "default" },
{ id: "wind_bop", name: "Balance of Plant", category: "HardCost", basis: "PER_MW_WIND", value: 0.80, depr_class: "Plant", attribution: "WindOnly", phasing_id: "default" },
{ id: "wind_land", name: "Land (per MW)", category: "HardCost", basis: "PER_MW_WIND", value: 0.30, depr_class: "Land_NoDepr", attribution: "WindOnly", phasing_id: "default" },
{ id: "wind_land_upfront", name: "Land Lease (upfront 5yr)", category: "HardCost", basis: "PER_ACRE", value: 30, depr_class: "LandLease_Amortized", attribution: "WindOnly", phasing_id: "default" },
{ id: "wind_e_and_c", name: "Erection & Commissioning", category: "HardCost", basis: "PER_MW_WIND", value: 0.40, depr_class: "Plant", attribution: "WindOnly", phasing_id: "default" },
{ id: "wind_epc_oh", name: "EPC Overhead", category: "EPCOverhead", basis: "PCT_OF_HARDCOST", value: 0.03, depr_class: "Plant", attribution: "WindOnly", phasing_id: "default" },
{ id: "wind_contingency", name: "Contingency", category: "Contingency", basis: "PCT_OF_HARDCOST", value: 0.02, depr_class: "Plant", attribution: "WindOnly", phasing_id: "default" },
];
const BESS_DEFAULT_ITEMS: CostItem[] = [
{ id: "bess_supply", name: "BESS Supply (cells + BMS + PCS)", category: "HardCost", basis: "PER_MWH_BESS", value: 3.50, depr_class: "BESS", attribution: "BESSOnly", phasing_id: "default" },
{ id: "bess_civil", name: "Civil & Integration", category: "HardCost", basis: "PER_MWH_BESS", value: 0.40, depr_class: "Building", attribution: "BESSOnly", phasing_id: "default" },
{ id: "bess_epc_oh", name: "EPC Overhead", category: "EPCOverhead", basis: "PCT_OF_HARDCOST", value: 0.03, depr_class: "BESS", attribution: "BESSOnly", phasing_id: "default" },
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function sec(inputs: Raw, key: string): Raw {
return (inputs[key] as Raw) ?? {};
}
function gv<T>(inputs: Raw, section: string, field: string, fallback: T): T {
const s = sec(inputs, section);
const v = s[field];
return v !== undefined && v !== null ? (v as T) : fallback;
}
function pct(raw: number, decimals = 4): number {
return parseFloat((raw * 100).toFixed(decimals));
}
function sv(inputs: Raw, section: string, field: string, value: unknown): Raw {
return { ...inputs, [section]: { ...sec(inputs, section), [field]: value } };
}
function deriveCodDate(project: Raw): string {
if (project.cod_date) return project.cod_date as string;
const year = (project.cod_year as number) ?? 2027;
return `${year}-04-01`;
}
// Compute Cr from a cost item given current capacities (including tax)
function computeItemCr(
item: CostItem,
dcMwp: number,
windMw: number,
bessMwh: number,
hardCostSum: number,
landAcres: number,
): number | null {
let baseCr: number | null;
switch (item.basis) {
case "PER_WP_DC":
baseCr = item.value * dcMwp * 0.1; // ₹/Wp × MWp × 0.1 = Cr
break;
case "PER_MWP_DC":
baseCr = item.value * dcMwp;
break;
case "PER_MW_WIND":
baseCr = item.value * windMw;
break;
case "PER_MWH_BESS":
baseCr = item.value * bessMwh;
break;
case "PCT_OF_HARDCOST":
baseCr = hardCostSum > 0 ? item.value * hardCostSum : null;
break;
case "PER_ACRE":
baseCr = item.value * (landAcres || 0) * 0.01; // Lakh/acre × acres × 0.01 = Cr (1 Lakh = 0.01 Cr)
break;
case "ABS_INR_CR":
baseCr = item.value;
break;
default:
baseCr = null;
}
// Add tax (GST) if applicable - default 5% for modules
if (baseCr != null) {
const taxPct = item.tax_pct ?? 5;
return baseCr * (1 + taxPct / 100);
}
return baseCr;
}
function basisLabel(basis: CostItem["basis"]): string {
switch (basis) {
case "PER_WP_DC": return "₹/Wp";
case "PER_MWP_DC": return "Cr/MWp";
case "PER_MW_WIND": return "Cr/MW";
case "PER_MWH_BESS": return "Cr/MWh";
case "PCT_OF_HARDCOST": return "% of HC";
case "PER_ACRE": return "Lakh/acre";
case "ABS_INR_CR": return "₹Cr";
default: return "";
}
}
function injectDefaultItems(inputs: Raw, solarEnabled: boolean, windEnabled: boolean, bessEnabled: boolean): Raw {
const capex = sec(inputs, "capex");
const existing = (capex.cost_items as CostItem[]) ?? [];
if (existing.length > 0) return inputs;
const items: CostItem[] = [];
if (solarEnabled) items.push(...SOLAR_DEFAULT_ITEMS);
if (windEnabled) items.push(...WIND_DEFAULT_ITEMS);
if (bessEnabled) items.push(...BESS_DEFAULT_ITEMS);
return sv(inputs, "capex", "cost_items", items);
}
// ---------------------------------------------------------------------------
// Field primitives
// ---------------------------------------------------------------------------
function Label({ text, sub }: { text: string; sub?: string }) {
return (
<div className="flex flex-col">
<span className="text-xs font-medium text-foreground">{text}</span>
{sub && <span className="text-[10px] text-muted-foreground">{sub}</span>}
</div>
);
}
function NumField({
label,
sub,
value,
onChange,
step = 0.01,
min,
max,
suffix,
readOnly,
}: {
label: string;
sub?: string;
value: number;
onChange: (v: number) => void;
step?: number;
min?: number;
max?: number;
suffix?: string;
readOnly?: boolean;
}) {
return (
<div className="flex flex-col gap-1">
<Label text={label} sub={sub} />
<div className="flex items-center gap-1.5">
<input
type="number"
step={step}
min={min}
max={max}
value={value}
readOnly={readOnly}
onChange={(e) => !readOnly && onChange(parseFloat(e.target.value) || 0)}
className={`w-24 rounded-md border border-input px-2.5 py-1.5 text-sm tabular-nums transition-colors focus:outline-none focus:ring-2 focus:ring-primary/30 ${
readOnly ? "bg-muted text-muted-foreground cursor-default" : "bg-background hover:border-primary/40"
}`}
/>
{suffix && (
<span className="text-xs text-muted-foreground whitespace-nowrap">{suffix}</span>
)}
</div>
</div>
);
}
function DateField({
label,
sub,
value,
onChange,
badge,
}: {
label: string;
sub?: string;
value: string;
onChange: (v: string) => void;
badge?: string;
}) {
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Label text={label} sub={sub} />
{badge && (
<span className="text-[9px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium">
{badge}
</span>
)}
</div>
<input
type="date"
value={value}
onChange={(e) => onChange(e.target.value)}
className="rounded border border-input bg-background px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 hover:border-primary/40 transition-colors"
/>
</div>
);
}
function SelectField({
label,
sub,
value,
onChange,
options,
}: {
label: string;
sub?: string;
value: string;
onChange: (v: string) => void;
options: { value: string; label: string }[];
}) {
return (
<div className="flex flex-col gap-1">
<Label text={label} sub={sub} />
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-40 rounded-md border border-input bg-background px-3 py-1.5 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-primary/30 hover:border-primary/40"
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
);
}
// ---------------------------------------------------------------------------
// Card components
// ---------------------------------------------------------------------------
function CardHeader({
title,
open,
onToggle,
rightSlot,
}: {
title: string;
open: boolean;
onToggle: () => void;
rightSlot?: React.ReactNode;
}) {
return (
<div
className="flex items-center justify-between px-4 py-3 cursor-pointer select-none hover:bg-muted/30 transition-colors rounded-t-lg"
onClick={onToggle}
>
<div className="flex items-center gap-2">
<span
className={`text-muted-foreground transition-transform duration-200 text-xs ${open ? "rotate-0" : "-rotate-90"}`}
>
</span>
<span className="font-semibold text-sm">{title}</span>
</div>
{rightSlot && (
<div onClick={(e) => e.stopPropagation()}>{rightSlot}</div>
)}
</div>
);
}
function CollapsibleCard({
title,
children,
cols = 2,
defaultOpen = true,
}: {
title: string;
children: React.ReactNode;
cols?: number;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="border border-border rounded-lg overflow-hidden hover:border-border/80 transition-colors shadow-sm">
<CardHeader title={title} open={open} onToggle={() => setOpen(!open)} />
{open && (
<div className={`px-4 pb-4 grid gap-3 border-t border-border/50 pt-4 grid-cols-${cols}`}>
{children}
</div>
)}
</div>
);
}
function ToggleCard({
title,
enabled,
onToggle,
children,
defaultOpen = true,
}: {
title: string;
enabled: boolean;
onToggle: (v: boolean) => void;
children: React.ReactNode;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="border border-border rounded-lg overflow-hidden hover:border-border/80 transition-colors shadow-sm">
<CardHeader
title={title}
open={open}
onToggle={() => setOpen(!open)}
rightSlot={
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={enabled}
onChange={(e) => onToggle(e.target.checked)}
className="w-4 h-4 accent-primary"
/>
<span className={`text-xs font-medium ${enabled ? "text-primary" : "text-muted-foreground"}`}>
{enabled ? "Enabled" : "Disabled"}
</span>
</label>
}
/>
{enabled && open && (
<div className="px-4 pb-4 grid grid-cols-2 gap-3 border-t border-border/50 pt-4">
{children}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Cost Items Table
// ---------------------------------------------------------------------------
function CostItemsTable({
items,
onChange,
dcMwp,
windMw,
bessMwh,
landAcres,
attribution,
}: {
items: CostItem[];
onChange: (items: CostItem[]) => void;
dcMwp: number;
windMw: number;
bessMwh: number;
landAcres: number;
attribution: "SolarOnly" | "WindOnly" | "BESSOnly";
}) {
const filtered = items.filter((it) => it.attribution === attribution);
// Compute hard cost sum for PCT items
const hardCostSum = filtered
.filter((it) => it.category === "HardCost" || it.category === "SoftCost")
.reduce((acc, it) => {
const cr = computeItemCr(it, dcMwp, windMw, bessMwh, 0, landAcres);
return acc + (cr ?? 0);
}, 0);
function updateItem(id: string, field: keyof CostItem, value: unknown) {
onChange(
items.map((it) =>
it.id === id ? { ...it, [field]: value } : it,
),
);
}
function removeItem(id: string) {
onChange(items.filter((it) => it.id !== id));
}
function addItem() {
const newId = `custom_${attribution.toLowerCase()}_${Date.now()}`;
const defaults: Record<string, CostItem["basis"]> = {
SolarOnly: "PER_WP_DC",
WindOnly: "PER_MW_WIND",
BESSOnly: "PER_MWH_BESS",
};
const newItem: CostItem = {
id: newId,
name: "Custom Item",
category: "HardCost",
basis: defaults[attribution],
value: 0,
depr_class: attribution === "BESSOnly" ? "BESS" : "Plant",
attribution,
phasing_id: "default",
};
onChange([...items, newItem]);
}
const totalCr = filtered.reduce((acc, it) => {
const cr = computeItemCr(it, dcMwp, windMw, bessMwh, hardCostSum, landAcres);
return acc + (cr ?? 0);
}, 0);
return (
<div className="col-span-2">
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-xs">
<thead>
<tr className="bg-muted/50">
<th className="px-3 py-2 text-left font-semibold text-muted-foreground min-w-[200px]">
Line Item
</th>
<th className="px-3 py-2 text-left font-semibold text-muted-foreground w-20">
Basis
</th>
<th className="px-2 py-2 text-right font-semibold text-muted-foreground w-20">
Rate
</th>
<th className="px-2 py-2 text-right font-semibold text-muted-foreground w-14">
Tax%
</th>
<th className="px-3 py-2 text-right font-semibold text-muted-foreground w-20">
Cr
</th>
<th className="w-6" />
</tr>
</thead>
<tbody>
{filtered.map((item) => {
const cr = computeItemCr(item, dcMwp, windMw, bessMwh, hardCostSum, landAcres);
return (
<tr
key={item.id}
className="border-t border-border/50 hover:bg-accent/30 transition-colors"
>
<td className="px-3 py-1.5">
<input
type="text"
value={item.name}
onChange={(e) => updateItem(item.id, "name", e.target.value)}
className="w-full bg-transparent focus:outline-none focus:bg-background focus:ring-1 focus:ring-primary/30 rounded px-1"
/>
</td>
<td className="px-3 py-1.5 text-muted-foreground text-[10px]">
{basisLabel(item.basis)}
</td>
<td className="px-3 py-1.5">
<input
type="number"
step={item.basis === "PCT_OF_HARDCOST" ? 0.005 : 0.1}
value={item.basis === "PCT_OF_HARDCOST" ? parseFloat((item.value * 100).toFixed(2)) : item.value}
onChange={(e) => {
const raw = parseFloat(e.target.value) || 0;
updateItem(item.id, "value", item.basis === "PCT_OF_HARDCOST" ? raw / 100 : raw);
}}
className="w-full text-right bg-transparent focus:outline-none focus:bg-background focus:ring-1 focus:ring-primary/30 rounded px-1 tabular-nums"
/>
</td>
<td className="px-2 py-1.5">
<input
type="text"
value={(item.tax_pct ?? 5) + "%"}
onChange={(e) => {
const num = e.target.value.replace("%", "").replace(/[^0-9.]/g, "");
updateItem(item.id, "tax_pct", parseFloat(num) || 0);
}}
className="w-full text-right bg-transparent focus:outline-none focus:bg-background focus:ring-1 focus:ring-primary/30 rounded px-1 tabular-nums"
/>
</td>
<td className="px-3 py-1.5 text-right tabular-nums font-medium">
{item.basis === "PCT_OF_HARDCOST" && cr == null ? (
<span className="text-muted-foreground italic text-xs">calc...</span>
) : cr != null ? (
<span title={item.basis === "PCT_OF_HARDCOST" ? `${(item.value * 100).toFixed(2)}% of ₹${hardCostSum?.toFixed(1) || 0}Cr` : undefined}>
{cr.toFixed(1)}
</span>
) : (
<span className="text-muted-foreground italic text-xs"></span>
)}
</td>
<td className="px-2 py-1.5">
<button
onClick={() => removeItem(item.id)}
className="text-muted-foreground/40 hover:text-destructive transition-colors text-xs"
>
</button>
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t-2 border-border bg-muted/30">
<td colSpan={4} className="px-3 py-2 font-semibold text-xs">
Total ({attribution.replace("Only", "")})
</td>
<td className="px-3 py-2 text-right font-semibold tabular-nums">
{totalCr.toFixed(1)}
</td>
<td />
</tr>
</tfoot>
</table>
</div>
<button
onClick={addItem}
className="mt-2 text-xs text-primary hover:text-primary/80 hover:underline transition-colors"
>
+ Add custom item
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
interface Props {
scenarioId: string;
inputsJson: string | null;
onSaved: () => void;
}
export function InputsTab({ scenarioId, inputsJson, onSaved }: Props) {
const [inputs, setInputs] = useState<Raw>(() => {
try {
const parsed = JSON.parse(inputsJson ?? "{}") as Raw;
// Always inject default cost items if missing (for old scenarios)
const capex = parsed.capex as { cost_items?: CostItem[] } | undefined;
const existingItems = capex?.cost_items ?? [];
const items: CostItem[] = [...existingItems];
if (existingItems.length === 0) {
items.push(...SOLAR_DEFAULT_ITEMS, ...WIND_DEFAULT_ITEMS, ...BESS_DEFAULT_ITEMS);
}
return sv({ ...parsed }, "capex", "cost_items", items);
} catch {
return {};
}
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
function upd(section: string, field: string, value: unknown) {
setInputs((prev) => sv(prev, section, field, value));
}
function setSection(key: string, value: unknown) {
setInputs((prev) => ({ ...prev, [key]: value }));
}
function updCostItems(newItems: CostItem[]) {
setInputs((prev) => sv(prev, "capex", "cost_items", newItems));
}
async function handleSave() {
setSaving(true);
setError(null);
try {
await updateScenarioInputs(scenarioId, inputs as ScenarioInputPayload);
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Save failed");
} finally {
setSaving(false);
}
}
const project = sec(inputs, "project");
const solar = inputs.solar as Raw | null | undefined;
const wind = inputs.wind as Raw | null | undefined;
const bess = inputs.bess as Raw | null | undefined;
const rtc = inputs.rtc as Raw | null | undefined;
const solarEnabled = solar !== null && solar !== undefined;
const windEnabled = wind !== null && wind !== undefined;
const bessEnabled = bess !== null && bess !== undefined;
const rtcEnabled = rtc !== null && rtc !== undefined;
const plantCod = deriveCodDate(project);
// Solar capacity helpers
const solarAcMw = gv(inputs, "solar", "capacity_ac_mw", 100);
const solarDcAcRatio = gv(inputs, "solar", "dc_ac_ratio", 1.4);
const solarDcMwp = parseFloat((solarAcMw * solarDcAcRatio).toFixed(3));
const moduleType = gv(inputs, "solar", "module_type", "fixed");
const windMw = gv(inputs, "wind", "capacity_mw", 50);
const bessMwh = gv(inputs, "bess", "capacity_mwh", 200);
// Land calculation: 2.5ac/MWp (fixed) or 3.5ac/MWp (tracker) + 0.5ac/MW (wind)
const solarLandFactor = (moduleType as string) === "tracker" ? 3.5 : 2.5;
const windLandFactor = 0.5;
const calculatedLandAcres = solarEnabled ? (solarDcMwp * solarLandFactor) + (windEnabled ? windMw * windLandFactor : 0) : windEnabled ? windMw * windLandFactor : 0;
// Use explicit value if set, otherwise auto-calculate
const explicitLandAcres = gv(inputs, "project", "land_acres", 0);
const effectiveLandAcres = explicitLandAcres > 0 ? explicitLandAcres : Math.max(calculatedLandAcres, 1);
const costItems = (gv(inputs, "capex", "cost_items", []) as CostItem[]);
// Compute cost summary with tax breakdown
function computeCostWithTax(items: CostItem[], attribution: string) {
const filtered = items.filter((it) => it.attribution === attribution);
const sum = filtered.reduce((acc, it) => {
const cr = computeItemCr(it, solarDcMwp, windMw, bessMwh, 0, effectiveLandAcres);
return acc + (cr ?? 0);
}, 0);
return sum;
}
const solarTotalCr = computeCostWithTax(costItems, "SolarOnly");
const windTotalCr = computeCostWithTax(costItems, "WindOnly");
const bessTotalCr = computeCostWithTax(costItems, "BESSOnly");
// Calculate upfront lease cost based on user inputs (acres * rate * years)
const leaseRate = gv(inputs, "project", "land_lease_rate", 0.4);
const leaseYears = gv(inputs, "project", "land_lease_years", 5);
const upfrontLeaseCostCr = effectiveLandAcres * leaseRate * leaseYears;
const totalCostCr = solarTotalCr + windTotalCr + bessTotalCr + upfrontLeaseCostCr;
// COD sync helper
function handlePlantCodChange(v: string) {
setInputs((prev) => {
let next = sv(prev, "project", "cod_date", v);
const p = sec(next, "project");
// Auto-sync tech CODs if they matched old plant COD or were empty
if (!p.solar_cod_date || p.solar_cod_date === plantCod) {
next = sv(next, "project", "solar_cod_date", v);
}
if (!p.wind_cod_date || p.wind_cod_date === plantCod) {
next = sv(next, "project", "wind_cod_date", v);
}
if (!p.bess_cod_date || p.bess_cod_date === plantCod) {
next = sv(next, "project", "bess_cod_date", v);
}
return next;
});
}
// Sync DC capacity whenever AC or ratio changes
function handleSolarAcChange(v: number) {
const newDc = parseFloat((v * solarDcAcRatio).toFixed(3));
setInputs((prev) => {
let next = sv(prev, "solar", "capacity_ac_mw", v);
next = sv(next, "solar", "capacity_dc_mwp", newDc);
next = sv(next, "project", "capacity_solar_mwp", newDc);
return next;
});
}
function handleDcAcRatioChange(v: number) {
const newDc = parseFloat((solarAcMw * v).toFixed(3));
setInputs((prev) => {
let next = sv(prev, "solar", "dc_ac_ratio", v);
next = sv(next, "solar", "capacity_dc_mwp", newDc);
next = sv(next, "project", "capacity_solar_mwp", newDc);
return next;
});
}
return (
<div className="space-y-3">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* ── Project Info ─────────────────────────────────────── */}
<CollapsibleCard title="Project Info" cols={2}>
<SelectField
label="State"
sub="Used for default CUF profile"
value={(project.state as string) ?? "RJ"}
onChange={(v) => {
upd("project", "state", v);
// Auto-set location_id for solar/wind
if (solarEnabled) upd("solar", "location_id", v);
if (windEnabled) upd("wind", "location_id", v);
}}
options={INDIAN_STATES}
/>
<DateField
label="Plant COD"
sub="Commercial Operation Date"
value={plantCod}
onChange={handlePlantCodChange}
/>
<DateField
label="Solar COD"
sub="Defaults to Plant COD"
value={(project.solar_cod_date as string) || plantCod}
onChange={(v) => upd("project", "solar_cod_date", v === plantCod ? null : v)}
badge={(project.solar_cod_date as string) && (project.solar_cod_date as string) !== plantCod ? "custom" : "synced"}
/>
{windEnabled && (
<DateField
label="Wind COD"
sub="Defaults to Plant COD"
value={(project.wind_cod_date as string) || plantCod}
onChange={(v) => upd("project", "wind_cod_date", v === plantCod ? null : v)}
badge={(project.wind_cod_date as string) && (project.wind_cod_date as string) !== plantCod ? "custom" : "synced"}
/>
)}
{bessEnabled && (
<DateField
label="BESS COD"
sub="Defaults to Plant COD"
value={(project.bess_cod_date as string) || plantCod}
onChange={(v) => upd("project", "bess_cod_date", v === plantCod ? null : v)}
badge={(project.bess_cod_date as string) && (project.bess_cod_date as string) !== plantCod ? "custom" : "synced"}
/>
)}
</CollapsibleCard>
{/* ── Cost Summary ────────────────────────────────────── */}
{(() => {
// Compute grouped from ALL cost items - don't check enablement
const solarItems = costItems.filter(it => it.attribution === "SolarOnly");
const windItems = costItems.filter(it => it.attribution === "WindOnly");
const bessItems = costItems.filter(it => it.attribution === "BESSOnly");
const solarBOS = solarItems.filter(it => ["solar_inverter", "solar_dc_bos", "solar_ac_bos", "solar_hr"].includes(it.id)).reduce((acc, it) => acc + (computeItemCr(it, solarDcMwp, windMw, bessMwh, 0, effectiveLandAcres) ?? 0), 0);
const solarModule = solarItems.filter(it => it.id === "solar_module").reduce((acc, it) => acc + (computeItemCr(it, solarDcMwp, windMw, bessMwh, 0, effectiveLandAcres) ?? 0), 0);
const windWTG = windItems.filter(it => ["wind_wtg", "wind_tower", "wind_bop", "wind_e_and_c"].includes(it.id)).reduce((acc, it) => acc + (computeItemCr(it, solarDcMwp, windMw, bessMwh, 0, effectiveLandAcres) ?? 0), 0);
// Solar land: only include items with id containing "land" AND attribution "SolarOnly"
const solarLandCost = solarItems.filter(it => it.id.includes("land") && it.attribution === "SolarOnly").reduce((acc, it) => acc + (computeItemCr(it, solarDcMwp, windMw, bessMwh, 0, effectiveLandAcres) ?? 0), 0);
// Wind land: only include items with id containing "land" AND attribution "WindOnly"
const windLandCost = windItems.filter(it => it.id.includes("land") && it.attribution === "WindOnly").reduce((acc, it) => acc + (computeItemCr(it, solarDcMwp, windMw, bessMwh, 0, effectiveLandAcres) ?? 0), 0);
const bessAll = bessItems.reduce((acc, it) => acc + (computeItemCr(it, solarDcMwp, windMw, bessMwh, 0, effectiveLandAcres) ?? 0), 0);
const solarEPC = solarItems.filter(it => it.category === "EPCOverhead").reduce((acc, it) => acc + (computeItemCr(it, solarDcMwp, windMw, bessMwh, 0, effectiveLandAcres) ?? 0), 0);
const windEPC = windItems.filter(it => it.category === "EPCOverhead").reduce((acc, it) => acc + (computeItemCr(it, solarDcMwp, windMw, bessMwh, 0, effectiveLandAcres) ?? 0), 0);
const solarCont = solarItems.filter(it => it.category === "Contingency" && it.attribution === "SolarOnly").reduce((acc, it) => acc + (computeItemCr(it, solarDcMwp, windMw, bessMwh, 0, effectiveLandAcres) ?? 0), 0);
const windCont = windItems.filter(it => it.category === "Contingency" && it.attribution === "WindOnly").reduce((acc, it) => acc + (computeItemCr(it, solarDcMwp, windMw, bessMwh, 0, effectiveLandAcres) ?? 0), 0);
// EPC Margins - include items with attribution "WindOnly" OR "Common" when wind is enabled
const epcMarginWTG = windEnabled ? windItems.filter(it => it.category === "EPCMargin" && (it.attribution === "WindOnly" || it.attribution === "Common")).reduce((acc, it) => acc + (computeItemCr(it, solarDcMwp, windMw, bessMwh, 0, effectiveLandAcres) ?? 0), 0) : 0;
const epcMarginSolarBOS = solarItems.filter(it => it.category === "EPCMargin" && (it.attribution === "SolarOnly" || it.attribution === "Common")).reduce((acc, it) => acc + (computeItemCr(it, solarDcMwp, windMw, bessMwh, 0, effectiveLandAcres) ?? 0), 0);
// Financing
const idcRate = gv(inputs, "capex", "interest_rate_annual", 0.09);
const constrMonths = gv(inputs, "capex", "construction_months", 24);
const debtFrac = gv(inputs, "capex", "debt_fraction", 0.75);
const upfrontPct = gv(inputs, "capex", "upfront_fee_pct", 0.01);
const bankGuaranteePct = gv(inputs, "capex", "bank_guarantee_pct", 0.001);
// DSRA (Debt Service Reserve Account) - typically 1-2 months of debt service
const dsraMonths = gv(inputs, "capex", "dsra_months", 2);
// First compute base hard cost without financing items
const baseHardCost = solarModule + solarBOS + windWTG + solarLandCost + windLandCost + bessAll + solarEPC + windEPC + epcMarginWTG + epcMarginSolarBOS + solarCont + windCont;
// Financing costs are based on base hard cost
const idcCost = baseHardCost * debtFrac * idcRate * constrMonths / 12;
const upfrontCost = baseHardCost * debtFrac * upfrontPct;
const bankGuaranteeCost = baseHardCost * bankGuaranteePct;
// DSRA: 2 months of debt service reserve (based on base hard cost)
const annualDebtService = baseHardCost * debtFrac * 0.12; // approximate annual debt service at 12% rate
const dsraCost = annualDebtService * dsraMonths / 12;
// Compute Total Hard Cost (sum of all component costs before financing)
const totalHardCost = baseHardCost;
return (
<div className="border border-border rounded-lg overflow-hidden">
<div className="bg-muted/50 px-4 py-2 border-b border-border">
<h3 className="font-semibold text-sm">Project Cost Breakdown</h3>
</div>
<table className="w-full text-xs">
<tbody>
<tr><td className="px-3 py-1.5">Solar Module</td><td className="px-3 py-1.5 text-right tabular-nums">{solarModule.toFixed(2)}</td></tr>
<tr><td className="px-3 py-1.5">Solar BoS</td><td className="px-3 py-1.5 text-right tabular-nums">{solarBOS.toFixed(2)}</td></tr>
{windEnabled && <tr><td className="px-3 py-1.5">Wind WTG</td><td className="px-3 py-1.5 text-right tabular-nums">{windWTG.toFixed(2)}</td></tr>}
<tr><td className="px-3 py-1.5">Solar Land Cost</td><td className="px-3 py-1.5 text-right tabular-nums">{solarLandCost.toFixed(2)}</td></tr>
{windEnabled && <tr><td className="px-3 py-1.5">Wind Land Cost</td><td className="px-3 py-1.5 text-right tabular-nums">{windLandCost.toFixed(2)}</td></tr>}
{bessEnabled && <tr><td className="px-3 py-1.5">Storage</td><td className="px-3 py-1.5 text-right tabular-nums">{bessAll.toFixed(2)}</td></tr>}
<tr><td className="px-3 py-1.5">DSRA</td><td className="px-3 py-1.5 text-right tabular-nums">{dsraCost.toFixed(2)}</td></tr>
<tr><td className="px-3 py-1.5">Solar EPC Overheads</td><td className="px-3 py-1.5 text-right tabular-nums">{solarEPC.toFixed(2)}</td></tr>
{windEnabled && <tr><td className="px-3 py-1.5">Wind EPC Overheads</td><td className="px-3 py-1.5 text-right tabular-nums">{windEPC.toFixed(2)}</td></tr>}
<tr><td className="px-3 py-1.5">EPC Margin - WTG Tower+BOP</td><td className="px-3 py-1.5 text-right tabular-nums">{epcMarginWTG.toFixed(2)}</td></tr>
<tr><td className="px-3 py-1.5">EPC Margin - Solar BOS</td><td className="px-3 py-1.5 text-right tabular-nums">{epcMarginSolarBOS.toFixed(2)}</td></tr>
<tr><td className="px-3 py-1.5">Contingency - Solar</td><td className="px-3 py-1.5 text-right tabular-nums">{solarCont.toFixed(2)}</td></tr>
{windEnabled && <tr><td className="px-3 py-1.5">Contingency - Wind (in WTG cost)</td><td className="px-3 py-1.5 text-right tabular-nums">{windCont.toFixed(2)}</td></tr>}
<tr className="border-t-2 border-border bg-muted/30">
<td className="px-3 py-2 font-semibold">Total Hard Cost</td>
<td className="px-3 py-2 text-right tabular-nums font-semibold">{totalHardCost.toFixed(2)}</td>
</tr>
<tr className="border-t border-dashed border-border/50">
<td className="px-3 py-1.5 text-muted-foreground">Upfront Financing Fee</td><td className="px-3 py-1.5 text-right tabular-nums text-muted-foreground">{upfrontCost.toFixed(2)}</td>
</tr>
<tr>
<td className="px-3 py-1.5 text-muted-foreground">Interest During Construction</td><td className="px-3 py-1.5 text-right tabular-nums text-muted-foreground">{idcCost.toFixed(2)}</td>
</tr>
<tr>
<td className="px-3 py-1.5 text-muted-foreground">Bank Guarantee Cost</td><td className="px-3 py-1.5 text-right tabular-nums text-muted-foreground">{bankGuaranteeCost.toFixed(2)}</td>
</tr>
<tr className="border-t-2 border-border bg-muted/30">
<td className="px-3 py-2 font-semibold">Total Project Cost</td>
<td className="px-3 py-2 text-right tabular-nums font-bold text-primary">{(totalHardCost + upfrontCost + idcCost + bankGuaranteeCost).toFixed(2)}</td>
</tr>
</tbody>
</table>
</div>
);
})()}
</div>
{/* ── Solar Generation ─────────────────────────────────── */}
<ToggleCard
title="Solar Generation"
enabled={solarEnabled}
onToggle={(on) =>
setSection(
"solar",
on
? {
location_id: (project.state as string) ?? "RJ",
capacity_ac_mw: 100,
dc_ac_ratio: 1.4,
capacity_dc_mwp: 140,
availability_fraction: 0.995,
degradation_y1: 0.007,
degradation_annual: 0.005,
stabilization_days: 60,
stabilization_energy_loss_frac: 0.20,
stabilization_dsm_addon_pct: 0.005,
}
: null,
)
}
>
<div className="flex flex-col gap-1">
<Label text="Location / Profile" sub="Irradiance data" />
<div className="flex items-center gap-2">
<select
value={gv(inputs, "solar", "location_id", "RJ")}
onChange={(e) => {
const v = e.target.value;
upd("solar", "location_id", v);
if (v === "custom") {
// TODO: Open custom profile upload modal
alert("Custom profile upload - coming soon!");
}
}}
className="w-40 rounded-md border border-input bg-background px-3 py-1.5 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-primary/30 hover:border-primary/40"
>
{LOCATIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-green-100 text-green-700 font-medium">
~27% CUF
</span>
</div>
</div>
<SelectField
label="Module Type"
sub="Fixed vs tracker"
value={gv(inputs, "solar", "module_type", "fixed")}
onChange={(v) => upd("solar", "module_type", v)}
options={[
{ value: "fixed", label: "Fixed" },
{ value: "tracker", label: "Tracker" },
]}
/>
<NumField
label="AC Capacity"
sub="inverter rating"
value={solarAcMw}
onChange={handleSolarAcChange}
step={5}
min={1}
suffix="MW"
/>
<NumField
label="DC:AC Ratio"
sub="overloading"
value={solarDcAcRatio}
onChange={handleDcAcRatioChange}
step={0.05}
min={1.0}
max={1.8}
/>
<NumField
label="DC Capacity"
sub="auto-calc"
value={solarDcMwp}
onChange={() => {}}
readOnly
suffix="MWp"
/>
<NumField
label="Availability"
sub="default 99.5%"
value={pct(gv(inputs, "solar", "availability_fraction", 0.995))}
onChange={(v) => upd("solar", "availability_fraction", v / 100)}
step={0.1}
suffix="%"
/>
<NumField
label="Y1 Degradation"
sub="LID + initial"
value={pct(gv(inputs, "solar", "degradation_y1", 0.007))}
onChange={(v) => upd("solar", "degradation_y1", v / 100)}
step={0.1}
suffix="%"
/>
<NumField
label="Annual Degradation"
sub="Y2-Y25"
value={pct(gv(inputs, "solar", "degradation_annual", 0.005))}
onChange={(v) => upd("solar", "degradation_annual", v / 100)}
step={0.05}
suffix="%/yr"
/>
<div className="col-span-3 border-t border-dashed border-border/60 pt-3 mt-1">
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">
Stabilisation Period
</p>
<div className="grid grid-cols-3 gap-3">
<NumField
label="Days from COD"
sub="default 60"
value={gv(inputs, "solar", "stabilization_days", 60)}
onChange={(v) => upd("solar", "stabilization_days", Math.round(v))}
step={1}
min={0}
suffix="days"
/>
<NumField
label="Energy Loss"
sub="default 20%"
value={pct(gv(inputs, "solar", "stabilization_energy_loss_frac", 0.20))}
onChange={(v) => upd("solar", "stabilization_energy_loss_frac", v / 100)}
step={1}
suffix="%"
/>
<NumField
label="DSM Add-on"
sub="default 0.5%"
value={pct(gv(inputs, "solar", "stabilization_dsm_addon_pct", 0.005))}
onChange={(v) => upd("solar", "stabilization_dsm_addon_pct", v / 100)}
step={0.1}
suffix="%"
/>
</div>
</div>
</ToggleCard>
{/* ── Land & Common ────────────────────────────────────── */}
<CollapsibleCard title="Land & Common Costs" cols={3}>
<NumField
label="Total Land Area"
sub="acres needed"
value={effectiveLandAcres}
onChange={(v) => upd("project", "land_acres", v)}
step={10}
min={0}
suffix="acres"
/>
<NumField
label="Lease Rate"
sub="Lakh/acre/year"
value={leaseRate}
onChange={(v) => upd("project", "land_lease_rate", v)}
step={0.05}
min={0}
suffix="L/acre"
/>
<NumField
label="Lease Years"
sub="3, 5, 10 or custom"
value={leaseYears}
onChange={(v) => upd("project", "land_lease_years", v)}
step={1}
min={1}
max={30}
suffix="yr"
/>
<div className="col-span-3 text-[10px] text-muted-foreground py-1">
Upfront Lease Cost (included in Total Project Cost): <span className="font-medium text-foreground">{upfrontLeaseCostCr.toFixed(2)} Cr</span>
</div>
</CollapsibleCard>
{/* ── Solar Capex ──────────────────────────────────────── */}
{solarEnabled && (
<CollapsibleCard title={`Solar Capex — DC Capacity: ${solarDcMwp} MWp`} cols={2}>
<CostItemsTable
items={costItems}
onChange={updCostItems}
dcMwp={solarDcMwp}
windMw={windMw}
bessMwh={bessMwh}
landAcres={gv(inputs, "project", "land_acres", 0)}
attribution="SolarOnly"
/>
</CollapsibleCard>
)}
{/* ── Wind Generation ──────────────────────────────────── */}
<ToggleCard
title="Wind Generation"
enabled={windEnabled}
onToggle={(on) =>
setSection(
"wind",
on
? {
location_id: (project.state as string) ?? "RJ",
capacity_mw: 50,
hub_height_m: 140,
availability_fraction: 0.97,
wake_loss_fraction: 0.05,
stabilization_days: 60,
stabilization_energy_loss_frac: 0.15,
stabilization_dsm_addon_pct: 0.005,
}
: null,
)
}
>
{/* Row 1: Location + Capacity */}
<SelectField
label="Location / Profile"
sub="Wind speed data"
value={gv(inputs, "wind", "location_id", "RJ")}
onChange={(v) => upd("wind", "location_id", v)}
options={LOCATIONS}
/>
<NumField
label="Capacity"
sub="nameplate"
value={gv(inputs, "wind", "capacity_mw", 50)}
onChange={(v) => {
upd("wind", "capacity_mw", v);
setInputs((prev) => sv(prev, "project", "capacity_wind_mw", v));
}}
step={5}
min={1}
suffix="MW"
/>
<NumField
label="Hub Height"
sub="tower height"
value={gv(inputs, "wind", "hub_height_m", 140)}
onChange={(v) => upd("wind", "hub_height_m", v)}
step={10}
min={80}
suffix="m"
/>
{/* Row 2: Performance */}
<NumField
label="Availability"
sub="default 97%"
value={pct(gv(inputs, "wind", "availability_fraction", 0.97))}
onChange={(v) => upd("wind", "availability_fraction", v / 100)}
step={0.1}
suffix="%"
/>
<NumField
label="Wake Loss"
sub="default 5%"
value={pct(gv(inputs, "wind", "wake_loss_fraction", 0.05))}
onChange={(v) => upd("wind", "wake_loss_fraction", v / 100)}
step={0.5}
suffix="%"
/>
<div className="border-t border-dashed border-border/60 pt-3 mt-1" />
{/* Row 3: Stabilization */}
<div className="col-span-3 border-t border-dashed border-border/60 pt-3 mt-1">
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">
Stabilisation Period
</p>
<div className="grid grid-cols-3 gap-3">
<NumField
label="Days from COD"
sub="default 60"
value={gv(inputs, "wind", "stabilization_days", 60)}
onChange={(v) => upd("wind", "stabilization_days", Math.round(v))}
step={1}
min={0}
suffix="days"
/>
<NumField
label="Energy Loss"
sub="default 15%"
value={pct(gv(inputs, "wind", "stabilization_energy_loss_frac", 0.15))}
onChange={(v) => upd("wind", "stabilization_energy_loss_frac", v / 100)}
step={1}
suffix="%"
/>
<NumField
label="DSM Add-on"
sub="default 0.5%"
value={pct(gv(inputs, "wind", "stabilization_dsm_addon_pct", 0.005))}
onChange={(v) => upd("wind", "stabilization_dsm_addon_pct", v / 100)}
step={0.1}
suffix="%"
/>
</div>
</div>
</ToggleCard>
{/* ── Wind Capex ───────────────────────────────────────── */}
{windEnabled && (
<CollapsibleCard title={`Wind Capex — Capacity: ${windMw} MW`} cols={2}>
<CostItemsTable
items={costItems}
onChange={updCostItems}
dcMwp={solarDcMwp}
windMw={windMw}
bessMwh={bessMwh}
landAcres={gv(inputs, "project", "land_acres", 0)}
attribution="WindOnly"
/>
</CollapsibleCard>
)}
{/* ── BESS ─────────────────────────────────────────────── */}
<ToggleCard
title="BESS (Battery Storage)"
enabled={bessEnabled}
onToggle={(on) =>
setSection(
"bess",
on
? {
capacity_mwh: gv(inputs, "project", "capacity_bess_mwh", 200),
power_mw: gv(inputs, "project", "capacity_bess_mw", 50),
rte: 0.85,
dod: 0.85,
}
: null,
)
}
>
{/* Row 1: Capacity trio */}
<NumField
label="Energy Capacity"
sub="nameplate"
value={gv(inputs, "bess", "capacity_mwh", 200)}
onChange={(v) => {
upd("bess", "capacity_mwh", v);
setInputs((prev) => sv(prev, "project", "capacity_bess_mwh", v));
}}
step={10}
min={10}
suffix="MWh"
/>
<NumField
label="Power Capacity"
sub="max charge/discharge"
value={gv(inputs, "bess", "power_mw", 50)}
onChange={(v) => {
upd("bess", "power_mw", v);
setInputs((prev) => sv(prev, "project", "capacity_bess_mw", v));
}}
step={5}
min={5}
suffix="MW"
/>
<div className="border-t border-dashed border-border/60 pt-3 mt-1" />
{/* Row 2: Performance */}
<NumField
label="RTE"
sub="round-trip"
value={pct(gv(inputs, "bess", "rte", 0.85))}
onChange={(v) => upd("bess", "rte", v / 100)}
step={1}
suffix="%"
/>
<NumField
label="DOD"
sub="depth of discharge"
value={pct(gv(inputs, "bess", "dod", 0.85))}
onChange={(v) => upd("bess", "dod", v / 100)}
step={1}
suffix="%"
/>
</ToggleCard>
{/* ── BESS Capex ───────────────────────────────────────── */}
{bessEnabled && (
<CollapsibleCard title={`BESS Capex — Capacity: ${bessMwh} MWh`} cols={2}>
<CostItemsTable
items={costItems}
onChange={updCostItems}
dcMwp={solarDcMwp}
windMw={windMw}
bessMwh={bessMwh}
landAcres={0}
attribution="BESSOnly"
/>
</CollapsibleCard>
)}
{/* ── RTC Dispatch ─────────────────────────────────────── */}
<ToggleCard
title="RTC Dispatch"
enabled={rtcEnabled}
onToggle={(on) =>
setSection("rtc", on ? { rtc_mw: 50, mcp_enabled: false, initial_soc_frac: 0.5 } : null)
}
>
<NumField
label="RTC Contracted Capacity"
sub="MW"
value={gv(inputs, "rtc", "rtc_mw", 50)}
onChange={(v) => upd("rtc", "rtc_mw", v)}
step={5}
min={0}
suffix="MW"
/>
<div className="flex flex-col gap-1">
<Label text="Sell Surplus at MCP" sub="Market Clearing Price" />
<label className="flex items-center gap-2 cursor-pointer mt-1">
<input
type="checkbox"
checked={gv(inputs, "rtc", "mcp_enabled", false) as boolean}
onChange={(e) => upd("rtc", "mcp_enabled", e.target.checked)}
className="w-4 h-4 accent-primary"
/>
<span className="text-sm">Enable MCP revenue</span>
</label>
</div>
</ToggleCard>
{/* ── Commercial ───────────────────────────────────────── */}
<CollapsibleCard title="Commercial / PPA" cols={3}>
{/* Row 1: Tariff */}
<NumField
label="PPA Tariff"
sub="₹/kWh"
value={gv(inputs, "commercial", "tariff_inr_per_kwh", 3.5)}
onChange={(v) => upd("commercial", "tariff_inr_per_kwh", v)}
step={0.05}
min={1}
suffix="₹/kWh"
/>
<div className="border-t border-dashed border-border/60 pt-3 mt-1" />
<div className="border-t border-dashed border-border/60 pt-3 mt-1" />
{/* Row 2: Losses */}
<NumField
label="Aux"
sub="default 0.5%"
value={pct(gv(inputs, "commercial", "aux_consumption_pct", 0.005))}
onChange={(v) => upd("commercial", "aux_consumption_pct", v / 100)}
step={0.1}
suffix="%"
/>
<NumField
label="Tx Loss"
sub="default 1%"
value={pct(gv(inputs, "commercial", "transmission_loss_pct", 0.01))}
onChange={(v) => upd("commercial", "transmission_loss_pct", v / 100)}
step={0.1}
suffix="%"
/>
<NumField
label="DSM/RTC"
sub="default 2%"
value={pct(gv(inputs, "commercial", "dsm_loss_pct", 0.02))}
onChange={(v) => upd("commercial", "dsm_loss_pct", v / 100)}
step={0.1}
suffix="%"
/>
{/* Row 3: Working Capital */}
<NumField
label="Bad Debt"
sub="default 0%"
value={pct(gv(inputs, "commercial", "bad_debt_pct", 0.0))}
onChange={(v) => upd("commercial", "bad_debt_pct", v / 100)}
step={0.1}
suffix="%"
/>
<NumField
label="Receivable"
sub="default 45 days"
value={gv(inputs, "commercial", "receivable_days", 45)}
onChange={(v) => upd("commercial", "receivable_days", v)}
step={1}
suffix="days"
/>
<div className="border-t border-dashed border-border/60 pt-3 mt-1" />
</CollapsibleCard>
{/* ── Operating Expenditure ────────────────────────────── */}
<CollapsibleCard title="Operating Expenditure" cols={3}>
{/* Solar O&M */}
{solarEnabled && (
<>
<div className="col-span-3 mt-1">
<p className="text-[10px] font-semibold uppercase tracking-wider text-primary/70 mb-2">
Solar O&M
</p>
</div>
<NumField
label="Rate"
sub="₹L/MWp/yr"
value={parseFloat(((gv(inputs, "opex", "om_solar_cr_per_mw", 0.025) as number) * 100).toFixed(4))}
onChange={(v) => upd("opex", "om_solar_cr_per_mw", v / 100)}
step={0.1}
suffix="L/MWp"
/>
<NumField
label="Escalation"
sub="default 5%/yr"
value={pct(gv(inputs, "opex", "om_solar_escalation_pct", 0.05))}
onChange={(v) => upd("opex", "om_solar_escalation_pct", v / 100)}
step={0.5}
suffix="%"
/>
<NumField
label="Start Yr"
sub="default Year 4"
value={gv(inputs, "opex", "om_solar_escalation_after_year", 4)}
onChange={(v) => upd("opex", "om_solar_escalation_after_year", Math.round(v))}
step={1}
min={1}
max={25}
/>
</>
)}
{/* Wind O&M */}
{windEnabled && (
<>
<div className="col-span-3 border-t border-dashed border-border/60 pt-3 mt-1">
<p className="text-[10px] font-semibold uppercase tracking-wider text-primary/70 mb-2">
Wind O&M
</p>
</div>
<NumField
label="Rate"
sub="₹L/MW/yr"
value={parseFloat(((gv(inputs, "opex", "om_wind_cr_per_mw", 0.08) as number) * 100).toFixed(4))}
onChange={(v) => upd("opex", "om_wind_cr_per_mw", v / 100)}
step={0.5}
suffix="L/MW"
/>
<NumField
label="Escalation"
sub="default 5%/yr"
value={pct(gv(inputs, "opex", "om_wind_escalation_pct", 0.05))}
onChange={(v) => upd("opex", "om_wind_escalation_pct", v / 100)}
step={0.5}
suffix="%"
/>
<NumField
label="Start Yr"
sub="default Year 5"
value={gv(inputs, "opex", "om_wind_escalation_after_year", 5)}
onChange={(v) => upd("opex", "om_wind_escalation_after_year", Math.round(v))}
step={1}
min={1}
max={25}
/>
</>
)}
{/* BESS O&M */}
{bessEnabled && (
<>
<div className="col-span-3 border-t border-dashed border-border/60 pt-3 mt-1">
<p className="text-[10px] font-semibold uppercase tracking-wider text-primary/70 mb-2">
BESS O&M
</p>
</div>
<NumField
label="O&M"
sub="% of capex/yr"
value={pct(gv(inputs, "opex", "om_bess_pct_of_capex", 0.03) as number)}
onChange={(v) => upd("opex", "om_bess_pct_of_capex", v / 100)}
step={0.1}
suffix="%"
/>
<div className="border-t border-dashed border-border/60 pt-3 mt-1" />
<div className="border-t border-dashed border-border/60 pt-3 mt-1" />
</>
)}
{/* Common */}
<div className="col-span-3 border-t border-dashed border-border/60 pt-3 mt-1">
<p className="text-[10px] font-semibold uppercase tracking-wider text-primary/70 mb-2">
Common
</p>
</div>
<NumField
label="Insurance"
sub="default 0.5%"
value={pct(gv(inputs, "opex", "insurance_pct_of_capex", 0.005))}
onChange={(v) => upd("opex", "insurance_pct_of_capex", v / 100)}
step={0.05}
suffix="%"
/>
<NumField
label="AM Fee"
sub="default 1%"
value={pct(gv(inputs, "opex", "am_fee_pct_of_revenue", 0.01))}
onChange={(v) => upd("opex", "am_fee_pct_of_revenue", v / 100)}
step={0.1}
suffix="%"
/>
<NumField
label="Misc"
sub="₹Cr/yr"
value={gv(inputs, "opex", "misc_cr", 0.5)}
onChange={(v) => upd("opex", "misc_cr", v)}
step={0.1}
suffix="Cr"
/>
</CollapsibleCard>
{/* ── Construction & Financing ─────────────────────────── */}
<CollapsibleCard title="Construction & Financing" cols={3}>
{/* Row 1 */}
<NumField
label="Period"
sub="months"
value={gv(inputs, "capex", "construction_months", 24)}
onChange={(v) => upd("capex", "construction_months", Math.round(v))}
step={1}
min={6}
max={60}
suffix="months"
/>
<NumField
label="IDC Rate"
sub="% pa"
value={pct(gv(inputs, "capex", "interest_rate_annual", 0.09))}
onChange={(v) => upd("capex", "interest_rate_annual", v / 100)}
step={0.1}
suffix="%"
/>
<div className="border-t border-dashed border-border/60 pt-3 mt-1" />
{/* Row 2 */}
<NumField
label="Debt Fraction"
sub="% of TPC"
value={pct(gv(inputs, "capex", "debt_fraction", 0.75))}
onChange={(v) => upd("capex", "debt_fraction", v / 100)}
step={1}
suffix="%"
/>
<NumField
label="Upfront Fee"
sub="% of debt"
value={pct(gv(inputs, "capex", "upfront_fee_pct", 0.0075 * 1.18))}
onChange={(v) => upd("capex", "upfront_fee_pct", v / 100)}
step={0.1}
suffix="%"
/>
</CollapsibleCard>
{/* ── Debt Financing ───────────────────────────────────── */}
<CollapsibleCard title="Debt Financing" cols={3}>
{/* Row 1 */}
<NumField
label="Interest"
sub="coupon pa"
value={pct(gv(inputs, "debt", "interest_rate_annual", 0.09))}
onChange={(v) => upd("debt", "interest_rate_annual", v / 100)}
step={0.25}
suffix="%"
/>
<NumField
label="Tenor"
sub="years"
value={gv(inputs, "debt", "tenor_years", 18)}
onChange={(v) => upd("debt", "tenor_years", Math.round(v))}
step={1}
min={5}
max={25}
suffix="years"
/>
<NumField
label="Moratorium"
sub="interest-only"
value={gv(inputs, "debt", "moratorium_years", 1)}
onChange={(v) => upd("debt", "moratorium_years", Math.round(v))}
step={1}
min={0}
suffix="years"
/>
{/* Row 2 */}
<NumField
label="D:E Ratio"
sub="max leverage"
value={gv(inputs, "debt", "de_ratio", 3.0)}
onChange={(v) => upd("debt", "de_ratio", v)}
step={0.25}
min={0.5}
/>
<NumField
label="Min DSCR"
sub="covenant"
value={gv(inputs, "debt", "min_dscr", 1.2)}
onChange={(v) => upd("debt", "min_dscr", v)}
step={0.05}
min={1.0}
/>
<NumField
label="Avg DSCR"
sub="for sculpting"
value={gv(inputs, "debt", "avg_dscr", 1.35)}
onChange={(v) => upd("debt", "avg_dscr", v)}
step={0.05}
min={1.0}
/>
<div className="col-span-3">
<SelectField
label="Repayment Shape"
value={gv(inputs, "debt", "schedule_shape", "equal_principal") as string}
onChange={(v) => upd("debt", "schedule_shape", v)}
options={DEBT_SHAPES}
/>
</div>
</CollapsibleCard>
{/* ── Tax ──────────────────────────────────────────────── */}
<CollapsibleCard title="Tax (Section 115BAA)" defaultOpen={false}>
<NumField
label="Effective Tax Rate"
sub="incl. surcharge & cess"
value={pct(gv(inputs, "tax", "rate", 0.2517))}
onChange={(v) => upd("tax", "rate", v / 100)}
step={0.1}
suffix="%"
/>
<NumField
label="WDV Plant Rate"
sub="40% for Solar/Wind"
value={pct(gv(inputs, "tax", "wdv_plant_rate", 0.4))}
onChange={(v) => upd("tax", "wdv_plant_rate", v / 100)}
step={1}
suffix="%"
/>
<NumField
label="WDV BESS Rate"
value={pct(gv(inputs, "tax", "wdv_bess_rate", 0.4))}
onChange={(v) => upd("tax", "wdv_bess_rate", v / 100)}
step={1}
suffix="%"
/>
<NumField
label="WDV Building Rate"
value={pct(gv(inputs, "tax", "wdv_building_rate", 0.1))}
onChange={(v) => upd("tax", "wdv_building_rate", v / 100)}
step={1}
suffix="%"
/>
</CollapsibleCard>
{/* ── Solver ───────────────────────────────────────────── */}
<CollapsibleCard title="Solver">
<div className="col-span-2">
<SelectField
label="Mode"
value={gv(inputs, "solver", "mode", "solve_tariff") as string}
onChange={(v) => upd("solver", "mode", v)}
options={[
{ value: "solve_tariff", label: "Solve tariff for target Equity IRR" },
{ value: "fixed_tariff", label: "Fixed tariff (compute IRR)" },
]}
/>
</div>
{gv(inputs, "solver", "mode", "solve_tariff") === "solve_tariff" ? (
<NumField
label="Target Equity IRR"
value={pct(gv(inputs, "solver", "target_equity_irr", 0.18))}
onChange={(v) => upd("solver", "target_equity_irr", v / 100)}
step={0.5}
min={5}
max={40}
suffix="%"
/>
) : (
<NumField
label="Fixed Tariff"
value={gv(inputs, "solver", "fixed_tariff", 3.5) as number}
onChange={(v) => upd("solver", "fixed_tariff", v)}
step={0.05}
min={1}
suffix="₹/kWh"
/>
)}
</CollapsibleCard>
{error && (
<p className="text-sm text-destructive bg-destructive/10 rounded-lg px-3 py-2 border border-destructive/20">
{error}
</p>
)}
<div className="flex justify-end pt-2 pb-4">
<Button onClick={handleSave} disabled={saving} className="px-6">
{saving ? "Saving…" : "Save & Re-run"}
</Button>
</div>
</div>
);
}