Remodel/packages/web/components/WorkbookView.tsx
Mannu dba1e6990f
Some checks are pending
CI / Engine — lint / typecheck / test (push) Waiting to run
CI / API — lint / typecheck / test (push) Waiting to run
CI / Web — typecheck / lint / build (push) Waiting to run
feat: add profile data columns to generation sheet for detailed breakdown
- Add hourly_solar_profile and hourly_wind_profile to store per-hour output values
- Add solar_dc_mwp, solar_ac_mw, wind_mw to PipelineResult for capacity display
- Add columns: Solar 8760, DC MW, Solar MW / Wind 8760, MW, Wind MW
- Updated UI to show detailed breakdown at each level (Year/Month/Day/Hour)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 13:31:20 +05:30

996 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

"use client";
import { useState } from "react";
import { 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 }: { kpis: KpiSummary; scenarioId: string }) {
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
/>
<KpiCard
label="Equity IRR"
value={kpis.equity_irr != null ? `${(kpis.equity_irr * 100).toFixed(1)}%` : null}
highlight
/>
<KpiCard label="Project IRR" value={kpis.project_irr != null ? `${(kpis.project_irr * 100).toFixed(1)}%` : null} />
<KpiCard label="Min DSCR" value={kpis.min_dscr?.toFixed(2) ?? null} />
<KpiCard label="Avg DSCR" value={kpis.avg_dscr?.toFixed(2) ?? null} />
<KpiCard label="Total Capex" value={kpis.total_capex_cr != null ? kpis.total_capex_cr.toFixed(1) : null} unit="Cr" />
<KpiCard label="Debt" value={kpis.debt_cr?.toFixed(1) ?? null} unit="Cr" />
<KpiCard label="IDC" value={kpis.idc_cr?.toFixed(1) ?? null} unit="Cr" />
<KpiCard label="LCOE" value={kpis.lcoe_inr_per_kwh?.toFixed(2) ?? null} unit="₹/kWh" />
<KpiCard label="Payback" value={kpis.payback_years?.toFixed(1) ?? null} unit="yrs" />
{kpis.solar_y1_cuf != null && (
<KpiCard label="Solar Y1 CUF" value={`${(kpis.solar_y1_cuf * 100).toFixed(1)}%`} />
)}
{kpis.wind_y1_plf != null && (
<KpiCard label="Wind Y1 PLF" value={`${(kpis.wind_y1_plf * 100).toFixed(1)}%`} />
)}
{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 }: { hourly: HourlyData; codYear?: 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 data for a specific year/month/day
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);
};
// 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;
// For profile totals - sum of hourly profile values = total energy before capacity division
const solarProfileSum = hasSolarProfile ? computeYearTotalNew(year, hourly_solar_profile) : 0;
const windProfileSum = hasWindProfile ? computeYearTotalNew(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>
{hasSolar && <span className="text-orange-700 w-20">{Math.round(solarProfileSum).toLocaleString()}</span>}
{hasSolar && <span className="text-orange-600/70 w-16">-</span>}
{hasSolar && <span className="text-orange-700 w-20">{Math.round(solarYr).toLocaleString()}</span>}
{hasWind && <span className="text-blue-700 w-20">{Math.round(windProfileSum).toLocaleString()}</span>}
{hasWind && <span className="text-blue-600/70 w-16">-</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;
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>
{hasSolar && <span className="text-orange-700/80 w-24">{Math.round(solarMo).toLocaleString()} MWh</span>}
{hasWind && <span className="text-blue-700/80 w-24">{Math.round(windMo).toLocaleString()} MWh</span>}
{hasTotalRe && <span className="text-green-700/80 w-24">{Math.round(totalReMo).toLocaleString()} MWh</span>}
{hasClientEnd && <span className="text-purple-700/80 w-24">{Math.round(clientEndMo).toLocaleString()} MWh</span>}
{hasLoad && <span className="text-gray-700/80 w-20">{Math.round(loadMo).toLocaleString()} MWh</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;
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>
{hasSolar && <span className="text-orange-600/70 w-20">{Math.round(solarDy).toLocaleString()}</span>}
{hasWind && <span className="text-blue-600/70 w-20">{Math.round(windDy).toLocaleString()}</span>}
{hasTotalRe && <span className="text-green-600/70 w-20">{Math.round(totalReDy).toLocaleString()}</span>}
{hasClientEnd && <span className="text-purple-600/70 w-20">{Math.round(clientEndDy).toLocaleString()}</span>}
{hasLoad && <span className="text-gray-600/70 w-16">{Math.round(loadDy).toLocaleString()}</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;
// Get new column data by hour index
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>
{hasSolar && <span className="text-orange-700 w-14 text-right">{Math.round(solarHr)}</span>}
{hasWind && <span className="text-blue-700 w-14 text-right">{Math.round(windHr)}</span>}
{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-12 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;
}
export function WorkbookView({ scenarioId, kpis, debtScheduleJson, activeSheet, codYear }: 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} />}
{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} />
) : 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>
);
}