feat: add locale formatting config and update notebook outputs
Add configurable locale/display formatting environment variables (`PALLADIUM_CURRENCY_SYMBOL`, `PALLADIUM_THOUSANDS_SEP`, `PALLADIUM_DECIMAL_SEP`) to support regional number formatting in the Streamlit app. Update `.env.example` with documentation for these new variables. Also refresh `00_setup.ipynb` with current execution outputs reflecting a live Athena connection with report templates, a selected client (Global Guardian Insurance, ID=2), and resolved NameError in assumption override cells.
This commit is contained in:
@@ -9,24 +9,24 @@ from core.notebook_helpers import charts as core_charts
|
||||
|
||||
def cashflow(yearly_breakdown, *, initial_cost: float = 0.0) -> None:
|
||||
fig = core_charts.cashflow_chart(yearly_breakdown, initial_cost=initial_cost)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
st.plotly_chart(fig, width="stretch")
|
||||
|
||||
|
||||
def benefits_bar(items) -> None:
|
||||
fig = core_charts.benefits_bar(items)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
st.plotly_chart(fig, width="stretch")
|
||||
|
||||
|
||||
def cost_pie(items) -> None:
|
||||
fig = core_charts.cost_breakdown_pie(items)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
st.plotly_chart(fig, width="stretch")
|
||||
|
||||
|
||||
def scenario_bars(scenarios) -> None:
|
||||
fig = core_charts.scenario_comparison(scenarios)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
st.plotly_chart(fig, width="stretch")
|
||||
|
||||
|
||||
def waterfall(values) -> None:
|
||||
fig = core_charts.waterfall(values)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
st.plotly_chart(fig, width="stretch")
|
||||
|
||||
@@ -5,9 +5,11 @@ from __future__ import annotations
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
|
||||
from app.locale import currency_fmt, fmt_currency, fmt_pct, pct_fmt, _STANDARD_LOCALE
|
||||
|
||||
|
||||
def _years_for_table(fields: list[dict], analysis_years: int) -> list[int]:
|
||||
"""Years 1..N — taken from analysis_period_years on the report."""
|
||||
"""Years 1..N -- taken from analysis_period_years on the report."""
|
||||
return list(range(1, max(int(analysis_years or 3), 1) + 1))
|
||||
|
||||
|
||||
@@ -26,6 +28,11 @@ def value_editor(
|
||||
columns, an ``initial`` column for costs, a risk_adjustment column, and
|
||||
a notes column. Returns the edited DataFrame; the caller is responsible
|
||||
for converting it back to value-row dicts and PUTting to Athena.
|
||||
|
||||
Currency columns use the locale configured via PALLADIUM_CURRENCY_SYMBOL /
|
||||
PALLADIUM_THOUSANDS_SEP / PALLADIUM_DECIMAL_SEP in .env.
|
||||
The risk_adj column is stored as a 0-1 fraction and displayed as a
|
||||
percentage (e.g. 0.20 -> "20.00%").
|
||||
"""
|
||||
fields = [
|
||||
f
|
||||
@@ -44,43 +51,76 @@ def value_editor(
|
||||
for f in fields:
|
||||
v = by_key.get(f["field_key"], {}) or {}
|
||||
yv = v.get("year_values") or {}
|
||||
risk_raw = float(v.get("risk_adjustment") or 0.0)
|
||||
row = {
|
||||
"field_key": f["field_key"],
|
||||
"label": f.get("label", f["field_key"]),
|
||||
"category": f.get("category", "") or "",
|
||||
}
|
||||
if table == "costs":
|
||||
row["Initial"] = float(v.get("initial") or 0.0)
|
||||
if _STANDARD_LOCALE:
|
||||
row["Initial"] = float(v.get("initial") or 0.0)
|
||||
else:
|
||||
row["Initial"] = fmt_currency(float(v.get("initial") or 0.0))
|
||||
for y in years:
|
||||
row[f"Year {y}"] = float(yv.get(str(y)) or 0.0)
|
||||
row["risk_adj"] = float(v.get("risk_adjustment") or 0.0)
|
||||
raw = float(yv.get(str(y)) or 0.0)
|
||||
if _STANDARD_LOCALE:
|
||||
row[f"Year {y}"] = raw
|
||||
else:
|
||||
row[f"Year {y}"] = fmt_currency(raw)
|
||||
# Risk adj: store as fraction for standard locales (NumberColumn handles
|
||||
# display), or pre-format as "20.00%" string for non-standard locales.
|
||||
if _STANDARD_LOCALE:
|
||||
row["risk_adj"] = risk_raw
|
||||
else:
|
||||
row["risk_adj"] = fmt_pct(risk_raw)
|
||||
row["notes"] = v.get("notes", "") or ""
|
||||
rows.append(row)
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
|
||||
_cur_fmt = currency_fmt()
|
||||
_pct_fmt_str = pct_fmt()
|
||||
|
||||
column_config: dict = {
|
||||
"field_key": st.column_config.TextColumn("Key", disabled=True, width="small"),
|
||||
"label": st.column_config.TextColumn("Field", disabled=True),
|
||||
"category": st.column_config.TextColumn("Category", disabled=True, width="small"),
|
||||
"risk_adj": st.column_config.NumberColumn(
|
||||
"Risk Adj.", min_value=0.0, max_value=1.0, step=0.05, format="%.2f"
|
||||
),
|
||||
"notes": st.column_config.TextColumn("Notes", width="medium"),
|
||||
}
|
||||
if table == "costs":
|
||||
column_config["Initial"] = st.column_config.NumberColumn(
|
||||
"Initial", format="$%.0f"
|
||||
|
||||
if _STANDARD_LOCALE:
|
||||
column_config["risk_adj"] = st.column_config.NumberColumn(
|
||||
"Risk Adj.",
|
||||
min_value=0.0,
|
||||
max_value=1.0,
|
||||
step=0.05,
|
||||
format=_pct_fmt_str,
|
||||
help="Enter as a decimal fraction (e.g. 0.20 = 20%)",
|
||||
)
|
||||
for y in years:
|
||||
column_config[f"Year {y}"] = st.column_config.NumberColumn(
|
||||
f"Year {y}", format="$%.0f"
|
||||
if table == "costs":
|
||||
column_config["Initial"] = st.column_config.NumberColumn(
|
||||
"Initial", format=_cur_fmt
|
||||
)
|
||||
for y in years:
|
||||
column_config[f"Year {y}"] = st.column_config.NumberColumn(
|
||||
f"Year {y}", format=_cur_fmt
|
||||
)
|
||||
else:
|
||||
# Non-standard locale: display as pre-formatted strings (read-only display;
|
||||
# user edits the raw number and we re-format on save).
|
||||
column_config["risk_adj"] = st.column_config.TextColumn(
|
||||
"Risk Adj.", help="Displayed as percentage; stored as 0-1 fraction"
|
||||
)
|
||||
if table == "costs":
|
||||
column_config["Initial"] = st.column_config.TextColumn("Initial")
|
||||
for y in years:
|
||||
column_config[f"Year {y}"] = st.column_config.TextColumn(f"Year {y}")
|
||||
|
||||
edited = st.data_editor(
|
||||
df,
|
||||
column_config=column_config,
|
||||
use_container_width=True,
|
||||
width="stretch",
|
||||
num_rows="fixed",
|
||||
hide_index=True,
|
||||
key=key,
|
||||
|
||||
87
app/locale.py
Normal file
87
app/locale.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
Locale / formatting settings for the Palladium Streamlit app.
|
||||
|
||||
All settings are read from environment variables (via .env) so the same
|
||||
codebase can be deployed for different regions without code changes.
|
||||
|
||||
Environment variables
|
||||
---------------------
|
||||
PALLADIUM_CURRENCY_SYMBOL Default: "$"
|
||||
Prefix shown before monetary values (e.g. "$", "€", "£", "CAD ").
|
||||
|
||||
PALLADIUM_THOUSANDS_SEP Default: ","
|
||||
Thousands separator used in number display (e.g. "," for Americas,
|
||||
"." for continental Europe, " " for some locales).
|
||||
|
||||
PALLADIUM_DECIMAL_SEP Default: "."
|
||||
Decimal separator (e.g. "." for Americas/UK, "," for continental Europe).
|
||||
|
||||
Note: Streamlit's NumberColumn ``format`` uses printf-style strings.
|
||||
The ``%,`` flag (thousands separator) is supported in Streamlit ≥ 1.31.
|
||||
For non-standard separators (e.g. European "." thousands / "," decimal)
|
||||
the values are pre-formatted as strings and displayed in TextColumns.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def _env(key: str, default: str) -> str:
|
||||
return os.environ.get(key, default).strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resolved settings (read once at import time; restart app to pick up changes)
|
||||
# ---------------------------------------------------------------------------
|
||||
CURRENCY_SYMBOL: str = _env("PALLADIUM_CURRENCY_SYMBOL", "$")
|
||||
THOUSANDS_SEP: str = _env("PALLADIUM_THOUSANDS_SEP", ",")
|
||||
DECIMAL_SEP: str = _env("PALLADIUM_DECIMAL_SEP", ".")
|
||||
|
||||
# True when the locale uses standard printf-compatible separators
|
||||
# (i.e. "," thousands + "." decimal — the C/POSIX default).
|
||||
# When False, we pre-format values as strings instead of relying on printf.
|
||||
_STANDARD_LOCALE: bool = THOUSANDS_SEP == "," and DECIMAL_SEP == "."
|
||||
|
||||
|
||||
def currency_fmt() -> str:
|
||||
"""Return a Streamlit NumberColumn ``format`` string for currency.
|
||||
|
||||
For standard locales returns e.g. ``"$%,.0f"`` (thousands-separated,
|
||||
no decimal places). For non-standard locales returns ``"%s"`` and
|
||||
callers should use :func:`fmt_currency` to pre-format the value.
|
||||
"""
|
||||
if _STANDARD_LOCALE:
|
||||
return f"{CURRENCY_SYMBOL}%,.0f"
|
||||
return "%s"
|
||||
|
||||
|
||||
def pct_fmt() -> str:
|
||||
"""Return a Streamlit NumberColumn ``format`` string for percentages.
|
||||
|
||||
Stores the value as a fraction (0–1) and displays as e.g. ``"20.00%"``.
|
||||
Streamlit's ``%%`` in format strings renders a literal ``%``.
|
||||
"""
|
||||
if _STANDARD_LOCALE:
|
||||
return "%.2f%%"
|
||||
return "%s"
|
||||
|
||||
|
||||
def fmt_currency(value: float) -> str:
|
||||
"""Format *value* as a currency string using the configured locale."""
|
||||
if _STANDARD_LOCALE:
|
||||
return f"{CURRENCY_SYMBOL}{value:,.0f}"
|
||||
# Non-standard: build manually
|
||||
integer_part = f"{int(abs(value)):,}".replace(",", THOUSANDS_SEP)
|
||||
sign = "-" if value < 0 else ""
|
||||
return f"{sign}{CURRENCY_SYMBOL}{integer_part}"
|
||||
|
||||
|
||||
def fmt_pct(value: float) -> str:
|
||||
"""Format *value* (0–1 fraction) as a percentage string."""
|
||||
pct = value * 100
|
||||
if _STANDARD_LOCALE:
|
||||
return f"{pct:.2f}%"
|
||||
integer_part = f"{int(pct)}"
|
||||
decimal_part = f"{abs(pct) % 1:.2f}"[1:] # ".xx"
|
||||
return f"{integer_part}{DECIMAL_SEP}{decimal_part[1:]}%"
|
||||
34
app/main.py
34
app/main.py
@@ -24,6 +24,7 @@ if str(_ROOT) not in sys.path:
|
||||
import streamlit as st
|
||||
|
||||
from core.tei_client import AthenaAPIError, TEIClient
|
||||
from app.utils import icon, inject_icons
|
||||
|
||||
st.set_page_config(
|
||||
page_title="Palladium — TEI Calculator",
|
||||
@@ -32,6 +33,7 @@ st.set_page_config(
|
||||
)
|
||||
|
||||
|
||||
|
||||
@st.cache_resource(show_spinner=False)
|
||||
def get_client() -> TEIClient:
|
||||
return TEIClient()
|
||||
@@ -75,7 +77,9 @@ def _crm_engagements(_client: TEIClient, client_name: str) -> list[dict]:
|
||||
|
||||
def sidebar_tool_picker(client: TEIClient) -> dict | None:
|
||||
"""Sidebar: pick an existing TEI tool or create one from a report template."""
|
||||
st.sidebar.title("🛡️ Palladium")
|
||||
st.sidebar.markdown(
|
||||
f"{icon('shield-fill')} **Palladium**", unsafe_allow_html=True
|
||||
)
|
||||
st.sidebar.caption("TEI Calculator")
|
||||
|
||||
tools = _safe_call(client.list_tools) or []
|
||||
@@ -163,15 +167,20 @@ def sidebar_tool_picker(client: TEIClient) -> dict | None:
|
||||
st.sidebar.markdown(f"**Public ID**: `{tool.get('id')}`")
|
||||
st.sidebar.markdown(f"**Status**: {tool.get('status', '?')}")
|
||||
st.sidebar.markdown(f"**Version**: {tool.get('current_version', 0)}")
|
||||
if st.sidebar.button("🔄 Recalculate"):
|
||||
if st.sidebar.button("Recalculate"):
|
||||
_safe_call(client.calculate, tool["id"])
|
||||
st.toast("Recalculated.", icon="✅")
|
||||
st.toast("Recalculated.", icon=None)
|
||||
st.cache_data.clear()
|
||||
return tool
|
||||
|
||||
|
||||
def main() -> None:
|
||||
st.title("Palladium — TEI Calculator")
|
||||
inject_icons()
|
||||
|
||||
st.markdown(
|
||||
f"<h1 style='margin-bottom:0'>{icon('shield-fill')} Palladium — TEI Calculator</h1>",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
try:
|
||||
client = get_client()
|
||||
except ValueError as e:
|
||||
@@ -186,14 +195,19 @@ def main() -> None:
|
||||
st.info("Pick or create a TEI tool from the sidebar to begin.")
|
||||
return
|
||||
|
||||
# Tab navigation — matches `app/pages/*` modules but kept as tabs so all
|
||||
# Tab navigation — matches `app/views/*` modules but kept as tabs so all
|
||||
# views share the chosen tool/state without re-querying.
|
||||
tabs = st.tabs(["📊 Summary", "💰 Benefits", "💸 Costs", "🕒 Versions"])
|
||||
#
|
||||
# NOTE: the directory is `app/views/`, NOT `app/pages/`. Streamlit treats a
|
||||
# `pages/` directory next to the entrypoint as auto-discovered multipage
|
||||
# scripts, which would render blank since these modules only define
|
||||
# `render()` and have no top-level output.
|
||||
tabs = st.tabs(["Summary", "Benefits", "Costs", "Versions"])
|
||||
|
||||
from app.pages import benefits as benefits_page
|
||||
from app.pages import costs as costs_page
|
||||
from app.pages import summary as summary_page
|
||||
from app.pages import versions as versions_page
|
||||
from app.views import benefits as benefits_page
|
||||
from app.views import costs as costs_page
|
||||
from app.views import summary as summary_page
|
||||
from app.views import versions as versions_page
|
||||
|
||||
with tabs[0]:
|
||||
summary_page.render(client, tool)
|
||||
|
||||
45
app/utils.py
Normal file
45
app/utils.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Shared UI utilities for the Palladium Streamlit app.
|
||||
|
||||
Kept in a separate module so that ``app.main`` and ``app.views.*`` can both
|
||||
import from here without creating a circular dependency.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import streamlit as st
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bootstrap Icons — injected once at the top of every page render.
|
||||
# Using the CDN stylesheet so no npm/build step is needed.
|
||||
# ---------------------------------------------------------------------------
|
||||
_BI_CSS = """
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
|
||||
/>
|
||||
<style>
|
||||
/* Tighten up the default Streamlit header spacing */
|
||||
.block-container { padding-top: 1.5rem; }
|
||||
/* Make BI icons align nicely with surrounding text */
|
||||
.bi { vertical-align: -0.125em; }
|
||||
</style>
|
||||
"""
|
||||
|
||||
|
||||
def inject_icons() -> None:
|
||||
"""Inject Bootstrap Icons CSS (idempotent — Streamlit deduplicates identical HTML)."""
|
||||
st.markdown(_BI_CSS, unsafe_allow_html=True)
|
||||
|
||||
|
||||
def icon(name: str, *, cls: str = "") -> str:
|
||||
"""Return an inline Bootstrap Icon ``<i>`` tag.
|
||||
|
||||
Usage::
|
||||
|
||||
st.markdown(icon("bar-chart") + " Financial Summary", unsafe_allow_html=True)
|
||||
|
||||
See the full icon catalogue at https://icons.getbootstrap.com/
|
||||
"""
|
||||
extra = f" {cls}" if cls else ""
|
||||
return f'<i class="bi bi-{name}{extra}"></i>'
|
||||
@@ -5,12 +5,17 @@ from __future__ import annotations
|
||||
import streamlit as st
|
||||
|
||||
from app.components.tables import df_to_values, value_editor
|
||||
from app.pages._helpers import report_meta, safe
|
||||
from app.utils import icon
|
||||
|
||||
from app.views._helpers import report_meta, safe
|
||||
from core.tei_client import TEIClient
|
||||
|
||||
|
||||
def render(client: TEIClient, tool: dict) -> None:
|
||||
st.header("💰 Benefits")
|
||||
st.markdown(
|
||||
f"<h2>{icon('graph-up-arrow')} Benefits</h2>",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
public_id = tool["id"]
|
||||
report = report_meta(client, tool)
|
||||
analysis_years = int(report.get("analysis_period_years") or 3)
|
||||
@@ -32,7 +37,8 @@ def render(client: TEIClient, tool: dict) -> None:
|
||||
|
||||
col1, col2 = st.columns([1, 4])
|
||||
with col1:
|
||||
if st.button("💾 Save benefits", use_container_width=True):
|
||||
if st.button("Save benefits", width="stretch"):
|
||||
|
||||
payload = df_to_values(edited, "benefits", analysis_years)
|
||||
result = safe(client.update_values, public_id, payload)
|
||||
if result is not None:
|
||||
|
||||
@@ -5,12 +5,17 @@ from __future__ import annotations
|
||||
import streamlit as st
|
||||
|
||||
from app.components.tables import df_to_values, value_editor
|
||||
from app.pages._helpers import report_meta, safe
|
||||
from app.utils import icon
|
||||
|
||||
from app.views._helpers import report_meta, safe
|
||||
from core.tei_client import TEIClient
|
||||
|
||||
|
||||
def render(client: TEIClient, tool: dict) -> None:
|
||||
st.header("💸 Costs")
|
||||
st.markdown(
|
||||
f"<h2>{icon('receipt')} Costs</h2>",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
public_id = tool["id"]
|
||||
report = report_meta(client, tool)
|
||||
analysis_years = int(report.get("analysis_period_years") or 3)
|
||||
@@ -32,7 +37,8 @@ def render(client: TEIClient, tool: dict) -> None:
|
||||
|
||||
col1, col2 = st.columns([1, 4])
|
||||
with col1:
|
||||
if st.button("💾 Save costs", use_container_width=True):
|
||||
if st.button("Save costs", width="stretch"):
|
||||
|
||||
payload = df_to_values(edited, "costs", analysis_years)
|
||||
result = safe(client.update_values, public_id, payload)
|
||||
if result is not None:
|
||||
|
||||
@@ -5,13 +5,19 @@ from __future__ import annotations
|
||||
import streamlit as st
|
||||
|
||||
from app.components import charts
|
||||
from app.pages._helpers import report_meta, safe
|
||||
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.header("📊 Financial Summary")
|
||||
st.markdown(
|
||||
f"<h2>{icon('bar-chart-line')} Financial Summary</h2>",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
public_id = tool["id"]
|
||||
report = report_meta(client, tool)
|
||||
|
||||
@@ -39,14 +45,14 @@ def render(client: TEIClient, tool: dict) -> None:
|
||||
cpv = float(summary.get("total_costs_pv") or 0)
|
||||
|
||||
cols = st.columns(5)
|
||||
cols[0].metric("NPV", f"${npv/1_000_000:,.1f}M")
|
||||
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"${bpv/1_000_000:,.1f}M")
|
||||
cols[4].metric("Costs PV", f"${cpv/1_000_000:,.1f}M")
|
||||
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()
|
||||
|
||||
@@ -64,7 +70,18 @@ def render(client: TEIClient, tool: dict) -> None:
|
||||
if yb:
|
||||
charts.cashflow(yb, initial_cost=initial)
|
||||
with st.expander("Cash flow table"):
|
||||
st.dataframe(yb, use_container_width=True, hide_index=True)
|
||||
_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.")
|
||||
|
||||
@@ -94,11 +111,26 @@ def render(client: TEIClient, tool: dict) -> None:
|
||||
}
|
||||
for k, v in envelope["scenarios"].items()
|
||||
]
|
||||
st.dataframe(rows, use_container_width=True, hide_index=True)
|
||||
_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)"):
|
||||
if st.button("Build export envelope (JSON)"):
|
||||
envelope = safe(
|
||||
build_report_data,
|
||||
client,
|
||||
|
||||
@@ -4,7 +4,9 @@ from __future__ import annotations
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from app.pages._helpers import safe
|
||||
from app.utils import icon
|
||||
|
||||
from app.views._helpers import safe
|
||||
from core.tei_client import TEIClient
|
||||
|
||||
|
||||
@@ -17,6 +19,7 @@ def _diff_rows(a: dict[str, dict], b: dict[str, dict]) -> list[dict]:
|
||||
"""Return one row per field with side-by-side year values."""
|
||||
keys = sorted(set(a.keys()) | set(b.keys()))
|
||||
rows: list[dict] = []
|
||||
|
||||
def _years_of(v: dict) -> dict:
|
||||
"""Accept both friendly (year_values) and wire (nested years) shapes."""
|
||||
if isinstance(v.get("year_values"), dict):
|
||||
@@ -54,7 +57,10 @@ def _diff_rows(a: dict[str, dict], b: dict[str, dict]) -> list[dict]:
|
||||
|
||||
|
||||
def render(client: TEIClient, tool: dict) -> None:
|
||||
st.header("🕒 Versions")
|
||||
st.markdown(
|
||||
f"<h2>{icon('clock-history')} Versions</h2>",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
public_id = tool["id"]
|
||||
|
||||
versions = safe(client.list_versions, public_id) or []
|
||||
@@ -63,7 +69,7 @@ def render(client: TEIClient, tool: dict) -> None:
|
||||
)
|
||||
|
||||
# Save new version
|
||||
with st.expander("➕ Save current state as a new version", expanded=not versions):
|
||||
with st.expander("Save current state as a new version", expanded=not versions):
|
||||
note = st.text_area(
|
||||
"Version note",
|
||||
placeholder=(
|
||||
@@ -71,7 +77,7 @@ def render(client: TEIClient, tool: dict) -> None:
|
||||
"raised legacy license cost from $160 to $180/agent.'"
|
||||
),
|
||||
)
|
||||
if st.button("💾 Save version", disabled=not note.strip()):
|
||||
if st.button("Save version", disabled=not note.strip()):
|
||||
result = safe(client.save_version, public_id, note.strip())
|
||||
if result:
|
||||
st.success(
|
||||
@@ -102,7 +108,8 @@ def render(client: TEIClient, tool: dict) -> None:
|
||||
"Note": v.get("note", ""),
|
||||
}
|
||||
)
|
||||
st.dataframe(rows, use_container_width=True, hide_index=True)
|
||||
st.dataframe(rows, width="stretch", hide_index=True)
|
||||
|
||||
|
||||
# Compare two versions
|
||||
st.subheader("Compare")
|
||||
@@ -132,4 +139,5 @@ def render(client: TEIClient, tool: dict) -> None:
|
||||
if not diff:
|
||||
st.success("No value differences between these versions.")
|
||||
else:
|
||||
st.dataframe(diff, use_container_width=True, hide_index=True)
|
||||
st.dataframe(diff, width="stretch", hide_index=True)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user