carrief0908 commited on
Commit
dc56dcd
Β·
verified Β·
1 Parent(s): 08be593

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +454 -19
src/streamlit_app.py CHANGED
@@ -18,12 +18,39 @@ st.set_page_config(
18
  layout="wide",
19
  )
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  st.markdown("""
22
  <style>
23
  @import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:wght@400;500;600&display=swap');
24
- html, body, [class*="css"] { font-family: 'DM Sans', sans-serif; }
25
- h1, h2, h3 { font-family: 'DM Serif Display', serif; }
 
 
 
 
 
 
26
  .block-container { padding-top: 2rem; max-width: 960px; }
 
27
  .status-card {
28
  background: white;
29
  border: 1px solid #e5e0d8;
@@ -33,11 +60,18 @@ st.markdown("""
33
  box-shadow: 0 1px 4px rgba(0,0,0,0.05);
34
  }
35
  .step-label {
36
- font-size: 0.72rem; font-weight: 600;
37
- letter-spacing: 0.1em; text-transform: uppercase;
38
- color: #9a8f82; margin-bottom: 0.25rem;
 
 
 
 
 
 
 
 
39
  }
40
- .step-title { font-size: 1.1rem; font-weight: 600; color: #1a1612; }
41
  .badge-success {
42
  background: #d4edda; color: #155724;
43
  padding: 2px 10px; border-radius: 20px;
@@ -49,26 +83,34 @@ st.markdown("""
49
  font-size: 0.78rem; font-weight: 600;
50
  }
51
  .stButton > button {
52
- background: #1a1612 !important; color: white !important;
53
- border: none !important; border-radius: 8px !important;
 
 
54
  font-family: 'DM Sans', sans-serif !important;
55
  font-weight: 600 !important;
56
  padding: 0.55rem 1.4rem !important;
57
  font-size: 0.9rem !important;
58
  }
59
- .stButton > button:hover { background: #3d3530 !important; }
60
- .login-btn a {
61
- display: inline-block;
62
- background: #0078d4;
63
- color: white !important;
64
- text-decoration: none;
65
- padding: 0.55rem 1.6rem;
66
- border-radius: 8px;
67
  font-weight: 600;
68
- font-size: 0.95rem;
69
- font-family: 'DM Sans', sans-serif;
 
 
 
 
 
 
 
 
70
  }
71
- .login-btn a:hover { background: #106ebe; }
72
  </style>
73
  """, unsafe_allow_html=True)
74
 
@@ -463,3 +505,396 @@ if st.session_state.rj_emails:
463
  file_name=f"rj_emails_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx",
464
  mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
465
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  layout="wide",
19
  )
20
 
21
+ import os
22
+ import json
23
+ import urllib.parse
24
+ import secrets
25
+
26
+ import requests
27
+ import streamlit as st
28
+ import pandas as pd
29
+ from bs4 import BeautifulSoup
30
+ from io import BytesIO, StringIO
31
+ import re
32
+ from datetime import datetime, timedelta
33
+
34
+ # ── Page config ────────────────────────────────────────────────────────────────
35
+ st.set_page_config(
36
+ page_title="RJ Email Processor",
37
+ page_icon="πŸ“¬",
38
+ layout="wide",
39
+ )
40
+
41
  st.markdown("""
42
  <style>
43
  @import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:wght@400;500;600&display=swap');
44
+
45
+ html, body, [class*="css"] {
46
+ font-family: 'DM Sans', sans-serif;
47
+ }
48
+ h1, h2, h3 {
49
+ font-family: 'DM Serif Display', serif;
50
+ }
51
+ .main { background: #f7f5f0; }
52
  .block-container { padding-top: 2rem; max-width: 960px; }
53
+
54
  .status-card {
55
  background: white;
56
  border: 1px solid #e5e0d8;
 
60
  box-shadow: 0 1px 4px rgba(0,0,0,0.05);
61
  }
62
  .step-label {
63
+ font-size: 0.72rem;
64
+ font-weight: 600;
65
+ letter-spacing: 0.1em;
66
+ text-transform: uppercase;
67
+ color: #9a8f82;
68
+ margin-bottom: 0.25rem;
69
+ }
70
+ .step-title {
71
+ font-size: 1.1rem;
72
+ font-weight: 600;
73
+ color: #1a1612;
74
  }
 
75
  .badge-success {
76
  background: #d4edda; color: #155724;
77
  padding: 2px 10px; border-radius: 20px;
 
83
  font-size: 0.78rem; font-weight: 600;
84
  }
85
  .stButton > button {
86
+ background: #1a1612 !important;
87
+ color: white !important;
88
+ border: none !important;
89
+ border-radius: 8px !important;
90
  font-family: 'DM Sans', sans-serif !important;
91
  font-weight: 600 !important;
92
  padding: 0.55rem 1.4rem !important;
93
  font-size: 0.9rem !important;
94
  }
95
+ .stButton > button:hover {
96
+ background: #3d3530 !important;
97
+ transform: translateY(-1px);
98
+ transition: all 0.15s ease;
99
+ }
100
+ div[data-testid="stNumberInput"] label,
101
+ div[data-testid="stTextInput"] label {
 
102
  font-weight: 600;
103
+ font-size: 0.85rem;
104
+ color: #3d3530;
105
+ }
106
+ .result-header {
107
+ font-size: 1rem;
108
+ font-weight: 700;
109
+ color: #1a1612;
110
+ margin-bottom: 0.4rem;
111
+ padding-bottom: 0.4rem;
112
+ border-bottom: 2px solid #e5e0d8;
113
  }
 
114
  </style>
115
  """, unsafe_allow_html=True)
116
 
 
505
  file_name=f"rj_emails_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx",
506
  mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
507
  )
