CB-Dashboard / streamlit_app.py
taotanapol's picture
Update Forecast bug
2eb6a03 verified
Raw
History Blame Contribute Delete
154 kB
"""
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
# ─────────────────────────────────────────────────────────────────────────────
@st.cache_data(show_spinner="Loading dashboard data ...")
def load_workbook(source) -> dict[str, pd.DataFrame]:
"""Parse every sheet of Dashboard_Data.xlsx into a {name: DataFrame} dict.
Date columns are coerced to datetime so downstream filters work uniformly.
"""
sheets = pd.read_excel(source, sheet_name=None, engine="openpyxl")
for name, df in sheets.items():
if "Date" in df.columns:
sheets[name]["Date"] = pd.to_datetime(df["Date"], errors="coerce")
if "Year" in df.columns:
sheets[name]["Year"] = pd.to_numeric(df["Year"], errors="coerce")
if "Month" in df.columns:
sheets[name]["Month"] = pd.to_numeric(df["Month"], errors="coerce")
return sheets
def find_local_file() -> Path | None:
"""Look for Dashboard_Data.xlsx in cwd, this script's dir, its parent,
and a 'Dashboard' subfolder of either (matches the current project layout)."""
here = Path(__file__).resolve().parent
candidates = [
Path.cwd() / "Dashboard_Data.xlsx",
Path.cwd() / "Dashboard" / "Dashboard_Data.xlsx",
here / "Dashboard_Data.xlsx",
here / "Dashboard" / "Dashboard_Data.xlsx",
here.parent / "Dashboard_Data.xlsx",
here.parent / "Dashboard" / "Dashboard_Data.xlsx",
here.parent.parent / "Dashboard" / "Dashboard_Data.xlsx",
]
for candidate in candidates:
if candidate.exists():
return candidate
return None
class FetchError(Exception):
"""Carries diagnostic info about a failed fetch so the UI can surface it."""
def __init__(self, message: str, diagnostics: dict):
super().__init__(message)
self.diagnostics = diagnostics
@st.cache_data(show_spinner="Fetching Dashboard_Data from GitHub ...")
def fetch_url(url: str, token: str | None = None) -> bytes:
"""GET the .xlsx bytes from a URL. Supports an optional Bearer token
for token-gated downloads (private repos).
GitHub redirects release-asset URLs to a signed S3 / CDN URL. Sending the
Authorization header on that follow-up request makes the CDN reject it
(the signed query params already authenticate the request). So we:
1. Hit github.com WITH the token; allow_redirects=False
2. Follow the Location header WITHOUT the token
This works for both public and private repos.
On any failure, raises FetchError with a dict of diagnostics (URL,
token-presence flag, HTTP status, redirect target, first line of body).
The token value itself is never included β€” only a yes/no flag.
"""
import requests
diag: dict = {
"url": url,
"token_present": bool(token),
"stage": "initial_request",
"status_code": None,
"redirected_to": None,
"final_status_code": None,
"body_first_line": None,
"exception": None,
}
headers = {}
if token:
headers["Authorization"] = f"Bearer {token}"
headers["Accept"] = "application/octet-stream"
try:
r = requests.get(url, headers=headers, timeout=180, allow_redirects=False)
diag["status_code"] = r.status_code
# 30x: follow the Location header WITHOUT the auth header.
if r.status_code in (301, 302, 303, 307, 308):
cdn_url = r.headers.get("Location") or url
diag["redirected_to"] = cdn_url
diag["stage"] = "follow_redirect"
r = requests.get(cdn_url, timeout=180, allow_redirects=True)
diag["final_status_code"] = r.status_code
else:
diag["final_status_code"] = r.status_code
if not r.ok:
body = (r.text or "").strip().splitlines()
diag["body_first_line"] = body[0][:300] if body else ""
raise FetchError(
f"HTTP {r.status_code} from {r.url}",
diag,
)
return r.content
except FetchError:
raise
except Exception as exc:
diag["exception"] = f"{type(exc).__name__}: {exc}"
raise FetchError(str(exc), diag) from exc
@st.cache_data(ttl=60, show_spinner=False)
def get_dataset_revision(repo_id: str, repo_type: str, token: str | None) -> str:
"""Return the latest commit SHA of an HF repo, cached for 60 seconds.
Used as a cache-key parameter to the data-fetch functions below:
when a new commit lands (i.e. the collection script just uploaded a
fresh parquet snapshot), the SHA changes, the cache key changes,
and `st.cache_data` automatically refetches the data on the next
page interaction β€” no manual cache-clear needed.
The 60 s TTL bounds how often we hit the HF API; with the script
refreshing once a day, that's a worst-case ~60-second delay before
viewers see new data.
"""
if not repo_id:
return ""
try:
from huggingface_hub import HfApi
api = HfApi(token=token)
info = api.repo_info(repo_id=repo_id, repo_type=repo_type)
return getattr(info, "sha", "") or ""
except Exception:
# If the API call fails we return an empty string. Subsequent
# fetches still work (just from the existing cache); we just
# lose auto-refresh on this rerun.
return ""
@st.cache_data(show_spinner="Fetching Dashboard parquet from Hugging Face Hub ...")
def fetch_hf_parquet(repo_id: str, repo_type: str, token: str | None,
subfolder: str = "parquet",
revision: str = "") -> dict[str, pd.DataFrame]:
"""Snapshot-download the parquet/ folder of a HF dataset repo and assemble
the {sheet_name: DataFrame} dict the rest of the app expects.
Parquet files are ~10x smaller than the xlsx mirror and load ~10-50x
faster, so this is the preferred data path when the dataset has been
refreshed by `UploadParquetToHuggingFaceHub()`.
Raises FetchError (with diagnostics) so the diagnostics expander still works.
"""
diag: dict = {
"url": f"hf://{repo_type}s/{repo_id}/{subfolder}/*.parquet",
"token_present": bool(token),
"stage": "snapshot_download",
"status_code": None,
"redirected_to": None,
"final_status_code": None,
"body_first_line": None,
"exception": None,
}
try:
from huggingface_hub import snapshot_download
local_dir = snapshot_download(
repo_id=repo_id,
repo_type=repo_type,
token=token,
allow_patterns=[f"{subfolder}/*.parquet"],
)
parquet_dir = os.path.join(local_dir, subfolder)
if not os.path.isdir(parquet_dir):
diag["exception"] = (
f"No '{subfolder}/' folder in the repo. "
"Run BuildDashboardParquet() + UploadParquetToHuggingFaceHub() "
"from the collection script."
)
raise FetchError(diag["exception"], diag)
files = sorted(f for f in os.listdir(parquet_dir) if f.endswith(".parquet"))
if not files:
diag["exception"] = f"No .parquet files found under {parquet_dir}."
raise FetchError(diag["exception"], diag)
sheets_out: dict[str, pd.DataFrame] = {}
for fn in files:
name = fn[:-len(".parquet")]
df = pd.read_parquet(os.path.join(parquet_dir, fn))
# Coerce known date columns back to datetime for downstream filters.
for c in ("Date",):
if c in df.columns:
df[c] = pd.to_datetime(df[c], errors="coerce")
for c in ("Year", "Month"):
if c in df.columns:
df[c] = pd.to_numeric(df[c], errors="coerce")
sheets_out[name] = df
return sheets_out
except FetchError:
raise
except Exception as exc:
diag["exception"] = f"{type(exc).__name__}: {exc}"
raise FetchError(str(exc), diag) from exc
@st.cache_data(show_spinner="Fetching Dashboard_Data from Hugging Face Hub ...")
def fetch_hf(repo_id: str, filename: str, repo_type: str, token: str | None,
revision: str = "") -> str:
"""Download a file from a Hugging Face Hub repo and return the local path.
Uses the huggingface_hub library which handles auth, redirects, and caching
automatically. The file is cached on the Space's filesystem, so subsequent
page loads in the same session are instant.
Raises FetchError (with diagnostics) so the diagnostics expander still works.
"""
diag: dict = {
"url": f"hf://{repo_type}s/{repo_id}/{filename}",
"token_present": bool(token),
"stage": "hf_hub_download",
"status_code": None,
"redirected_to": None,
"final_status_code": None,
"body_first_line": None,
"exception": None,
}
try:
from huggingface_hub import hf_hub_download
local = hf_hub_download(
repo_id=repo_id,
filename=filename,
repo_type=repo_type,
token=token,
)
return local
except Exception as exc:
diag["exception"] = f"{type(exc).__name__}: {exc}"
raise FetchError(str(exc), diag) from exc
sheets: dict[str, pd.DataFrame] | None = None
source_label = ""
# 1) Hosted deployment path β€” st.secrets points at a hosted data file.
# Two routes, in priority order:
#
# a) Hugging Face Hub (RECOMMENDED, most reliable for HF Spaces):
# HF_REPO = "<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)