"""Financial summary dashboard tab.""" from __future__ import annotations import streamlit as st from app.components import charts from app.locale import CURRENCY_SYMBOL, currency_fmt, fmt_currency from app.utils import icon from app.views._helpers import report_meta, safe from core.export import build_report_data from core.tei_client import AthenaAPIError, TEIClient def render(client: TEIClient, tool: dict) -> None: st.markdown( f"

{icon('bar-chart-line')} Financial Summary

", unsafe_allow_html=True, ) public_id = tool["id"] report = report_meta(client, tool) try: summary = client.get_summary(public_id) except AthenaAPIError as e: if e.status_code == 404: st.info( "No summary yet — click **Recalculate** in the sidebar after " "filling in benefits and costs." ) return st.error(f"Athena API error: {e.detail}") return npv = float(summary.get("net_present_value") or summary.get("npv") or 0) roi = float( summary.get("roi_percentage") or summary.get("roi") or summary.get("roi_pct") or 0 ) payback = summary.get("payback_period_months", summary.get("payback_months")) bpv = float(summary.get("total_benefits_pv") or 0) cpv = float(summary.get("total_costs_pv") or 0) cols = st.columns(5) cols[0].metric("NPV", f"{CURRENCY_SYMBOL}{npv/1_000_000:,.1f}M") cols[1].metric("ROI", f"{roi:,.0f}%") cols[2].metric( "Payback", f"{float(payback):.1f} months" if payback is not None else "N/A", ) cols[3].metric("Benefits PV", f"{CURRENCY_SYMBOL}{bpv/1_000_000:,.1f}M") cols[4].metric("Costs PV", f"{CURRENCY_SYMBOL}{cpv/1_000_000:,.1f}M") st.divider() # ── Financial visualizations ──────────────────────────────────── # Built from the live value rows so Year-0 "Initial" amounts stay # separate (Athena's per-year summary folds them into Year 1). values = safe(client.get_values, public_id) or [] benefit_rows = [v for v in values if v.get("table") == "benefits"] cost_rows = [v for v in values if v.get("table") == "costs"] if benefit_rows or cost_rows: col_pie, col_bar = st.columns(2) with col_pie: charts.cost_pie(cost_rows, key=f"summary_pie_{public_id}") with col_bar: charts.benefits_bar(benefit_rows, key=f"summary_bar_{public_id}") charts.benefits_vs_costs_by_year( benefit_rows, cost_rows, key=f"summary_by_year_{public_id}" ) # Cash flow + cumulative net — the Forrester-style exhibit. def _yearly_breakdown_from_values(): initial = sum(float(c.get("initial") or 0) for c in cost_rows) years: set[int] = set() for v in [*benefit_rows, *cost_rows]: years.update(int(y) for y in (v.get("year_values") or {})) rows, cumulative = [], -initial for y in sorted(years): b = sum( float((v.get("year_values") or {}).get(str(y), 0) or 0) * (1 - float(v.get("risk_adjustment") or 0)) for v in benefit_rows ) c = sum( float((v.get("year_values") or {}).get(str(y), 0) or 0) for v in cost_rows ) cumulative += b - c rows.append( {"year": y, "benefits": b, "costs": c, "net": b - c, "cumulative_net": cumulative} ) return rows, initial yb, initial = ([], 0.0) if benefit_rows or cost_rows: yb, initial = _yearly_breakdown_from_values() if not yb: # Fallback: documented per-year summary keys (initial folded in Y1). n = 1 while f"benefits_year_{n}" in summary or f"costs_year_{n}" in summary: b = float(summary.get(f"benefits_year_{n}") or 0) c = float(summary.get(f"costs_year_{n}") or 0) yb.append({"year": n, "benefits": b, "costs": c, "net": b - c}) n += 1 initial = float(summary.get("initial_costs") or 0) if yb: charts.cashflow(yb, initial_cost=initial, key=f"summary_cashflow_{public_id}") with st.expander("Cash flow table"): _cur = currency_fmt() st.dataframe( yb, column_config={ "year": st.column_config.NumberColumn("Year", format="%d"), "benefits": st.column_config.NumberColumn("Benefits", format=_cur), "costs": st.column_config.NumberColumn("Costs", format=_cur), "net": st.column_config.NumberColumn("Net", format=_cur), }, width="stretch", hide_index=True, ) else: st.caption("No yearly breakdown in this summary.") # Waterfall — Benefits PV down to NPV. if bpv or cpv: charts.waterfall([ ("Benefits PV", bpv), ("Costs PV", -cpv), ("NPV", npv), ], key=f"summary_waterfall_{public_id}") # Scenario comparison — computed locally from current values with st.expander("Scenario analysis (conservative / moderate / aggressive)"): envelope = safe( build_report_data, client, public_id, include_scenarios=True, study_slug=report.get("name", ""), ) if envelope and envelope.get("scenarios"): charts.scenario_bars( envelope["scenarios"], key=f"summary_scenarios_{public_id}" ) rows = [ { "Scenario": k, "Benefits PV": float(v.get("total_benefits_pv") or 0), "Costs PV": float(v.get("total_costs_pv") or 0), "NPV": float(v.get("npv") or 0), "ROI %": float(v.get("roi_pct") or 0), "Payback (months)": ( round(float(v.get("payback_months") or 0), 1) if v.get("payback_months") is not None else None ), } for k, v in envelope["scenarios"].items() ] _cur = currency_fmt() st.dataframe( rows, column_config={ "Scenario": st.column_config.TextColumn("Scenario"), "Benefits PV": st.column_config.NumberColumn("Benefits PV", format=_cur), "Costs PV": st.column_config.NumberColumn("Costs PV", format=_cur), "NPV": st.column_config.NumberColumn("NPV", format=_cur), "ROI %": st.column_config.NumberColumn("ROI %", format="%.1f%%"), "Payback (months)": st.column_config.NumberColumn( "Payback (months)", format="%.1f" ), }, width="stretch", hide_index=True, ) # Export button st.divider() if st.button("Build export envelope (JSON)"): envelope = safe( build_report_data, client, public_id, include_scenarios=True, study_slug=report.get("name", ""), ) if envelope: import json data = json.dumps(envelope, indent=2, default=str) st.download_button( "Download export.json", data=data, file_name=f"{public_id}_export.json", mime="application/json", )