508
+
509
+
510
+ # ── Config (from HF Secrets) ───────────────────────────────────────────────────
511
+ CLIENT_ID = os.environ.get("AZURE_CLIENT_ID", "bfcbb298-4cc1-496e-9d9b-ff8c2d967a3a")
512
+ CLIENT_SECRET = os.environ.get("AZURE_CLIENT_SECRET", "") # set in HF Secrets
513
+ TENANT_ID = os.environ.get("AZURE_TENANT_ID", "5dac2bf2-8842-4788-ae07-33fb103b55d6")
514
+ REDIRECT_URI = os.environ.get("REDIRECT_URI", "") # e.g. https://yourspace.hf.space/
515
+
516
+ AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}"
517
+ AUTH_ENDPOINT = f"{AUTHORITY}/oauth2/v2.0/authorize"
518
+ TOKEN_ENDPOINT= f"{AUTHORITY}/oauth2/v2.0/token"
519
+ SCOPES = "Mail.Read offline_access"
520
+
521
+ DEFAULT_SENDER = "agabrielse@newfrontieradvisors.com"
522
+ DEFAULT_TARGET = "cfeng@newfrontieradvisors.com"
523
+
524
+ # ── Session defaults ───────────────────────────────────────────────────────────
525
+ for k, v in {
526
+ "token": None,
527
+ "oauth_state": None,
528
+ "rj_emails": [],
529
+ "cash_df": None,
530
+ "withdrawals_df": None,
531
+ "deposits_df": None,
532
+ "notice_df": None,
533
+ }.items():
534
+ if k not in st.session_state:
535
+ st.session_state[k] = v
536
+
537
+ # ── OAuth helpers ──────────────────────────────────────────────────────────────
538
+
539
+ def build_auth_url():
540
+ state = secrets.token_urlsafe(16)
541
+ st.session_state.oauth_state = state
542
+ params = {
543
+ "client_id": CLIENT_ID,
544
+ "response_type": "code",
545
+ "redirect_uri": REDIRECT_URI,
546
+ "response_mode": "query",
547
+ "scope": SCOPES,
548
+ "state": state,
549
+ }
550
+ return AUTH_ENDPOINT + "?" + urllib.parse.urlencode(params)
551
+
552
+
553
+ def exchange_code_for_token(code: str) -> str:
554
+ resp = requests.post(TOKEN_ENDPOINT, data={
555
+ "client_id": CLIENT_ID,
556
+ "client_secret": CLIENT_SECRET,
557
+ "code": code,
558
+ "redirect_uri": REDIRECT_URI,
559
+ "grant_type": "authorization_code",
560
+ }, timeout=30)
561
+ resp.raise_for_status()
562
+ return resp.json()["access_token"]
563
+
564
+
565
+ # ── Check for OAuth callback (code in URL query params) ───────────────────────
566
+ query_params = st.query_params
567
+ if not st.session_state.token and "code" in query_params:
568
+ code = query_params["code"]
569
+ state = query_params.get("state", "")
570
+ if state == st.session_state.oauth_state or not st.session_state.oauth_state:
571
+ try:
572
+ with st.spinner("Completing sign-in..."):
573
+ st.session_state.token = exchange_code_for_token(code)
574
+ # Clear the code from the URL
575
+ st.query_params.clear()
576
+ st.rerun()
577
+ except Exception as e:
578
+ st.error(f"Sign-in failed: {e}")
579
+
580
+ # ── Email helpers ──────────────────────────────────────────────────────────────
581
+
582
+ def get_emails(token, sender, top=50):
583
+ url = "https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messages"
584
+ headers = {
585
+ "Authorization": f"Bearer {token}",
586
+ "Prefer": 'outlook.body-content-type="html"',
587
+ }
588
+ params = {
589
+ "$top": top,
590
+ "$filter": f"from/emailAddress/address eq '{sender}'",
591
+ "$select": "id,subject,from,toRecipients,ccRecipients,receivedDateTime,body",
592
+ }
593
+ r = requests.get(url, headers=headers, params=params, timeout=60)
594
+ r.raise_for_status()
595
+ return r.json().get("value", [])
596
+
597
+
598
+ def extract_info(msg):
599
+ def addrs(lst):
600
+ return [(r.get("emailAddress") or {}).get("address") for r in (lst or [])
601
+ if (r.get("emailAddress") or {}).get("address")]
602
+ body_html = (msg.get("body") or {}).get("content", "")
603
+ return {
604
+ "id": msg.get("id"),
605
+ "subject": msg.get("subject"),
606
+ "from": ((msg.get("from") or {}).get("emailAddress") or {}).get("address"),
607
+ "to": addrs(msg.get("toRecipients")),
608
+ "cc": addrs(msg.get("ccRecipients")),
609
+ "received_time": msg.get("receivedDateTime"),
610
+ "body_html": body_html,
611
+ }
612
+
613
+
614
+ def html_to_text(html):
615
+ return BeautifulSoup(html, "html.parser").get_text("\n", strip=True) if html else ""
616
+
617
+
618
+ def reply_marker_html(body_html):
619
+ if not body_html:
620
+ return ""
621
+ soup = BeautifulSoup(body_html, "html.parser")
622
+ marker = soup.find(id="divRplyFwdMsg")
623
+ if not marker:
624
+ return ""
625
+ return "".join(str(n) for n in marker.next_siblings).strip()
626
+
627
+
628
+ def fwd_headers(body_html):
629
+ text = html_to_text(body_html)
630
+ def grab(label):
631
+ m = re.search(rf"{label}:\s*(.*)", text)
632
+ return m.group(1).strip() if m else ""
633
+ return {k: grab(v) for k, v in {
634
+ "forwarded_from": "From",
635
+ "forwarded_sent_time": "Sent",
636
+ "forwarded_to": "To",
637
+ "forwarded_cc": "Cc",
638
+ "forwarded_subject": "Subject",
639
+ }.items()}
640
+
641
+
642
+ def is_rj(info):
643
+ subj = (info.get("subject") or "").lower()
644
+ html = info.get("body_html") or ""
645
+ text = html_to_text(html).lower()
646
+ if "rj emails--overview" in subj:
647
+ return False
648
+ has_fwd = 'id="divRplyFwdMsg"' in html or ("from:" in text and "sent:" in text)
649
+ if not has_fwd:
650
+ return False
651
+ return any([
652
+ "raymond james" in subj, "raymond james" in text,
653
+ "amstradingadmin@raymondjames.com" in text,
654
+ "ams managed operations" in subj, "ams managed operations" in text,
655
+ "direct asset transfers" in subj,
656
+ "distribution & cash balance check" in subj,
657
+ ])
658
+
659
+
660
+ def build_rj(messages, target):
661
+ rows = []
662
+ for i, msg in enumerate(messages, 1):
663
+ info = extract_info(msg)
664
+ recips = [a.lower() for a in info.get("to", []) + info.get("cc", [])]
665
+ if target.lower() not in recips:
666
+ continue
667
+ if not is_rj(info):
668
+ continue
669
+ fwd = fwd_headers(info.get("body_html", ""))
670
+ rows.append({
671
+ "email_id": info.get("id") or f"msg_{i}",
672
+ "fw_subject": info.get("subject"),
673
+ "fw_from": info.get("from"),
674
+ "fw_to": ", ".join(info.get("to", [])),
675
+ "fw_cc": ", ".join(info.get("cc", [])),
676
+ "fw_received_time": info.get("received_time"),
677
+ "fw_body_html": info.get("body_html", ""),
678
+ **{k: fwd.get(k, "") for k in fwd},
679
+ "original_body_html": reply_marker_html(info.get("body_html", "")),
680
+ })
681
+ return rows
682
+
683
+
684
+ def extract_tables(body_html):
685
+ if not body_html:
686
+ return []
687
+ soup = BeautifulSoup(body_html, "html.parser")
688
+ out = []
689
+ for node in soup.find_all(string=True):
690
+ label = node.strip()
691
+ if label not in {"Cash Only Transactions", "Withdrawals", "Deposits"}:
692
+ continue
693
+ table = node.find_next("table")
694
+ if not table:
695
+ continue
696
+ try:
697
+ dfs = pd.read_html(StringIO(str(table)))
698
+ except ValueError:
699
+ continue
700
+ for df in dfs:
701
+ df.columns = [str(c).strip() for c in df.columns]
702
+ df = df.dropna(how="all").reset_index(drop=True)
703
+ out.append((label, df))
704
+ return out
705
+
706
+
707
+ def parse_date(v):
708
+ if not v:
709
+ return None
710
+ for fmt in ("%m/%d/%Y", "%m/%d/%y", "%A, %B %d, %Y %I:%M %p"):
711
+ try:
712
+ return datetime.strptime(v.strip(), fmt)
713
+ except ValueError:
714
+ pass
715
+ return None
716
+
717
+
718
+ def build_notice(row):
719
+ section = row.get("source_section")
720
+ account = row.get("Account") or row.get("customer_account")
721
+ amount = row.get("Cash Amount") or row.get("Amount")
722
+ if section == "Withdrawals":
723
+ input_date = row.get("Input date")
724
+ deadline = parse_date(input_date)
725
+ response_time = (
726
+ (deadline + timedelta(days=8)).strftime("%Y-%m-%d")
727
+ if deadline else f"8 calendar days after {input_date}"
728
+ )
729
+ action = (
730
+ f"Enter withdrawal in portal for account {account} for {amount}. "
731
+ f"Schedule 8 calendar days after input date {input_date}."
732
+ )
733
+ elif section == "Deposits" or (row.get("Type") or "").lower() == "deposit":
734
+ response_time = "As soon as practical after email receipt"
735
+ action = (
736
+ f"Message dutytrader in Teams: contribution/deposit for account {account} "
737
+ f"({amount}) approved to invest in model."
738
+ )
739
+ else:
740
+ response_time = "ASAP"
741
+ action = (
742
+ f"Halt account and advise of pending trades on {account}. "
743
+ f"Immediate cash movement: {amount}."
744
+ )
745
+ return {
746
+ "email_id": row.get("email_id"),
747
+ "sent_time": row.get("fw_received_time"),
748
+ "response_time_needed": response_time,
749
+ "account": account,
750
+ "action": action,
751
+ "source_section": section,
752
+ }
753
+
754
+
755
+ def to_excel(dfs):
756
+ buf = BytesIO()
757
+ with pd.ExcelWriter(buf, engine="openpyxl") as w:
758
+ for name, df in dfs.items():
759
+ if df is not None and not df.empty:
760
+ df.to_excel(w, sheet_name=name, index=False)
761
+ return buf.getvalue()
762
+
763
+
764
+ # ── UI ─────────────────────────────────────────────────────────────────────────
765
+
766
+ st.markdown("## πŸ“¬ Raymond James Email Processor")
767
+ st.markdown("Pulls forwarded RJ emails, extracts cash movement tables, and generates action notices.")
768
+ st.divider()
769
+
770
+ # Step 1 β€” Sign in
771
+ st.markdown('<div class="status-card">', unsafe_allow_html=True)
772
+ st.markdown('<div class="step-label">Step 1</div>', unsafe_allow_html=True)
773
+ col1, col2 = st.columns([5, 1])
774
+ with col1:
775
+ st.markdown('<div class="step-title">Sign in to Microsoft</div>', unsafe_allow_html=True)
776
+ with col2:
777
+ if st.session_state.token:
778
+ st.markdown('<span class="badge-success">βœ“ Signed in</span>', unsafe_allow_html=True)
779
+ else:
780
+ st.markdown('<span class="badge-pending">Not signed in</span>', unsafe_allow_html=True)
781
+
782
+ if not st.session_state.token:
783
+ auth_url = build_auth_url()
784
+ st.markdown(
785
+ f'<div class="login-btn"><a href="{auth_url}" target="_self">πŸ” Sign in with Microsoft</a></div>',
786
+ unsafe_allow_html=True,
787
+ )
788
+ st.caption("You'll be redirected to Microsoft's login page and back automatically.")
789
+ else:
790
+ st.markdown("You are signed in. βœ“")
791
+ if st.button("Sign out"):
792
+ st.session_state.token = None
793
+ st.rerun()
794
+
795
+ st.markdown('</div>', unsafe_allow_html=True)
796
+
797
+ # Step 2 β€” Config
798
+ st.markdown('<div class="status-card">', unsafe_allow_html=True)
799
+ st.markdown('<div class="step-label">Step 2</div>', unsafe_allow_html=True)
800
+ st.markdown('<div class="step-title">Configure</div>', unsafe_allow_html=True)
801
+ st.markdown("")
802
+ col_a, col_b, col_c = st.columns(3)
803
+ with col_a:
804
+ sender_email = st.text_input("Sender email (who forwards RJ emails)", value=DEFAULT_SENDER)
805
+ with col_b:
806
+ target_email = st.text_input("Recipient email to filter for", value=DEFAULT_TARGET)
807
+ with col_c:
808
+ top_n = st.number_input("Max emails to fetch", min_value=5, max_value=200, value=50, step=5)
809
+ st.markdown('</div>', unsafe_allow_html=True)
810
+
811
+ # Step 3 β€” Run
812
+ st.markdown('<div class="status-card">', unsafe_allow_html=True)
813
+ st.markdown('<div class="step-label">Step 3</div>', unsafe_allow_html=True)
814
+ st.markdown('<div class="step-title">Fetch & Process</div>', unsafe_allow_html=True)
815
+ st.markdown("")
816
+
817
+ if st.button("β–Ά Run", disabled=not st.session_state.token):
818
+ with st.spinner("Fetching emails from Outlook..."):
819
+ try:
820
+ raw = get_emails(st.session_state.token, sender_email, int(top_n))
821
+ except requests.HTTPError as e:
822
+ if e.response.status_code == 401:
823
+ st.error("Session expired. Please sign in again.")
824
+ st.session_state.token = None
825
+ else:
826
+ st.error(f"API error: {e}")
827
+ st.stop()
828
+
829
+ with st.spinner("Filtering Raymond James emails..."):
830
+ st.session_state.rj_emails = build_rj(raw, target_email)
831
+
832
+ with st.spinner("Extracting cash tables..."):
833
+ cash_rows, wd_rows, dep_rows = [], [], []
834
+ for email in st.session_state.rj_emails:
835
+ for section, df in extract_tables(email.get("original_body_html", "")):
836
+ rows = df.to_dict("records")
837
+ for r in rows:
838
+ r.update({
839
+ "email_id": email.get("email_id"),
840
+ "fw_subject": email.get("fw_subject"),
841
+ "fw_received_time": email.get("fw_received_time"),
842
+ "original_subject": email.get("forwarded_subject", ""),
843
+ "original_from": email.get("forwarded_from", ""),
844
+ "original_sent_time": email.get("forwarded_sent_time", ""),
845
+ "source_section": section,
846
+ })
847
+ if section == "Cash Only Transactions": cash_rows.extend(rows)
848
+ elif section == "Withdrawals": wd_rows.extend(rows)
849
+ elif section == "Deposits": dep_rows.extend(rows)
850
+
851
+ st.session_state.cash_df = pd.DataFrame(cash_rows) if cash_rows else pd.DataFrame()
852
+ st.session_state.withdrawals_df = pd.DataFrame(wd_rows) if wd_rows else pd.DataFrame()
853
+ st.session_state.deposits_df = pd.DataFrame(dep_rows) if dep_rows else pd.DataFrame()
854
+
855
+ with st.spinner("Building action notices..."):
856
+ all_rows = cash_rows + wd_rows + dep_rows
857
+ st.session_state.notice_df = (
858
+ pd.DataFrame([build_notice(r) for r in all_rows])
859
+ if all_rows else pd.DataFrame()
860
+ )
861
+
862
+ st.success(f"Done! Found **{len(st.session_state.rj_emails)}** RJ forwarded emails.")
863
+
864
+ st.markdown('</div>', unsafe_allow_html=True)
865
+
866
+ # ── Results ────────────────────────────────────────────────────────────────────
867
+ if st.session_state.rj_emails:
868
+ st.divider()
869
+ st.markdown("### Results")
870
+ tab1, tab2, tab3, tab4 = st.tabs([
871
+ "πŸ“‹ Action Notices", "πŸ’΅ Cash Transactions", "⬆ Withdrawals", "⬇ Deposits"
872
+ ])
873
+ for tab, key, label in [
874
+ (tab1, "notice_df", "No action notices generated."),
875
+ (tab2, "cash_df", "No cash transactions found."),
876
+ (tab3, "withdrawals_df", "No withdrawals found."),
877
+ (tab4, "deposits_df", "No deposits found."),
878
+ ]:
879
+ with tab:
880
+ df = st.session_state[key]
881
+ if df is not None and not df.empty:
882
+ # Hide HTML columns
883
+ display_df = df.drop(columns=[c for c in df.columns if "html" in c.lower()], errors="ignore")
884
+ st.dataframe(display_df, use_container_width=True, hide_index=True)
885
+ else:
886
+ st.info(label)
887
+
888
+ st.divider()
889
+ excel_bytes = to_excel({
890
+ "Action Notices": st.session_state.notice_df,
891
+ "Cash Transactions": st.session_state.cash_df,
892
+ "Withdrawals": st.session_state.withdrawals_df,
893
+ "Deposits": st.session_state.deposits_df,
894
+ })
895
+ st.download_button(
896
+ label="⬇ Download Excel Report",
897
+ data=excel_bytes,
898
+ file_name=f"rj_emails_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx",
899
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
900
+ )