Spaces:
Running
Running
| """ | |
| 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 <code>CB-Group</code> 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 " | |
| "<code>CB-Group</code> 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": "ΰΉΰΈΰΈΰΈ²ΰΈ°ΰΈͺΰΈ‘ΰΈ²ΰΈΰΈ΄ΰΈΰΈΰΈΰΈΰΈΰΈΰΈΰΉΰΈΰΈ£ <code>CB-Group</code> ΰΈΰΈ Hugging Face ΰΉΰΈΰΉΰΈ²ΰΈΰΈ±ΰΉΰΈ " | |
| "ΰΉΰΈΰΉΰΈ²ΰΈͺΰΈΉΰΉΰΈ£ΰΈ°ΰΈΰΈΰΈΰΉΰΈ§ΰΈ’ΰΈΰΈ±ΰΈΰΈΰΈ΅ HF ΰΈΰΈΰΈΰΈΰΈΈΰΈΰΉΰΈΰΈ·ΰΉΰΈΰΈΰΈ³ΰΉΰΈΰΈ΄ΰΈΰΈΰΈ²ΰΈ£ΰΈΰΉΰΈ", | |
| "auth_button": "ΰΉΰΈΰΉΰΈ²ΰΈͺΰΈΉΰΉΰΈ£ΰΈ°ΰΈΰΈΰΈΰΉΰΈ§ΰΈ’ Hugging Face", | |
| "auth_no_acct": "ΰΈ’ΰΈ±ΰΈΰΉΰΈ‘ΰΉΰΈ‘ΰΈ΅ΰΈΰΈ±ΰΈΰΈΰΈ΅ HF? ΰΈΰΈΰΉΰΈ«ΰΉΰΉΰΈΰΉΰΈ²ΰΈΰΈΰΈΰΉΰΈΰΈΰΈΰΈΰΈ£ΰΉΰΈΰΉΰΈΰΈ΄ΰΈΰΈΰΈΈΰΈΰΉΰΈΰΉΰΈ² " | |
| "ΰΈΰΈΰΈΰΉΰΈΰΈ£ <code>CB-Group</code> ΰΈΰΈ²ΰΈΰΈΰΈ±ΰΉΰΈ", | |
| "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( | |
| """ | |
| <style> | |
| /* Light-theme Copper Group palette β synced with the weekly deck. | |
| --copper = COPPER #976A4D (Copper Buffet primary) | |
| --copper-soft = GOLD #D4A574 (light accent, borders) | |
| --cream = CREAM #FAF7F2 (page surface) | |
| --cream-2 = warm cream-2 #F2EBE0 (card / sidebar) | |
| --ink = NAVY #1E2B3A (body + chart text) | |
| --muted = MUTED #6B7280 (secondary text) */ | |
| :root { | |
| --copper: #976A4D; | |
| --copper-soft: #D4A574; | |
| --cream: #FAF7F2; | |
| --cream-2: #F2EBE0; | |
| --ink: #1E2B3A; | |
| --muted: #6B7280; | |
| } | |
| div[data-testid="stMetric"] { | |
| background: var(--cream-2); | |
| border: 1px solid var(--copper-soft); | |
| border-radius: 10px; | |
| padding: 16px 20px; | |
| } | |
| div[data-testid="stMetric"] [data-testid="stMetricValue"] { | |
| font-size: 28px; | |
| font-weight: 600; | |
| color: var(--ink); | |
| } | |
| div[data-testid="stMetric"] [data-testid="stMetricLabel"] { | |
| color: var(--muted); | |
| font-size: 12px; | |
| letter-spacing: 0.5px; | |
| text-transform: uppercase; | |
| } | |
| section[data-testid="stSidebar"] { | |
| background: var(--cream-2); | |
| } | |
| /* Lock the sidebar open on desktop so Restaurant / Branch / Date | |
| filters are always one click away. Mobile keeps its slide-out | |
| toggle because the sidebar would otherwise eat the whole screen. | |
| Selectors cover several Streamlit versions of the collapse chevron | |
| β when one matches the others are harmless no-ops. */ | |
| @media (min-width: 769px) { | |
| button[data-testid="stSidebarCollapseButton"], | |
| button[data-testid="stSidebarCollapsedControl"], | |
| button[data-testid="collapsedControl"], | |
| [data-testid="stSidebarCollapseButton"], | |
| [data-testid="collapsedControl"] { | |
| display: none !important; | |
| } | |
| /* Belt and suspenders: also catch the chevron rendered inside | |
| the sidebar header on newer Streamlit builds. */ | |
| section[data-testid="stSidebar"] > div:first-child > button { | |
| display: none !important; | |
| } | |
| } | |
| .small-caption { | |
| color: var(--muted); | |
| font-size: 12px; | |
| } | |
| /* Make the page background pick up the cream tone consistently. */ | |
| .stApp { background: var(--cream); } | |
| /* Tab labels lean copper to reinforce the brand on the main nav. */ | |
| button[data-baseweb="tab"] { | |
| color: var(--muted) !important; | |
| } | |
| button[data-baseweb="tab"][aria-selected="true"] { | |
| color: var(--copper) !important; | |
| } | |
| /* ββ Mobile-responsive overrides (phones / narrow tablets) βββββββββββ | |
| Streamlit's default st.columns() lays out equal-width children in a | |
| flex row that never wraps. On a 380 px screen the 5 KPI tiles or the | |
| 2-column chart pairs become unreadably narrow. The rules below tell | |
| any horizontal block to wrap, and force each child column to take | |
| the full row width below 768 px. */ | |
| @media (max-width: 768px) { | |
| div[data-testid="stHorizontalBlock"] { | |
| flex-wrap: wrap !important; | |
| } | |
| div[data-testid="stHorizontalBlock"] > div[data-testid="column"] { | |
| flex: 1 1 100% !important; | |
| min-width: 100% !important; | |
| width: 100% !important; | |
| } | |
| div[data-testid="stMetric"] { | |
| padding: 12px 14px; | |
| } | |
| div[data-testid="stMetric"] [data-testid="stMetricValue"] { | |
| font-size: 22px; | |
| } | |
| div[data-testid="stMetric"] [data-testid="stMetricLabel"] { | |
| font-size: 11px; | |
| } | |
| /* Give Plotly charts a bit more vertical room since they now occupy | |
| the full width β Plotly aspect ratio reads weird otherwise. */ | |
| div[data-testid="stPlotlyChart"] { | |
| min-height: 300px; | |
| } | |
| /* Tighten the title so the KPI tiles are visible above the fold. */ | |
| h1 { | |
| font-size: 24px !important; | |
| margin-bottom: 4px !important; | |
| } | |
| } | |
| </style> | |
| """, | |
| 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""" | |
| <div style="max-width: 420px; margin: 80px auto 0 auto; text-align: center;"> | |
| <div style="font-size: 32px; margin-bottom: 8px;">π</div> | |
| <h2 style="margin: 0 0 8px 0; color: #1E2B3A;">{t("auth_title")}</h2> | |
| <p style="color: #6B7280; margin: 0 0 28px 0; line-height: 1.5;"> | |
| {t("auth_intro")} | |
| </p> | |
| <a href="{auth_url}" target="_self" style=" | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 10px; | |
| background: #976A4D; | |
| color: #FAF7F2; | |
| padding: 12px 28px; | |
| border-radius: 10px; | |
| font-weight: 600; | |
| font-size: 15px; | |
| text-decoration: none; | |
| box-shadow: 0 1px 3px rgba(30, 43, 58, 0.15); | |
| ">π€ {t("auth_button")}</a> | |
| <p style="color: #6B7280; font-size: 12px; margin-top: 32px;"> | |
| {t("auth_no_acct")} | |
| <a href="https://huggingface.co/join" target="_blank" | |
| style="color: #976A4D;">{t("auth_signup")}</a>. | |
| </p> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| st.stop() | |
| _oauth_gate() | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Data loading | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 | |
| 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 | |
| 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 "" | |
| 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 | |
| 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 = "<user>/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 = "<stable URL to Dashboard_Data.xlsx>" | |
| # 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"<span class='small-caption'>{t('filtered_period')}: <b>{period_str}</b> Β· " | |
| f"{t('n_restaurants', n=len(sel_restaurants))}, {t('n_branches', n=len(sel_branches))}</span>", | |
| 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}<br>ΰΈΏ%{value:,.0f}<br>%{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}<br>%{value:,.0f}<br>%{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) | |