Remodel/packages/web/components/ScenarioCompare.tsx
Mannu e6dc39aa33 [S1-T12/T13] P&L revenue breakdown + collapsible rows + UI polish
- Engine: Add ppa_revenue_cr, mcp_revenue_cr, tariff, units to PnLRow
- Engine: Split PPA vs MCP revenue in P&L computation
- Web: Collapsible rows for PPA/MCP Revenue and Opex
- Web: Highlighted rows (Total Revenue, EBITDA, EBIT, PBT, PAT)
- Web: Units above Tariff in breakdown, bg-blue-50 highlight
- Fix sticky column z-index for horizontal scroll
- CLAUDE.md: Add project documentation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 10:42:36 +05:30

119 lines
3.7 KiB
TypeScript

"use client";
import { useQuery } from "@tanstack/react-query";
import { getKpis, type KpiSummary } from "@/lib/api";
interface Props {
scenarioIds: string[];
scenarioNames: Record<string, string>;
}
const KPI_LABELS: { key: keyof KpiSummary; label: string; format: "pct" | "num" | "inr" }[] = [
{ key: "solved_tariff_inr_per_kwh", label: "Tariff (₹/kWh)", format: "inr" },
{ key: "equity_irr", label: "Equity IRR", format: "pct" },
{ key: "project_irr", label: "Project IRR", format: "pct" },
{ key: "min_dscr", label: "Min DSCR", format: "num" },
{ key: "avg_dscr", label: "Avg DSCR", format: "num" },
{ key: "total_capex_cr", label: "Total Capex (Cr)", format: "num" },
{ key: "lcoe_inr_per_kwh", label: "LCOE (₹/kWh)", format: "inr" },
{ key: "payback_years", label: "Payback (yrs)", format: "num" },
{ key: "solar_y1_cuf", label: "Solar Y1 CUF", format: "pct" },
{ key: "wind_y1_plf", label: "Wind Y1 PLF", format: "pct" },
{ key: "rtc_cuf_achieved", label: "RTC CUF", format: "pct" },
{ key: "total_shortfall_mwh", label: "Shortfall (MWh)", format: "num" },
{ key: "total_mcp_revenue_cr", label: "MCP Revenue (Cr)", format: "num" },
];
function fmtCell(v: number | null | undefined, format: "pct" | "num" | "inr"): string {
if (v == null) return "—";
if (format === "pct") return `${(v * 100).toFixed(1)}%`;
if (format === "inr") return `${v.toFixed(2)}`;
return v.toFixed(2);
}
function KpiRow({
rowKey,
label,
format,
kpiMap,
ids,
}: {
rowKey: keyof KpiSummary;
label: string;
format: "pct" | "num" | "inr";
kpiMap: Record<string, KpiSummary>;
ids: string[];
}) {
const values = ids.map((id) => kpiMap[id]?.[rowKey] as number | null | undefined);
const nums = values.filter((v): v is number => v != null);
const best = nums.length > 0 ? Math.max(...nums) : null;
return (
<tr className="border-t">
<td className="px-3 py-1.5 text-sm font-medium text-muted-foreground">{label}</td>
{ids.map((id, i) => {
const v = values[i];
const isBest = v != null && v === best && nums.length > 1;
return (
<td
key={id}
className={`px-3 py-1.5 text-sm tabular-nums text-right ${isBest ? "font-semibold text-green-700" : ""}`}
>
{fmtCell(v, format)}
</td>
);
})}
</tr>
);
}
export function ScenarioCompare({ scenarioIds, scenarioNames }: Props) {
const queries = scenarioIds.map((id) =>
// eslint-disable-next-line react-hooks/rules-of-hooks
useQuery({
queryKey: ["kpis", id],
queryFn: () => getKpis(id),
})
);
const kpiMap: Record<string, KpiSummary> = {};
for (let i = 0; i < scenarioIds.length; i++) {
const data = queries[i].data;
if (data) kpiMap[scenarioIds[i]] = data;
}
const isLoading = queries.some((q) => q.isLoading);
if (isLoading) {
return <div className="text-muted-foreground text-sm py-4">Loading comparison</div>;
}
return (
<div className="overflow-x-auto border rounded">
<table className="w-full text-xs">
<thead className="bg-muted/50">
<tr>
<th className="px-3 py-2 text-left font-medium">KPI</th>
{scenarioIds.map((id) => (
<th key={id} className="px-3 py-2 text-right font-medium">
{scenarioNames[id] ?? id.slice(0, 8)}
</th>
))}
</tr>
</thead>
<tbody>
{KPI_LABELS.map(({ key, label, format }) => (
<KpiRow
key={key}
rowKey={key}
label={label}
format={format}
kpiMap={kpiMap}
ids={scenarioIds}
/>
))}
</tbody>
</table>
</div>
);
}