""" Copper Group Dashboard — Streamlit app. Reads Dashboard_Data.xlsx (built by Optimized_Daily_Data_Collection.py) and serves an interactive dashboard with KPIs, monthly trends, branch comparisons, P&L breakdowns and inventory snapshots. Run locally: pip install -r requirements.txt streamlit run streamlit_app.py The app looks for Dashboard_Data.xlsx in, in order: 1. The current working directory 2. The same directory as this script 3. The parent directory of this script (i.e. the project root) If none is found, an upload widget appears instead. Deploy to Streamlit Community Cloud: Push this folder to a public GitHub repo and connect it at https://share.streamlit.io. See README_Streamlit.md for the data-hosting options (the .xlsx is too big for GitHub's normal 100 MB limit). """ from __future__ import annotations import os from pathlib import Path import numpy as np import pandas as pd import plotly.express as px import streamlit as st # ───────────────────────────────────────────────────────────────────────────── # Bilingual support (English / Thai) # ───────────────────────────────────────────────────────────────────────────── # Restaurant names ("Copper Buffet" / "Tiew Copper") and data cell values are # intentionally NOT translated — they're brand names / source-system labels. # Only UI chrome (tabs, widget labels, headings, chart titles) flips. LANG = { "en": { # Tab labels "tab_overview": "Overview", "tab_summary": "Sales", "tab_forecast": "Forecast", "tab_items": "Items", "tab_pl": "P&L", "tab_inventory": "Inventory", # Sidebar "sb_language": "Language", "sb_filters": "Filters", "sb_restaurant": "Restaurant", "sb_branch": "Branch", "sb_date_range": "Date range", "sb_source": "Source", "sb_sheets_loaded": "Sheets loaded", "sb_signed_in_as": "Signed in as", "sb_sign_out": "Sign out", # Top header / period caption "title": "Copper Group Dashboard", "filtered_period": "Filtered period", "all_dates": "all dates", "n_restaurants": "{n} restaurant(s)", "n_branches": "{n} branch(es)", "kpi_total_revenue": "Total Revenue", "kpi_total_customers": "Total Customers", "kpi_rev_per_head": "Revenue / Head", "kpi_yoy_suffix": "YoY", # Overview tab "ov_monthly_revenue_trend": "Monthly Revenue Trend", "ov_channel_mix": "Channel Mix", "ov_daytype_perf": "Day Type Performance", "ov_no_monthly": "No monthly rows match the current filters.", "ov_no_channel": "No channel data for the current filters.", "ov_rev_per_head": "Revenue per Head (THB)", # Summary tab "sm_trends": "Trends", "sm_monthly_summary": "Monthly summary", "sm_daily_detail": "Daily detail", "sm_no_data": "No data for {name} in the current filters.", "sm_no_monthly_rows": "No monthly rows in this filter window.", "sm_no_daily_rows": "No daily rows in this filter window.", "sm_chart_revenue": "Monthly Revenue (THB)", "sm_chart_customers": "Monthly Customers", "sm_chart_cap": "%Cap — Capacity utilised", "sm_chart_premium": "%Premium — premium share of customers", "sm_chart_rounds": "Customers by Round (monthly)", "sm_col_normal": "Normal", "sm_col_premium": "Premium", "sm_col_delivery": "Delivery", "sm_col_partypack": "Party Pack", "sm_chart_rev_split": "Monthly Revenue by Channel", # Forecast tab "fc_month_title": "This Month Forecast", "fc_month_customers": "Forecast Customers (this month)", "fc_month_revenue": "Forecast Revenue (this month, est.)", "fc_month_basis": "Revenue is estimated as forecast customers × trailing 3-month Rev/Head per branch.", "fc_month_basis_full":"This month total combines actual customers and revenue for days that have already " "passed (from kpi_daily) with projections for remaining days. Copper Buffet's " "remaining days use the model's per-day prediction × trailing 3-month Rev/Head per " "branch. Tiew Copper's remaining days are projected at the trailing 90-day average " "per day-of-week, so weekdays and weekends are weighted separately.", "fc_header": "Copper Buffet — Forecast & Bookings", "fc_caption": "Forecasted customer counts and confirmed bookings for upcoming " "service dates. Data is captured only for Copper Buffet.", "fc_no_data": "No booking or forecast data is loaded.", "fc_horizon": "Forecast horizon (days from today)", "fc_kpi_forecast": "Forecast (next {n}d)", "fc_kpi_booked": "Booked so far (next {n}d)", "fc_kpi_pct_booked": "% Booked vs Forecast", "fc_outlook": "Daily outlook", "fc_no_horizon": "No forecast rows in the selected horizon.", "fc_no_bookings": "No booking rows in the selected horizon.", "fc_chart_trend": "Predicted Customers — next {n} days", "fc_section_bookings":"Booked seats by round", "fc_chart_booked": "Booked Seats by Round — next {n} days", "fc_section_trend": "Forecast trend", # P&L tab "pl_no_data": "No P&L rows for the current filters.", "pl_month_picker": "Month", "pl_top_subcat_title": "P&L — Top Sub-Categories ({ym})", "pl_monthly_ts": "Monthly P&L Time Series", "pl_cat_picker": "Filter to one category", "pl_all_categories": "All categories", "pl_subcat_picker": "Filter to one sub-category", "pl_all_subcats": "All sub-categories", "pl_amount_axis": "Amount (THB)", # Inventory tab "inv_no_data": "No inventory rows for the current filters.", "inv_month_picker": "Month", "inv_sort_by": "Sort by", "inv_snapshot": "Inventory snapshot ({ym})", "inv_kpi_value_used": "Total Value Used", "inv_kpi_value_per_cust": "Value Used / Customer", "inv_kpi_qty_used": "Total Qty Used", "inv_kpi_qty_per_cust": "Quantity Used / Customer", "inv_chart_vpc_trend": "Value Used / Customer — monthly trend", "inv_chart_vpc_item": "Value Used / Customer — {item} (monthly)", "inv_chart_qpc_trend": "Quantity Used / Customer — monthly trend", "inv_chart_qpc_item": "Quantity Used / Customer — {item} (monthly)", "inv_table_hint": "Click any row to filter the chart below to that item. Click the same row again to clear.", "inv_store_filter": "Store", # Sign-in screen "auth_title": "Copper Group Dashboard", "auth_intro": "Restricted to members of the CB-Group organization on Hugging Face. " "Sign in with your HF account to continue.", "auth_button": "Sign in with Hugging Face", "auth_no_acct": "Don't have an HF account? Ask the dashboard owner to invite you to the " "CB-Group org, then", "auth_signup": "sign up here", # Items tab "it_no_data": "No item data available for the current filters.", "it_type": "Type", "it_subtype": "Sub-type", "it_all_subtypes": "All sub-types", "it_top_n": "Top N items", "it_kpi_total": "Items ordered (total)", "it_kpi_unique": "Unique items", "it_kpi_top": "Top item", "it_chart_qty": "Top {n} items by quantity ordered", "it_by_cat": "Items by sub-type", "it_by_protein": "Items by Protein", "it_other": "Other", "it_detail": "Item detail", "it_search": "Search item", "it_search_help": "Type any part of the item name (English or Thai). Case-insensitive.", "it_search_no_match": "No items match \"{q}\". Clear the search box to see everything.", }, "th": { # Tab labels "tab_overview": "ภาพรวม", "tab_summary": "ยอดขาย", "tab_forecast": "พยากรณ์", "tab_items": "รายการ", "tab_pl": "งบกำไรขาดทุน", "tab_inventory": "สินค้าคงคลัง", # Sidebar "sb_language": "ภาษา", "sb_filters": "ตัวกรอง", "sb_restaurant": "ร้านอาหาร", "sb_branch": "สาขา", "sb_date_range": "ช่วงวันที่", "sb_source": "แหล่งข้อมูล", "sb_sheets_loaded": "จำนวนชีท", "sb_signed_in_as": "เข้าสู่ระบบในนาม", "sb_sign_out": "ออกจากระบบ", # Top header / period caption "title": "แดชบอร์ดคอปเปอร์กรุ๊ป", "filtered_period": "ช่วงที่กรอง", "all_dates": "ทุกวันที่", "n_restaurants": "{n} ร้าน", "n_branches": "{n} สาขา", "kpi_total_revenue": "รายได้รวม", "kpi_total_customers": "ลูกค้ารวม", "kpi_rev_per_head": "รายได้ต่อหัว", "kpi_yoy_suffix": "YoY", # Overview tab "ov_monthly_revenue_trend": "แนวโน้มรายได้รายเดือน", "ov_channel_mix": "สัดส่วนช่องทาง", "ov_daytype_perf": "ผลการดำเนินงานตามประเภทวัน", "ov_no_monthly": "ไม่มีข้อมูลรายเดือนสำหรับตัวกรองปัจจุบัน", "ov_no_channel": "ไม่มีข้อมูลช่องทางสำหรับตัวกรองปัจจุบัน", "ov_rev_per_head": "รายได้ต่อหัว (บาท)", # Summary tab "sm_trends": "แนวโน้ม", "sm_monthly_summary": "สรุปรายเดือน", "sm_daily_detail": "รายละเอียดรายวัน", "sm_no_data": "ไม่มีข้อมูล {name} สำหรับตัวกรองปัจจุบัน", "sm_no_monthly_rows": "ไม่มีข้อมูลรายเดือนในช่วงที่เลือก", "sm_no_daily_rows": "ไม่มีข้อมูลรายวันในช่วงที่เลือก", "sm_chart_revenue": "รายได้รายเดือน (บาท)", "sm_chart_customers": "ลูกค้ารายเดือน", "sm_chart_cap": "%ใช้พื้นที่", "sm_chart_premium": "%ลูกค้าพรีเมียม", "sm_chart_rounds": "ลูกค้าตามรอบ (รายเดือน)", "sm_col_normal": "ปกติ", "sm_col_premium": "พรีเมียม", "sm_col_delivery": "เดลิเวอรี่", "sm_col_partypack": "พาร์ตี้แพ็ค", "sm_chart_rev_split": "รายได้รายเดือนตามช่องทาง", # Forecast tab "fc_month_title": "พยากรณ์ของเดือนนี้", "fc_month_customers": "พยากรณ์จำนวนลูกค้า (เดือนนี้)", "fc_month_revenue": "พยากรณ์รายได้ (เดือนนี้, ประมาณการ)", "fc_month_basis": "ประมาณการรายได้จาก: พยากรณ์จำนวนลูกค้า × รายได้ต่อหัวเฉลี่ย 3 เดือนล่าสุดของแต่ละสาขา", "fc_month_basis_full":"ยอดรวมเดือนนี้รวมข้อมูลจริงของวันที่ผ่านมาแล้ว (จาก kpi_daily) " "กับการประมาณการสำหรับวันที่เหลือ คอปเปอร์บุฟเฟ่ต์ใช้พยากรณ์รายวันจากโมเดล " "× รายได้ต่อหัวเฉลี่ย 3 เดือนล่าสุดของแต่ละสาขา ส่วนเตี่ยวคอปเปอร์ " "ใช้ค่าเฉลี่ย 90 วันล่าสุดตามวันในสัปดาห์สำหรับวันที่เหลือ " "(วันธรรมดาและวันหยุดสุดสัปดาห์จะถูกถ่วงน้ำหนักแยกกัน)", "fc_header": "คอปเปอร์บุฟเฟ่ต์ — พยากรณ์และการจอง", "fc_caption": "พยากรณ์จำนวนลูกค้าและการจองที่ยืนยันแล้วสำหรับวันที่บริการในอนาคต " "ข้อมูลมีเฉพาะของคอปเปอร์บุฟเฟ่ต์เท่านั้น", "fc_no_data": "ไม่มีข้อมูลการจองหรือพยากรณ์ที่โหลดอยู่", "fc_horizon": "ระยะเวลาพยากรณ์ (วันจากวันนี้)", "fc_kpi_forecast": "พยากรณ์ ({n} วันข้างหน้า)", "fc_kpi_booked": "จองแล้ว ({n} วันข้างหน้า)", "fc_kpi_pct_booked": "% จองเทียบกับพยากรณ์", "fc_outlook": "ภาพรวมรายวัน", "fc_no_horizon": "ไม่มีข้อมูลพยากรณ์ในช่วงที่เลือก", "fc_no_bookings": "ไม่มีข้อมูลการจองในช่วงที่เลือก", "fc_chart_trend": "พยากรณ์จำนวนลูกค้า — {n} วันข้างหน้า", "fc_section_bookings":"ที่นั่งที่จองตามรอบ", "fc_chart_booked": "ที่นั่งที่จองตามรอบ — {n} วันข้างหน้า", "fc_section_trend": "แนวโน้มพยากรณ์", # P&L tab "pl_no_data": "ไม่มีข้อมูลงบกำไรขาดทุนสำหรับตัวกรองปัจจุบัน", "pl_month_picker": "เดือน", "pl_top_subcat_title": "งบกำไรขาดทุน — หมวดย่อยอันดับต้น ({ym})", "pl_monthly_ts": "งบกำไรขาดทุนรายเดือน", "pl_cat_picker": "กรองเฉพาะหมวด", "pl_all_categories": "ทุกหมวด", "pl_subcat_picker": "กรองเฉพาะหมวดย่อย", "pl_all_subcats": "ทุกหมวดย่อย", "pl_amount_axis": "จำนวน (บาท)", # Inventory tab "inv_no_data": "ไม่มีข้อมูลสินค้าคงคลังสำหรับตัวกรองปัจจุบัน", "inv_month_picker": "เดือน", "inv_sort_by": "เรียงตาม", "inv_snapshot": "ภาพรวมสินค้าคงคลัง ({ym})", "inv_kpi_value_used": "มูลค่าที่ใช้ทั้งหมด", "inv_kpi_value_per_cust": "มูลค่าที่ใช้ต่อลูกค้า", "inv_kpi_qty_used": "ปริมาณที่ใช้ทั้งหมด", "inv_kpi_qty_per_cust": "ปริมาณที่ใช้ต่อลูกค้า", "inv_chart_vpc_trend": "มูลค่าที่ใช้ต่อลูกค้า — แนวโน้มรายเดือน", "inv_chart_vpc_item": "มูลค่าที่ใช้ต่อลูกค้า — {item} (รายเดือน)", "inv_chart_qpc_trend": "ปริมาณที่ใช้ต่อลูกค้า — แนวโน้มรายเดือน", "inv_chart_qpc_item": "ปริมาณที่ใช้ต่อลูกค้า — {item} (รายเดือน)", "inv_table_hint": "คลิกแถวใดก็ได้เพื่อกรองกราฟด้านล่างเฉพาะรายการนั้น คลิกแถวเดิมอีกครั้งเพื่อล้าง", "inv_store_filter": "สโตร์", # Sign-in screen "auth_title": "แดชบอร์ดคอปเปอร์กรุ๊ป", "auth_intro": "เฉพาะสมาชิกขององค์กร CB-Group บน Hugging Face เท่านั้น " "เข้าสู่ระบบด้วยบัญชี HF ของคุณเพื่อดำเนินการต่อ", "auth_button": "เข้าสู่ระบบด้วย Hugging Face", "auth_no_acct": "ยังไม่มีบัญชี HF? ขอให้เจ้าของแดชบอร์ดเชิญคุณเข้า " "องค์กร CB-Group จากนั้น", "auth_signup": "สมัครได้ที่นี่", # Items tab "it_no_data": "ไม่มีข้อมูลรายการสำหรับตัวกรองปัจจุบัน", "it_type": "ประเภท", "it_subtype": "ประเภทย่อย", "it_all_subtypes": "ทุกประเภทย่อย", "it_top_n": "จำนวนรายการอันดับต้น", "it_kpi_total": "จำนวนรายการที่สั่งทั้งหมด", "it_kpi_unique": "จำนวนรายการที่ไม่ซ้ำ", "it_kpi_top": "รายการขายดี", "it_chart_qty": "{n} รายการที่ขายดีที่สุด (จำนวน)", "it_by_cat": "รายการแบ่งตามประเภทย่อย", "it_by_protein": "รายการแบ่งตามโปรตีน", "it_other": "อื่นๆ", "it_detail": "รายละเอียดรายการ", "it_search": "ค้นหารายการ", "it_search_help": "พิมพ์ส่วนใดส่วนหนึ่งของชื่อรายการ (ภาษาไทยหรืออังกฤษ) ไม่สนใจตัวพิมพ์เล็ก-ใหญ่", "it_search_no_match": "ไม่พบรายการที่ตรงกับ \"{q}\" ล้างช่องค้นหาเพื่อแสดงทั้งหมด", }, } def t(key: str, **kwargs) -> str: """Return the user-facing string for ``key`` in the current language. Falls back to English if a key is missing in Thai, and falls back to the raw key if it's missing in both — so a missed translation shows up as e.g. ``sm_chart_revenue`` instead of crashing. """ lang = st.session_state.get("_lang", "en") s = LANG.get(lang, {}).get(key) or LANG["en"].get(key, key) return s.format(**kwargs) if kwargs else s # ───────────────────────────────────────────────────────────────────────────── # Page setup # ───────────────────────────────────────────────────────────────────────────── st.set_page_config( page_title="Copper Group Dashboard", layout="wide", initial_sidebar_state="expanded", ) # Small CSS polish so KPI tiles look like cards, not raw text. st.markdown( """ """, unsafe_allow_html=True, ) # ───────────────────────────────────────────────────────────────────────────── # Sign-in with Hugging Face (OAuth / OIDC) # ───────────────────────────────────────────────────────────────────────────── # The Space's README.md sets: # hf_oauth: true # hf_oauth_authorized_org: copper-group # which makes HF restrict sign-in to copper-group org members at the IdP # layer. The container is given OAUTH_CLIENT_ID / OAUTH_CLIENT_SECRET env # vars; we implement the OAuth code-exchange + userinfo flow below. # # Defense-in-depth: the optional ALLOWED_USERS secret (comma-separated HF # usernames) further restricts who can view, even within the org. # # Local dev: if OAUTH_CLIENT_ID isn't set in the environment, the gate # bypasses and the dashboard runs without authentication. import urllib.parse def _read_allowlist() -> set[str]: """Optional second-layer allowlist of HF usernames.""" raw = "" try: raw = st.secrets.get("ALLOWED_USERS", "") or "" except Exception: raw = "" raw = raw or os.environ.get("ALLOWED_USERS", "") or "" return {u.strip().lower() for u in raw.split(",") if u.strip()} def _oauth_gate() -> None: """Block the app until the user has signed in with Hugging Face.""" client_id = os.environ.get("OAUTH_CLIENT_ID", "") client_secret = os.environ.get("OAUTH_CLIENT_SECRET", "") space_host = os.environ.get("SPACE_HOST", "") # Local dev / OAuth not enabled → no gate. Useful for laptop testing. if not (client_id and client_secret and space_host): return # Already signed in? if st.session_state.get("_oauth_user"): return redirect_uri = f"https://{space_host}/" qp = st.query_params code = qp.get("code") state = qp.get("state") # ── 1) Handle the OAuth callback (HF redirected back here with ?code=…) ── if code: # Relaxed state check: Streamlit's session_state does not reliably # survive the OAuth round-trip in HF Spaces' iframe context (the # browser navigates away to huggingface.co and back, which can land # in a fresh session). If we still have the original state we verify # it; if it was lost, we proceed — the primary auth layer is HF's # org-membership check (`hf_oauth_authorized_org`), which already # ensures only copper-group members can reach this callback. expected_state = st.session_state.pop("_oauth_state", None) if expected_state and state and state != expected_state: st.error("Sign-in failed: OAuth state mismatch. Please try again.") st.query_params.clear() st.stop() import requests as _rq try: tok_resp = _rq.post( "https://huggingface.co/oauth/token", data={ "grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri, }, auth=(client_id, client_secret), timeout=30, ) except Exception as exc: st.error(f"Sign-in failed (token request): {exc}") st.query_params.clear() st.stop() if tok_resp.status_code != 200: st.error(f"Sign-in failed: token exchange returned HTTP {tok_resp.status_code}.") st.code(tok_resp.text[:500] or "(empty body)") st.query_params.clear() st.stop() access_token = (tok_resp.json() or {}).get("access_token", "") if not access_token: st.error("Sign-in failed: no access token in response.") st.query_params.clear() st.stop() try: user_resp = _rq.get( "https://huggingface.co/oauth/userinfo", headers={"Authorization": f"Bearer {access_token}"}, timeout=30, ) except Exception as exc: st.error(f"Sign-in failed (userinfo request): {exc}") st.query_params.clear() st.stop() if user_resp.status_code != 200: st.error(f"Sign-in failed: userinfo returned HTTP {user_resp.status_code}.") st.query_params.clear() st.stop() user = user_resp.json() or {} username = (user.get("preferred_username") or user.get("name") or "").lower() # Second-layer allowlist (optional). allowed = _read_allowlist() if allowed and username not in allowed: st.error( f"Access denied. The Hugging Face user `{username}` is not on " f"the dashboard's allowlist. Contact the dashboard owner if " f"you believe this is in error." ) st.query_params.clear() # Don't store the user — just stop. st.stop() st.session_state["_oauth_user"] = user st.query_params.clear() st.rerun() # ── 2) Not signed in yet → render the sign-in screen ───────────────────── # Generate the state token once per session; reuse it across reruns so # the link in the sign-in button doesn't change underneath the user. if "_oauth_state" not in st.session_state: import secrets as _secrets st.session_state["_oauth_state"] = _secrets.token_urlsafe(24) new_state = st.session_state["_oauth_state"] auth_url = ( "https://huggingface.co/oauth/authorize?" f"client_id={urllib.parse.quote(client_id, safe='')}&" f"redirect_uri={urllib.parse.quote(redirect_uri, safe='')}&" f"response_type=code&" f"scope={urllib.parse.quote('openid profile')}&" f"state={urllib.parse.quote(new_state, safe='')}" ) st.markdown( f"""
🔒

