- Split Solar Land and Wind Land into separate lines - Add DSRA (Debt Service Reserve Account) - Split EPC overheads into Solar and Wind - Add EPC Margin categories (WTG Tower+BOP, Solar BOS) - Split Contingency into Solar and Wind - Add Total Hard Cost subtotal - Add financing costs (upfront fee, IDC, bank guarantee)
1667 lines
65 KiB
TypeScript
1667 lines
65 KiB
TypeScript
"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);
|
||
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);
|
||
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
|
||
const epcMarginWTG = [...windItems, ...bessItems].filter(it => it.category === "EPCMargin" && it.attribution === "WindOnly").reduce((acc, it) => acc + (computeItemCr(it, solarDcMwp, windMw, bessMwh, 0, effectiveLandAcres) ?? 0), 0);
|
||
const epcMarginSolarBOS = solarItems.filter(it => it.category === "EPCMargin" && it.attribution === "SolarOnly").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 idcCost = totalCostCr * debtFrac * idcRate * constrMonths / 12;
|
||
const upfrontPct = gv(inputs, "capex", "upfront_fee_pct", 0.01);
|
||
const upfrontCost = totalCostCr * debtFrac * upfrontPct;
|
||
const bankGuaranteePct = gv(inputs, "capex", "bank_guarantee_pct", 0.001);
|
||
const bankGuaranteeCost = totalCostCr * bankGuaranteePct;
|
||
// DSRA (Debt Service Reserve Account) - typically 1-2 months of debt service
|
||
const dsraMonths = gv(inputs, "capex", "dsra_months", 2);
|
||
const annualDebtService = totalCostCr * debtFrac * 0.12; // approximate annual debt service
|
||
const dsraCost = annualDebtService * dsraMonths / 12;
|
||
|
||
// Compute Total Hard Cost (sum of all component costs before financing)
|
||
const totalHardCost = solarModule + solarBOS + windWTG + solarLandCost + windLandCost + bessAll + dsraCost + solarEPC + windEPC + epcMarginWTG + epcMarginSolarBOS + solarCont + windCont;
|
||
|
||
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>
|
||
{solarModule > 0 && <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>}
|
||
{solarBOS > 0 && <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 && windWTG > 0 && <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>}
|
||
{solarLandCost > 0 && <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 && windLandCost > 0 && <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 && bessAll > 0 && <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>}
|
||
{dsraCost > 0 && <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>}
|
||
{solarEPC > 0 && <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 && windEPC > 0 && <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>}
|
||
{epcMarginWTG > 0 && <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>}
|
||
{epcMarginSolarBOS > 0 && <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>}
|
||
{solarCont > 0 && <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 && windCont > 0 && <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>
|
||
);
|
||
}
|