The financing cost (IDC + upfront fees) was being displayed both as a separate line item AND included in the total calculation, causing the Total Project Cost to be inflated by the financing amount. This fix removes the separate financing row from the breakdown table while keeping the correct total calculation.
1633 lines
61 KiB
TypeScript
1633 lines
61 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 allLand = [...solarItems, ...windItems].filter(it => it.id.includes("land")).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 epcAll = [...solarItems, ...windItems, ...bessItems].filter(it => it.category === "EPCOverhead").reduce((acc, it) => acc + (computeItemCr(it, solarDcMwp, windMw, bessMwh, 0, effectiveLandAcres) ?? 0), 0);
|
||
const contAll = [...solarItems, ...windItems].filter(it => it.category === "Contingency").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;
|
||
|
||
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>}
|
||
{allLand > 0 && <tr><td className="px-3 py-1.5">Land Cost</td><td className="px-3 py-1.5 text-right tabular-nums">{allLand.toFixed(2)}</td></tr>}
|
||
{bessEnabled && bessAll > 0 && <tr><td className="px-3 py-1.5">Storage (BESS)</td><td className="px-3 py-1.5 text-right tabular-nums">{bessAll.toFixed(2)}</td></tr>}
|
||
{epcAll > 0 && <tr><td className="px-3 py-1.5">EPC Overheads</td><td className="px-3 py-1.5 text-right tabular-nums">{epcAll.toFixed(2)}</td></tr>}
|
||
{contAll > 0 && <tr><td className="px-3 py-1.5">Contingency</td><td className="px-3 py-1.5 text-right tabular-nums">{contAll.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">{(totalCostCr + idcCost + upfrontCost).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>
|
||
);
|
||
}
|