Remodel/packages/web/components/WorkbookView.tsx
Mannu 093e62b011
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 25-year hourly generation data with expandable drill-down
- Engine: generate all 25 years × 8760 hours of hourly generation
- Schema: add solar_hourly and wind_hourly fields to ScenarioResult
- API: expose hourly data in statements endpoint
- UI: new HourlyGenerationSheet with Year → Month → Day → Hour drill-down
- Add TYPEOF for hourly generation in web API types

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 10:41:25 +05:30

864 lines
37 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[];
}
function HourlyGenerationSheet({ hourly }: { hourly: HourlyData }) {
const { solar_hourly, wind_hourly } = hourly;
const hasSolar = solar_hourly && solar_hourly.length > 0;
const hasWind = wind_hourly && wind_hourly.length > 0;
const hasData = hasSolar || hasWind;
// 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);
};
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">
{hasSolar && <span className="text-orange-600"> Solar</span>}
{hasWind && <span className="text-blue-600"> Wind</span>}
<span className="text-muted-foreground/60">Click to expand: Year Month Day Hour</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;
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-12">Y{year}</span>
{hasSolar && <span className="text-orange-700 w-24">{Math.round(solarYr).toLocaleString()} MWh</span>}
{hasWind && <span className="text-blue-700 w-24">{Math.round(windYr).toLocaleString()} MWh</span>}
</button>
{/* Month rows (expandable) */}
{isYearExpanded && [...Array(12)].map((_, mi) => {
const month = mi + 1;
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 daysInMonth = MONTH_DAYS[month - 1];
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-12 text-muted-foreground">M{month}</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>}
</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;
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">D{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>}
</button>
{/* Hour values */}
{isDayExpanded && (
<div className="ml-8 flex flex-wrap gap-0.5 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;
return (
<div key={h} className="w-10 text-center text-[9px] border rounded px-0.5">
<span className="text-muted-foreground">{h}:00</span>
{hasSolar && <div className="text-orange-700">{Math.round(solarHr)}</div>}
{hasWind && <div className="text-blue-700">{Math.round(windHr)}</div>}
</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;
}
export function WorkbookView({ scenarioId, kpis, debtScheduleJson, activeSheet }: 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 ? (
<HourlyGenerationSheet hourly={{ solar_hourly: stmts?.solar_hourly, wind_hourly: stmts?.wind_hourly }} />
) : 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>
);
}