""" Palladium Streamlit app — TEI data entry, calculation, versioning, export. Run from the project root:: streamlit run app/main.py The app picks a TEI tool by ``public_id`` (or creates one from a Report template) and exposes Benefits, Costs, Summary, and Versions pages. It is study-agnostic — the field set is loaded dynamically from Athena based on the linked Report template. """ from __future__ import annotations import sys from pathlib import Path # Allow `streamlit run app/main.py` from project root without `pip install -e .` _ROOT = Path(__file__).resolve().parent.parent if str(_ROOT) not in sys.path: sys.path.insert(0, str(_ROOT)) 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", page_icon="🛡️", layout="wide", ) @st.cache_resource(show_spinner=False) def get_client() -> TEIClient: return TEIClient() def _safe_call(fn, *args, **kwargs): """Run an API call, surfacing errors as Streamlit messages.""" try: return fn(*args, **kwargs) except AthenaAPIError as e: st.error(f"Athena API error {e.status_code}: {e.detail}") except ValueError as e: st.error(str(e)) return None # CRM lookups, cached briefly so the cascading selects stay snappy. @st.cache_data(ttl=120, show_spinner=False) def _crm_clients(_client: TEIClient) -> list[dict]: try: return _client.list_clients() except AthenaAPIError: return [] @st.cache_data(ttl=120, show_spinner=False) def _crm_proposals(_client: TEIClient, client_id: int) -> list[dict]: try: return _client.proposals_for_client(client_id) except AthenaAPIError: return [] @st.cache_data(ttl=120, show_spinner=False) def _crm_engagements(_client: TEIClient, client_name: str) -> list[dict]: try: return _client.engagements_for_client(client_name) except AthenaAPIError: return [] def sidebar_tool_picker(client: TEIClient) -> dict | None: """Sidebar: pick an existing TEI tool or create one from a report template.""" st.sidebar.markdown( f"{icon('shield-fill')} **Palladium**", unsafe_allow_html=True ) st.sidebar.caption("TEI Calculator") tools = _safe_call(client.list_tools) or [] if tools: labels = { f"{t.get('name', '(unnamed)')} — {t.get('id', '')[:8]}…": t for t in tools } choice = st.sidebar.selectbox("TEI Tool", list(labels.keys())) tool = labels[choice] else: st.sidebar.info("No TEI tools yet. Create one below.") tool = None with st.sidebar.expander("Create new tool"): reports = _safe_call(client.list_reports) or [] if not reports: st.write("No report templates available.") else: report_labels = {f"{r['name']} ({r['vendor']} {r['version']})": r for r in reports} r_choice = st.selectbox("Report template", list(report_labels.keys())) # A TEI tool must attach to a Proposal OR an Engagement. # Cascade: client → proposal/engagement, pulled from the CRM. clients = _crm_clients(client) if not clients: st.warning("No CRM clients found — create one in Athena first.") return tool client_labels = {c["name"]: c for c in clients} c_choice = st.selectbox("Client", list(client_labels.keys())) crm_client = client_labels[c_choice] attach_kind = st.radio( "Attach to", ["Proposal", "Engagement"], horizontal=True ) proposal_id: int | None = None engagement_id: int | None = None if attach_kind == "Proposal": proposals = _crm_proposals(client, crm_client["id"]) if proposals: p_labels = { f"{p.get('name')} ({p.get('status')})": p for p in proposals } p_choice = st.selectbox("Proposal", list(p_labels.keys())) proposal_id = p_labels[p_choice]["id"] else: st.info( f"{crm_client['name']} has no proposals. Create one in " "Athena (or via 00_provision.ipynb) first." ) else: engagements = _crm_engagements(client, crm_client["name"]) if engagements: e_labels = { f"{e.get('name')} ({e.get('status')})": e for e in engagements } e_choice = st.selectbox("Engagement", list(e_labels.keys())) engagement_id = e_labels[e_choice]["id"] else: st.info(f"{crm_client['name']} has no engagements.") default_name = f"{crm_client['name']} — {report_labels[r_choice]['name']}" new_name = st.text_input("Tool name", default_name) if st.button( "Create", disabled=proposal_id is None and engagement_id is None ): report = report_labels[r_choice] created = _safe_call( client.create_tool, report_public_id=report["id"], proposal=proposal_id, engagement=engagement_id, name=new_name or None, ) if created: st.success(f"Created tool {created.get('id')}") st.cache_data.clear() st.rerun() if tool: st.sidebar.divider() _opp = tool.get("opportunity") or {} _client_name = (_opp.get("client") or {}).get("name") if _client_name: st.sidebar.markdown(f"**Client**: {_client_name}") 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"): _safe_call(client.calculate, tool["id"]) st.toast("Recalculated.", icon=None) st.cache_data.clear() return tool def main() -> None: inject_icons() st.markdown( f"