{t("auth_title")}

{t("auth_intro")}

🤗 {t("auth_button")}

{t("auth_no_acct")} {t("auth_signup")}.

""", unsafe_allow_html=True, ) st.stop() _oauth_gate() # ───────────────────────────────────────────────────────────────────────────── # Data loading # ───────────────────────────────────────────────────────────────────────────── @st.cache_data(show_spinner="Loading dashboard data ...") def load_workbook(source) -> dict[str, pd.DataFrame]: """Parse every sheet of Dashboard_Data.xlsx into a {name: DataFrame} dict. Date columns are coerced to datetime so downstream filters work uniformly. """ sheets = pd.read_excel(source, sheet_name=None, engine="openpyxl") for name, df in sheets.items(): if "Date" in df.columns: sheets[name]["Date"] = pd.to_datetime(df["Date"], errors="coerce") if "Year" in df.columns: sheets[name]["Year"] = pd.to_numeric(df["Year"], errors="coerce") if "Month" in df.columns: sheets[name]["Month"] = pd.to_numeric(df["Month"], errors="coerce") return sheets def find_local_file() -> Path | None: """Look for Dashboard_Data.xlsx in cwd, this script's dir, its parent, and a 'Dashboard' subfolder of either (matches the current project layout).""" here = Path(__file__).resolve().parent candidates = [ Path.cwd() / "Dashboard_Data.xlsx", Path.cwd() / "Dashboard" / "Dashboard_Data.xlsx", here / "Dashboard_Data.xlsx", here / "Dashboard" / "Dashboard_Data.xlsx", here.parent / "Dashboard_Data.xlsx", here.parent / "Dashboard" / "Dashboard_Data.xlsx", here.parent.parent / "Dashboard" / "Dashboard_Data.xlsx", ] for candidate in candidates: if candidate.exists(): return candidate return None class FetchError(Exception): """Carries diagnostic info about a failed fetch so the UI can surface it.""" def __init__(self, message: str, diagnostics: dict): super().__init__(message) self.diagnostics = diagnostics @st.cache_data(show_spinner="Fetching Dashboard_Data from GitHub ...") def fetch_url(url: str, token: str | None = None) -> bytes: """GET the .xlsx bytes from a URL. Supports an optional Bearer token for token-gated downloads (private repos). GitHub redirects release-asset URLs to a signed S3 / CDN URL. Sending the Authorization header on that follow-up request makes the CDN reject it (the signed query params already authenticate the request). So we: 1. Hit github.com WITH the token; allow_redirects=False 2. Follow the Location header WITHOUT the token This works for both public and private repos. On any failure, raises FetchError with a dict of diagnostics (URL, token-presence flag, HTTP status, redirect target, first line of body). The token value itself is never included — only a yes/no flag. """ import requests diag: dict = { "url": url, "token_present": bool(token), "stage": "initial_request", "status_code": None, "redirected_to": None, "final_status_code": None, "body_first_line": None, "exception": None, } headers = {} if token: headers["Authorization"] = f"Bearer {token}" headers["Accept"] = "application/octet-stream" try: r = requests.get(url, headers=headers, timeout=180, allow_redirects=False) diag["status_code"] = r.status_code # 30x: follow the Location header WITHOUT the auth header. if r.status_code in (301, 302, 303, 307, 308): cdn_url = r.headers.get("Location") or url diag["redirected_to"] = cdn_url diag["stage"] = "follow_redirect" r = requests.get(cdn_url, timeout=180, allow_redirects=True) diag["final_status_code"] = r.status_code else: diag["final_status_code"] = r.status_code if not r.ok: body = (r.text or "").strip().splitlines() diag["body_first_line"] = body[0][:300] if body else "" raise FetchError( f"HTTP {r.status_code} from {r.url}", diag, ) return r.content except FetchError: raise except Exception as exc: diag["exception"] = f"{type(exc).__name__}: {exc}" raise FetchError(str(exc), diag) from exc @st.cache_data(ttl=60, show_spinner=False) def get_dataset_revision(repo_id: str, repo_type: str, token: str | None) -> str: """Return the latest commit SHA of an HF repo, cached for 60 seconds. Used as a cache-key parameter to the data-fetch functions below: when a new commit lands (i.e. the collection script just uploaded a fresh parquet snapshot), the SHA changes, the cache key changes, and `st.cache_data` automatically refetches the data on the next page interaction — no manual cache-clear needed. The 60 s TTL bounds how often we hit the HF API; with the script refreshing once a day, that's a worst-case ~60-second delay before viewers see new data. """ if not repo_id: return "" try: from huggingface_hub import HfApi api = HfApi(token=token) info = api.repo_info(repo_id=repo_id, repo_type=repo_type) return getattr(info, "sha", "") or "" except Exception: # If the API call fails we return an empty string. Subsequent # fetches still work (just from the existing cache); we just # lose auto-refresh on this rerun. return "" @st.cache_data(show_spinner="Fetching Dashboard parquet from Hugging Face Hub ...") def fetch_hf_parquet(repo_id: str, repo_type: str, token: str | None, subfolder: str = "parquet", revision: str = "") -> dict[str, pd.DataFrame]: """Snapshot-download the parquet/ folder of a HF dataset repo and assemble the {sheet_name: DataFrame} dict the rest of the app expects. Parquet files are ~10x smaller than the xlsx mirror and load ~10-50x faster, so this is the preferred data path when the dataset has been refreshed by `UploadParquetToHuggingFaceHub()`. Raises FetchError (with diagnostics) so the diagnostics expander still works. """ diag: dict = { "url": f"hf://{repo_type}s/{repo_id}/{subfolder}/*.parquet", "token_present": bool(token), "stage": "snapshot_download", "status_code": None, "redirected_to": None, "final_status_code": None, "body_first_line": None, "exception": None, } try: from huggingface_hub import snapshot_download local_dir = snapshot_download( repo_id=repo_id, repo_type=repo_type, token=token, allow_patterns=[f"{subfolder}/*.parquet"], ) parquet_dir = os.path.join(local_dir, subfolder) if not os.path.isdir(parquet_dir): diag["exception"] = ( f"No '{subfolder}/' folder in the repo. " "Run BuildDashboardParquet() + UploadParquetToHuggingFaceHub() " "from the collection script." ) raise FetchError(diag["exception"], diag) files = sorted(f for f in os.listdir(parquet_dir) if f.endswith(".parquet")) if not files: diag["exception"] = f"No .parquet files found under {parquet_dir}." raise FetchError(diag["exception"], diag) sheets_out: dict[str, pd.DataFrame] = {} for fn in files: name = fn[:-len(".parquet")] df = pd.read_parquet(os.path.join(parquet_dir, fn)) # Coerce known date columns back to datetime for downstream filters. for c in ("Date",): if c in df.columns: df[c] = pd.to_datetime(df[c], errors="coerce") for c in ("Year", "Month"): if c in df.columns: df[c] = pd.to_numeric(df[c], errors="coerce") sheets_out[name] = df return sheets_out except FetchError: raise except Exception as exc: diag["exception"] = f"{type(exc).__name__}: {exc}" raise FetchError(str(exc), diag) from exc @st.cache_data(show_spinner="Fetching Dashboard_Data from Hugging Face Hub ...") def fetch_hf(repo_id: str, filename: str, repo_type: str, token: str | None, revision: str = "") -> str: """Download a file from a Hugging Face Hub repo and return the local path. Uses the huggingface_hub library which handles auth, redirects, and caching automatically. The file is cached on the Space's filesystem, so subsequent page loads in the same session are instant. Raises FetchError (with diagnostics) so the diagnostics expander still works. """ diag: dict = { "url": f"hf://{repo_type}s/{repo_id}/{filename}", "token_present": bool(token), "stage": "hf_hub_download", "status_code": None, "redirected_to": None, "final_status_code": None, "body_first_line": None, "exception": None, } try: from huggingface_hub import hf_hub_download local = hf_hub_download( repo_id=repo_id, filename=filename, repo_type=repo_type, token=token, ) return local except Exception as exc: diag["exception"] = f"{type(exc).__name__}: {exc}" raise FetchError(str(exc), diag) from exc sheets: dict[str, pd.DataFrame] | None = None source_label = "" # 1) Hosted deployment path — st.secrets points at a hosted data file. # Two routes, in priority order: # # a) Hugging Face Hub (RECOMMENDED, most reliable for HF Spaces): # HF_REPO = "/copper-dashboard-data" # HF_FILENAME = "Dashboard_Data.xlsx" (optional, this default) # HF_REPO_TYPE = "dataset" (optional, this default) # HF_TOKEN = "hf_xxxxxxxxxxxxxxxxxxxxxxxxxxx" (required for private repos) # # b) Plain URL (GitHub Release, raw HTTPS, etc.): # DASHBOARD_URL = "" # HF_TOKEN or DASHBOARD_TOKEN or GITHUB_TOKEN — sent as Bearer on the # first hop only; the follow-up redirect goes through without auth. hf_repo = "" hf_filename = "Dashboard_Data.xlsx" hf_repo_type = "dataset" secrets_url = "" secrets_token = None try: hf_repo = st.secrets.get("HF_REPO", "") or "" hf_filename = st.secrets.get("HF_FILENAME", "Dashboard_Data.xlsx") or "Dashboard_Data.xlsx" hf_repo_type = st.secrets.get("HF_REPO_TYPE", "dataset") or "dataset" secrets_url = st.secrets.get("DASHBOARD_URL", "") or "" # Accept any of HF_TOKEN / DASHBOARD_TOKEN / GITHUB_TOKEN, in that order. secrets_token = ( st.secrets.get("HF_TOKEN", "") or st.secrets.get("DASHBOARD_TOKEN", "") or st.secrets.get("GITHUB_TOKEN", "") or None ) except Exception: # st.secrets is unavailable when running outside Streamlit Cloud without a # secrets.toml — silently fall through to local-file discovery. pass # HF Spaces also exposes HF_TOKEN as an env var by default — pick it up if # the user only configured it as a Space "Secret" (not via secrets.toml). if secrets_token is None: secrets_token = os.environ.get("HF_TOKEN") or None # Header (we show it early so the loading status is visible even on slow links) st.title(t("title")) def _show_diagnostics(exc: "FetchError", source_hint: str) -> None: """Render the failure expander we added so the user can self-diagnose.""" with st.expander("Diagnostics — why did the fetch fail?"): d = exc.diagnostics st.markdown( f""" - **Source attempted:** `{source_hint}` - **URL / repo tried:** `{d.get('url')}` - **Token present in secrets:** **{'yes' if d.get('token_present') else 'no'}** - **Stage when it failed:** `{d.get('stage')}` - **Initial HTTP status:** `{d.get('status_code') or '—'}` - **Redirected to:** `{d.get('redirected_to') or '—'}` - **Final HTTP status:** `{d.get('final_status_code') or '—'}` - **Response body (first line):** `{d.get('body_first_line') or '—'}` - **Exception (if any):** `{d.get('exception') or '—'}` """ ) st.caption( "Common causes: (1) the repo / release / file doesn't exist yet; " "(2) the repo is private and the token is missing or lacks read " "scope on it; (3) the secrets key is mis-cased (must be all caps, " "exactly `HF_TOKEN` / `HF_REPO` / `DASHBOARD_URL`); (4) the latest " "streamlit_app.py wasn't pushed to the deployment platform." ) # Pull the dataset's latest commit SHA before fetching the data. The SHA # is passed as a cache-key parameter into the fetch functions — when the # collection script pushes a new commit (i.e. uploads new parquet files), # the SHA changes, the cache key changes, and the data is automatically # refetched on the next page interaction. The SHA poll itself is cached # for 60 s so we don't hammer the HF API on every Streamlit rerun. _data_revision = get_dataset_revision(hf_repo, hf_repo_type, secrets_token) if hf_repo else "" # 1a-i) Parquet snapshot from HF (FAST: ~10x smaller, ~10-50x faster than xlsx). # Tried first whenever an HF repo is configured. if hf_repo: try: sheets = fetch_hf_parquet( hf_repo, hf_repo_type, secrets_token, subfolder="parquet", revision=_data_revision, ) source_label = f"Hugging Face Hub: {hf_repo}/parquet/ ({len(sheets)} sheets)" except FetchError as exc: # Don't surface as a hard warning — parquet may simply not be uploaded # yet, in which case the xlsx fallback below will handle it silently. st.info( "Fast parquet snapshot not available — falling back to the xlsx mirror. " "Run BuildDashboardParquet() + UploadParquetToHuggingFaceHub() in the " "collection script for a much faster load." ) # 1a-ii) Hugging Face Hub xlsx fallback (slower but compatible with the # original Dashboard_Data.xlsx layout). if sheets is None and hf_repo: try: local_path = fetch_hf( hf_repo, hf_filename, hf_repo_type, secrets_token, revision=_data_revision, ) sheets = load_workbook(local_path) source_label = f"Hugging Face Hub: {hf_repo}/{hf_filename}" except FetchError as exc: st.warning(f"Couldn't fetch from Hugging Face Hub ({exc}); trying other sources.") _show_diagnostics(exc, source_hint=f"HF Hub: {hf_repo}/{hf_filename}") # 1b) Plain URL fetch (works for GitHub Release, public HF resolve URL, etc.) if sheets is None and secrets_url: try: import io raw = fetch_url(secrets_url, secrets_token) sheets = load_workbook(io.BytesIO(raw)) source_label = "DASHBOARD_URL (auto-fetched)" except FetchError as exc: st.warning(f"Couldn't fetch from DASHBOARD_URL ({exc}); falling back to local file.") _show_diagnostics(exc, source_hint=f"URL: {secrets_url}") except Exception as exc: st.warning(f"Couldn't fetch from DASHBOARD_URL ({exc}); falling back to local file.") # 2) Local-file path (dev / on the analyst's machine). if sheets is None: local_path = find_local_file() if local_path is not None: try: sheets = load_workbook(str(local_path)) source_label = f"{local_path.name} (auto-detected)" except Exception as exc: st.error(f"Could not read {local_path}: {exc}") # 3) Manual upload as final fallback. if sheets is None: st.write("Upload **Dashboard_Data.xlsx** to begin.") uploaded = st.file_uploader(" ", type=["xlsx"], label_visibility="collapsed") if uploaded is not None: try: sheets = load_workbook(uploaded) source_label = uploaded.name except Exception as exc: st.error(f"Could not read the uploaded file: {exc}") st.stop() else: st.info( "Either: (a) set `DASHBOARD_URL` in Streamlit secrets to your " "GitHub release asset, (b) put the workbook in this folder or " "its Dashboard/ subfolder, or (c) upload it above." ) st.stop() # ───────────────────────────────────────────────────────────────────────────── # Sheet shortcuts # ───────────────────────────────────────────────────────────────────────────── kpi_daily = sheets.get("kpi_daily", pd.DataFrame()) kpi_monthly = sheets.get("kpi_monthly", pd.DataFrame()) fact_sales = sheets.get("fact_sales", pd.DataFrame()) fact_items = sheets.get("fact_items", pd.DataFrame()) fact_pl = sheets.get("fact_pl", pd.DataFrame()) fact_inventory = sheets.get("fact_inventory", pd.DataFrame()) fact_shift_items = sheets.get("fact_shift_items", pd.DataFrame()) fact_bookings = sheets.get("fact_bookings", pd.DataFrame()) fact_predictions = sheets.get("fact_predictions", pd.DataFrame()) dim_branch = sheets.get("dim_branch", pd.DataFrame()) # Copper Buffet service rounds (Shift number → human label). SHIFT_LABELS = { 1: "Breakfast", 2: "Lunch", 3: "Dinner", 4: "Late Dinner", 5: "Special", } SHIFT_ORDER = ["Breakfast", "Lunch", "Dinner", "Late Dinner", "Special"] # ───────────────────────────────────────────────────────────────────────────── # Sidebar — filters # ───────────────────────────────────────────────────────────────────────────── def gather_branch_options() -> tuple[list[str], list[str]]: """Union of (Restaurant, Branch) values across every sheet that has them. This keeps corporate Group rows (Holding / Central Kitchen / Consolidated) in the filter even though they only appear in fact_pl. """ candidates = [dim_branch, kpi_daily, fact_sales, fact_pl, fact_inventory] frames = [ df[["Restaurant", "Branch"]].dropna() for df in candidates if not df.empty and {"Restaurant", "Branch"}.issubset(df.columns) ] if not frames: return [], [] bag = pd.concat(frames, ignore_index=True).drop_duplicates() return sorted(bag["Restaurant"].unique()), sorted(bag["Branch"].unique()) restaurants_all, branches_all = gather_branch_options() # Default to Copper Buffet (TheSense + Gaysorn) on first load. Fall back to # "everything" if those values don't appear in the dataset — e.g. before the # first data refresh after a schema change. _DEFAULT_RESTAURANTS = ["Copper Buffet"] _DEFAULT_BRANCHES = ["TheSense", "Gaysorn"] _restaurant_defaults = [r for r in _DEFAULT_RESTAURANTS if r in restaurants_all] \ or restaurants_all _branch_defaults = [b for b in _DEFAULT_BRANCHES if b in branches_all] \ or branches_all # Language selector — placed BEFORE other widgets so labels switch # immediately on the same rerun. _lang_label = {"en": "English", "th": "ภาษาไทย"} _lang_default = st.session_state.get("_lang", "en") _lang_choice = st.sidebar.radio( t("sb_language"), options=["en", "th"], format_func=lambda code: _lang_label[code], horizontal=True, index=0 if _lang_default == "en" else 1, key="_lang_radio", ) if _lang_choice != st.session_state.get("_lang"): st.session_state["_lang"] = _lang_choice st.rerun() st.sidebar.divider() st.sidebar.title(t("sb_filters")) sel_restaurants = st.sidebar.multiselect( t("sb_restaurant"), restaurants_all, default=_restaurant_defaults ) sel_branches = st.sidebar.multiselect( t("sb_branch"), branches_all, default=_branch_defaults ) # Year + date range if not kpi_daily.empty and "Date" in kpi_daily.columns: daily_dates = kpi_daily["Date"].dropna() min_date = daily_dates.min().date() if not daily_dates.empty else None max_date = daily_dates.max().date() if not daily_dates.empty else None else: min_date = max_date = None from datetime import datetime as _dt if min_date and max_date: # Default range: Jan 1 of the current year → yesterday. Clamp both ends # to the data's actual available range so st.date_input doesn't reject # the defaults when the dataset is older or hasn't been refreshed yet. from datetime import timedelta as _td _ytd_start = _dt(_dt.now().year, 1, 1).date() _yesterday = (_dt.now() - _td(days=1)).date() _default_start = max(min_date, _ytd_start) _default_end = min(max_date, _yesterday) if _default_start > _default_end: # Edge case: dataset entirely outside [Jan 1, yesterday] — fall # back to the full available range so the dashboard isn't empty. _default_start, _default_end = min_date, max_date sel_dates = st.sidebar.date_input( t("sb_date_range"), value=(_default_start, _default_end), min_value=min_date, max_value=max_date, ) if isinstance(sel_dates, tuple) and len(sel_dates) == 2: date_from, date_to = sel_dates else: date_from = date_to = sel_dates else: date_from = date_to = None st.sidebar.divider() st.sidebar.caption(f"{t('sb_source')}: {source_label}") st.sidebar.caption(f"{t('sb_sheets_loaded')}: {len(sheets)}") # ── Signed-in user badge + sign-out (only shown when OAuth is active) ──────── _oauth_user = st.session_state.get("_oauth_user") if _oauth_user: _name = _oauth_user.get("preferred_username") or _oauth_user.get("name") or "user" st.sidebar.divider() st.sidebar.markdown(f"{t('sb_signed_in_as')} **{_name}**") if st.sidebar.button(t("sb_sign_out"), use_container_width=True): # Drop everything OAuth-related and force the sign-in screen on rerun. for _k in ("_oauth_user", "_oauth_state"): st.session_state.pop(_k, None) st.rerun() # ───────────────────────────────────────────────────────────────────────────── # Filter helper # ───────────────────────────────────────────────────────────────────────────── def apply_filters(df: pd.DataFrame, *, use_date: bool = True) -> pd.DataFrame: if df.empty: return df out = df if "Restaurant" in out.columns and sel_restaurants: out = out[out["Restaurant"].isin(sel_restaurants)] if "Branch" in out.columns and sel_branches: out = out[out["Branch"].isin(sel_branches)] if use_date: if "Date" in out.columns: if date_from is not None: out = out[out["Date"] >= pd.to_datetime(date_from)] if date_to is not None: out = out[out["Date"] <= pd.to_datetime(date_to)] elif {"Year", "Month"}.issubset(out.columns): # Year/Month-only tables (e.g. kpi_monthly) — clip to the # months that overlap the date range. if date_from is not None: ym_from = date_from.year * 12 + date_from.month out_ym = out["Year"].astype(int) * 12 + out["Month"].astype(int) out = out[out_ym >= ym_from] if date_to is not None: ym_to = date_to.year * 12 + date_to.month out_ym = out["Year"].astype(int) * 12 + out["Month"].astype(int) out = out[out_ym <= ym_to] return out # ───────────────────────────────────────────────────────────────────────────── # Formatting helpers # ───────────────────────────────────────────────────────────────────────────── def fmt_money(n) -> str: """Currency with a comma separator every thousand (no K/M/B suffixes).""" if n is None or pd.isna(n): return "—" return f"฿{n:,.0f}" def fmt_num(n) -> str: if n is None or pd.isna(n): return "—" return f"{n:,.0f}" def fmt_pct(n) -> str: """Percentage with one decimal — for %Cap, %Premium etc.""" if n is None or pd.isna(n): return "—" return f"{n:.1f}%" def fmt_qty(n) -> str: """Quantity with a comma separator and one decimal place.""" if n is None or pd.isna(n): return "—" return f"{n:,.1f}" def style_plotly(fig, *, height: int | None = None): """Common Plotly cosmetics — transparent background + light gridlines, NAVY chart text to match the weekly deck.""" fig.update_layout( paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", margin=dict(l=10, r=10, t=30, b=10), legend=dict(orientation="h", yanchor="bottom", y=-0.25, x=0), font=dict(color="#1E2B3A"), # NAVY ) if height is not None: fig.update_layout(height=height) fig.update_xaxes(gridcolor="#E5E7EB", zerolinecolor="#D4A574", linecolor="#D4A574") fig.update_yaxes(gridcolor="#E5E7EB", zerolinecolor="#D4A574", linecolor="#D4A574") return fig # ── Brand chart palette (synced with Weekly_Report/build_deck_w23.py) ──── # Canonical hex values from the weekly deck so the dashboard and the # printed report read as the same brand: # NAVY #1E2B3A COPPER #976A4D TIEW #DC7D3D # GOLD #D4A574 CREAM #FAF7F2 MUTED #6B7280 # DARK #1F2937 GREEN #16A34A RED #DC2626 RESTAURANT_COLOR = { "Copper Buffet": "#976A4D", # COPPER "Tiew Copper": "#DC7D3D", # TIEW "Group": "#6B7280", # MUTED } # Branch colors keep two siblings inside the same restaurant visually # distinct while staying inside the weekly-deck palette. BRANCH_COLOR = { "TheSense": "#976A4D", # COPPER (Buffet main + Tiew main) "Gaysorn": "#D4A574", # GOLD (Buffet Gaysorn) "Paragon": "#DC7D3D", # TIEW (Tiew Paragon) # Corporate Group rows (P&L tab): "Holding Company": "#1E2B3A", # NAVY "Central Kitchen": "#6B7280", # MUTED "Consolidated": "#1F2937", # DARK } # Service rounds: ordered light → dark for time-of-day intuition, with the # weekly deck's COPPER as the headline 'Dinner' segment and TIEW orange as # the accent for the off-program 'Special' bucket. ROUND_COLOR = { "Breakfast": "#D4A574", # GOLD (morning, lightest) "Lunch": "#DC7D3D", # TIEW (midday burst) "Dinner": "#976A4D", # COPPER (primary evening service) "Late Dinner": "#1E2B3A", # NAVY (deep night) "Special": "#6B7280", # MUTED (off-program accent) } # Generic qualitative palette — used when no explicit mapping fits the # series (Overview Revenue series, P&L sub-categories, ad-hoc Channel # slices). Order picked to maximise hue contrast between the first few. BRAND_SEQUENCE = [ "#976A4D", # COPPER "#DC7D3D", # TIEW "#1E2B3A", # NAVY "#D4A574", # GOLD "#6B7280", # MUTED "#1F2937", # DARK "#A88158", # mid copper (variety) ] # ───────────────────────────────────────────────────────────────────────────── # Header + KPI tiles # ───────────────────────────────────────────────────────────────────────────── # (Title already rendered up-top; just print the filtered-period caption.) period_str = f"{date_from} → {date_to}" if date_from else t("all_dates") st.markdown( f"{t('filtered_period')}: {period_str} · " f"{t('n_restaurants', n=len(sel_restaurants))}, {t('n_branches', n=len(sel_branches))}", unsafe_allow_html=True, ) filtered_daily = apply_filters(kpi_daily) total_rev = filtered_daily["Revenue"].sum() if "Revenue" in filtered_daily.columns else 0 total_cust = filtered_daily["Customers"].sum() if "Customers" in filtered_daily.columns else 0 total_iqty = filtered_daily["ItemQty"].sum() if "ItemQty" in filtered_daily.columns else 0 total_irev = filtered_daily["ItemRevenue"].sum() if "ItemRevenue" in filtered_daily.columns else 0 rev_phead = (total_rev / total_cust) if total_cust else 0 # ── Year-on-year comparison ──────────────────────────────────────────── # Pull the same date window one calendar year earlier from kpi_daily, # apply the same Restaurant / Branch sidebar filters, and compute the # same three totals. The percent delta is what we show under each tile. # DateOffset(years=1) handles the Feb-29 → Feb-28 edge case for us. yoy_rev = yoy_cust = yoy_rph = None if date_from is not None and date_to is not None and not kpi_daily.empty: try: _yoy_from = (pd.Timestamp(date_from) - pd.DateOffset(years=1)) _yoy_to = (pd.Timestamp(date_to) - pd.DateOffset(years=1)) _y = kpi_daily.copy() _y["Date"] = pd.to_datetime(_y["Date"], errors="coerce") _y = _y[(_y["Date"] >= _yoy_from) & (_y["Date"] <= _yoy_to)] if sel_restaurants and "Restaurant" in _y.columns: _y = _y[_y["Restaurant"].isin(sel_restaurants)] if sel_branches and "Branch" in _y.columns: _y = _y[_y["Branch"].isin(sel_branches)] if not _y.empty: yoy_rev = float(_y["Revenue"].sum()) if "Revenue" in _y.columns else None yoy_cust = float(_y["Customers"].sum()) if "Customers" in _y.columns else None yoy_rph = (yoy_rev / yoy_cust) if (yoy_rev is not None and yoy_cust) else None except Exception: pass def _yoy_delta(current, previous) -> "str | None": """% change vs prior year, formatted with a sign + 'YoY' suffix. Returns None when there's no comparable prior-year value so the delta indicator is hidden instead of misleading.""" if previous is None or previous == 0 or pd.isna(previous): return None pct = (current - previous) / previous * 100 return f"{pct:+.1f}% {t('kpi_yoy_suffix')}" c1, c2, c3 = st.columns(3) c1.metric(t("kpi_total_revenue"), fmt_money(total_rev), delta=_yoy_delta(total_rev, yoy_rev)) c2.metric(t("kpi_total_customers"), fmt_num(total_cust), delta=_yoy_delta(total_cust, yoy_cust)) c3.metric(t("kpi_rev_per_head"), fmt_money(rev_phead), delta=_yoy_delta(rev_phead, yoy_rph)) st.divider() # ───────────────────────────────────────────────────────────────────────────── # Tabs # ───────────────────────────────────────────────────────────────────────────── tab_overview, tab_pl, tab_summary, tab_forecast, tab_items, tab_inv = st.tabs( [t("tab_overview"), t("tab_pl"), t("tab_summary"), t("tab_forecast"), t("tab_items"), t("tab_inventory")] ) # ── Overview ─────────────────────────────────────────────────────────────── with tab_overview: left, right = st.columns([2, 1]) with left: st.subheader(t("ov_monthly_revenue_trend")) if not kpi_monthly.empty: mf = apply_filters(kpi_monthly) if not mf.empty: mf = mf.copy() mf["YearMonth"] = ( mf["Year"].astype(int).astype(str) + "-" + mf["Month"].astype(int).astype(str).str.zfill(2) ) mf["Series"] = mf["Restaurant"] + " / " + mf["Branch"] mf = mf.sort_values("YearMonth") fig = px.line( mf, x="YearMonth", y="Revenue", color="Series", markers=True, color_discrete_sequence=BRAND_SEQUENCE, text="Revenue", ) fig.update_traces( texttemplate="฿%{y:,.0f}", textposition="top center", textfont=dict(size=10), ) fig.update_yaxes(tickformat=",.0f") fig.update_layout(xaxis_title=None, yaxis_title="Revenue (THB)") st.plotly_chart(style_plotly(fig, height=420), use_container_width=True) else: st.info(t("ov_no_monthly")) with right: st.subheader(t("ov_channel_mix")) if not fact_sales.empty: sf = apply_filters(fact_sales) # Exclude roll-up rows from the source data — "Grand Total" / # "SubTotal" double-count the per-channel rows and dominate the # pie chart otherwise. Match case-insensitively + ignore spaces # so variants like "Sub Total" / "GRAND TOTAL" are also dropped. _CHANNEL_BLACKLIST = { "grandtotal", "subtotal", "total", # POS summary metadata that leaks into the Channel column # but isn't a channel (averages-per-receipt, averages-per- # pax). Excluded for every restaurant since these are # never legitimate channels. "ave/chk", "ave/pax", } # Tiew Copper-specific extras: these aren't real revenue # channels in Tiew's POS export (they're cost / adjustment # buckets that leak into the Channel column). Strip them only # from Tiew Copper rows so Copper Buffet's legitimate # 'Delivery' channel still shows in the pie when both # restaurants are selected. _TIEW_CHANNEL_BLACKLIST = { "food", "delivery", "promotion", "tax", "svc", "bev", "bev.", # Beverage cost bucket — same idea } # Normalize Channel for matching: lowercase, strip outer # whitespace, collapse internal whitespace, drop trailing # dots so "Bev." matches "bev". _ch_norm = ( sf["Channel"].astype(str) .str.strip() .str.replace(r"\s+", "", regex=True) .str.lower() ) _keep = ~_ch_norm.isin(_CHANNEL_BLACKLIST) if "Restaurant" in sf.columns: _tiew_drop = (sf["Restaurant"] == "Tiew Copper") & _ch_norm.isin(_TIEW_CHANNEL_BLACKLIST) _keep = _keep & ~_tiew_drop sf = sf[_keep] channel = ( sf.groupby("Channel", as_index=False)["Amount"] .sum() .sort_values("Amount", ascending=False) ) channel = channel[channel["Amount"] > 0].head(12) if not channel.empty: fig = px.pie( channel, names="Channel", values="Amount", hole=0.55, color_discrete_sequence=BRAND_SEQUENCE, ) fig.update_traces( textposition="inside", texttemplate="%{label}
฿%{value:,.0f}
%{percent}", insidetextfont=dict(size=11), ) st.plotly_chart(style_plotly(fig, height=420), use_container_width=True) else: st.info(t("ov_no_channel")) st.subheader(t("ov_daytype_perf")) if not filtered_daily.empty and "DayType" in filtered_daily.columns: order = ["Weekday", "Weekend", "Holiday"] daytype = ( filtered_daily.groupby("DayType", as_index=False) .agg(Revenue=("Revenue", "sum"), Customers=("Customers", "sum")) ) daytype["Rev_Per_Head"] = daytype["Revenue"] / daytype["Customers"].replace(0, np.nan) daytype["_order"] = daytype["DayType"].map({k: i for i, k in enumerate(order)}).fillna(99) daytype = daytype.sort_values("_order").drop(columns="_order") # DayType bars get a fixed brand mapping — Weekday is the steady # baseline (COPPER), Weekend is the highlight (TIEW), Holiday is # the dark anchor (NAVY). Hex values come from the weekly deck. _DAYTYPE_COLOR = { "Weekday": "#976A4D", "Weekend": "#DC7D3D", "Holiday": "#1E2B3A", } col1, col2 = st.columns(2) with col1: fig = px.bar(daytype, x="DayType", y="Rev_Per_Head", color="DayType", color_discrete_map=_DAYTYPE_COLOR, text=daytype["Rev_Per_Head"].apply(lambda v: f"฿{v:,.0f}" if pd.notna(v) else "")) fig.update_layout(showlegend=False, xaxis_title=None, yaxis_title=t("ov_rev_per_head")) fig.update_yaxes(tickformat=",.0f") st.plotly_chart(style_plotly(fig, height=320), use_container_width=True) with col2: fig = px.bar(daytype, x="DayType", y="Customers", color="DayType", color_discrete_map=_DAYTYPE_COLOR, text=daytype["Customers"].apply(lambda v: fmt_num(v))) fig.update_layout(showlegend=False, xaxis_title=None, yaxis_title="Customers") fig.update_yaxes(tickformat=",.0f") st.plotly_chart(style_plotly(fig, height=320), use_container_width=True) # ── Summary ───────────────────────────────────────────────────────────────── # Restaurant-by-restaurant summary tables, mirroring the layout of the # original Summary.xlsx (Copper Buffet) and Summary_Tiew.xlsx (Tiew Copper). # Restaurant filter is intentionally ignored here so both restaurants are # always shown; Branch / date / year filters still apply. with tab_summary: def _summary_filter(df: pd.DataFrame, *, use_date: bool = True) -> pd.DataFrame: """Like apply_filters() but skips the Restaurant filter.""" if df.empty: return df out = df if "Branch" in out.columns and sel_branches: out = out[out["Branch"].isin(sel_branches)] if use_date: if "Date" in out.columns: if date_from is not None: out = out[out["Date"] >= pd.to_datetime(date_from)] if date_to is not None: out = out[out["Date"] <= pd.to_datetime(date_to)] elif {"Year", "Month"}.issubset(out.columns): if date_from is not None: ym_from = date_from.year * 12 + date_from.month out_ym = out["Year"].astype(int) * 12 + out["Month"].astype(int) out = out[out_ym >= ym_from] if date_to is not None: ym_to = date_to.year * 12 + date_to.month out_ym = out["Year"].astype(int) * 12 + out["Month"].astype(int) out = out[out_ym <= ym_to] return out def _render_restaurant_summary(restaurant_name: str) -> None: st.subheader(restaurant_name) monthly = _summary_filter(kpi_monthly) monthly = monthly[monthly.get("Restaurant", "") == restaurant_name] \ if "Restaurant" in monthly.columns else monthly.iloc[0:0] daily = _summary_filter(kpi_daily) daily = daily[daily.get("Restaurant", "") == restaurant_name] \ if "Restaurant" in daily.columns else daily.iloc[0:0] if monthly.empty and daily.empty: st.info(t("sm_no_data", name=restaurant_name)) return # Pre-compute extended monthly DataFrame *once*, reused for both the # trend charts (below) and the Monthly summary table further down. # For Copper Buffet, append per-round customer columns + %Cap + # %Premium derived from fact_shift_items. # - Per-round customer counts: sum(Qty) where Group3 ∈ (Adult,Kid) # - %Cap = total customers / sum(Max Cap per Date×Shift) × 100 # - %Premium = premium customers (SubType='Premium') / total × 100 # Tiew Copper has no shift data → keeps the base columns only. m = monthly.copy().sort_values( ["Year", "Month", "Branch"], ascending=[True, True, True] ) if not monthly.empty else monthly.copy() # ── Split Revenue into Normal / Premium / Delivery / Party Pack ── # Per-row revenue = GrossRev + SVC (net of discount, including # service charge but excluding tax — matches how the ops team # accounts for revenue). # # Tagging differs between the two restaurants: # • Copper Buffet uses Type='Package' + SubType ∈ # ('Normal', 'Premium', 'Delivery', 'Party Pack'). # • Tiew Copper is à la carte, so its delivery is tagged with # Type='Delivery' (no SubType split). It has no Premium / # Party Pack channels — those columns stay at 0. channel_cols: list[str] = [] if not m.empty and not fact_items.empty: fi = fact_items.copy() if "Date" in fi.columns: fi["Date"] = pd.to_datetime(fi["Date"], errors="coerce") fi = fi.dropna(subset=["Date"]) if "Restaurant" in fi.columns: fi = fi[fi["Restaurant"] == restaurant_name] if sel_branches and "Branch" in fi.columns: fi = fi[fi["Branch"].isin(sel_branches)] # Apply the same date-range filter the rest of the Summary tab uses. if date_from is not None and "Date" in fi.columns: fi = fi[fi["Date"] >= pd.to_datetime(date_from)] if date_to is not None and "Date" in fi.columns: fi = fi[fi["Date"] <= pd.to_datetime(date_to)] if not fi.empty and "Year" not in fi.columns: fi["Year"] = fi["Date"].dt.year fi["Month"] = fi["Date"].dt.month # Revenue per row = GrossRev + SVC. Both coerced to numeric # (NaN→0) so the sum is safe when columns are missing. if not fi.empty: _gross = pd.to_numeric(fi.get("GrossRev", 0), errors="coerce").fillna(0) _svc = pd.to_numeric(fi.get("SVC", 0), errors="coerce").fillna(0) fi["_rev"] = _gross + _svc def _bucket_into(source: pd.DataFrame, dest_col: str) -> None: """Sum `source['_rev']` per (Year, Month, Branch) into m[dest_col].""" nonlocal m if source.empty: m[dest_col] = 0.0 return agg = ( source.groupby(["Year", "Month", "Branch"], as_index=False)["_rev"] .sum() .rename(columns={"_rev": dest_col}) ) m = m.merge(agg, on=["Year", "Month", "Branch"], how="left") m[dest_col] = m[dest_col].fillna(0.0) if restaurant_name == "Copper Buffet": # Filter to Package rows, then bucket by SubType. fi_pkg = (fi[fi["Type"] == "Package"] if "Type" in fi.columns else fi.iloc[0:0]) def _by_subtype(sub_value: str) -> pd.DataFrame: if "SubType" not in fi_pkg.columns: return fi_pkg.iloc[0:0] return fi_pkg[fi_pkg["SubType"] == sub_value] _bucket_into(_by_subtype("Normal"), "Normal") _bucket_into(_by_subtype("Premium"), "Premium") _bucket_into(_by_subtype("Delivery"), "Delivery") _bucket_into(_by_subtype("Party Pack"), "PartyPack") elif restaurant_name == "Tiew Copper": # Tiew Copper tags delivery via Type='Delivery'; the # rest of the revenue is "Normal" (à la carte food + # beverage). Derive Normal as Revenue − Delivery so the # column lines up with the kpi_monthly Revenue total # that drives the other tiles. Premium / Party Pack # don't apply here — the columns are intentionally NOT # added so they're omitted from both the table and the # stacked-bar chart legend. delivery_rows = (fi[fi["Type"] == "Delivery"] if "Type" in fi.columns else fi.iloc[0:0]) _bucket_into(delivery_rows, "Delivery") if "Revenue" in m.columns: m["Normal"] = (m["Revenue"] - m.get("Delivery", 0.0)).clip(lower=0) else: m["Normal"] = 0.0 else: # Group-level rows (Holding / CK / Conso) — no channel # split applies. Leave the columns at 0 for consistency. for c in ("Normal", "Premium", "Delivery", "PartyPack"): m[c] = 0.0 channel_cols = [c for c in ("Normal", "Premium", "Delivery", "PartyPack") if c in m.columns] round_cols: list[str] = [] if (restaurant_name == "Copper Buffet" and not fact_shift_items.empty and not m.empty): si_all = _summary_filter(fact_shift_items) if "Restaurant" in si_all.columns: si_all = si_all[si_all["Restaurant"] == "Copper Buffet"] # Only rows in the customer-paying tiers count toward the # round customer total: Normal + Premium + Party Pack # (Delivery and off-menu rows are excluded). si_cust = ( si_all[si_all["SubType"].isin(["Normal", "Premium", "Party Pack"])] if "SubType" in si_all.columns else si_all ) if not si_cust.empty: si_cust = si_cust.copy() si_cust["Round"] = si_cust["Shift"].map(SHIFT_LABELS).fillna( si_cust["Shift"].astype(str).radd("Shift ") ) if "Year" not in si_cust.columns and "Date" in si_cust.columns: si_cust["Year"] = si_cust["Date"].dt.year si_cust["Month"] = si_cust["Date"].dt.month round_pivot = ( si_cust.groupby(["Year", "Month", "Branch", "Round"], as_index=False)["Qty"] .sum() .pivot_table( index=["Year", "Month", "Branch"], columns="Round", values="Qty", aggfunc="sum", fill_value=0, ) .reset_index() ) ordered = [r for r in SHIFT_ORDER if r in round_pivot.columns] extras = [c for c in round_pivot.columns if c not in (["Year", "Month", "Branch"] + ordered)] round_cols = ordered + extras m = m.merge( round_pivot[["Year", "Month", "Branch"] + round_cols], on=["Year", "Month", "Branch"], how="left", ) for c in round_cols: m[c] = m[c].fillna(0) if "SubType" in si_cust.columns: prem = ( si_cust[si_cust["SubType"] == "Premium"] .groupby(["Year", "Month", "Branch"])["Qty"] .sum().rename("_PremCust").reset_index() ) tot = ( si_cust.groupby(["Year", "Month", "Branch"])["Qty"] .sum().rename("_TotalCust").reset_index() ) m = m.merge(tot, on=["Year", "Month", "Branch"], how="left") m = m.merge(prem, on=["Year", "Month", "Branch"], how="left") m["_PremCust"] = m["_PremCust"].fillna(0) m["_TotalCust"] = m["_TotalCust"].fillna(0) m["%Premium"] = np.where( m["_TotalCust"] > 0, m["_PremCust"] / m["_TotalCust"] * 100, np.nan, ) m = m.drop(columns=["_PremCust", "_TotalCust"]) if "Max Cap" in si_all.columns: si_all_dated = si_all.copy() if "Year" not in si_all_dated.columns and "Date" in si_all_dated.columns: si_all_dated["Year"] = si_all_dated["Date"].dt.year si_all_dated["Month"] = si_all_dated["Date"].dt.month # Exclude Delivery rows from the capacity calculation # — delivery customers don't take a seat, so their # Max Cap shouldn't inflate the denominator. Without # this filter, a delivery-only shift (e.g. Shift 5 # when delivery launched this month) would add its # Max Cap to the month's capacity without any # corresponding customers, deflating %Cap. if "SubType" in si_all_dated.columns: si_all_dated = si_all_dated[si_all_dated["SubType"] != "Delivery"] cap_per_shift = ( si_all_dated.groupby(["Date", "Year", "Month", "Branch", "Shift"])["Max Cap"] .max().reset_index() ) cap_month = ( cap_per_shift.groupby(["Year", "Month", "Branch"])["Max Cap"] .sum().rename("_Cap").reset_index() ) tot2 = ( si_cust.groupby(["Year", "Month", "Branch"])["Qty"] .sum().rename("_TotCust2").reset_index() ) m = m.merge(cap_month, on=["Year", "Month", "Branch"], how="left") m = m.merge(tot2, on=["Year", "Month", "Branch"], how="left") m["_Cap"] = m["_Cap"].fillna(0) m["_TotCust2"] = m["_TotCust2"].fillna(0) m["%Cap"] = np.where( m["_Cap"] > 0, m["_TotCust2"] / m["_Cap"] * 100, np.nan, ) m = m.drop(columns=["_Cap", "_TotCust2"]) # ── Trends — small charts above the tables ─────────────────────── if not m.empty: st.markdown(f"**{t('sm_trends')}**") mf = m.copy() mf["YearMonth"] = ( mf["Year"].astype(int).astype(str) + "-" + mf["Month"].astype(int).astype(str).str.zfill(2) ) mf = mf.sort_values("YearMonth") tc1, tc2 = st.columns(2) with tc1: fig = px.line( mf, x="YearMonth", y="Revenue", color="Branch", markers=True, color_discrete_map=BRANCH_COLOR, text="Revenue", ) fig.update_traces( texttemplate="฿%{y:,.0f}", textposition="top center", textfont=dict(size=9), ) fig.update_yaxes(tickformat=",.0f") fig.update_layout(title=t("sm_chart_revenue"), xaxis_title=None, yaxis_title=None) st.plotly_chart(style_plotly(fig, height=280), use_container_width=True) with tc2: fig = px.line( mf, x="YearMonth", y="Customers", color="Branch", markers=True, color_discrete_map=BRANCH_COLOR, text="Customers", ) fig.update_traces( texttemplate="%{y:,.0f}", textposition="top center", textfont=dict(size=9), ) fig.update_yaxes(tickformat=",.0f") fig.update_layout(title=t("sm_chart_customers"), xaxis_title=None, yaxis_title=None) st.plotly_chart(style_plotly(fig, height=280), use_container_width=True) # %Cap / %Premium trend (Copper Buffet only) if "%Cap" in mf.columns or "%Premium" in mf.columns: tc3, tc4 = st.columns(2) if "%Cap" in mf.columns: with tc3: fig = px.line( mf, x="YearMonth", y="%Cap", color="Branch", markers=True, color_discrete_map=BRANCH_COLOR, text="%Cap", ) fig.update_traces( texttemplate="%{y:.1f}%", textposition="top center", textfont=dict(size=9), ) fig.update_yaxes(ticksuffix="%") fig.update_layout(title=t("sm_chart_cap"), xaxis_title=None, yaxis_title=None) st.plotly_chart(style_plotly(fig, height=280), use_container_width=True) if "%Premium" in mf.columns: with tc4: fig = px.line( mf, x="YearMonth", y="%Premium", color="Branch", markers=True, color_discrete_map=BRANCH_COLOR, text="%Premium", ) fig.update_traces( texttemplate="%{y:.1f}%", textposition="top center", textfont=dict(size=9), ) fig.update_yaxes(ticksuffix="%") fig.update_layout(title=t("sm_chart_premium"), xaxis_title=None, yaxis_title=None) st.plotly_chart(style_plotly(fig, height=280), use_container_width=True) # Stacked-bar of customers by round (Copper Buffet only) if round_cols: long_df = mf[["YearMonth", "Branch"] + round_cols].melt( id_vars=["YearMonth", "Branch"], value_vars=round_cols, var_name="Round", value_name="Customers", ) long_df = long_df.groupby(["YearMonth", "Round"], as_index=False)["Customers"].sum() long_df["Round"] = pd.Categorical( long_df["Round"], categories=round_cols, ordered=True, ) long_df = long_df.sort_values(["YearMonth", "Round"]) fig = px.bar( long_df, x="YearMonth", y="Customers", color="Round", barmode="stack", category_orders={"Round": round_cols}, color_discrete_map=ROUND_COLOR, text="Customers", ) fig.update_traces( texttemplate="%{y:,.0f}", textposition="inside", textfont=dict(size=10, color="#FAF7F2"), insidetextanchor="middle", ) fig.update_yaxes(tickformat=",.0f") fig.update_layout(title=t("sm_chart_rounds"), xaxis_title=None, yaxis_title=None) st.plotly_chart(style_plotly(fig, height=340), use_container_width=True) # Stacked-bar revenue split — Normal / Premium / Delivery / # Party Pack per month, summed across the branches in scope. # Skipped only when every channel column is flat-zero in the # filter window. if channel_cols and any( (col in m.columns) and m[col].sum() > 0 for col in channel_cols ): _RC_COLOR = { t("sm_col_normal"): "#976A4D", # COPPER (primary baseline) t("sm_col_premium"): "#1E2B3A", # NAVY (premium = anchor) t("sm_col_delivery"): "#DC7D3D", # TIEW (delivery accent) t("sm_col_partypack"): "#D4A574", # GOLD (party pack accent) } rev_long = mf[["YearMonth"] + channel_cols].copy() # Rename the internal column names to their localized # labels before melting so the chart legend reads in the # user's language. rev_long = rev_long.rename(columns={ "Normal": t("sm_col_normal"), "Premium": t("sm_col_premium"), "Delivery": t("sm_col_delivery"), "PartyPack": t("sm_col_partypack"), }) value_vars = [ t("sm_col_normal"), t("sm_col_premium"), t("sm_col_delivery"), t("sm_col_partypack"), ] value_vars = [c for c in value_vars if c in rev_long.columns] long_df = rev_long.melt( id_vars=["YearMonth"], value_vars=value_vars, var_name="Channel", value_name="Revenue", ) long_df = ( long_df.groupby(["YearMonth", "Channel"], as_index=False)["Revenue"] .sum() .sort_values(["YearMonth"]) ) fig = px.bar( long_df, x="YearMonth", y="Revenue", color="Channel", barmode="stack", color_discrete_map=_RC_COLOR, category_orders={"Channel": value_vars}, text="Revenue", ) fig.update_traces( texttemplate="฿%{y:,.0f}", textposition="inside", textfont=dict(size=9, color="#FAF7F2"), insidetextanchor="middle", ) fig.update_yaxes(tickformat=",.0f") fig.update_layout(title=t("sm_chart_rev_split"), xaxis_title=None, yaxis_title=None, legend_title=None) st.plotly_chart(style_plotly(fig, height=340), use_container_width=True) # ── Monthly summary table ──────────────────────────────────────── st.markdown(f"**{t('sm_monthly_summary')}**") if not m.empty: base_cols = [c for c in ["Year", "Month", "Branch", "Revenue", "Customers"] if c in m.columns] tail_cols = [c for c in ["Rev_Per_Head"] if c in m.columns] # Column order: base · channels · %Cap · %Premium · Rev/Head · rounds. metric_cols = [c for c in ["%Cap", "%Premium"] if c in m.columns] cols = base_cols + channel_cols + metric_cols + tail_cols + round_cols # Pre-format money / count / percent columns to strings (printf # "," flag is not supported on older Streamlit versions). disp = m[cols].copy() for c in ("Revenue", "Rev_Per_Head"): if c in disp.columns: disp[c] = disp[c].map(fmt_money) if "Customers" in disp.columns: disp["Customers"] = disp["Customers"].map(fmt_num) for c in channel_cols: disp[c] = disp[c].map(fmt_money) for c in round_cols: disp[c] = disp[c].map(fmt_num) for c in metric_cols: disp[c] = disp[c].map(fmt_pct) disp = disp.rename(columns={ "Rev_Per_Head": "Rev / Head", "Normal": t("sm_col_normal"), "Premium": t("sm_col_premium"), "Delivery": t("sm_col_delivery"), "PartyPack": t("sm_col_partypack"), }) st.dataframe( disp, use_container_width=True, hide_index=True, column_config={ "Year": st.column_config.NumberColumn(format="%d"), "Month": st.column_config.NumberColumn(format="%d"), }, ) else: st.caption(t("sm_no_monthly_rows")) # ── Daily detail (collapsed by default — can be long) ──────────── # Mirror the Monthly summary column layout, just with Date instead # of Year / Month. Adds %Cap, %Premium and per-round customer # columns for Copper Buffet. with st.expander(t("sm_daily_detail"), expanded=False): if not daily.empty: d = daily.copy().sort_values(["Date", "Branch"], ascending=[True, True]) if "Revenue" in d.columns and "Customers" in d.columns: d["Rev_Per_Head"] = d["Revenue"] / d["Customers"].replace(0, np.nan) d_base = [c for c in ["Date", "Branch", "Revenue", "Customers"] if c in d.columns] d_tail = [c for c in ["Rev_Per_Head"] if c in d.columns] d_round_cols: list[str] = [] d_metric_cols: list[str] = [] d_channel_cols: list[str] = [] # ── Daily channel split — Normal / Premium / Delivery / # Party Pack per (Date, Branch). Same source rules as the # Monthly summary version (GrossRev + SVC; Copper Buffet # uses Type=Package + SubType; Tiew Copper uses # Type='Delivery' with Normal derived as Revenue − # Delivery). The result merges onto `d` so the daily # table renders the same column set as the monthly one. if not fact_items.empty: fi_d = fact_items.copy() if "Date" in fi_d.columns: fi_d["Date"] = pd.to_datetime(fi_d["Date"], errors="coerce") fi_d = fi_d.dropna(subset=["Date"]) if "Restaurant" in fi_d.columns: fi_d = fi_d[fi_d["Restaurant"] == restaurant_name] if sel_branches and "Branch" in fi_d.columns: fi_d = fi_d[fi_d["Branch"].isin(sel_branches)] if date_from is not None and "Date" in fi_d.columns: fi_d = fi_d[fi_d["Date"] >= pd.to_datetime(date_from)] if date_to is not None and "Date" in fi_d.columns: fi_d = fi_d[fi_d["Date"] <= pd.to_datetime(date_to)] if not fi_d.empty: _g = pd.to_numeric(fi_d.get("GrossRev", 0), errors="coerce").fillna(0) _s = pd.to_numeric(fi_d.get("SVC", 0), errors="coerce").fillna(0) fi_d["_rev"] = _g + _s def _bucket_into_daily(source: pd.DataFrame, dest_col: str) -> None: nonlocal d if source.empty: d[dest_col] = 0.0 return agg = ( source.groupby(["Date", "Branch"], as_index=False)["_rev"] .sum().rename(columns={"_rev": dest_col}) ) d = d.merge(agg, on=["Date", "Branch"], how="left") d[dest_col] = d[dest_col].fillna(0.0) if restaurant_name == "Copper Buffet": fi_pkg_d = (fi_d[fi_d["Type"] == "Package"] if "Type" in fi_d.columns else fi_d.iloc[0:0]) def _by_subtype_d(sub_value: str) -> pd.DataFrame: if "SubType" not in fi_pkg_d.columns: return fi_pkg_d.iloc[0:0] return fi_pkg_d[fi_pkg_d["SubType"] == sub_value] _bucket_into_daily(_by_subtype_d("Normal"), "Normal") _bucket_into_daily(_by_subtype_d("Premium"), "Premium") _bucket_into_daily(_by_subtype_d("Delivery"), "Delivery") _bucket_into_daily(_by_subtype_d("Party Pack"), "PartyPack") elif restaurant_name == "Tiew Copper": # Same restaurant-specific rules as the monthly # version above: only Normal + Delivery; Premium # and Party Pack columns are not added so they're # absent from the table and the chart legend. delivery_rows_d = (fi_d[fi_d["Type"] == "Delivery"] if "Type" in fi_d.columns else fi_d.iloc[0:0]) _bucket_into_daily(delivery_rows_d, "Delivery") if "Revenue" in d.columns: d["Normal"] = (d["Revenue"] - d.get("Delivery", 0.0)).clip(lower=0) else: d["Normal"] = 0.0 else: for c in ("Normal", "Premium", "Delivery", "PartyPack"): d[c] = 0.0 d_channel_cols = [c for c in ("Normal", "Premium", "Delivery", "PartyPack") if c in d.columns] if restaurant_name == "Copper Buffet" and not fact_shift_items.empty: si_all = _summary_filter(fact_shift_items) if "Restaurant" in si_all.columns: si_all = si_all[si_all["Restaurant"] == "Copper Buffet"] # Only Normal + Premium + Party Pack rows count as # customers (matches the monthly logic above). si_cust = ( si_all[si_all["SubType"].isin(["Normal", "Premium", "Party Pack"])] if "SubType" in si_all.columns else si_all ) if not si_cust.empty: si_cust = si_cust.copy() si_cust["Round"] = si_cust["Shift"].map(SHIFT_LABELS).fillna( si_cust["Shift"].astype(str).radd("Shift ") ) # ── Per-round customer pivot (Date × Branch × Round) round_pivot_d = ( si_cust.groupby(["Date", "Branch", "Round"], as_index=False)["Qty"] .sum() .pivot_table( index=["Date", "Branch"], columns="Round", values="Qty", aggfunc="sum", fill_value=0, ) .reset_index() ) ordered_d = [r for r in SHIFT_ORDER if r in round_pivot_d.columns] extras_d = [c for c in round_pivot_d.columns if c not in (["Date", "Branch"] + ordered_d)] d_round_cols = ordered_d + extras_d d = d.merge( round_pivot_d[["Date", "Branch"] + d_round_cols], on=["Date", "Branch"], how="left", ) for c in d_round_cols: d[c] = d[c].fillna(0) # ── %Premium per day if "SubType" in si_cust.columns: prem_d = ( si_cust[si_cust["SubType"] == "Premium"] .groupby(["Date", "Branch"])["Qty"] .sum().rename("_PremCust").reset_index() ) tot_d = ( si_cust.groupby(["Date", "Branch"])["Qty"] .sum().rename("_TotalCust").reset_index() ) d = d.merge(tot_d, on=["Date", "Branch"], how="left") d = d.merge(prem_d, on=["Date", "Branch"], how="left") d["_PremCust"] = d["_PremCust"].fillna(0) d["_TotalCust"] = d["_TotalCust"].fillna(0) d["%Premium"] = np.where( d["_TotalCust"] > 0, d["_PremCust"] / d["_TotalCust"] * 100, np.nan, ) d = d.drop(columns=["_PremCust", "_TotalCust"]) d_metric_cols.append("%Premium") # ── %Cap per day # Same Delivery exclusion as the monthly version: # delivery rows don't take a seat, so their Max Cap # shouldn't inflate the per-day capacity denominator. if "Max Cap" in si_all.columns: _si_for_cap = ( si_all[si_all["SubType"] != "Delivery"] if "SubType" in si_all.columns else si_all ) cap_per_shift = ( _si_for_cap.groupby(["Date", "Branch", "Shift"])["Max Cap"] .max().reset_index() ) cap_day = ( cap_per_shift.groupby(["Date", "Branch"])["Max Cap"] .sum().rename("_Cap").reset_index() ) tot_d2 = ( si_cust.groupby(["Date", "Branch"])["Qty"] .sum().rename("_TotCust2").reset_index() ) d = d.merge(cap_day, on=["Date", "Branch"], how="left") d = d.merge(tot_d2, on=["Date", "Branch"], how="left") d["_Cap"] = d["_Cap"].fillna(0) d["_TotCust2"] = d["_TotCust2"].fillna(0) d["%Cap"] = np.where( d["_Cap"] > 0, d["_TotCust2"] / d["_Cap"] * 100, np.nan, ) d = d.drop(columns=["_Cap", "_TotCust2"]) d_metric_cols.append("%Cap") # Column order matches the Monthly summary table: # Date · Branch · Revenue · Customers · channels · # %Cap · %Premium · Rev/Head · rounds. metric_cols_d = [c for c in ["%Cap", "%Premium"] if c in d.columns] cols = d_base + d_channel_cols + metric_cols_d + d_tail + d_round_cols disp = d[cols].copy() for c in ("Revenue", "Rev_Per_Head"): if c in disp.columns: disp[c] = disp[c].map(fmt_money) if "Customers" in disp.columns: disp["Customers"] = disp["Customers"].map(fmt_num) for c in d_channel_cols: disp[c] = disp[c].map(fmt_money) for c in d_round_cols: disp[c] = disp[c].map(fmt_num) for c in metric_cols_d: disp[c] = disp[c].map(fmt_pct) disp = disp.rename(columns={ "Rev_Per_Head": "Rev / Head", "Normal": t("sm_col_normal"), "Premium": t("sm_col_premium"), "Delivery": t("sm_col_delivery"), "PartyPack": t("sm_col_partypack"), }) st.dataframe( disp, use_container_width=True, hide_index=True, column_config={ "Date": st.column_config.DateColumn(format="YYYY-MM-DD"), }, ) else: st.caption(t("sm_no_daily_rows")) _render_restaurant_summary("Copper Buffet") st.divider() _render_restaurant_summary("Tiew Copper") # ── Forecast ──────────────────────────────────────────────────────────────── # Bookings (fact_bookings) + predictions (fact_predictions) are currently # captured only for Copper Buffet. Both tables hold many snapshots per # service date — for any (Date, Branch[, Round]) we keep the row with the # smallest Date_Diff, which is the freshest snapshot relative to the # service date. with tab_forecast: if fact_predictions.empty and fact_bookings.empty: st.info(t("fc_no_data")) else: st.subheader(t("fc_header")) st.caption(t("fc_caption")) # ── This Month forecast — actual MTD + projection for remaining ─ # For days that have already passed, use real customers + revenue # from kpi_daily. For days that haven't happened yet: # • Copper Buffet — model-based per-day prediction from # fact_predictions × trailing 3-month Rev/Head per branch. # • Tiew Copper — trailing 3-month average daily rate × # remaining days (no per-day model exists for Tiew). st.markdown(f"**{t('fc_month_title')}**") _now = pd.Timestamp(_dt.now().date()) _month_start = _now.replace(day=1) _month_end = (_month_start + pd.offsets.MonthEnd(0)).normalize() def _actual_mtd(restaurant: str) -> tuple[float, float]: """Sum of actual Customers + Revenue from kpi_daily for days in the current month that are strictly before today.""" if kpi_daily.empty: return 0.0, 0.0 kd = kpi_daily.copy() if "Date" in kd.columns: kd["Date"] = pd.to_datetime(kd["Date"], errors="coerce") if "Restaurant" in kd.columns: kd = kd[kd["Restaurant"] == restaurant] if sel_branches and "Branch" in kd.columns: kd = kd[kd["Branch"].isin(sel_branches)] kd = kd[(kd["Date"] >= _month_start) & (kd["Date"] < _now)] if kd.empty: return 0.0, 0.0 cust = float(kd["Customers"].sum()) if "Customers" in kd.columns else 0.0 rev = float(kd["Revenue"].sum()) if "Revenue" in kd.columns else 0.0 return cust, rev def _cb_month_forecast() -> tuple[float, float]: """Copper Buffet — actual MTD + per-day prediction for remaining.""" actual_cust, actual_rev = _actual_mtd("Copper Buffet") pred_cust = 0.0 pred_rev = 0.0 if not fact_predictions.empty: _mp = fact_predictions.copy() _mp["Date"] = pd.to_datetime(_mp["Date"], errors="coerce") # Only days from today onwards within current month. _mp = _mp[(_mp["Date"] >= _now) & (_mp["Date"] <= _month_end)] if "Restaurant" in _mp.columns: _mp = _mp[_mp["Restaurant"] == "Copper Buffet"] if sel_branches and "Branch" in _mp.columns: _mp = _mp[_mp["Branch"].isin(sel_branches)] if not _mp.empty: # Latest snapshot per (Date, Branch). if "Date_Diff" in _mp.columns: _mp = _mp.assign(_a=_mp["Date_Diff"].abs()) \ .sort_values("_a") \ .drop_duplicates(["Date", "Branch"], keep="first") \ .drop(columns="_a") pred_cust = float(_mp["Prediction"].sum()) # Per-branch trailing 3-month Rev/Head from kpi_monthly. if not kpi_monthly.empty and {"Restaurant", "Branch", "Rev_Per_Head", "Year", "Month"}.issubset(kpi_monthly.columns): hist = kpi_monthly[kpi_monthly["Restaurant"] == "Copper Buffet"].sort_values(["Year", "Month"]) rph_map = ( hist.groupby("Branch").tail(3) .groupby("Branch")["Rev_Per_Head"].mean().to_dict() ) fallback = sum(rph_map.values()) / len(rph_map) if rph_map else 0.0 for _br, _cust in _mp.groupby("Branch")["Prediction"].sum().items(): pred_rev += float(_cust) * rph_map.get(_br, fallback) return actual_cust + pred_cust, actual_rev + pred_rev def _tc_month_forecast() -> tuple[float, float]: """Tiew Copper — actual MTD + per-day projection using day-of-week weighted averages from the trailing 90 days. Why day-of-week? Restaurant traffic varies sharply by DOW (weekends ≫ weekdays in most cases). Averaging by DOW means a Sunday at the end of the month gets projected at a Sunday-typical rate instead of a "mean of every day this quarter" rate, which would massively under-count weekend nights and over-count weekday nights. """ actual_cust, actual_rev = _actual_mtd("Tiew Copper") remaining = pd.date_range(_now, _month_end, freq="D") if len(remaining) == 0 or kpi_daily.empty: return actual_cust, actual_rev # Trailing 90 days before the start of the current month. trailing_start = _month_start - pd.Timedelta(days=90) kd = kpi_daily.copy() kd["Date"] = pd.to_datetime(kd["Date"], errors="coerce") kd = kd[kd.get("Restaurant", "") == "Tiew Copper"] if sel_branches and "Branch" in kd.columns: kd = kd[kd["Branch"].isin(sel_branches)] kd = kd[(kd["Date"] >= trailing_start) & (kd["Date"] < _month_start)] if kd.empty: return actual_cust, actual_rev # Sum branches first to get a single per-day total, then average # across days within each day-of-week bucket. DOW: Mon=0 … Sun=6. daily_totals = ( kd.groupby("Date", as_index=False) .agg(Customers=("Customers", "sum"), Revenue=("Revenue", "sum")) ) daily_totals["DOW"] = daily_totals["Date"].dt.dayofweek dow_avg = ( daily_totals.groupby("DOW", as_index=False) .agg(AvgCust=("Customers", "mean"), AvgRev=("Revenue", "mean")) ) # Fallback rate if a DOW has no historical samples (e.g. closed # on Mondays during the trailing window). fb_cust = float(daily_totals["Customers"].mean()) fb_rev = float(daily_totals["Revenue"].mean()) dow_cust = dict(zip(dow_avg["DOW"], dow_avg["AvgCust"])) dow_rev = dict(zip(dow_avg["DOW"], dow_avg["AvgRev"])) proj_cust = 0.0 proj_rev = 0.0 for d in remaining: dow = int(d.dayofweek) proj_cust += float(dow_cust.get(dow, fb_cust)) proj_rev += float(dow_rev.get(dow, fb_rev)) return actual_cust + proj_cust, actual_rev + proj_rev cb_cust, cb_rev = _cb_month_forecast() tc_cust, tc_rev = _tc_month_forecast() # Copper Buffet block st.markdown("**Copper Buffet**") cb1, cb2 = st.columns(2) cb1.metric(t("fc_month_customers"), fmt_num(cb_cust)) cb2.metric(t("fc_month_revenue"), fmt_money(cb_rev) if cb_rev > 0 else "—") # Tiew Copper block st.markdown("**Tiew Copper**") tc1, tc2 = st.columns(2) tc1.metric(t("fc_month_customers"), fmt_num(tc_cust)) tc2.metric(t("fc_month_revenue"), fmt_money(tc_rev) if tc_rev > 0 else "—") st.caption(t("fc_month_basis_full")) st.divider() # Local horizon control — the sidebar date range is historical-focused # by default (Jan 1 → yesterday), so the forecast tab keeps its own. horizon_days = st.slider( t("fc_horizon"), min_value=7, max_value=60, value=14, step=1, ) today = pd.Timestamp(_dt.now().date()) end_date = today + pd.Timedelta(days=horizon_days) def _latest_snapshot(df: pd.DataFrame, key_cols: list[str]) -> pd.DataFrame: """For each (Date, Branch[, Round]), keep the row with the smallest Date_Diff — i.e. the most recently collected snapshot. Negative Date_Diff (collection after service) is treated as most-recent so already-served days fall in too.""" if df.empty or "Date_Diff" not in df.columns: return df # |Date_Diff| ascending = closest to today first. tmp = df.assign(_absdiff=df["Date_Diff"].abs()) return tmp.sort_values("_absdiff").drop_duplicates(key_cols, keep="first").drop(columns=["_absdiff"]) # Window the two facts to the horizon + apply the sidebar Branch filter. preds = fact_predictions.copy() if not preds.empty: preds["Date"] = pd.to_datetime(preds["Date"], errors="coerce") preds = preds[(preds["Date"] >= today) & (preds["Date"] <= end_date)] if sel_branches and "Branch" in preds.columns: preds = preds[preds["Branch"].isin(sel_branches)] preds = _latest_snapshot(preds, ["Date", "Branch"]) books = fact_bookings.copy() if not books.empty: books["Date"] = pd.to_datetime(books["Date"], errors="coerce") books = books[(books["Date"] >= today) & (books["Date"] <= end_date)] if sel_branches and "Branch" in books.columns: books = books[books["Branch"].isin(sel_branches)] books = _latest_snapshot(books, ["Date", "Branch", "Round", "Time"]) # ── Headline tiles: next-horizon roll-ups ──────────────────────── total_forecast = int(preds["Prediction"].sum()) if "Prediction" in preds.columns else 0 total_booked = int(books["Total_Seats"].sum()) if "Total_Seats" in books.columns else 0 avg_pct_booked = (total_booked / total_forecast * 100) if total_forecast > 0 else None fk1, fk2, fk3 = st.columns(3) fk1.metric(t("fc_kpi_forecast", n=horizon_days), fmt_num(total_forecast)) fk2.metric(t("fc_kpi_booked", n=horizon_days), fmt_num(total_booked)) fk3.metric(t("fc_kpi_pct_booked"), fmt_pct(avg_pct_booked) if avg_pct_booked is not None else "—") # ── Daily outlook table ────────────────────────────────────────── st.markdown(f"**{t('fc_outlook')}**") if preds.empty: st.info(t("fc_no_horizon")) else: out = preds[["Date", "Branch", "DayType", "Prediction", "Round_1", "Round_2", "Round_3", "Round_4", "Round_5"]].copy() out = out.rename(columns={ "Prediction": "Forecast", "Round_1": "Breakfast", "Round_2": "Lunch", "Round_3": "Dinner", "Round_4": "Late Dinner", "Round_5": "Special", }) if not books.empty: booked_day = books.groupby(["Date", "Branch"], as_index=False)["Total_Seats"].sum() \ .rename(columns={"Total_Seats": "Booked"}) out = out.merge(booked_day, on=["Date", "Branch"], how="left") out["Booked"] = out.get("Booked", pd.Series(dtype=float)).fillna(0) out["%Booked"] = np.where( out["Forecast"] > 0, out["Booked"] / out["Forecast"] * 100, np.nan, ) out["Day"] = out["Date"].dt.strftime("%a") out = out.sort_values(["Date", "Branch"]) cols = ["Date", "Day", "Branch", "DayType", "Forecast", "Booked", "%Booked", "Breakfast", "Lunch", "Dinner", "Late Dinner", "Special"] cols = [c for c in cols if c in out.columns] disp = out[cols].copy() for c in ("Forecast", "Booked", "Breakfast", "Lunch", "Dinner", "Late Dinner", "Special"): if c in disp.columns: disp[c] = disp[c].map(fmt_num) disp["%Booked"] = disp["%Booked"].map(fmt_pct) st.dataframe( disp, use_container_width=True, hide_index=True, column_config={ "Date": st.column_config.DateColumn(format="YYYY-MM-DD"), }, ) # ── Forecast trend line — predicted customers per day ──────────── st.markdown(f"**{t('fc_section_trend')}**") if preds.empty: st.caption(t("fc_no_horizon")) else: fig = px.line( preds, x="Date", y="Prediction", color="Branch", markers=True, color_discrete_map=BRANCH_COLOR, text="Prediction", ) fig.update_traces( texttemplate="%{y:,.0f}", textposition="top center", textfont=dict(size=9), ) fig.update_yaxes(tickformat=",.0f") fig.update_layout(title=t("fc_chart_trend", n=horizon_days), xaxis_title=None, yaxis_title=None) st.plotly_chart(style_plotly(fig, height=320), use_container_width=True) # ── Booked seats stacked by round ──────────────────────────────── st.markdown(f"**{t('fc_section_bookings')}**") if books.empty: st.caption(t("fc_no_bookings")) else: ROUND_NUM_LABEL = {1: "Breakfast", 2: "Lunch", 3: "Dinner", 4: "Late Dinner", 5: "Special"} b = books.copy() b["RoundLabel"] = b["Round"].map(ROUND_NUM_LABEL).fillna( b["Round"].astype(str).radd("Round ") ) book_agg = b.groupby(["Date", "RoundLabel"], as_index=False)["Total_Seats"].sum() round_order = ["Breakfast", "Lunch", "Dinner", "Late Dinner", "Special"] book_agg["RoundLabel"] = pd.Categorical( book_agg["RoundLabel"], categories=[r for r in round_order if r in book_agg["RoundLabel"].unique()] + [r for r in book_agg["RoundLabel"].unique() if r not in round_order], ordered=True, ) book_agg = book_agg.sort_values(["Date", "RoundLabel"]) fig = px.bar( book_agg, x="Date", y="Total_Seats", color="RoundLabel", barmode="stack", color_discrete_map=ROUND_COLOR, category_orders={"RoundLabel": round_order}, text="Total_Seats", ) fig.update_traces( texttemplate="%{y:,.0f}", textposition="inside", textfont=dict(size=10, color="#FAF7F2"), insidetextanchor="middle", ) fig.update_yaxes(tickformat=",.0f") fig.update_layout( title=t("fc_chart_booked", n=horizon_days), xaxis_title=None, yaxis_title=None, ) st.plotly_chart(style_plotly(fig, height=320), use_container_width=True) # ── Items ──────────────────────────────────────────────────────────────── # Shows what items customers actually ordered, ranked by quantity. Uses # fact_items, which has one row per (Date, Restaurant, Branch, Item) plus # Type / SubType / GroupN columns for filtering. with tab_items: if fact_items.empty: st.info(t("it_no_data")) else: items_filt = apply_filters(fact_items) if items_filt.empty: st.info(t("it_no_data")) else: # ── Local filters (Type / Sub-type / Top-N) ────────────────── types_available = sorted(items_filt["Type"].dropna().unique().tolist()) \ if "Type" in items_filt.columns else [] default_types = ["Food"] if "Food" in types_available else types_available fc1, fc2, fc3 = st.columns([2, 2, 1]) with fc1: sel_types = st.multiselect( t("it_type"), types_available, default=default_types, key="it_types", ) it_view = items_filt[items_filt["Type"].isin(sel_types)] \ if sel_types and "Type" in items_filt.columns else items_filt with fc2: if "SubType" in it_view.columns and not it_view.empty: subtypes = sorted(it_view["SubType"].dropna().unique().tolist()) sub_options = [t("it_all_subtypes")] + subtypes sub_pick = st.selectbox( t("it_subtype"), sub_options, index=0, key="it_subtype", ) if sub_pick != t("it_all_subtypes"): it_view = it_view[it_view["SubType"] == sub_pick] else: sub_pick = None with fc3: top_n = st.slider(t("it_top_n"), 5, 50, 20, key="it_top_n") # ── Item search — case-insensitive substring match ──────────── # Applied AFTER the Type/SubType filters so it narrows whatever # subset those produced. Empty search = pass-through. search_q = st.text_input( t("it_search"), key="it_search", placeholder=t("it_search_help"), ).strip() if search_q and "Item" in it_view.columns: _matched = ( it_view["Item"].astype(str) .str.contains(search_q, case=False, na=False, regex=False) ) it_view = it_view[_matched] if it_view.empty: st.info(t("it_search_no_match", q=search_q)) if it_view.empty: if not search_q: st.info(t("it_no_data")) else: # ── KPI tiles ──────────────────────────────────────────── total_qty = int(it_view["Qty"].sum()) if "Qty" in it_view.columns else 0 unique_items = int(it_view["Item"].nunique()) if "Item" in it_view.columns else 0 item_agg = ( it_view.groupby("Item", as_index=False)["Qty"].sum() .sort_values("Qty", ascending=False) ) top_item_name = item_agg.iloc[0]["Item"] if not item_agg.empty else "—" k1, k2, k3 = st.columns(3) k1.metric(t("it_kpi_total"), fmt_num(total_qty)) k2.metric(t("it_kpi_unique"), fmt_num(unique_items)) k3.metric(t("it_kpi_top"), str(top_item_name)) # ── Top-N items horizontal bar ─────────────────────────── top_n_df = item_agg.head(top_n).sort_values("Qty") if not top_n_df.empty: fig = px.bar( top_n_df, x="Qty", y="Item", orientation="h", text="Qty", color_discrete_sequence=["#976A4D"], ) fig.update_traces( texttemplate="%{x:,.0f}", textposition="outside", cliponaxis=False, ) fig.update_xaxes(tickformat=",.0f") fig.update_layout( title=t("it_chart_qty", n=top_n), xaxis_title=None, yaxis_title=None, showlegend=False, ) st.plotly_chart( style_plotly(fig, height=max(320, 22 * len(top_n_df))), use_container_width=True, ) # ── Items by sub-type pie + Items by Protein pie ──────── # Two donut charts side by side on desktop, stacked on # mobile (the responsive CSS handles the column collapse). # Sub-type = the menu category (Teppan, Sushi, etc.). # Group1 = the protein family (Seafood, Duck, Fish, etc.) # — captured by fact_items' Group1 column for Food rows. # Aggregate everything past the top N into a single # "Other" slice so the legend stays compact instead of # filling half the chart with single-item categories. def _top_n_with_other(df: pd.DataFrame, name_col: str, value_col: str, n: int = 8) -> pd.DataFrame: if df.empty or len(df) <= n: return df top = df.nlargest(n, value_col) rest = df[~df[name_col].isin(top[name_col])][value_col].sum() if rest > 0: other = pd.DataFrame({name_col: [t("it_other")], value_col: [rest]}) return pd.concat([top, other], ignore_index=True) return top def _render_donut(df: pd.DataFrame, name_col: str, title: str) -> None: """Pie chart with bottom-horizontal legend so the chart stays roughly square instead of being squeezed by a tall right-side legend.""" fig = px.pie( df, names=name_col, values="Qty", hole=0.55, color_discrete_sequence=BRAND_SEQUENCE, ) fig.update_traces( textposition="inside", texttemplate="%{label}
%{value:,.0f}
%{percent}", insidetextfont=dict(size=11), ) fig.update_layout( title=None, margin=dict(l=10, r=10, t=20, b=80), legend=dict( orientation="h", yanchor="top", y=-0.05, xanchor="center", x=0.5, font=dict(size=11), ), ) st.markdown(f"**{title}**") st.plotly_chart( style_plotly(fig, height=420), use_container_width=True, ) pie_left, pie_right = st.columns(2) with pie_left: if "SubType" in it_view.columns: cat_agg = ( it_view.groupby("SubType", as_index=False)["Qty"].sum() .sort_values("Qty", ascending=False) ) cat_agg = cat_agg[cat_agg["Qty"] > 0] cat_agg = _top_n_with_other(cat_agg, "SubType", "Qty", n=8) if len(cat_agg) >= 2: _render_donut(cat_agg, "SubType", t("it_by_cat")) with pie_right: if "Group1" in it_view.columns: protein_agg = ( it_view.groupby("Group1", as_index=False)["Qty"].sum() .sort_values("Qty", ascending=False) ) protein_agg = protein_agg[protein_agg["Qty"] > 0] protein_agg = _top_n_with_other(protein_agg, "Group1", "Qty", n=8) if len(protein_agg) >= 2: _render_donut(protein_agg, "Group1", t("it_by_protein")) # ── Item detail table ─────────────────────────────────── st.markdown(f"**{t('it_detail')}**") detail_cols = [c for c in ["Item", "Code", "Type", "SubType", "Group1", "Group2", "Qty"] if c in it_view.columns] # Sum qty per item but keep one representative row of the # category columns for context. if {"Item"}.issubset(it_view.columns): agg_map = {"Qty": "sum"} for c in ("Code", "Type", "SubType", "Group1", "Group2"): if c in it_view.columns: agg_map[c] = "first" detail = ( it_view.groupby("Item", as_index=False) .agg(agg_map) .sort_values("Qty", ascending=False) ) disp = detail[detail_cols].copy() if "Qty" in disp.columns: disp["Qty"] = disp["Qty"].map(fmt_num) st.dataframe( disp, use_container_width=True, hide_index=True, ) # ── P&L ───────────────────────────────────────────────────────────────────── with tab_pl: pl_filt = apply_filters(fact_pl) if pl_filt.empty: st.info(t("pl_no_data")) else: # Month picker — list every (Year, Month) present in the filtered # data, newest first; default to the most recent one. _pl_ym = ( pl_filt[["Date"]].dropna() .assign(_y=lambda d: d["Date"].dt.year, _m=lambda d: d["Date"].dt.month) [["_y", "_m"]].drop_duplicates() .sort_values(["_y", "_m"], ascending=[False, False]) ) _pl_options = [f"{int(y)}-{int(m):02d}" for y, m in _pl_ym.itertuples(index=False)] if not _pl_options: st.info("No P&L rows for the current filters.") st.stop() ym = st.selectbox(t("pl_month_picker"), _pl_options, index=0, key="pl_month") _y_sel, _m_sel = (int(p) for p in ym.split("-")) st.subheader(t("pl_top_subcat_title", ym=ym)) latest_rows = pl_filt[ (pl_filt["Date"].dt.year == _y_sel) & (pl_filt["Date"].dt.month == _m_sel) ] group_col = "SubCat" if "SubCat" in latest_rows.columns else "Cat" agg = ( latest_rows.groupby(group_col, as_index=False)["Amount"] .sum() ) agg = agg.reindex(agg["Amount"].abs().sort_values(ascending=False).index).head(15) agg["Flow"] = np.where(agg["Amount"] >= 0, "Income", "Expense") # Pre-format the label as a column so it travels with the row when # we sort below — the previous version computed `text` against the # original DataFrame and then passed a re-sorted one to px.bar, # so labels ended up paired with the wrong bars. agg["AmountLabel"] = agg["Amount"].apply(fmt_money) fig = px.bar( agg.sort_values("Amount"), x="Amount", y=group_col, orientation="h", color="Flow", color_discrete_map={"Income": "#16A34A", "Expense": "#DC2626"}, text="AmountLabel", ) fig.update_traces(textposition="outside", cliponaxis=False) fig.update_layout(yaxis_title=None, xaxis_title=t("pl_amount_axis"), showlegend=True) fig.update_xaxes(tickformat=",.0f") st.plotly_chart(style_plotly(fig, height=520), use_container_width=True) st.subheader(t("pl_monthly_ts")) pl_filt = pl_filt.copy() pl_filt["YearMonth"] = pl_filt["Date"].dt.to_period("M").astype(str) # Two cascading filters: Category, then Sub-Category (only the # sub-cats that exist inside the chosen Cat are offered). _all_cats_label = t("pl_all_categories") _all_subcats_label = t("pl_all_subcats") cats = [_all_cats_label] + sorted(pl_filt["Cat"].dropna().unique().tolist()) cat_col, subcat_col = st.columns(2) with cat_col: cat_pick = st.selectbox(t("pl_cat_picker"), cats, key="pl_ts_cat") # Narrow pool first by category, then offer the sub-cats that exist # in that pool. If "All categories" is selected we draw sub-cats # from the whole filtered set. ts_pool = pl_filt if cat_pick == _all_cats_label else pl_filt[pl_filt["Cat"] == cat_pick] if "SubCat" in ts_pool.columns: subcats = [_all_subcats_label] + sorted(ts_pool["SubCat"].dropna().unique().tolist()) with subcat_col: subcat_pick = st.selectbox(t("pl_subcat_picker"), subcats, key="pl_ts_subcat") ts_src = ts_pool if subcat_pick == _all_subcats_label else ts_pool[ts_pool["SubCat"] == subcat_pick] else: ts_src = ts_pool ts = ( ts_src.groupby(["YearMonth", "Restaurant"], as_index=False)["Amount"].sum() .sort_values("YearMonth") ) fig = px.line( ts, x="YearMonth", y="Amount", color="Restaurant", color_discrete_map=RESTAURANT_COLOR, markers=True, text="Amount", ) fig.update_traces( texttemplate="฿%{y:,.0f}", textposition="top center", textfont=dict(size=9), ) fig.update_yaxes(tickformat=",.0f") st.plotly_chart(style_plotly(fig, height=400), use_container_width=True) # ── Inventory ─────────────────────────────────────────────────────────────── with tab_inv: inv_filt = apply_filters(fact_inventory) if inv_filt.empty: st.info(t("inv_no_data")) else: # Store filter (multiselect) — applied BEFORE the month list is # built so the picker only shows months that still have rows # after the store narrowing. Default is empty (no chip # selected) which the filter logic below treats as "no filter", # i.e. equivalent to all stores being included. if "Store Name" in inv_filt.columns: _stores_all = sorted(inv_filt["Store Name"].dropna().unique().tolist()) if _stores_all: sel_stores = st.multiselect( t("inv_store_filter"), _stores_all, default=[], key="inv_store", ) if sel_stores: inv_filt = inv_filt[inv_filt["Store Name"].isin(sel_stores)] if inv_filt.empty: st.info(t("inv_no_data")) st.stop() # Month picker — list every (Year, Month) present in the filtered # inventory data, newest first; default to the most recent. _inv_ym = ( inv_filt[["Date"]].dropna() .assign(_y=lambda d: d["Date"].dt.year, _m=lambda d: d["Date"].dt.month) [["_y", "_m"]].drop_duplicates() .sort_values(["_y", "_m"], ascending=[False, False]) ) _inv_options = [f"{int(y)}-{int(m):02d}" for y, m in _inv_ym.itertuples(index=False)] if not _inv_options: st.info("No inventory rows for the current filters.") st.stop() col1, col2, col3 = st.columns([2, 1, 1]) with col1: ym = st.selectbox(t("inv_month_picker"), _inv_options, index=0, key="inv_month") with col2: sort_by = st.selectbox( t("inv_sort_by"), ["Value_Closing", "Qty_Closing", "Value_Used", "Qty_Used"], index=2, # default → Value_Used key="inv_sort", ) with col3: st.markdown(" ") # spacer _y_sel, _m_sel = (int(p) for p in ym.split("-")) st.subheader(t("inv_snapshot", ym=ym)) latest_rows = inv_filt[ (inv_filt["Date"].dt.year == _y_sel) & (inv_filt["Date"].dt.month == _m_sel) ] # ── Read prior table selection (if any) BEFORE computing KPIs ──── # `st.dataframe(on_select="rerun", key="inv_table")` (rendered # further down) stores its selection in st.session_state under # that key. By reading it here at the top of the rerun we can # use the picked item to filter both the KPI tiles AND the # downstream charts — keeping all three in sync. _ranked_preview = ( latest_rows[latest_rows[sort_by] > 0] .sort_values(sort_by, ascending=False) .head(100) ) _selected_item: "str | None" = None _prior_table_state = st.session_state.get("inv_table") if _prior_table_state is not None and "Item" in _ranked_preview.columns: try: # The state object exposes `.selection.rows` in recent # Streamlit; fall back to dict access for older builds. if hasattr(_prior_table_state, "selection"): _sel_rows = list(_prior_table_state.selection.rows) elif isinstance(_prior_table_state, dict): _sel_rows = list(_prior_table_state.get("selection", {}).get("rows", [])) else: _sel_rows = [] if _sel_rows: _i = _sel_rows[0] if 0 <= _i < len(_ranked_preview): _selected_item = str(_ranked_preview.iloc[_i]["Item"]) except Exception: _selected_item = None # KPI source rows: when a row is selected, narrow to just that # item; otherwise use the full month-filtered set. _kpi_rows = ( latest_rows[latest_rows["Item"] == _selected_item] if (_selected_item is not None and "Item" in latest_rows.columns) else latest_rows ) # ── KPI tiles — total Value_Used in the month + per-customer rate # Customers for the same month come from kpi_daily, filtered by # the same sidebar Restaurant / Branch filters so the ratio is # consistent with whichever scope the user is viewing. _value_used_total = ( float(_kpi_rows["Value_Used"].sum()) if "Value_Used" in _kpi_rows.columns else 0.0 ) _cust_in_month = kpi_daily.copy() if not kpi_daily.empty else pd.DataFrame() if not _cust_in_month.empty and "Date" in _cust_in_month.columns: _cust_in_month["Date"] = pd.to_datetime(_cust_in_month["Date"], errors="coerce") _cust_in_month = _cust_in_month[ (_cust_in_month["Date"].dt.year == _y_sel) & (_cust_in_month["Date"].dt.month == _m_sel) ] if sel_restaurants and "Restaurant" in _cust_in_month.columns: _cust_in_month = _cust_in_month[_cust_in_month["Restaurant"].isin(sel_restaurants)] if sel_branches and "Branch" in _cust_in_month.columns: _cust_in_month = _cust_in_month[_cust_in_month["Branch"].isin(sel_branches)] _cust_total = ( float(_cust_in_month["Customers"].sum()) if "Customers" in _cust_in_month.columns and not _cust_in_month.empty else 0.0 ) _value_per_cust = (_value_used_total / _cust_total) if _cust_total > 0 else None _qty_used_total = ( float(_kpi_rows["Qty_Used"].sum()) if "Qty_Used" in _kpi_rows.columns else 0.0 ) _qty_per_cust = (_qty_used_total / _cust_total) if _cust_total > 0 else None ik1, ik2, ik3, ik4 = st.columns(4) ik1.metric(t("inv_kpi_value_used"), fmt_money(_value_used_total)) ik2.metric( t("inv_kpi_value_per_cust"), fmt_money(_value_per_cust) if _value_per_cust is not None else "—", ) ik3.metric(t("inv_kpi_qty_used"), fmt_qty(_qty_used_total)) ik4.metric( t("inv_kpi_qty_per_cust"), fmt_qty(_qty_per_cust) if _qty_per_cust is not None else "—", ) # ── Snapshot table (with row selection) ────────────────────── # Render the table FIRST. A single-row selection here drives the # chart below — clicking an item filters its monthly Value Used # / Customer trend; clicking again clears. ranked = latest_rows[latest_rows[sort_by] > 0].sort_values(sort_by, ascending=False).head(100) cols_to_show = [c for c in ["Item", "Restaurant", "Branch", "Store Name", "Unit", "Qty_Closing", "Value_Closing", "Qty_Used", "Value_Used"] if c in ranked.columns] disp = ranked[cols_to_show].copy() for c in ("Qty_Closing", "Qty_Used"): if c in disp.columns: disp[c] = disp[c].map(fmt_qty) for c in ("Value_Closing", "Value_Used"): if c in disp.columns: disp[c] = disp[c].map(fmt_money) disp = disp.rename(columns={ "Value_Closing": "Value Closing (THB)", "Value_Used": "Value Used (THB)", }) st.caption(t("inv_table_hint")) _table_event = st.dataframe( disp, use_container_width=True, hide_index=True, on_select="rerun", selection_mode="single-row", key="inv_table", ) # Translate the picked row index back to the underlying Item name. # The picker indexes into the displayed (formatted) DataFrame, # which has the same row order as `ranked`, so we can look it up # there to recover the original (unformatted) Item value. _selected_item: "str | None" = None try: _sel_rows = _table_event.selection.rows # list[int] if _sel_rows and "Item" in ranked.columns: _idx = _sel_rows[0] if 0 <= _idx < len(ranked): _selected_item = str(ranked.iloc[_idx]["Item"]) except Exception: _selected_item = None # ── Monthly trend: Value Used / Customer ───────────────────── # If a row was selected above, filter the numerator (Value_Used) # to that item only — the denominator (Customers) stays as the # period total because we're asking "for this item, how much # value per customer did we burn each month?". _inv_for_trend = inv_filt.copy() _inv_for_trend["Date"] = pd.to_datetime(_inv_for_trend["Date"], errors="coerce") _inv_for_trend = _inv_for_trend.dropna(subset=["Date"]) if _selected_item is not None and "Item" in _inv_for_trend.columns: _inv_for_trend = _inv_for_trend[_inv_for_trend["Item"] == _selected_item] if not _inv_for_trend.empty and "Value_Used" in _inv_for_trend.columns: _inv_for_trend["YearMonth"] = _inv_for_trend["Date"].dt.to_period("M").astype(str) _inv_for_trend["Year"] = _inv_for_trend["Date"].dt.year _inv_for_trend["Month"] = _inv_for_trend["Date"].dt.month _value_by_month = ( _inv_for_trend.groupby(["Year", "Month", "YearMonth"], as_index=False)["Value_Used"] .sum() .rename(columns={"Value_Used": "ValueUsed"}) ) # Customers per month from kpi_daily — apply the same sidebar # Restaurant/Branch filters + the same date range so the # ratio's numerator and denominator are scope-consistent. _cust_for_trend = kpi_daily.copy() if not kpi_daily.empty else pd.DataFrame() if not _cust_for_trend.empty: _cust_for_trend["Date"] = pd.to_datetime(_cust_for_trend["Date"], errors="coerce") _cust_for_trend = _cust_for_trend.dropna(subset=["Date"]) if date_from is not None: _cust_for_trend = _cust_for_trend[_cust_for_trend["Date"] >= pd.to_datetime(date_from)] if date_to is not None: _cust_for_trend = _cust_for_trend[_cust_for_trend["Date"] <= pd.to_datetime(date_to)] if sel_restaurants and "Restaurant" in _cust_for_trend.columns: _cust_for_trend = _cust_for_trend[_cust_for_trend["Restaurant"].isin(sel_restaurants)] if sel_branches and "Branch" in _cust_for_trend.columns: _cust_for_trend = _cust_for_trend[_cust_for_trend["Branch"].isin(sel_branches)] _cust_for_trend["Year"] = _cust_for_trend["Date"].dt.year _cust_for_trend["Month"] = _cust_for_trend["Date"].dt.month _cust_by_month = ( _cust_for_trend.groupby(["Year", "Month"], as_index=False)["Customers"] .sum() ) else: _cust_by_month = pd.DataFrame(columns=["Year", "Month", "Customers"]) _trend = _value_by_month.merge( _cust_by_month, on=["Year", "Month"], how="left", ) _trend["Customers"] = _trend["Customers"].fillna(0) _trend["VperCust"] = np.where( _trend["Customers"] > 0, _trend["ValueUsed"] / _trend["Customers"], np.nan, ) _trend = _trend.dropna(subset=["VperCust"]).sort_values(["Year", "Month"]) if not _trend.empty: fig = px.line( _trend, x="YearMonth", y="VperCust", markers=True, color_discrete_sequence=["#976A4D"], # COPPER text="VperCust", ) fig.update_traces( texttemplate="฿%{y:,.0f}", textposition="top center", textfont=dict(size=9), ) fig.update_yaxes(tickformat=",.0f") _chart_title = ( t("inv_chart_vpc_item", item=_selected_item) if _selected_item is not None else t("inv_chart_vpc_trend") ) fig.update_layout( title=_chart_title, xaxis_title=None, yaxis_title=None, showlegend=False, ) st.plotly_chart(style_plotly(fig, height=320), use_container_width=True) # ── Monthly trend: Quantity Used / Customer ────────────── # Same inputs and same row-selection filter as the Value # chart, just swapping Value_Used → Qty_Used in the # numerator. Reuses the already-built _cust_by_month so the # denominator stays scope-consistent across both charts. if "Qty_Used" in _inv_for_trend.columns: _qty_by_month = ( _inv_for_trend.groupby(["Year", "Month", "YearMonth"], as_index=False)["Qty_Used"] .sum() .rename(columns={"Qty_Used": "QtyUsed"}) ) _qtrend = _qty_by_month.merge( _cust_by_month, on=["Year", "Month"], how="left", ) _qtrend["Customers"] = _qtrend["Customers"].fillna(0) _qtrend["QperCust"] = np.where( _qtrend["Customers"] > 0, _qtrend["QtyUsed"] / _qtrend["Customers"], np.nan, ) _qtrend = _qtrend.dropna(subset=["QperCust"]).sort_values(["Year", "Month"]) if not _qtrend.empty: fig = px.line( _qtrend, x="YearMonth", y="QperCust", markers=True, color_discrete_sequence=["#DC7D3D"], # TIEW orange text="QperCust", ) fig.update_traces( texttemplate="%{y:,.2f}", textposition="top center", textfont=dict(size=9), ) fig.update_yaxes(tickformat=",.2f") _qchart_title = ( t("inv_chart_qpc_item", item=_selected_item) if _selected_item is not None else t("inv_chart_qpc_trend") ) fig.update_layout( title=_qchart_title, xaxis_title=None, yaxis_title=None, showlegend=False, ) st.plotly_chart(style_plotly(fig, height=320), use_container_width=True)