"
+ ],
+ "text/plain": [
+ " id name vendor version status \\\n",
+ "0 xsUTbjh4iDnJ Amazon Connect 2026 AWS 1.0 active \n",
+ "\n",
+ " analysis_period_years discount_rate field_count instance_count \n",
+ "0 3 0.1000 11 0 "
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
}
],
"source": [
@@ -172,7 +225,7 @@
},
{
"cell_type": "code",
- "execution_count": 5,
+ "execution_count": 10,
"id": "a247bedd",
"metadata": {},
"outputs": [
@@ -209,6 +262,30 @@
"2. **Work the study** → notebooks `01_benefits` → `04_export` in the same folder.\n",
"3. **Interactive data entry** → `make app` (or `streamlit run app/main.py`)."
]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "d20d824f-e464-4ff7-8191-10c2495842a0",
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "630ee935-7c7b-47e5-9c13-6285316823e2",
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "7eba3877-8e51-443f-9953-9d0a48425f9f",
+ "metadata": {},
+ "outputs": [],
+ "source": []
}
],
"metadata": {
diff --git a/README.md b/README.md
index 32e0c88..b7bb5dd 100644
--- a/README.md
+++ b/README.md
@@ -261,7 +261,7 @@ palladium/
│ └── __main__.py
├── app/ # Streamlit UI — works with any TEI study
│ ├── main.py # entry point
-│ ├── pages/ # benefits, costs, summary, versions
+│ ├── views/ # benefits, costs, summary, versions (NOT `pages/` — avoids Streamlit auto-multipage)
│ └── components/ # tables, charts
├── studies/ # One folder per TEI engagement
│ └── 202602_AmazonConnect/
diff --git a/app/components/charts.py b/app/components/charts.py
index e278fc5..edd4c6d 100644
--- a/app/components/charts.py
+++ b/app/components/charts.py
@@ -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")
diff --git a/app/components/tables.py b/app/components/tables.py
index 0e35f0b..009fb1f 100644
--- a/app/components/tables.py
+++ b/app/components/tables.py
@@ -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,
diff --git a/app/locale.py b/app/locale.py
new file mode 100644
index 0000000..99f1764
--- /dev/null
+++ b/app/locale.py
@@ -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:]}%"
diff --git a/app/main.py b/app/main.py
index 1246907..51ef145 100644
--- a/app/main.py
+++ b/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"
{icon('shield-fill')} Palladium — TEI Calculator
",
+ 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)
diff --git a/app/utils.py b/app/utils.py
new file mode 100644
index 0000000..739d36d
--- /dev/null
+++ b/app/utils.py
@@ -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 = """
+
+
+"""
+
+
+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 ```` 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''
diff --git a/app/views/benefits.py b/app/views/benefits.py
index 255d0ac..9c56e22 100644
--- a/app/views/benefits.py
+++ b/app/views/benefits.py
@@ -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"
{icon('graph-up-arrow')} Benefits
",
+ 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:
diff --git a/app/views/costs.py b/app/views/costs.py
index 24e8185..3eb0fe0 100644
--- a/app/views/costs.py
+++ b/app/views/costs.py
@@ -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"
{icon('receipt')} Costs
",
+ 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:
diff --git a/app/views/summary.py b/app/views/summary.py
index eb39f57..fd6139e 100644
--- a/app/views/summary.py
+++ b/app/views/summary.py
@@ -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"