- Added windProfileAvg to Year level display (similar to solarProfileAvg) - Added windProfileMo to Month level display - Added windProfileDay to Day level display - Added hourProfileW (raw wind profile) to Hour level display - This makes Wind profile display consistent with Solar profile display
1121 lines
51 KiB
TypeScript
1121 lines
51 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import {
|
||
BarChart,
|
||
Bar,
|
||
XAxis,
|
||
YAxis,
|
||
Tooltip,
|
||
ResponsiveContainer,
|
||
LineChart,
|
||
Line,
|
||
CartesianGrid,
|
||
Legend,
|
||
} from "recharts";
|
||
import {
|
||
getStatements,
|
||
type KpiSummary,
|
||
type PnLRow,
|
||
type CfsRow,
|
||
type BsRow,
|
||
type DebtYearRow,
|
||
type GenerationRow,
|
||
type IdcPhasing,
|
||
} from "@/lib/api";
|
||
import { KpiCard } from "@/components/KpiCard";
|
||
|
||
// Horizontal table
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface TableRow {
|
||
label: string;
|
||
values: (number | null)[];
|
||
isBold?: boolean;
|
||
isSeparator?: boolean;
|
||
isHeader?: boolean;
|
||
format?: (v: number | null) => string;
|
||
indent?: boolean;
|
||
collapsible?: boolean;
|
||
isHighlight?: boolean;
|
||
children?: TableRow[];
|
||
}
|
||
|
||
function n1(v: number | null) {
|
||
return v == null ? "—" : v.toFixed(1);
|
||
}
|
||
function n2(v: number | null) {
|
||
return v == null ? "—" : v.toFixed(2);
|
||
}
|
||
function pct1(v: number | null) {
|
||
return v == null ? "—" : `${(v * 100).toFixed(1)}%`;
|
||
}
|
||
|
||
function HorizontalTable({
|
||
years,
|
||
rows,
|
||
unit = "INR Cr",
|
||
yearPrefix = "Y",
|
||
}: {
|
||
years: number[];
|
||
rows: TableRow[];
|
||
unit?: string;
|
||
yearPrefix?: string;
|
||
}) {
|
||
const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set());
|
||
|
||
const toggleRow = (idx: number) => {
|
||
setExpandedRows((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(idx)) {
|
||
next.delete(idx);
|
||
} else {
|
||
next.add(idx);
|
||
}
|
||
return next;
|
||
});
|
||
};
|
||
|
||
let rowIndex = 0;
|
||
|
||
return (
|
||
<div className="overflow-x-auto rounded-lg border border-border">
|
||
<table className="text-xs border-collapse whitespace-nowrap">
|
||
<thead>
|
||
<tr className="bg-muted/70">
|
||
<th className="sticky left-0 z-20 bg-muted/70 px-3 py-2 text-left font-semibold border-r border-b min-w-[220px] text-muted-foreground">
|
||
Metric ({unit})
|
||
</th>
|
||
{years.map((y) => (
|
||
<th
|
||
key={y}
|
||
className="px-3 py-2 text-right font-semibold border-b min-w-[68px] text-muted-foreground"
|
||
>
|
||
{yearPrefix}{y}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((row, i) => {
|
||
rowIndex = i;
|
||
const isExpanded = expandedRows.has(i);
|
||
|
||
if (row.isSeparator) {
|
||
return (
|
||
<tr key={i}>
|
||
<td colSpan={years.length + 1} className="bg-border/40 h-px p-0" />
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
if (row.isHeader) {
|
||
return (
|
||
<tr key={i} className="bg-primary/5">
|
||
<td
|
||
colSpan={years.length + 1}
|
||
className="sticky left-0 z-10 px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-primary/70 bg-primary/5"
|
||
>
|
||
{row.label}
|
||
</td>
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<tr
|
||
key={i}
|
||
className={`border-t border-border/50 transition-colors hover:bg-accent/40 ${
|
||
row.isBold ? "font-semibold bg-muted/20" : ""
|
||
}`}
|
||
>
|
||
<td
|
||
className={`sticky left-0 z-10 border-r border-border/50 px-3 py-1.5 ${
|
||
row.isHighlight ? "bg-blue-50" : "bg-background"
|
||
} ${
|
||
row.indent ? "pl-6 text-muted-foreground" : ""
|
||
} ${row.collapsible ? "cursor-pointer hover:text-primary" : ""}`}
|
||
onClick={() => row.collapsible && toggleRow(i)}
|
||
>
|
||
{row.collapsible && (
|
||
<span className="mr-0.5 text-[6px]">{isExpanded ? "▼" : "▶"}</span>
|
||
)}
|
||
{row.label}
|
||
</td>
|
||
{row.values.map((v, j) => (
|
||
<td key={j} className={`px-3 py-1.5 text-right tabular-nums ${row.isHighlight ? "bg-blue-50" : ""}`}>
|
||
{row.format ? row.format(v) : n1(v)}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
{row.collapsible && row.children && isExpanded && row.children.map((child, ci) => (
|
||
<tr key={`${i}-${ci}`} className="border-t border-border/30 bg-muted/30">
|
||
<td className="sticky left-0 z-10 bg-muted/30 border-r border-border/30 px-3 pl-8 py-1.5 text-muted-foreground">
|
||
{child.label}
|
||
</td>
|
||
{child.values.map((v, j) => (
|
||
<td key={j} className="px-3 py-1.5 text-right tabular-nums text-muted-foreground">
|
||
{child.format ? child.format(v) : n1(v)}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Sheet content builders
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function buildPnLRows(pnl: PnLRow[]): TableRow[] {
|
||
const ppaChildren: TableRow[] = [
|
||
{ label: "Units (MWh)", values: pnl.map((r) => r.ppa_units_mwh), format: (v) => v == null ? "—" : Math.round(v).toLocaleString() },
|
||
{ label: "Tariff (₹/kWh)", values: pnl.map((r) => r.ppa_tariff_inr_per_kwh) },
|
||
];
|
||
const mcpChildren: TableRow[] = [
|
||
{ label: "MCP Units (MWh)", values: pnl.map((r) => r.mcp_units_mwh), format: (v) => v == null ? "—" : Math.round(v).toLocaleString() },
|
||
];
|
||
const opexChildren: TableRow[] = [
|
||
{ label: "O&M Opex", values: pnl.map((r) => r.om_cr) },
|
||
{ label: "Insurance", values: pnl.map((r) => r.insurance_cr) },
|
||
{ label: "Land Lease", values: pnl.map((r) => r.land_lease_cr) },
|
||
{ label: "AM Fee", values: pnl.map((r) => r.am_fee_cr) },
|
||
{ label: "Misc Opex", values: pnl.map((r) => r.misc_opex_cr) },
|
||
];
|
||
return [
|
||
{
|
||
label: "PPA Revenue",
|
||
values: pnl.map((r) => r.ppa_revenue_cr),
|
||
isBold: true,
|
||
collapsible: true,
|
||
children: ppaChildren
|
||
},
|
||
{ isSeparator: true, label: "", values: [] },
|
||
{
|
||
label: "MCP Revenue",
|
||
values: pnl.map((r) => r.mcp_revenue_cr),
|
||
collapsible: true,
|
||
children: mcpChildren
|
||
},
|
||
{ isSeparator: true, label: "", values: [] },
|
||
{ label: "Total Revenue", values: pnl.map((r) => r.revenue_cr), isBold: true, isHighlight: true },
|
||
{ isSeparator: true, label: "", values: [] },
|
||
{
|
||
label: "Operating Expenditure",
|
||
values: pnl.map((r) => r.opex_total_cr),
|
||
collapsible: true,
|
||
children: opexChildren
|
||
},
|
||
{ isSeparator: true, label: "", values: [] },
|
||
{ label: "EBITDA", values: pnl.map((r) => r.ebitda_cr), isBold: true, isHighlight: true },
|
||
{ label: "Book Depreciation", values: pnl.map((r) => r.depreciation_book_cr), indent: true },
|
||
{ label: "EBIT", values: pnl.map((r) => r.ebit_cr), isBold: true, isHighlight: true, format: n2 },
|
||
{ label: "Interest", values: pnl.map((r) => r.interest_cr), indent: true },
|
||
{ label: "PBT", values: pnl.map((r) => r.pbt_cr), isBold: true, isHighlight: true, format: n2 },
|
||
{ label: "Tax", values: pnl.map((r) => r.tax_cr), indent: true },
|
||
{ label: "PAT", values: pnl.map((r) => r.pat_cr), isBold: true, isHighlight: true, format: n2 },
|
||
];
|
||
}
|
||
|
||
function buildCfsRows(cfs: CfsRow[], kpis: KpiSummary, pnl: PnLRow[]): TableRow[] {
|
||
// CFADS = CFO + Interest (add back since CFO is post-interest in this model)
|
||
const cfads = cfs.map((r, i) => r.cfo_cr + (pnl[i]?.interest_cr ?? 0));
|
||
const equityCf = cfs.map((r, i) => cfads[i] - r.debt_repayment_cr - (pnl[i]?.interest_cr ?? 0));
|
||
|
||
return [
|
||
{ isHeader: true, label: "Operating Cash Flow", values: [] },
|
||
{ label: "PAT", values: cfs.map((r) => r.pat_cr), indent: true },
|
||
{ label: "Add: Depreciation", values: cfs.map((r) => r.depreciation_cr), indent: true },
|
||
{ label: "Δ Working Capital", values: cfs.map((r) => r.delta_working_capital_cr), indent: true },
|
||
{ label: "CFO", values: cfs.map((r) => r.cfo_cr), isBold: true },
|
||
{ isSeparator: true, label: "", values: [] },
|
||
{ isHeader: true, label: "Investing Cash Flow", values: [] },
|
||
{ label: "Capex", values: cfs.map((r) => r.capex_cr), indent: true },
|
||
{ label: "CFI", values: cfs.map((r) => r.cfi_cr), isBold: true },
|
||
{ isSeparator: true, label: "", values: [] },
|
||
{ isHeader: true, label: "Financing Cash Flow", values: [] },
|
||
{ label: "Debt Drawdown", values: cfs.map((r) => r.debt_drawdown_cr), indent: true },
|
||
{ label: "Debt Repayment", values: cfs.map((r) => r.debt_repayment_cr), indent: true },
|
||
{ label: "Equity Injection", values: cfs.map((r) => r.equity_injection_cr), indent: true },
|
||
{ label: "CFF", values: cfs.map((r) => r.cff_cr), isBold: true },
|
||
{ isSeparator: true, label: "", values: [] },
|
||
{ label: "Net Cash Flow", values: cfs.map((r) => r.net_cash_flow_cr), isBold: true },
|
||
{ label: "Opening Cash", values: cfs.map((r) => r.opening_cash_cr), indent: true },
|
||
{ label: "Closing Cash", values: cfs.map((r) => r.closing_cash_cr), isBold: true },
|
||
{ isSeparator: true, label: "", values: [] },
|
||
{ isHeader: true, label: "Returns Analysis", values: [] },
|
||
{ label: "CFADS (pre-debt service)", values: cfads, isBold: true },
|
||
{ label: "Equity Free Cash Flow", values: equityCf },
|
||
{
|
||
label: `Project IRR: ${kpis.project_irr != null ? (kpis.project_irr * 100).toFixed(1) + "%" : "—"} | Equity IRR: ${kpis.equity_irr != null ? (kpis.equity_irr * 100).toFixed(1) + "%" : "—"}`,
|
||
values: [],
|
||
isBold: true,
|
||
},
|
||
];
|
||
}
|
||
|
||
function buildBsRows(bs: BsRow[]): TableRow[] {
|
||
return [
|
||
{ isHeader: true, label: "Assets", values: [] },
|
||
{ label: "Gross Block", values: bs.map((r) => r.gross_block_cr), indent: true },
|
||
{ label: "Less: Accum Depr", values: bs.map((r) => r.accumulated_depr_cr), indent: true },
|
||
{ label: "Net Block", values: bs.map((r) => r.net_block_cr), isBold: true },
|
||
{ isSeparator: true, label: "", values: [] },
|
||
{ label: "Cash & Bank", values: bs.map((r) => r.cash_cr), indent: true },
|
||
{ label: "Receivables", values: bs.map((r) => r.receivables_cr), indent: true },
|
||
{ label: "Total Assets", values: bs.map((r) => r.total_assets_cr), isBold: true },
|
||
{ isSeparator: true, label: "", values: [] },
|
||
{ isHeader: true, label: "Liabilities & Equity", values: [] },
|
||
{ label: "Equity Share Capital", values: bs.map((r) => r.equity_cr), indent: true },
|
||
{ label: "Reserves & Surplus", values: bs.map((r) => r.reserves_cr), indent: true },
|
||
{ label: "Long-term Debt", values: bs.map((r) => r.long_term_debt_cr), indent: true },
|
||
{ label: "Payables", values: bs.map((r) => r.payables_cr), indent: true },
|
||
{ label: "Total Liabilities", values: bs.map((r) => r.total_liabilities_cr), isBold: true },
|
||
];
|
||
}
|
||
|
||
function buildDebtRows(debt: DebtYearRow[]): TableRow[] {
|
||
return [
|
||
{ label: "Opening Balance", values: debt.map((r) => r.opening_balance_cr) },
|
||
{ label: "Interest", values: debt.map((r) => r.interest_cr) },
|
||
{ label: "Principal Repayment", values: debt.map((r) => r.principal_cr) },
|
||
{ label: "Total Debt Service", values: debt.map((r) => r.total_debt_service_cr), isBold: true },
|
||
{ label: "Closing Balance", values: debt.map((r) => r.closing_balance_cr), isBold: true },
|
||
{ isSeparator: true, label: "", values: [] },
|
||
{ label: "DSCR", values: debt.map((r) => r.dscr), isBold: true, format: n2 },
|
||
];
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Sheet views
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function SummarySheet({ kpis, scenarioId, onNavigate }: { kpis: KpiSummary; scenarioId: string; onNavigate?: (sheet: string) => void }) {
|
||
const { data: stmts } = useQuery({
|
||
queryKey: ["statements", scenarioId],
|
||
queryFn: () => getStatements(scenarioId),
|
||
});
|
||
|
||
// Custom KPIs - stored in state, persist to localStorage
|
||
const [customKpis, setCustomKpis] = useState<{ label: string; value: string; unit: string }[]>(() => {
|
||
try {
|
||
const stored = localStorage.getItem(`customKpis-${scenarioId}`);
|
||
if (!stored) return [];
|
||
const parsed = JSON.parse(stored);
|
||
if (!Array.isArray(parsed)) return [];
|
||
// Filter out invalid entries
|
||
return parsed.filter(
|
||
(k) => k && typeof k.label === "string" && typeof k.value === "string" && k.value !== "0.0" && k.value !== "null"
|
||
);
|
||
} catch {
|
||
return [];
|
||
}
|
||
});
|
||
|
||
function addCustomKpi() {
|
||
const label = prompt("Enter KPI label (e.g. O&M Cost)");
|
||
if (!label) return;
|
||
const value = prompt(`Enter value for ${label} (without unit)`);
|
||
if (!value) return;
|
||
const unit = prompt("Enter unit (e.g. Cr, %, yrs)") || "";
|
||
const newKpis = [...customKpis, { label, value, unit }];
|
||
setCustomKpis(newKpis);
|
||
try {
|
||
localStorage.setItem(`customKpis-${scenarioId}`, JSON.stringify(newKpis));
|
||
} catch {
|
||
// localStorage unavailable
|
||
}
|
||
}
|
||
|
||
function removeCustomKpi(index: number) {
|
||
const newKpis = customKpis.filter((_, i) => i !== index);
|
||
setCustomKpis(newKpis);
|
||
try {
|
||
localStorage.setItem(`customKpis-${scenarioId}`, JSON.stringify(newKpis));
|
||
} catch {
|
||
// localStorage unavailable
|
||
}
|
||
}
|
||
|
||
const pnlChart =
|
||
stmts?.pnl.map((r) => ({
|
||
year: r.year,
|
||
Revenue: r.revenue_cr,
|
||
EBITDA: r.ebitda_cr,
|
||
PAT: r.pat_cr,
|
||
})) ?? [];
|
||
|
||
const cashChart =
|
||
stmts?.cfs.map((r) => ({ year: r.year, "Closing Cash": r.closing_cash_cr })) ?? [];
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||
<KpiCard
|
||
label="Solved Tariff"
|
||
value={kpis.solved_tariff_inr_per_kwh != null ? kpis.solved_tariff_inr_per_kwh.toFixed(2) : null}
|
||
unit="₹/kWh"
|
||
highlight
|
||
onClick={() => onNavigate?.("generation")}
|
||
/>
|
||
<KpiCard
|
||
label="Equity IRR"
|
||
value={kpis.equity_irr != null ? `${(kpis.equity_irr * 100).toFixed(1)}%` : null}
|
||
highlight
|
||
onClick={() => onNavigate?.("irr")}
|
||
/>
|
||
<KpiCard
|
||
label="Project IRR"
|
||
value={kpis.project_irr != null ? `${(kpis.project_irr * 100).toFixed(1)}%` : null}
|
||
onClick={() => onNavigate?.("irr")}
|
||
/>
|
||
<KpiCard
|
||
label="Min DSCR"
|
||
value={kpis.min_dscr?.toFixed(2) ?? null}
|
||
onClick={() => onNavigate?.("debt")}
|
||
/>
|
||
<KpiCard
|
||
label="Avg DSCR"
|
||
value={kpis.avg_dscr?.toFixed(2) ?? null}
|
||
onClick={() => onNavigate?.("debt")}
|
||
/>
|
||
<KpiCard
|
||
label="Total Capex"
|
||
value={kpis.total_capex_cr != null ? kpis.total_capex_cr.toFixed(1) : null}
|
||
unit="Cr"
|
||
onClick={() => onNavigate?.("debt")}
|
||
/>
|
||
<KpiCard
|
||
label="Debt"
|
||
value={kpis.debt_cr?.toFixed(1) ?? null}
|
||
unit="Cr"
|
||
onClick={() => onNavigate?.("debt")}
|
||
/>
|
||
<KpiCard
|
||
label="IDC"
|
||
value={kpis.idc_cr?.toFixed(1) ?? null}
|
||
unit="Cr"
|
||
onClick={() => onNavigate?.("idc")}
|
||
/>
|
||
<KpiCard
|
||
label="LCOE"
|
||
value={kpis.lcoe_inr_per_kwh?.toFixed(2) ?? null}
|
||
unit="₹/kWh"
|
||
onClick={() => onNavigate?.("irr")}
|
||
/>
|
||
<KpiCard
|
||
label="Payback"
|
||
value={kpis.payback_years?.toFixed(1) ?? null}
|
||
unit="yrs"
|
||
onClick={() => onNavigate?.("irr")}
|
||
/>
|
||
{kpis.solar_y1_cuf != null && (
|
||
<KpiCard
|
||
label="Solar Y1 CUF"
|
||
value={`${(kpis.solar_y1_cuf * 100).toFixed(1)}%`}
|
||
onClick={() => onNavigate?.("generation")}
|
||
/>
|
||
)}
|
||
{kpis.wind_y1_plf != null && (
|
||
<KpiCard
|
||
label="Wind Y1 PLF"
|
||
value={`${(kpis.wind_y1_plf * 100).toFixed(1)}%`}
|
||
onClick={() => onNavigate?.("generation")}
|
||
/>
|
||
)}
|
||
{kpis.rtc_cuf_achieved != null && (
|
||
<KpiCard label="RTC CUF" value={`${(kpis.rtc_cuf_achieved * 100).toFixed(1)}%`} highlight />
|
||
)}
|
||
|
||
{/* Add custom KPI button */}
|
||
<button
|
||
onClick={addCustomKpi}
|
||
className="border border-dashed border-primary/30 rounded-lg p-4 flex items-center justify-center text-sm text-primary hover:bg-primary/5 transition-colors"
|
||
>
|
||
+ Add KPI
|
||
</button>
|
||
|
||
{/* Custom KPIs */}
|
||
{customKpis.map((kpi: { label: string; value: string; unit: string }, i: number) => (
|
||
<div key={i} className="relative">
|
||
<KpiCard label={kpi.label} value={kpi.value} unit={kpi.unit} />
|
||
<button
|
||
onClick={() => removeCustomKpi(i)}
|
||
className="absolute top-1 right-2 text-muted-foreground/40 hover:text-destructive text-xs"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{stmts && (
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||
<div className="border rounded-lg p-4">
|
||
<p className="text-sm font-medium mb-3 text-muted-foreground">P&L Overview (₹Cr)</p>
|
||
<ResponsiveContainer width="100%" height={200}>
|
||
<BarChart data={pnlChart} barCategoryGap="30%">
|
||
<XAxis dataKey="year" tick={{ fontSize: 10 }} />
|
||
<YAxis tick={{ fontSize: 10 }} />
|
||
<Tooltip />
|
||
<Legend iconSize={10} />
|
||
<Bar dataKey="Revenue" fill="oklch(0.52 0.22 262)" />
|
||
<Bar dataKey="EBITDA" fill="oklch(0.60 0.17 178)" />
|
||
<Bar dataKey="PAT" fill="oklch(0.70 0.18 55)" />
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
<div className="border rounded-lg p-4">
|
||
<p className="text-sm font-medium mb-3 text-muted-foreground">Closing Cash (₹Cr)</p>
|
||
<ResponsiveContainer width="100%" height={200}>
|
||
<LineChart data={cashChart}>
|
||
<CartesianGrid strokeDasharray="3 3" stroke="oklch(0.90 0.01 262)" />
|
||
<XAxis dataKey="year" tick={{ fontSize: 10 }} />
|
||
<YAxis tick={{ fontSize: 10 }} />
|
||
<Tooltip />
|
||
<Line
|
||
type="monotone"
|
||
dataKey="Closing Cash"
|
||
stroke="oklch(0.52 0.22 262)"
|
||
dot={false}
|
||
strokeWidth={2}
|
||
/>
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function IrrSheet({ kpis }: { kpis: KpiSummary }) {
|
||
const sections = [
|
||
{
|
||
title: "Returns",
|
||
rows: [
|
||
{ label: "Equity IRR (Leveraged)", value: kpis.equity_irr != null ? pct1(kpis.equity_irr) : "—" },
|
||
{ label: "Project IRR (Unlevered)", value: kpis.project_irr != null ? pct1(kpis.project_irr) : "—" },
|
||
{ label: "LCOE", value: kpis.lcoe_inr_per_kwh != null ? `₹${kpis.lcoe_inr_per_kwh.toFixed(2)}/kWh` : "—" },
|
||
{ label: "Payback Period", value: kpis.payback_years != null ? `${kpis.payback_years.toFixed(1)} yrs` : "—" },
|
||
],
|
||
},
|
||
{
|
||
title: "Debt Metrics",
|
||
rows: [
|
||
{ label: "Min DSCR", value: kpis.min_dscr?.toFixed(2) ?? "—" },
|
||
{ label: "Avg DSCR", value: kpis.avg_dscr?.toFixed(2) ?? "—" },
|
||
],
|
||
},
|
||
{
|
||
title: "Project Economics",
|
||
rows: [
|
||
{ label: "Solved / Fixed Tariff", value: kpis.solved_tariff_inr_per_kwh != null ? `₹${kpis.solved_tariff_inr_per_kwh.toFixed(2)}/kWh` : "—" },
|
||
{ label: "Total Capex", value: kpis.total_capex_cr != null ? `₹${kpis.total_capex_cr.toFixed(1)} Cr` : "—" },
|
||
{ label: "Debt Sized", value: kpis.debt_cr != null ? `₹${kpis.debt_cr.toFixed(1)} Cr` : "—" },
|
||
{ label: "IDC", value: kpis.idc_cr != null ? `₹${kpis.idc_cr.toFixed(1)} Cr` : "—" },
|
||
],
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div className="max-w-xl space-y-4">
|
||
{sections.map((s) => (
|
||
<div key={s.title} className="border rounded-lg overflow-hidden">
|
||
<div className="bg-muted/50 px-4 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||
{s.title}
|
||
</div>
|
||
<table className="w-full text-sm">
|
||
<tbody>
|
||
{s.rows.map(({ label, value }) => (
|
||
<tr key={label} className="border-t hover:bg-accent/30 transition-colors">
|
||
<td className="px-4 py-2.5 text-muted-foreground">{label}</td>
|
||
<td className="px-4 py-2.5 tabular-nums font-semibold text-right">{value}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function buildGenerationRows(gen: GenerationRow[]): TableRow[] {
|
||
const hasSolar = gen.some((r) => r.solar_mwh > 0);
|
||
const hasWind = gen.some((r) => r.wind_mwh > 0);
|
||
const rows: TableRow[] = [];
|
||
if (hasSolar) {
|
||
rows.push({ label: "Solar Generation (MWh)", values: gen.map((r) => r.solar_mwh), format: (v) => v == null ? "—" : Math.round(v).toLocaleString() });
|
||
rows.push({ label: "Solar CUF (%)", values: gen.map((r) => r.solar_cuf_pct), format: (v) => v == null ? "—" : `${v.toFixed(1)}%`, indent: true });
|
||
}
|
||
if (hasWind) {
|
||
rows.push({ label: "Wind Generation (MWh)", values: gen.map((r) => r.wind_mwh), format: (v) => v == null ? "—" : Math.round(v).toLocaleString() });
|
||
rows.push({ label: "Wind PLF (%)", values: gen.map((r) => r.wind_plf_pct), format: (v) => v == null ? "—" : `${v.toFixed(1)}%`, indent: true });
|
||
}
|
||
rows.push({ label: "Gross Total (MWh)", values: gen.map((r) => r.gross_mwh), isBold: true, format: (v) => v == null ? "—" : Math.round(v).toLocaleString() });
|
||
rows.push({ label: "Less: Aux Consumption", values: gen.map((r) => -r.aux_loss_mwh), format: (v) => v == null ? "—" : Math.round(v).toLocaleString(), indent: true });
|
||
rows.push({ label: "Less: Transmission Loss", values: gen.map((r) => -r.tx_loss_mwh), format: (v) => v == null ? "—" : Math.round(v).toLocaleString(), indent: true });
|
||
rows.push({ label: "Less: DSM Penalty", values: gen.map((r) => -r.dsm_loss_mwh), format: (v) => v == null ? "—" : Math.round(v).toLocaleString(), indent: true });
|
||
rows.push({ label: "Net Billable (MWh)", values: gen.map((r) => r.net_billable_mwh), isBold: true, format: (v) => v == null ? "—" : Math.round(v).toLocaleString() });
|
||
rows.push({ label: "Revenue (₹ Cr)", values: gen.map((r) => r.revenue_cr), isBold: true, format: n2 });
|
||
return rows;
|
||
}
|
||
|
||
// Hourly generation tree: Year > Month > Day > Hour (expandable)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||
|
||
interface HourlyData {
|
||
solar_hourly?: number[];
|
||
wind_hourly?: number[];
|
||
// New columns
|
||
hourly_timestamps?: string[];
|
||
hourly_fy?: string[];
|
||
hourly_proj_year?: number[];
|
||
hourly_total_re?: number[];
|
||
hourly_client_end?: number[];
|
||
hourly_load?: number[];
|
||
// Profile data for detailed display
|
||
hourly_solar_profile?: number[];
|
||
hourly_wind_profile?: number[];
|
||
}
|
||
|
||
function HourlyGenerationSheet({ hourly, codYear, solarDCMW, windMW }: { hourly: HourlyData; codYear?: number; solarDCMW?: number; windMW?: number }) {
|
||
const { solar_hourly, wind_hourly, hourly_total_re, hourly_client_end, hourly_load, hourly_solar_profile, hourly_wind_profile } = hourly;
|
||
const hasSolar = solar_hourly && solar_hourly.length > 0;
|
||
const hasWind = wind_hourly && wind_hourly.length > 0;
|
||
const hasTotalRe = hourly_total_re && hourly_total_re.length > 0;
|
||
const hasClientEnd = hourly_client_end && hourly_client_end.length > 0;
|
||
const hasLoad = hourly_load && hourly_load.length > 0;
|
||
const hasSolarProfile = hourly_solar_profile && hourly_solar_profile.length > 0;
|
||
const hasWindProfile = hourly_wind_profile && hourly_wind_profile.length > 0;
|
||
const hasData = hasSolar || hasWind || hasTotalRe;
|
||
|
||
// FY labels: if COD is April 2026, FY 2026-27 = Year 1
|
||
const startYear = codYear || new Date().getFullYear();
|
||
const getFyLabel = (yearIndex: number) => {
|
||
const fyStart = startYear + yearIndex;
|
||
const fyEnd = fyStart + 1;
|
||
return `FY ${String(fyStart).slice(-2)}-${String(fyEnd).slice(-2)}`;
|
||
};
|
||
|
||
// Month names - in reverse order (April first for Indian FY starting Apr)
|
||
const monthNames = ['Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', 'Jan', 'Feb', 'Mar'];
|
||
// Mapping from position 0-11 to actual month 4-12, 1-3
|
||
const monthPositions = [4, 5, 6, 7, 8, 9, 10, 11, 12, 1, 2, 3];
|
||
const getMonthLabel = (pos: number, yearIndex: number) => {
|
||
const month = monthPositions[pos];
|
||
const fyStart = startYear + yearIndex;
|
||
const fyEnd = fyStart + 1;
|
||
const fySuffix = month <= 3 ? String(fyEnd).slice(-2) : String(fyStart).slice(-2);
|
||
return `${monthNames[pos]} ${fySuffix}`;
|
||
};
|
||
|
||
// Expand state: which years/months/days are expanded
|
||
const [expandedYears, setExpandedYears] = useState<Set<number>>(new Set());
|
||
const [expandedMonths, setExpandedMonths] = useState<Set<string>>(new Set());
|
||
const [expandedDays, setExpandedDays] = useState<Set<string>>(new Set());
|
||
|
||
const toggleYear = (yr: number) => setExpandedYears((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(yr)) next.delete(yr);
|
||
else next.add(yr);
|
||
return next;
|
||
});
|
||
|
||
const toggleMonth = (key: string) => setExpandedMonths((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(key)) next.delete(key);
|
||
else next.add(key);
|
||
return next;
|
||
});
|
||
|
||
const toggleDay = (key: string) => setExpandedDays((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(key)) next.delete(key);
|
||
else next.add(key);
|
||
return next;
|
||
});
|
||
|
||
// Helper: get hourly output data for a specific year/month/day (sums)
|
||
const getYearData = (year: number, isSolar: boolean) => {
|
||
const data = isSolar ? solar_hourly : wind_hourly;
|
||
if (!data) return [];
|
||
const start = (year - 1) * 8760;
|
||
return data.slice(start, start + 8760);
|
||
};
|
||
|
||
const getMonthData = (year: number, month: number, isSolar: boolean) => {
|
||
const yearData = getYearData(year, isSolar);
|
||
if (!yearData) return [];
|
||
const start = MONTH_DAYS.slice(0, month - 1).reduce((a, d) => a + d, 0) * 24;
|
||
const days = MONTH_DAYS[month - 1];
|
||
return yearData.slice(start, start + days * 24);
|
||
};
|
||
|
||
const getDayData = (year: number, month: number, day: number, isSolar: boolean) => {
|
||
const monthData = getMonthData(year, month, isSolar);
|
||
if (!monthData) return [];
|
||
const start = (day - 1) * 24;
|
||
return monthData.slice(start, start + 24);
|
||
};
|
||
|
||
// Helper: get raw profile data (0-1 normalized) for averages
|
||
const getYearProfile = (year: number, data?: number[]) => {
|
||
if (!data) return [];
|
||
const start = (year - 1) * 8760;
|
||
return data.slice(start, start + 8760);
|
||
};
|
||
|
||
const getMonthProfile = (year: number, month: number, data?: number[]) => {
|
||
if (!data) return [];
|
||
const yearData = getYearProfile(year, data);
|
||
if (!yearData.length) return [];
|
||
const start = MONTH_DAYS.slice(0, month - 1).reduce((a, d) => a + d, 0) * 24;
|
||
const days = MONTH_DAYS[month - 1];
|
||
return yearData.slice(start, start + days * 24);
|
||
};
|
||
|
||
const getDayProfile = (year: number, month: number, day: number, data?: number[]) => {
|
||
if (!data) return [];
|
||
const yearData = getYearProfile(year, data);
|
||
if (!yearData.length) return [];
|
||
const start = MONTH_DAYS.slice(0, month - 1).reduce((a, d) => a + d, 0) * 24;
|
||
const monthData = yearData.slice(start, start + MONTH_DAYS[month - 1] * 24);
|
||
const dayStart = (day - 1) * 24;
|
||
return monthData.slice(dayStart, dayStart + 24);
|
||
};
|
||
|
||
// Compute averages from raw profile (0-1 values)
|
||
const computeYearAvgProfile = (year: number, data?: number[]) => {
|
||
const d = getYearProfile(year, data);
|
||
if (!d.length) return 0;
|
||
return d.reduce((a, v) => a + v, 0) / d.length;
|
||
};
|
||
|
||
const computeMonthAvgProfile = (year: number, month: number, data?: number[]) => {
|
||
const d = getMonthProfile(year, month, data);
|
||
if (!d.length) return 0;
|
||
return d.reduce((a, v) => a + v, 0) / d.length;
|
||
};
|
||
|
||
const computeDayAvgProfile = (year: number, month: number, day: number, data?: number[]) => {
|
||
const d = getDayProfile(year, month, day, data);
|
||
if (!d.length) return 0;
|
||
return d.reduce((a, v) => a + v, 0) / d.length;
|
||
};
|
||
|
||
// Compute totals for display
|
||
const computeYearTotal = (year: number, isSolar: boolean) => {
|
||
const d = getYearData(year, isSolar);
|
||
return d.reduce((a, v) => a + v, 0);
|
||
};
|
||
|
||
const computeMonthTotal = (year: number, month: number, isSolar: boolean) => {
|
||
const d = getMonthData(year, month, isSolar);
|
||
return d.reduce((a, v) => a + v, 0);
|
||
};
|
||
|
||
const computeDayTotal = (year: number, month: number, day: number, isSolar: boolean) => {
|
||
const d = getDayData(year, month, day, isSolar);
|
||
return d.reduce((a, v) => a + v, 0);
|
||
};
|
||
|
||
// Helper functions for new columns (Total RE, Client End, Load)
|
||
const getYearDataNew = (year: number, data?: number[]) => {
|
||
if (!data) return [];
|
||
const start = (year - 1) * 8760;
|
||
return data.slice(start, start + 8760);
|
||
};
|
||
|
||
const computeYearTotalNew = (year: number, data?: number[]) => {
|
||
const d = getYearDataNew(year, data);
|
||
return d.reduce((a, v) => a + v, 0);
|
||
};
|
||
|
||
const computeMonthTotalNew = (year: number, month: number, data?: number[]) => {
|
||
if (!data) return 0;
|
||
const yearData = getYearDataNew(year, data);
|
||
if (!yearData.length) return 0;
|
||
const start = MONTH_DAYS.slice(0, month - 1).reduce((a, d) => a + d, 0) * 24;
|
||
const days = MONTH_DAYS[month - 1];
|
||
const monthData = yearData.slice(start, start + days * 24);
|
||
return monthData.reduce((a, v) => a + v, 0);
|
||
};
|
||
|
||
const computeDayTotalNew = (year: number, month: number, day: number, data?: number[]) => {
|
||
if (!data) return 0;
|
||
const yearData = getYearDataNew(year, data);
|
||
if (!yearData.length) return 0;
|
||
const start = MONTH_DAYS.slice(0, month - 1).reduce((a, d) => a + d, 0) * 24;
|
||
const monthData = yearData.slice(start, start + MONTH_DAYS[month - 1] * 24);
|
||
const dayStart = (day - 1) * 24;
|
||
const dayData = monthData.slice(dayStart, dayStart + 24);
|
||
return dayData.reduce((a, v) => a + v, 0);
|
||
};
|
||
|
||
if (!hasData) {
|
||
return <div className="text-muted-foreground p-4">No hourly generation data available</div>;
|
||
}
|
||
|
||
// Build tree: Years (1-25), expandable to months, days, hours
|
||
return (
|
||
<div className="space-y-1 text-xs">
|
||
<div className="flex items-center gap-2 pb-2 text-muted-foreground flex-wrap">
|
||
{hasSolar && <span className="text-orange-600">● Solar</span>}
|
||
{hasWind && <span className="text-blue-600">● Wind</span>}
|
||
{hasTotalRe && <span className="text-green-600">● Total RE</span>}
|
||
{hasClientEnd && <span className="text-purple-600">● Client End</span>}
|
||
{hasLoad && <span className="text-gray-600">● Load</span>}
|
||
<span className="text-muted-foreground/60 ml-2">Click: Year → Month → Day → Hour</span>
|
||
</div>
|
||
|
||
{/* Column Header */}
|
||
<div className="flex items-center gap-2 py-1 px-2 bg-muted/50 rounded font-medium text-[10px] text-muted-foreground">
|
||
<span className="w-20">Period</span>
|
||
{hasSolar && <span className="w-20 text-orange-700">Solar 8760</span>}
|
||
{hasSolar && <span className="w-16 text-orange-600/70">DC MW</span>}
|
||
{hasSolar && <span className="w-20 text-orange-700">Solar MW</span>}
|
||
{hasWind && <span className="w-20 text-blue-700">Wind 8760</span>}
|
||
{hasWind && <span className="w-16 text-blue-600/70">MW</span>}
|
||
{hasWind && <span className="w-20 text-blue-700">Wind MW</span>}
|
||
{hasTotalRe && <span className="w-20 text-green-700">Total RE</span>}
|
||
{hasClientEnd && <span className="w-20 text-purple-700">Client End</span>}
|
||
{hasLoad && <span className="w-16 text-gray-700">Load</span>}
|
||
</div>
|
||
|
||
{/* Year rows */}
|
||
{[...Array(25)].map((_, i) => {
|
||
const year = i + 1;
|
||
const isYearExpanded = expandedYears.has(year);
|
||
const solarYr = hasSolar ? computeYearTotal(year, true) : 0;
|
||
const windYr = hasWind ? computeYearTotal(year, false) : 0;
|
||
const totalReYr = hasTotalRe ? computeYearTotalNew(year, hourly_total_re) : 0;
|
||
const clientEndYr = hasClientEnd ? computeYearTotalNew(year, hourly_client_end) : 0;
|
||
const loadYr = hasLoad ? computeYearTotalNew(year, hourly_load) : 0;
|
||
// Solar 8760: average profile value (0-1), DC MW: capacity (placeholder), Solar MW: sum output
|
||
const solarProfileAvg = hasSolarProfile ? computeYearAvgProfile(year, hourly_solar_profile) : 0;
|
||
const windProfileAvg = hasWindProfile ? computeYearAvgProfile(year, hourly_wind_profile) : 0;
|
||
const fyLabel = getFyLabel(i);
|
||
|
||
return (
|
||
<div key={year}>
|
||
<button
|
||
onClick={() => toggleYear(year)}
|
||
className="w-full flex items-center gap-2 py-1.5 hover:bg-accent/40 rounded px-2 text-left font-medium"
|
||
>
|
||
<span className="text-[10px] w-4">{isYearExpanded ? "▼" : "▶"}</span>
|
||
<span className="w-20 font-medium">{fyLabel}</span>
|
||
{/* Solar 8760: avg profile (show as %), DC MW, Solar MW */}
|
||
{hasSolar && <span className="text-orange-700 w-20">{(solarProfileAvg * 100).toFixed(1)}%</span>}
|
||
{hasSolar && <span className="text-orange-600/70 w-16">{solarDCMW || '-'}</span>}
|
||
{hasSolar && <span className="text-orange-700 w-20">{Math.round(solarYr).toLocaleString()}</span>}
|
||
{/* Wind 8760, MW, Wind MW */}
|
||
{hasWind && <span className="text-blue-700 w-20">{(windProfileAvg * 100).toFixed(1)}%</span>}
|
||
{hasWind && <span className="text-blue-600/70 w-16">{windMW || '-'}</span>}
|
||
{hasWind && <span className="text-blue-700 w-20">{Math.round(windYr).toLocaleString()}</span>}
|
||
{hasTotalRe && <span className="text-green-700 w-20">{Math.round(totalReYr).toLocaleString()}</span>}
|
||
{hasClientEnd && <span className="text-purple-700 w-20">{Math.round(clientEndYr).toLocaleString()}</span>}
|
||
{hasLoad && <span className="text-gray-700 w-16">{Math.round(loadYr).toLocaleString()}</span>}
|
||
</button>
|
||
|
||
{/* Month rows - forward order (Apr first, then subsequent months) */}
|
||
{isYearExpanded && [...Array(12)].map((_, mi) => {
|
||
const pos = mi; // Forward: 0=Apr, 1=May, etc.
|
||
const month = monthPositions[pos];
|
||
const monthKey = `${year}-${month}`;
|
||
const isMonthExpanded = expandedMonths.has(monthKey);
|
||
const solarMo = hasSolar ? computeMonthTotal(year, month, true) : 0;
|
||
const windMo = hasWind ? computeMonthTotal(year, month, false) : 0;
|
||
// Average profile for month
|
||
const solarProfileMo = hasSolarProfile ? computeMonthAvgProfile(year, month, hourly_solar_profile) : 0;
|
||
const windProfileMo = hasWindProfile ? computeMonthAvgProfile(year, month, hourly_wind_profile) : 0;
|
||
const totalReMo = hasTotalRe ? computeMonthTotalNew(year, month, hourly_total_re) : 0;
|
||
const clientEndMo = hasClientEnd ? computeMonthTotalNew(year, month, hourly_client_end) : 0;
|
||
const loadMo = hasLoad ? computeMonthTotalNew(year, month, hourly_load) : 0;
|
||
const daysInMonth = MONTH_DAYS[month - 1];
|
||
const monthLabel = getMonthLabel(pos, i);
|
||
|
||
return (
|
||
<div key={monthKey} className="ml-4">
|
||
<button
|
||
onClick={() => toggleMonth(monthKey)}
|
||
className="w-full flex items-center gap-2 py-1 hover:bg-accent/30 rounded px-2 text-left"
|
||
>
|
||
<span className="text-[10px] w-4">{isMonthExpanded ? "▼" : "▶"}</span>
|
||
<span className="w-20 text-muted-foreground">{monthLabel}</span>
|
||
{/* Month: avg profile %, DC MW, sum MWh */}
|
||
{hasSolar && <span className="text-orange-700/80 w-14">{(solarProfileMo * 100).toFixed(1)}%</span>}
|
||
{hasSolar && <span className="text-orange-700/80 w-10">{solarDCMW || '-'}</span>}
|
||
{hasSolar && <span className="text-orange-700/80 w-14">{Math.round(solarMo).toLocaleString()}</span>}
|
||
{hasWind && <span className="text-blue-700/80 w-14">{(windProfileMo * 100).toFixed(1)}%</span>}
|
||
{hasWind && <span className="text-blue-700/80 w-10">{windMW || '-'}</span>}
|
||
{hasWind && <span className="text-blue-700/80 w-14">{Math.round(windMo).toLocaleString()}</span>}
|
||
{hasTotalRe && <span className="text-green-700/80 w-14">{Math.round(totalReMo).toLocaleString()}</span>}
|
||
{hasClientEnd && <span className="text-purple-700/80 w-14">{Math.round(clientEndMo).toLocaleString()}</span>}
|
||
{hasLoad && <span className="text-gray-700/80 w-10">{Math.round(loadMo).toLocaleString()}</span>}
|
||
</button>
|
||
|
||
{/* Day rows (expandable) */}
|
||
{isMonthExpanded && [...Array(daysInMonth)].map((_, di) => {
|
||
const day = di + 1;
|
||
const dayKey = `${year}-${month}-${day}`;
|
||
const isDayExpanded = expandedDays.has(dayKey);
|
||
const solarDy = hasSolar ? computeDayTotal(year, month, day, true) : 0;
|
||
const windDy = hasWind ? computeDayTotal(year, month, day, false) : 0;
|
||
// Average profile for day
|
||
const solarProfileDay = hasSolarProfile ? computeDayAvgProfile(year, month, day, hourly_solar_profile) : 0;
|
||
const windProfileDay = hasWindProfile ? computeDayAvgProfile(year, month, day, hourly_wind_profile) : 0;
|
||
const totalReDy = hasTotalRe ? computeDayTotalNew(year, month, day, hourly_total_re) : 0;
|
||
const clientEndDy = hasClientEnd ? computeDayTotalNew(year, month, day, hourly_client_end) : 0;
|
||
const loadDy = hasLoad ? computeDayTotalNew(year, month, day, hourly_load) : 0;
|
||
|
||
return (
|
||
<div key={dayKey} className="ml-4">
|
||
<button
|
||
onClick={() => toggleDay(dayKey)}
|
||
className="w-full flex items-center gap-2 py-0.5 hover:bg-accent/20 rounded px-2 text-left"
|
||
>
|
||
<span className="text-[10px] w-4">{isDayExpanded ? "▼" : "▶"}</span>
|
||
<span className="w-12 text-muted-foreground/80">{monthLabel} {day}</span>
|
||
{/* Day: average profile %, DC MW, sum MWh */}
|
||
{hasSolar && <span className="text-orange-600/70 w-14">{(solarProfileDay * 100).toFixed(1)}%</span>}
|
||
{hasSolar && <span className="text-orange-600/70 w-10">{solarDCMW || '-'}</span>}
|
||
{hasSolar && <span className="text-orange-600/70 w-14">{Math.round(solarDy)}</span>}
|
||
{hasWind && <span className="text-blue-600/70 w-14">{(windProfileDay * 100).toFixed(1)}%</span>}
|
||
{hasWind && <span className="text-blue-600/70 w-10">{windMW || '-'}</span>}
|
||
{hasWind && <span className="text-blue-600/70 w-14">{Math.round(windDy)}</span>}
|
||
{hasTotalRe && <span className="text-green-600/70 w-14">{Math.round(totalReDy)}</span>}
|
||
{hasClientEnd && <span className="text-purple-600/70 w-14">{Math.round(clientEndDy)}</span>}
|
||
{hasLoad && <span className="text-gray-600/70 w-10">{Math.round(loadDy)}</span>}
|
||
</button>
|
||
|
||
{/* Hour values - vertical stack */}
|
||
{isDayExpanded && (
|
||
<div className="ml-8 flex flex-col gap-px py-1">
|
||
{[...Array(24)].map((_, h) => {
|
||
const hour = h;
|
||
const hourIdx = (day - 1) * 24 + hour;
|
||
const solarHr = hasSolar ? getDayData(year, month, day, true)[hourIdx % 24] : 0;
|
||
const windHr = hasWind ? getDayData(year, month, day, false)[hourIdx % 24] : 0;
|
||
// Raw profile values
|
||
const yearDataProfile = getYearProfile(year, hourly_solar_profile);
|
||
const hourProfile = yearDataProfile.length > hourIdx ? yearDataProfile[hourIdx] : 0;
|
||
const yearDataProfileW = getYearProfile(year, hourly_wind_profile);
|
||
const hourProfileW = yearDataProfileW.length > hourIdx ? yearDataProfileW[hourIdx] : 0;
|
||
// Total RE and Client End
|
||
const yearDataNew = getYearDataNew(year, hourly_total_re);
|
||
const hourDataTotalRe = yearDataNew.length > hourIdx ? yearDataNew[hourIdx] : 0;
|
||
const yearDataClient = getYearDataNew(year, hourly_client_end);
|
||
const hourDataClient = yearDataClient.length > hourIdx ? yearDataClient[hourIdx] : 0;
|
||
const yearDataLoad = getYearDataNew(year, hourly_load);
|
||
const hourDataLoad = yearDataLoad.length > hourIdx ? yearDataLoad[hourIdx] : 0;
|
||
return (
|
||
<div key={h} className="flex items-center gap-2 text-[9px] border-b border-border/30 px-1">
|
||
<span className="w-8 text-muted-foreground">{String(h).padStart(2, '0')}:00</span>
|
||
{/* Solar 8760: raw profile, DC MW, Solar MW */}
|
||
{hasSolar && <span className="text-orange-700 w-14 text-right">{(hourProfile * 100).toFixed(1)}%</span>}
|
||
{hasSolar && <span className="text-orange-600/70 w-10 text-right">{solarDCMW || '-'}</span>}
|
||
{hasSolar && <span className="text-orange-700 w-14 text-right">{Math.round(solarHr)}</span>}
|
||
{/* Wind 8760, MW, Wind MW */}
|
||
{hasWind && <span className="text-blue-700 w-14 text-right">{(hourProfileW * 100).toFixed(1)}%</span>}
|
||
{hasWind && <span className="text-blue-600/70 w-10 text-right">{windMW || '-'}</span>}
|
||
{hasWind && <span className="text-blue-700 w-14 text-right">{Math.round(windHr)}</span>}
|
||
{/* Total RE, Client End, Load */}
|
||
{hasTotalRe && <span className="text-green-700 w-14 text-right">{Math.round(hourDataTotalRe)}</span>}
|
||
{hasClientEnd && <span className="text-purple-700 w-14 text-right">{Math.round(hourDataClient)}</span>}
|
||
{hasLoad && <span className="text-gray-700 w-10 text-right">{Math.round(hourDataLoad)}</span>}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function buildOpexRows(pnl: PnLRow[]): TableRow[] {
|
||
const total = pnl.map((r) => r.om_cr + r.insurance_cr + r.land_lease_cr + r.am_fee_cr + r.misc_opex_cr);
|
||
return [
|
||
{ label: "O&M (₹ Cr)", values: pnl.map((r) => r.om_cr), format: n2 },
|
||
{ label: "Insurance (₹ Cr)", values: pnl.map((r) => r.insurance_cr), format: n2, indent: true },
|
||
{ label: "Land Lease (₹ Cr)", values: pnl.map((r) => r.land_lease_cr), format: n2, indent: true },
|
||
{ label: "AM Fee (₹ Cr)", values: pnl.map((r) => r.am_fee_cr), format: n2, indent: true },
|
||
{ label: "Miscellaneous (₹ Cr)", values: pnl.map((r) => r.misc_opex_cr), format: n2, indent: true },
|
||
{ label: "Total OPEX (₹ Cr)", values: total, isBold: true, format: n2 },
|
||
{ label: "OPEX as % of Revenue", values: pnl.map((r, i) => r.revenue_cr > 0 ? total[i] / r.revenue_cr * 100 : null), format: (v) => v == null ? "—" : `${v.toFixed(1)}%`, indent: true },
|
||
];
|
||
}
|
||
|
||
function IdcSheet({ idc }: { idc: IdcPhasing }) {
|
||
if (!idc?.base_capex_cr) return <div className="text-muted-foreground p-4">No IDC data available</div>;
|
||
|
||
const months = idc.monthly ?? [];
|
||
const nMonths = months.length;
|
||
const nCols = Math.min(nMonths, 24); // Cap at 24 months for display
|
||
|
||
// Build ALL-IN-ONE matrix: both component costs AND funding sources
|
||
const monthlyRate = 1 / nMonths;
|
||
const solarPct = 0.70, windPct = 0.0, landPct = 0.10, epcPct = 0.12, contPct = 0.08;
|
||
|
||
const matrixRows: TableRow[] = [
|
||
// === Component Costs (what's being built) ===
|
||
{ isHeader: true, label: "COMPONENT COSTS", values: [] },
|
||
{ label: "Solar Capex", values: Array(nCols).fill(0).map((_, i) => months[i] ? idc.base_capex_cr * solarPct * monthlyRate : 0) },
|
||
{ label: "Wind Capex", values: Array(nCols).fill(0).map((_, i) => months[i] ? idc.base_capex_cr * windPct * monthlyRate : 0) },
|
||
{ label: "Land & Common", values: Array(nCols).fill(0).map((_, i) => months[i] ? idc.base_capex_cr * landPct * monthlyRate : 0) },
|
||
{ label: "EPC Overhead", values: Array(nCols).fill(0).map((_, i) => months[i] ? idc.base_capex_cr * epcPct * monthlyRate : 0) },
|
||
{ label: "Contingency", values: Array(nCols).fill(0).map((_, i) => months[i] ? idc.base_capex_cr * contPct * monthlyRate : 0) },
|
||
{ isSeparator: true, label: "", values: [] },
|
||
// === Funding Sources (how it's paid) ===
|
||
{ isHeader: true, label: "FUNDING SOURCES", values: [] },
|
||
{ label: "Equity Draw", values: months.slice(0, nCols).map((m) => m.equity_draw_cr) },
|
||
{ label: "Debt Draw", values: months.slice(0, nCols).map((m) => m.debt_draw_cr) },
|
||
{ label: "IDC Interest", values: months.slice(0, nCols).map((m) => m.idc_accrual_cr), indent: true },
|
||
{ isSeparator: true, label: "", values: [] },
|
||
// === Cumulative (running totals) ===
|
||
{ isHeader: true, label: "CUMULATIVE", values: [] },
|
||
{ label: "Cum. Equity", values: months.slice(0, nCols).map((m) => m.cum_equity_cr), indent: true },
|
||
{ label: "Cum. Debt", values: months.slice(0, nCols).map((m) => m.cum_debt_cr), indent: true },
|
||
{ label: "Cum. IDC", values: months.slice(0, nCols).map((m) => m.cum_idc_cr), indent: true },
|
||
{ isSeparator: true, label: "", values: [] },
|
||
{ label: "Total Project Cost", values: months.slice(0, nCols).map((m) => m.cum_tpc_cr), isBold: true },
|
||
];
|
||
|
||
// Compact header
|
||
const fundingMix = [
|
||
{ label: "Base", val: idc.base_capex_cr },
|
||
{ label: "IDC", val: idc.idc_cr },
|
||
{ label: "Total", val: idc.total_capex_cr },
|
||
{ label: "Equity", val: idc.equity_cr },
|
||
{ label: "Debt", val: idc.debt_cr },
|
||
];
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* Header Cards */}
|
||
<div className="grid grid-cols-5 gap-2">
|
||
{fundingMix.map((f) => (
|
||
<div key={f.label} className="border rounded px-3 py-2 text-center">
|
||
<p className="text-[10px] text-muted-foreground">{f.label}</p>
|
||
<p className="text-sm font-bold tabular-nums">₹{f.val.toFixed(0)}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Single ALL-IN-ONE Matrix Table */}
|
||
<div className="border rounded-lg overflow-hidden">
|
||
<div className="bg-muted/50 px-4 py-2 border-b border-border">
|
||
<h3 className="font-semibold text-sm">IDC Construction Phasing Matrix ({nMonths} months)</h3>
|
||
</div>
|
||
<HorizontalTable years={months.slice(0, nCols).map((m) => m.month)} rows={matrixRows} unit="₹ Cr" yearPrefix="M" />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Main workbook
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface Props {
|
||
scenarioId: string;
|
||
kpis: KpiSummary;
|
||
debtScheduleJson: string | null;
|
||
activeSheet: string;
|
||
codYear?: number;
|
||
solarDCMW?: number;
|
||
windMW?: number;
|
||
onNavigate?: (sheet: string) => void;
|
||
}
|
||
|
||
export function WorkbookView({ scenarioId, kpis, debtScheduleJson, activeSheet, codYear, solarDCMW, windMW, onNavigate }: Props) {
|
||
const { data: stmts } = useQuery({
|
||
queryKey: ["statements", scenarioId],
|
||
queryFn: () => getStatements(scenarioId),
|
||
});
|
||
|
||
const debtSchedule: DebtYearRow[] = (() => {
|
||
try {
|
||
const safe = (debtScheduleJson ?? "[]")
|
||
.replace(/:\s*Infinity/g, ": null")
|
||
.replace(/:\s*-Infinity/g, ": null")
|
||
.replace(/:\s*NaN\b/g, ": null");
|
||
return JSON.parse(safe) as DebtYearRow[];
|
||
} catch {
|
||
return [];
|
||
}
|
||
})();
|
||
|
||
const pnl = stmts?.pnl ?? [];
|
||
const cfs = stmts?.cfs ?? [];
|
||
const bs = stmts?.bs ?? [];
|
||
const generation = stmts?.generation ?? [];
|
||
const idcPhasing = stmts?.idc_phasing;
|
||
const years = pnl.map((r) => r.year);
|
||
const debtYears = debtSchedule.map((r) => r.year);
|
||
const genYears = generation.map((r) => r.year);
|
||
|
||
return (
|
||
<div className="space-y-0">
|
||
<p className="text-xs text-muted-foreground mb-4">
|
||
All monetary values in INR Crore unless noted
|
||
</p>
|
||
|
||
{activeSheet === "summary" && <SummarySheet kpis={kpis} scenarioId={scenarioId} onNavigate={onNavigate} />}
|
||
{activeSheet === "pnl" && pnl.length > 0 && (
|
||
<HorizontalTable years={years} rows={buildPnLRows(pnl)} />
|
||
)}
|
||
{activeSheet === "cfs" && cfs.length > 0 && (
|
||
<HorizontalTable years={years} rows={buildCfsRows(cfs, kpis, pnl)} />
|
||
)}
|
||
{activeSheet === "bs" && bs.length > 0 && (
|
||
<HorizontalTable years={years} rows={buildBsRows(bs)} />
|
||
)}
|
||
{activeSheet === "debt" && debtSchedule.length > 0 && (
|
||
<HorizontalTable years={debtYears} rows={buildDebtRows(debtSchedule)} />
|
||
)}
|
||
{activeSheet === "irr" && <IrrSheet kpis={kpis} />}
|
||
{activeSheet === "generation" && (stmts?.solar_hourly || stmts?.wind_hourly || stmts?.hourly_total_re ? (
|
||
<HourlyGenerationSheet hourly={{
|
||
solar_hourly: stmts?.solar_hourly,
|
||
wind_hourly: stmts?.wind_hourly,
|
||
hourly_total_re: stmts?.hourly_total_re,
|
||
hourly_client_end: stmts?.hourly_client_end,
|
||
hourly_load: stmts?.hourly_load,
|
||
hourly_solar_profile: stmts?.hourly_solar_profile,
|
||
hourly_wind_profile: stmts?.hourly_wind_profile,
|
||
}} codYear={codYear} solarDCMW={solarDCMW} windMW={windMW} />
|
||
) : generation.length > 0 ? (
|
||
<HorizontalTable years={genYears} rows={buildGenerationRows(generation)} unit="MWh / ₹Cr" />
|
||
) : (
|
||
<div className="text-muted-foreground p-4">No generation data available</div>
|
||
))}
|
||
{activeSheet === "idc" && idcPhasing && (
|
||
<IdcSheet idc={idcPhasing} />
|
||
)}
|
||
{activeSheet === "opex" && pnl.length > 0 && (
|
||
<HorizontalTable years={years} rows={buildOpexRows(pnl)} unit="INR Cr" />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|