taotanapol commited on
Commit
db6147c
Β·
verified Β·
1 Parent(s): ba2b57f

Support Thai language

Browse files
Files changed (1) hide show
  1. streamlit_app.py +305 -83
streamlit_app.py CHANGED
@@ -31,6 +31,190 @@ import pandas as pd
31
  import plotly.express as px
32
  import streamlit as st
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  # ─────────────────────────────────────────────────────────────────────────────
35
  # Page setup
36
  # ─────────────────────────────────────────────────────────────────────────────
@@ -282,11 +466,9 @@ def _oauth_gate() -> None:
282
  f"""
283
  <div style="max-width: 420px; margin: 80px auto 0 auto; text-align: center;">
284
  <div style="font-size: 32px; margin-bottom: 8px;">πŸ”’</div>
285
- <h2 style="margin: 0 0 8px 0; color: #1E2B3A;">Copper Group Dashboard</h2>
286
  <p style="color: #6B7280; margin: 0 0 28px 0; line-height: 1.5;">
287
- Restricted to members of the
288
- <code>CB-Group</code> organization on Hugging Face.
289
- Sign in with your HF account to continue.
290
  </p>
291
  <a href="{auth_url}" target="_self" style="
292
  display: inline-flex;
@@ -300,12 +482,11 @@ def _oauth_gate() -> None:
300
  font-size: 15px;
301
  text-decoration: none;
302
  box-shadow: 0 1px 3px rgba(30, 43, 58, 0.15);
303
- ">πŸ€— Sign in with Hugging Face</a>
304
  <p style="color: #6B7280; font-size: 12px; margin-top: 32px;">
305
- Don't have an HF account? Ask the dashboard owner to invite
306
- you to the <code>CB-Group</code> org, then
307
  <a href="https://huggingface.co/join" target="_blank"
308
- style="color: #976A4D;">sign up here</a>.
309
  </p>
310
  </div>
311
  """,
@@ -569,7 +750,7 @@ if secrets_token is None:
569
  secrets_token = os.environ.get("HF_TOKEN") or None
570
 
571
  # Header (we show it early so the loading status is visible even on slow links)
572
- st.title("Copper Group Dashboard")
573
 
574
 
575
  def _show_diagnostics(exc: "FetchError", source_hint: str) -> None:
@@ -723,12 +904,29 @@ _restaurant_defaults = [r for r in _DEFAULT_RESTAURANTS if r in restaurants_all]
723
  _branch_defaults = [b for b in _DEFAULT_BRANCHES if b in branches_all] \
724
  or branches_all
725
 
726
- st.sidebar.title("Filters")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
727
  sel_restaurants = st.sidebar.multiselect(
728
- "Restaurant", restaurants_all, default=_restaurant_defaults
729
  )
730
  sel_branches = st.sidebar.multiselect(
731
- "Branch", branches_all, default=_branch_defaults
732
  )
733
 
734
  # Year + date range
@@ -756,7 +954,7 @@ if min_date and max_date:
756
  _default_start, _default_end = min_date, max_date
757
 
758
  sel_dates = st.sidebar.date_input(
759
- "Date range",
760
  value=(_default_start, _default_end),
761
  min_value=min_date,
762
  max_value=max_date,
@@ -769,16 +967,16 @@ else:
769
  date_from = date_to = None
770
 
771
  st.sidebar.divider()
772
- st.sidebar.caption(f"Source: {source_label}")
773
- st.sidebar.caption(f"Sheets loaded: {len(sheets)}")
774
 
775
  # ── Signed-in user badge + sign-out (only shown when OAuth is active) ────────
776
  _oauth_user = st.session_state.get("_oauth_user")
777
  if _oauth_user:
778
  _name = _oauth_user.get("preferred_username") or _oauth_user.get("name") or "user"
779
  st.sidebar.divider()
780
- st.sidebar.markdown(f"Signed in as **{_name}**")
781
- if st.sidebar.button("Sign out", use_container_width=True):
782
  # Drop everything OAuth-related and force the sign-in screen on rerun.
783
  for _k in ("_oauth_user", "_oauth_state"):
784
  st.session_state.pop(_k, None)
@@ -914,10 +1112,10 @@ BRAND_SEQUENCE = [
914
  # Header + KPI tiles
915
  # ─────────────────────────────────────────────────────────────────────────────
916
  # (Title already rendered up-top; just print the filtered-period caption.)
917
- period_str = f"{date_from} β†’ {date_to}" if date_from else "all dates"
918
  st.markdown(
919
- f"<span class='small-caption'>Filtered period: <b>{period_str}</b> Β· "
920
- f"{len(sel_restaurants)} restaurant(s), {len(sel_branches)} branch(es)</span>",
921
  unsafe_allow_html=True,
922
  )
923
 
@@ -940,7 +1138,8 @@ st.divider()
940
  # Tabs
941
  # ─────────────────────────────────────────────────────────────────────────────
942
  tab_overview, tab_summary, tab_forecast, tab_pl, tab_inv = st.tabs(
943
- ["Overview", "Summary", "Forecast", "P&L", "Inventory"]
 
944
  )
945
 
946
  # ── Overview ───────────────────────────────────────────────────────────────
@@ -948,7 +1147,7 @@ with tab_overview:
948
  left, right = st.columns([2, 1])
949
 
950
  with left:
951
- st.subheader("Monthly Revenue Trend")
952
  if not kpi_monthly.empty:
953
  mf = apply_filters(kpi_monthly)
954
  if not mf.empty:
@@ -975,10 +1174,10 @@ with tab_overview:
975
  fig.update_layout(xaxis_title=None, yaxis_title="Revenue (THB)")
976
  st.plotly_chart(style_plotly(fig, height=420), use_container_width=True)
977
  else:
978
- st.info("No monthly rows match the current filters.")
979
 
980
  with right:
981
- st.subheader("Channel Mix")
982
  if not fact_sales.empty:
983
  sf = apply_filters(fact_sales)
984
  # Exclude roll-up rows from the source data β€” "Grand Total" /
@@ -1008,9 +1207,9 @@ with tab_overview:
1008
  )
1009
  st.plotly_chart(style_plotly(fig, height=420), use_container_width=True)
1010
  else:
1011
- st.info("No channel data for the current filters.")
1012
 
1013
- st.subheader("Day Type Performance")
1014
  if not filtered_daily.empty and "DayType" in filtered_daily.columns:
1015
  order = ["Weekday", "Weekend", "Holiday"]
1016
  daytype = (
@@ -1035,7 +1234,7 @@ with tab_overview:
1035
  color_discrete_map=_DAYTYPE_COLOR,
1036
  text=daytype["Rev_Per_Head"].apply(lambda v: f"ΰΈΏ{v:,.0f}" if pd.notna(v) else ""))
1037
  fig.update_layout(showlegend=False, xaxis_title=None,
1038
- yaxis_title="Revenue per Head (THB)")
1039
  fig.update_yaxes(tickformat=",.0f")
1040
  st.plotly_chart(style_plotly(fig, height=320), use_container_width=True)
1041
  with col2:
@@ -1087,7 +1286,7 @@ with tab_summary:
1087
  if "Restaurant" in daily.columns else daily.iloc[0:0]
1088
 
1089
  if monthly.empty and daily.empty:
1090
- st.info(f"No data for {restaurant_name} in the current filters.")
1091
  return
1092
 
1093
  # Pre-compute extended monthly DataFrame *once*, reused for both the
@@ -1192,7 +1391,7 @@ with tab_summary:
1192
 
1193
  # ── Trends β€” small charts above the tables ───────────────────────
1194
  if not m.empty:
1195
- st.markdown("**Trends**")
1196
  mf = m.copy()
1197
  mf["YearMonth"] = (
1198
  mf["Year"].astype(int).astype(str)
@@ -1214,7 +1413,7 @@ with tab_summary:
1214
  textfont=dict(size=9),
1215
  )
1216
  fig.update_yaxes(tickformat=",.0f")
1217
- fig.update_layout(title="Monthly Revenue (THB)",
1218
  xaxis_title=None, yaxis_title=None)
1219
  st.plotly_chart(style_plotly(fig, height=280), use_container_width=True)
1220
  with tc2:
@@ -1229,7 +1428,7 @@ with tab_summary:
1229
  textfont=dict(size=9),
1230
  )
1231
  fig.update_yaxes(tickformat=",.0f")
1232
- fig.update_layout(title="Monthly Customers",
1233
  xaxis_title=None, yaxis_title=None)
1234
  st.plotly_chart(style_plotly(fig, height=280), use_container_width=True)
1235
 
@@ -1249,7 +1448,7 @@ with tab_summary:
1249
  textfont=dict(size=9),
1250
  )
1251
  fig.update_yaxes(ticksuffix="%")
1252
- fig.update_layout(title="%Cap β€” Capacity utilised",
1253
  xaxis_title=None, yaxis_title=None)
1254
  st.plotly_chart(style_plotly(fig, height=280), use_container_width=True)
1255
  if "%Premium" in mf.columns:
@@ -1265,7 +1464,7 @@ with tab_summary:
1265
  textfont=dict(size=9),
1266
  )
1267
  fig.update_yaxes(ticksuffix="%")
1268
- fig.update_layout(title="%Premium β€” premium share of customers",
1269
  xaxis_title=None, yaxis_title=None)
1270
  st.plotly_chart(style_plotly(fig, height=280), use_container_width=True)
1271
 
@@ -1294,12 +1493,12 @@ with tab_summary:
1294
  insidetextanchor="middle",
1295
  )
1296
  fig.update_yaxes(tickformat=",.0f")
1297
- fig.update_layout(title="Customers by Round (monthly)",
1298
- xaxis_title=None, yaxis_title="Customers")
1299
  st.plotly_chart(style_plotly(fig, height=340), use_container_width=True)
1300
 
1301
  # ── Monthly summary table ────────────────────────────────────────
1302
- st.markdown("**Monthly summary**")
1303
  if not m.empty:
1304
  base_cols = [c for c in
1305
  ["Year", "Month", "Branch", "Revenue", "Customers"]
@@ -1331,13 +1530,13 @@ with tab_summary:
1331
  },
1332
  )
1333
  else:
1334
- st.caption("No monthly rows in this filter window.")
1335
 
1336
  # ── Daily detail (collapsed by default β€” can be long) ────────────
1337
  # Mirror the Monthly summary column layout, just with Date instead
1338
  # of Year / Month. Adds %Cap, %Premium and per-round customer
1339
  # columns for Copper Buffet.
1340
- with st.expander("Daily detail", expanded=False):
1341
  if not daily.empty:
1342
  d = daily.copy().sort_values(["Date", "Branch"], ascending=[True, True])
1343
  if "Revenue" in d.columns and "Customers" in d.columns:
@@ -1460,7 +1659,7 @@ with tab_summary:
1460
  },
1461
  )
1462
  else:
1463
- st.caption("No daily rows in this filter window.")
1464
 
1465
  _render_restaurant_summary("Copper Buffet")
1466
  st.divider()
@@ -1474,21 +1673,15 @@ with tab_summary:
1474
  # service date.
1475
  with tab_forecast:
1476
  if fact_predictions.empty and fact_bookings.empty:
1477
- st.info(
1478
- "No booking or forecast data is loaded. The collection script "
1479
- "writes `fact_bookings` and `fact_predictions` for Copper Buffet."
1480
- )
1481
  else:
1482
- st.subheader("Copper Buffet β€” Forecast & Bookings")
1483
- st.caption(
1484
- "Forecasted customer counts and confirmed bookings for upcoming "
1485
- "service dates. Data is captured only for Copper Buffet."
1486
- )
1487
 
1488
  # Local horizon control β€” the sidebar date range is historical-focused
1489
  # by default (Jan 1 β†’ yesterday), so the forecast tab keeps its own.
1490
  horizon_days = st.slider(
1491
- "Forecast horizon (days from today)",
1492
  min_value=7, max_value=60, value=14, step=1,
1493
  )
1494
  today = pd.Timestamp(_dt.now().date())
@@ -1528,15 +1721,15 @@ with tab_forecast:
1528
  avg_pct_booked = (total_booked / total_forecast * 100) if total_forecast > 0 else None
1529
 
1530
  fk1, fk2, fk3 = st.columns(3)
1531
- fk1.metric(f"Forecast (next {horizon_days}d)", fmt_num(total_forecast))
1532
- fk2.metric(f"Booked so far (next {horizon_days}d)", fmt_num(total_booked))
1533
- fk3.metric("% Booked vs Forecast",
1534
  fmt_pct(avg_pct_booked) if avg_pct_booked is not None else "β€”")
1535
 
1536
  # ── Daily outlook table ──────────────────────────────────────────
1537
- st.markdown("**Daily outlook**")
1538
  if preds.empty:
1539
- st.info("No forecast rows in the selected horizon.")
1540
  else:
1541
  out = preds[["Date", "Branch", "DayType", "Prediction",
1542
  "Round_1", "Round_2", "Round_3", "Round_4", "Round_5"]].copy()
@@ -1573,9 +1766,9 @@ with tab_forecast:
1573
  )
1574
 
1575
  # ── Forecast trend line β€” predicted customers per day ────────────
1576
- st.markdown("**Forecast trend**")
1577
  if preds.empty:
1578
- st.caption("No forecast rows in the selected horizon.")
1579
  else:
1580
  fig = px.line(
1581
  preds, x="Date", y="Prediction", color="Branch",
@@ -1587,14 +1780,14 @@ with tab_forecast:
1587
  textfont=dict(size=9),
1588
  )
1589
  fig.update_yaxes(tickformat=",.0f")
1590
- fig.update_layout(title=f"Predicted Customers β€” next {horizon_days} days",
1591
- xaxis_title=None, yaxis_title="Customers")
1592
  st.plotly_chart(style_plotly(fig, height=320), use_container_width=True)
1593
 
1594
  # ── Booked seats stacked by round ────────────────────────────────
1595
- st.markdown("**Booked seats by round**")
1596
  if books.empty:
1597
- st.caption("No booking rows in the selected horizon.")
1598
  else:
1599
  ROUND_NUM_LABEL = {1: "Breakfast", 2: "Lunch", 3: "Dinner",
1600
  4: "Late Dinner", 5: "Special"}
@@ -1625,9 +1818,8 @@ with tab_forecast:
1625
  )
1626
  fig.update_yaxes(tickformat=",.0f")
1627
  fig.update_layout(
1628
- title=f"Booked Seats by Round β€” next {horizon_days} days",
1629
- xaxis_title=None, yaxis_title="Booked Seats",
1630
- legend_title="Round",
1631
  )
1632
  st.plotly_chart(style_plotly(fig, height=320), use_container_width=True)
1633
 
@@ -1635,15 +1827,28 @@ with tab_forecast:
1635
  with tab_pl:
1636
  pl_filt = apply_filters(fact_pl)
1637
  if pl_filt.empty:
1638
- st.info("No P&L rows for the current filters. (Try selecting the Group restaurant to see Conso/CK/CH.)")
1639
  else:
1640
- latest = pl_filt["Date"].max()
1641
- ym = f"{latest.year}-{latest.month:02d}"
 
 
 
 
 
 
 
 
 
 
 
 
 
1642
 
1643
- st.subheader(f"P&L β€” Top Sub-Categories ({ym})")
1644
  latest_rows = pl_filt[
1645
- (pl_filt["Date"].dt.year == latest.year)
1646
- & (pl_filt["Date"].dt.month == latest.month)
1647
  ]
1648
  group_col = "SubCat" if "SubCat" in latest_rows.columns else "Cat"
1649
  agg = (
@@ -1664,16 +1869,17 @@ with tab_pl:
1664
  text="AmountLabel",
1665
  )
1666
  fig.update_traces(textposition="outside", cliponaxis=False)
1667
- fig.update_layout(yaxis_title=None, xaxis_title="Amount (THB)", showlegend=True)
1668
  fig.update_xaxes(tickformat=",.0f")
1669
  st.plotly_chart(style_plotly(fig, height=520), use_container_width=True)
1670
 
1671
- st.subheader("Monthly P&L Time Series")
1672
  pl_filt = pl_filt.copy()
1673
  pl_filt["YearMonth"] = pl_filt["Date"].dt.to_period("M").astype(str)
1674
- cats = ["All categories"] + sorted(pl_filt["Cat"].dropna().unique().tolist())
1675
- cat_pick = st.selectbox("Filter to one category", cats)
1676
- ts_src = pl_filt if cat_pick == "All categories" else pl_filt[pl_filt["Cat"] == cat_pick]
 
1677
  ts = (
1678
  ts_src.groupby(["YearMonth", "Restaurant"], as_index=False)["Amount"].sum()
1679
  .sort_values("YearMonth")
@@ -1694,23 +1900,39 @@ with tab_pl:
1694
  with tab_inv:
1695
  inv_filt = apply_filters(fact_inventory)
1696
  if inv_filt.empty:
1697
- st.info("No inventory rows for the current filters.")
1698
  else:
1699
- latest = inv_filt["Date"].max()
1700
- ym = f"{latest.year}-{latest.month:02d}"
 
 
 
 
 
 
 
 
 
 
 
1701
 
1702
- col1, col2 = st.columns(2)
1703
  with col1:
1704
- st.subheader(f"Inventory snapshot ({ym})")
1705
  with col2:
1706
  sort_by = st.selectbox(
1707
- "Sort by",
1708
  ["Value_Closing", "Qty_Closing", "Value_Used", "Qty_Used"],
1709
  index=0,
 
1710
  )
 
 
 
 
1711
  latest_rows = inv_filt[
1712
- (inv_filt["Date"].dt.year == latest.year)
1713
- & (inv_filt["Date"].dt.month == latest.month)
1714
  ]
1715
  ranked = latest_rows[latest_rows[sort_by] > 0].sort_values(sort_by, ascending=False).head(100)
1716
 
@@ -1736,7 +1958,7 @@ with tab_inv:
1736
  )
1737
 
1738
  # Per-branch inventory totals
1739
- st.subheader("Closing inventory value by branch")
1740
  totals = (
1741
  latest_rows.groupby(["Restaurant", "Branch"], as_index=False)["Value_Closing"]
1742
  .sum()
 
31
  import plotly.express as px
32
  import streamlit as st
33
 
34
+
35
+ # ─────────────────────────────────────────────────────────────────────────────
36
+ # Bilingual support (English / Thai)
37
+ # ─────────────────────────────────────────────────────────────────────────────
38
+ # Restaurant names ("Copper Buffet" / "Tiew Copper") and data cell values are
39
+ # intentionally NOT translated β€” they're brand names / source-system labels.
40
+ # Only UI chrome (tabs, widget labels, headings, chart titles) flips.
41
+ LANG = {
42
+ "en": {
43
+ # Tab labels
44
+ "tab_overview": "Overview",
45
+ "tab_summary": "Summary",
46
+ "tab_forecast": "Forecast",
47
+ "tab_pl": "P&L",
48
+ "tab_inventory": "Inventory",
49
+ # Sidebar
50
+ "sb_language": "Language",
51
+ "sb_filters": "Filters",
52
+ "sb_restaurant": "Restaurant",
53
+ "sb_branch": "Branch",
54
+ "sb_date_range": "Date range",
55
+ "sb_source": "Source",
56
+ "sb_sheets_loaded": "Sheets loaded",
57
+ "sb_signed_in_as": "Signed in as",
58
+ "sb_sign_out": "Sign out",
59
+ # Top header / period caption
60
+ "title": "Copper Group Dashboard",
61
+ "filtered_period": "Filtered period",
62
+ "all_dates": "all dates",
63
+ "n_restaurants": "{n} restaurant(s)",
64
+ "n_branches": "{n} branch(es)",
65
+ # Overview tab
66
+ "ov_monthly_revenue_trend": "Monthly Revenue Trend",
67
+ "ov_channel_mix": "Channel Mix",
68
+ "ov_daytype_perf": "Day Type Performance",
69
+ "ov_no_monthly": "No monthly rows match the current filters.",
70
+ "ov_no_channel": "No channel data for the current filters.",
71
+ "ov_rev_per_head": "Revenue per Head (THB)",
72
+ # Summary tab
73
+ "sm_trends": "Trends",
74
+ "sm_monthly_summary": "Monthly summary",
75
+ "sm_daily_detail": "Daily detail",
76
+ "sm_no_data": "No data for {name} in the current filters.",
77
+ "sm_no_monthly_rows": "No monthly rows in this filter window.",
78
+ "sm_no_daily_rows": "No daily rows in this filter window.",
79
+ "sm_chart_revenue": "Monthly Revenue (THB)",
80
+ "sm_chart_customers": "Monthly Customers",
81
+ "sm_chart_cap": "%Cap β€” Capacity utilised",
82
+ "sm_chart_premium": "%Premium β€” premium share of customers",
83
+ "sm_chart_rounds": "Customers by Round (monthly)",
84
+ # Forecast tab
85
+ "fc_header": "Copper Buffet β€” Forecast & Bookings",
86
+ "fc_caption": "Forecasted customer counts and confirmed bookings for upcoming "
87
+ "service dates. Data is captured only for Copper Buffet.",
88
+ "fc_no_data": "No booking or forecast data is loaded.",
89
+ "fc_horizon": "Forecast horizon (days from today)",
90
+ "fc_kpi_forecast": "Forecast (next {n}d)",
91
+ "fc_kpi_booked": "Booked so far (next {n}d)",
92
+ "fc_kpi_pct_booked": "% Booked vs Forecast",
93
+ "fc_outlook": "Daily outlook",
94
+ "fc_no_horizon": "No forecast rows in the selected horizon.",
95
+ "fc_no_bookings": "No booking rows in the selected horizon.",
96
+ "fc_chart_trend": "Predicted Customers β€” next {n} days",
97
+ "fc_section_bookings":"Booked seats by round",
98
+ "fc_chart_booked": "Booked Seats by Round β€” next {n} days",
99
+ "fc_section_trend": "Forecast trend",
100
+ # P&L tab
101
+ "pl_no_data": "No P&L rows for the current filters.",
102
+ "pl_month_picker": "Month",
103
+ "pl_top_subcat_title": "P&L β€” Top Sub-Categories ({ym})",
104
+ "pl_monthly_ts": "Monthly P&L Time Series",
105
+ "pl_cat_picker": "Filter to one category",
106
+ "pl_all_categories": "All categories",
107
+ "pl_amount_axis": "Amount (THB)",
108
+ # Inventory tab
109
+ "inv_no_data": "No inventory rows for the current filters.",
110
+ "inv_month_picker": "Month",
111
+ "inv_sort_by": "Sort by",
112
+ "inv_snapshot": "Inventory snapshot ({ym})",
113
+ "inv_closing_by_branch": "Closing inventory value by branch",
114
+ # Sign-in screen
115
+ "auth_title": "Copper Group Dashboard",
116
+ "auth_intro": "Restricted to members of the <code>CB-Group</code> organization on Hugging Face. "
117
+ "Sign in with your HF account to continue.",
118
+ "auth_button": "Sign in with Hugging Face",
119
+ "auth_no_acct": "Don't have an HF account? Ask the dashboard owner to invite you to the "
120
+ "<code>CB-Group</code> org, then",
121
+ "auth_signup": "sign up here",
122
+ },
123
+ "th": {
124
+ # Tab labels
125
+ "tab_overview": "ภาพรวฑ",
126
+ "tab_summary": "ΰΈͺΰΈ£ΰΈΈΰΈ›",
127
+ "tab_forecast": "ΰΈžΰΈ’ΰΈ²ΰΈΰΈ£ΰΈ“ΰΉŒ",
128
+ "tab_pl": "ΰΈ‡ΰΈšΰΈΰΈ³ΰΉ„ΰΈ£ΰΈ‚ΰΈ²ΰΈ”ΰΈ—ΰΈΈΰΈ™",
129
+ "tab_inventory": "ΰΈͺΰΈ΄ΰΈ™ΰΈ„ΰΉ‰ΰΈ²ΰΈ„ΰΈ‡ΰΈ„ΰΈ₯ΰΈ±ΰΈ‡",
130
+ # Sidebar
131
+ "sb_language": "ΰΈ ΰΈ²ΰΈ©ΰΈ²",
132
+ "sb_filters": "ตัวกรอง",
133
+ "sb_restaurant": "ΰΈ£ΰΉ‰ΰΈ²ΰΈ™ΰΈ­ΰΈ²ΰΈ«ΰΈ²ΰΈ£",
134
+ "sb_branch": "ΰΈͺΰΈ²ΰΈ‚ΰΈ²",
135
+ "sb_date_range": "ΰΈŠΰΉˆΰΈ§ΰΈ‡ΰΈ§ΰΈ±ΰΈ™ΰΈ—ΰΈ΅ΰΉˆ",
136
+ "sb_source": "แหΰΈ₯ΰΉˆΰΈ‡ΰΈ‚ΰΉ‰ΰΈ­ΰΈ‘ΰΈΉΰΈ₯",
137
+ "sb_sheets_loaded": "ΰΈˆΰΈ³ΰΈ™ΰΈ§ΰΈ™ΰΈŠΰΈ΅ΰΈ—",
138
+ "sb_signed_in_as": "ΰΉ€ΰΈ‚ΰΉ‰ΰΈ²ΰΈͺΰΈΉΰΉˆΰΈ£ΰΈ°ΰΈšΰΈšΰΉƒΰΈ™ΰΈ™ΰΈ²ΰΈ‘",
139
+ "sb_sign_out": "ออกจากระบบ",
140
+ # Top header / period caption
141
+ "title": "ΰΉΰΈ”ΰΈŠΰΈšΰΈ­ΰΈ£ΰΉŒΰΈ”ΰΈ„ΰΈ­ΰΈ›ΰΉ€ΰΈ›ΰΈ­ΰΈ£ΰΉŒΰΈΰΈ£ΰΈΈΰΉŠΰΈ›",
142
+ "filtered_period": "ΰΈŠΰΉˆΰΈ§ΰΈ‡ΰΈ—ΰΈ΅ΰΉˆΰΈΰΈ£ΰΈ­ΰΈ‡",
143
+ "all_dates": "ΰΈ—ΰΈΈΰΈΰΈ§ΰΈ±ΰΈ™ΰΈ—ΰΈ΅ΰΉˆ",
144
+ "n_restaurants": "{n} ΰΈ£ΰΉ‰ΰΈ²ΰΈ™",
145
+ "n_branches": "{n} ΰΈͺΰΈ²ΰΈ‚ΰΈ²",
146
+ # Overview tab
147
+ "ov_monthly_revenue_trend": "แนวโน้ฑราฒได้ราฒเดือน",
148
+ "ov_channel_mix": "ΰΈͺΰΈ±ΰΈ”ΰΈͺΰΉˆΰΈ§ΰΈ™ΰΈŠΰΉˆΰΈ­ΰΈ‡ΰΈ—ΰΈ²ΰΈ‡",
149
+ "ov_daytype_perf": "ผΰΈ₯การดำเนินงานตาฑประเภทวัน",
150
+ "ov_no_monthly": "ΰΉ„ΰΈ‘ΰΉˆΰΈ‘ΰΈ΅ΰΈ‚ΰΉ‰ΰΈ­ΰΈ‘ΰΈΉΰΈ₯ΰΈ£ΰΈ²ΰΈ’ΰΉ€ΰΈ”ΰΈ·ΰΈ­ΰΈ™ΰΈͺΰΈ³ΰΈ«ΰΈ£ΰΈ±ΰΈšΰΈ•ΰΈ±ΰΈ§ΰΈΰΈ£ΰΈ­ΰΈ‡ΰΈ›ΰΈ±ΰΈˆΰΈˆΰΈΈΰΈšΰΈ±ΰΈ™",
151
+ "ov_no_channel": "ΰΉ„ΰΈ‘ΰΉˆΰΈ‘ΰΈ΅ΰΈ‚ΰΉ‰ΰΈ­ΰΈ‘ΰΈΉΰΈ₯ΰΈŠΰΉˆΰΈ­ΰΈ‡ΰΈ—ΰΈ²ΰΈ‡ΰΈͺΰΈ³ΰΈ«ΰΈ£ΰΈ±ΰΈšΰΈ•ΰΈ±ΰΈ§ΰΈΰΈ£ΰΈ­ΰΈ‡ΰΈ›ΰΈ±ΰΈˆΰΈˆΰΈΈΰΈšΰΈ±ΰΈ™",
152
+ "ov_rev_per_head": "ΰΈ£ΰΈ²ΰΈ’ΰΉ„ΰΈ”ΰΉ‰ΰΈ•ΰΉˆΰΈ­ΰΈ«ΰΈ±ΰΈ§ (ΰΈšΰΈ²ΰΈ—)",
153
+ # Summary tab
154
+ "sm_trends": "แนวโน้ฑ",
155
+ "sm_monthly_summary": "ΰΈͺΰΈ£ΰΈΈΰΈ›ΰΈ£ΰΈ²ΰΈ’ΰΉ€ΰΈ”ΰΈ·ΰΈ­ΰΈ™",
156
+ "sm_daily_detail": "ΰΈ£ΰΈ²ΰΈ’ΰΈ₯ΰΈ°ΰΉ€ΰΈ­ΰΈ΅ΰΈ’ΰΈ”ΰΈ£ΰΈ²ΰΈ’ΰΈ§ΰΈ±ΰΈ™",
157
+ "sm_no_data": "ΰΉ„ΰΈ‘ΰΉˆΰΈ‘ΰΈ΅ΰΈ‚ΰΉ‰ΰΈ­ΰΈ‘ΰΈΉΰΈ₯ {name} ΰΈͺΰΈ³ΰΈ«ΰΈ£ΰΈ±ΰΈšΰΈ•ΰΈ±ΰΈ§ΰΈΰΈ£ΰΈ­ΰΈ‡ΰΈ›ΰΈ±ΰΈˆΰΈˆΰΈΈΰΈšΰΈ±ΰΈ™",
158
+ "sm_no_monthly_rows": "ΰΉ„ΰΈ‘ΰΉˆΰΈ‘ΰΈ΅ΰΈ‚ΰΉ‰ΰΈ­ΰΈ‘ΰΈΉΰΈ₯ΰΈ£ΰΈ²ΰΈ’ΰΉ€ΰΈ”ΰΈ·ΰΈ­ΰΈ™ΰΉƒΰΈ™ΰΈŠΰΉˆΰΈ§ΰΈ‡ΰΈ—ΰΈ΅ΰΉˆΰΉ€ΰΈ₯ือก",
159
+ "sm_no_daily_rows": "ΰΉ„ΰΈ‘ΰΉˆΰΈ‘ΰΈ΅ΰΈ‚ΰΉ‰ΰΈ­ΰΈ‘ΰΈΉΰΈ₯ΰΈ£ΰΈ²ΰΈ’ΰΈ§ΰΈ±ΰΈ™ΰΉƒΰΈ™ΰΈŠΰΉˆΰΈ§ΰΈ‡ΰΈ—ΰΈ΅ΰΉˆΰΉ€ΰΈ₯ือก",
160
+ "sm_chart_revenue": "ΰΈ£ΰΈ²ΰΈ’ΰΉ„ΰΈ”ΰΉ‰ΰΈ£ΰΈ²ΰΈ’ΰΉ€ΰΈ”ΰΈ·ΰΈ­ΰΈ™ (ΰΈšΰΈ²ΰΈ—)",
161
+ "sm_chart_customers": "ΰΈ₯ูกค้าราฒเดือน",
162
+ "sm_chart_cap": "%ΰΉƒΰΈŠΰΉ‰ΰΈžΰΈ·ΰΉ‰ΰΈ™ΰΈ—ΰΈ΅ΰΉˆ",
163
+ "sm_chart_premium": "%ΰΈ₯ΰΈΉΰΈΰΈ„ΰΉ‰ΰΈ²ΰΈžΰΈ£ΰΈ΅ΰΉ€ΰΈ‘ΰΈ΅ΰΈ’ΰΈ‘",
164
+ "sm_chart_rounds": "ΰΈ₯ΰΈΉΰΈΰΈ„ΰΉ‰ΰΈ²ΰΈ•ΰΈ²ΰΈ‘ΰΈ£ΰΈ­ΰΈš (ΰΈ£ΰΈ²ΰΈ’ΰΉ€ΰΈ”ΰΈ·ΰΈ­ΰΈ™)",
165
+ # Forecast tab
166
+ "fc_header": "ΰΈ„ΰΈ­ΰΈ›ΰΉ€ΰΈ›ΰΈ­ΰΈ£ΰΉŒΰΈšΰΈΈΰΈŸΰΉ€ΰΈŸΰΉˆΰΈ•ΰΉŒ β€” ΰΈžΰΈ’ΰΈ²ΰΈΰΈ£ΰΈ“ΰΉŒΰΉΰΈ₯ΰΈ°ΰΈΰΈ²ΰΈ£ΰΈˆΰΈ­ΰΈ‡",
167
+ "fc_caption": "ΰΈžΰΈ’ΰΈ²ΰΈΰΈ£ΰΈ“ΰΉŒΰΈˆΰΈ³ΰΈ™ΰΈ§ΰΈ™ΰΈ₯ูกค้าแΰΈ₯ΰΈ°ΰΈΰΈ²ΰΈ£ΰΈˆΰΈ­ΰΈ‡ΰΈ—ΰΈ΅ΰΉˆΰΈ’ΰΈ·ΰΈ™ΰΈ’ΰΈ±ΰΈ™ΰΉΰΈ₯ΰΉ‰ΰΈ§ΰΈͺΰΈ³ΰΈ«ΰΈ£ΰΈ±ΰΈšΰΈ§ΰΈ±ΰΈ™ΰΈ—ΰΈ΅ΰΉˆΰΈšΰΈ£ΰΈ΄ΰΈΰΈ²ΰΈ£ΰΉƒΰΈ™ΰΈ­ΰΈ™ΰΈ²ΰΈ„ΰΈ• "
168
+ "ΰΈ‚ΰΉ‰ΰΈ­ΰΈ‘ΰΈΉΰΈ₯ΰΈ‘ΰΈ΅ΰΉ€ΰΈ‰ΰΈžΰΈ²ΰΈ°ΰΈ‚ΰΈ­ΰΈ‡ΰΈ„ΰΈ­ΰΈ›ΰΉ€ΰΈ›ΰΈ­ΰΈ£ΰΉŒΰΈšΰΈΈΰΈŸΰΉ€ΰΈŸΰΉˆΰΈ•ΰΉŒΰΉ€ΰΈ—ΰΉˆΰΈ²ΰΈ™ΰΈ±ΰΉ‰ΰΈ™",
169
+ "fc_no_data": "ΰΉ„ΰΈ‘ΰΉˆΰΈ‘ΰΈ΅ΰΈ‚ΰΉ‰ΰΈ­ΰΈ‘ΰΈΉΰΈ₯ΰΈΰΈ²ΰΈ£ΰΈˆΰΈ­ΰΈ‡ΰΈ«ΰΈ£ΰΈ·ΰΈ­ΰΈžΰΈ’ΰΈ²ΰΈΰΈ£ΰΈ“ΰΉŒΰΈ—ΰΈ΅ΰΉˆΰΉ‚ΰΈ«ΰΈ₯ΰΈ”ΰΈ­ΰΈ’ΰΈΉΰΉˆ",
170
+ "fc_horizon": "ΰΈ£ΰΈ°ΰΈ’ΰΈ°ΰΉ€ΰΈ§ΰΈ₯ΰΈ²ΰΈžΰΈ’ΰΈ²ΰΈΰΈ£ΰΈ“ΰΉŒ (ΰΈ§ΰΈ±ΰΈ™ΰΈˆΰΈ²ΰΈΰΈ§ΰΈ±ΰΈ™ΰΈ™ΰΈ΅ΰΉ‰)",
171
+ "fc_kpi_forecast": "ΰΈžΰΈ’ΰΈ²ΰΈΰΈ£ΰΈ“ΰΉŒ ({n} ΰΈ§ΰΈ±ΰΈ™ΰΈ‚ΰΉ‰ΰΈ²ΰΈ‡ΰΈ«ΰΈ™ΰΉ‰ΰΈ²)",
172
+ "fc_kpi_booked": "ΰΈˆΰΈ­ΰΈ‡ΰΉΰΈ₯ΰΉ‰ΰΈ§ ({n} ΰΈ§ΰΈ±ΰΈ™ΰΈ‚ΰΉ‰ΰΈ²ΰΈ‡ΰΈ«ΰΈ™ΰΉ‰ΰΈ²)",
173
+ "fc_kpi_pct_booked": "% ΰΈˆΰΈ­ΰΈ‡ΰΉ€ΰΈ—ΰΈ΅ΰΈ’ΰΈšΰΈΰΈ±ΰΈšΰΈžΰΈ’ΰΈ²ΰΈΰΈ£ΰΈ“ΰΉŒ",
174
+ "fc_outlook": "ΰΈ ΰΈ²ΰΈžΰΈ£ΰΈ§ΰΈ‘ΰΈ£ΰΈ²ΰΈ’ΰΈ§ΰΈ±ΰΈ™",
175
+ "fc_no_horizon": "ΰΉ„ΰΈ‘ΰΉˆΰΈ‘ΰΈ΅ΰΈ‚ΰΉ‰ΰΈ­ΰΈ‘ΰΈΉΰΈ₯ΰΈžΰΈ’ΰΈ²ΰΈΰΈ£ΰΈ“ΰΉŒΰΉƒΰΈ™ΰΈŠΰΉˆΰΈ§ΰΈ‡ΰΈ—ΰΈ΅ΰΉˆΰΉ€ΰΈ₯ือก",
176
+ "fc_no_bookings": "ΰΉ„ΰΈ‘ΰΉˆΰΈ‘ΰΈ΅ΰΈ‚ΰΉ‰ΰΈ­ΰΈ‘ΰΈΉΰΈ₯ΰΈΰΈ²ΰΈ£ΰΈˆΰΈ­ΰΈ‡ΰΉƒΰΈ™ΰΈŠΰΉˆΰΈ§ΰΈ‡ΰΈ—ΰΈ΅ΰΉˆΰΉ€ΰΈ₯ือก",
177
+ "fc_chart_trend": "ΰΈžΰΈ’ΰΈ²ΰΈΰΈ£ΰΈ“ΰΉŒΰΈˆΰΈ³ΰΈ™ΰΈ§ΰΈ™ΰΈ₯ูกค้า β€” {n} ΰΈ§ΰΈ±ΰΈ™ΰΈ‚ΰΉ‰ΰΈ²ΰΈ‡ΰΈ«ΰΈ™ΰΉ‰ΰΈ²",
178
+ "fc_section_bookings":"ΰΈ—ΰΈ΅ΰΉˆΰΈ™ΰΈ±ΰΉˆΰΈ‡ΰΈ—ΰΈ΅ΰΉˆΰΈˆΰΈ­ΰΈ‡ΰΈ•ΰΈ²ΰΈ‘ΰΈ£ΰΈ­ΰΈš",
179
+ "fc_chart_booked": "ΰΈ—ΰΈ΅ΰΉˆΰΈ™ΰΈ±ΰΉˆΰΈ‡ΰΈ—ΰΈ΅ΰΉˆΰΈˆΰΈ­ΰΈ‡ΰΈ•ΰΈ²ΰΈ‘ΰΈ£ΰΈ­ΰΈš β€” {n} ΰΈ§ΰΈ±ΰΈ™ΰΈ‚ΰΉ‰ΰΈ²ΰΈ‡ΰΈ«ΰΈ™ΰΉ‰ΰΈ²",
180
+ "fc_section_trend": "ΰΉΰΈ™ΰΈ§ΰΉ‚ΰΈ™ΰΉ‰ΰΈ‘ΰΈžΰΈ’ΰΈ²ΰΈΰΈ£ΰΈ“ΰΉŒ",
181
+ # P&L tab
182
+ "pl_no_data": "ΰΉ„ΰΈ‘ΰΉˆΰΈ‘ΰΈ΅ΰΈ‚ΰΉ‰ΰΈ­ΰΈ‘ΰΈΉΰΈ₯ΰΈ‡ΰΈšΰΈΰΈ³ΰΉ„ΰΈ£ΰΈ‚ΰΈ²ΰΈ”ΰΈ—ΰΈΈΰΈ™ΰΈͺΰΈ³ΰΈ«ΰΈ£ΰΈ±ΰΈšΰΈ•ΰΈ±ΰΈ§ΰΈΰΈ£ΰΈ­ΰΈ‡ΰΈ›ΰΈ±ΰΈˆΰΈˆΰΈΈΰΈšΰΈ±ΰΈ™",
183
+ "pl_month_picker": "ΰΉ€ΰΈ”ΰΈ·ΰΈ­ΰΈ™",
184
+ "pl_top_subcat_title": "ΰΈ‡ΰΈšΰΈΰΈ³ΰΉ„ΰΈ£ΰΈ‚ΰΈ²ΰΈ”ΰΈ—ΰΈΈΰΈ™ β€” ΰΈ«ΰΈ‘ΰΈ§ΰΈ”ΰΈ’ΰΉˆΰΈ­ΰΈ’ΰΈ­ΰΈ±ΰΈ™ΰΈ”ΰΈ±ΰΈšΰΈ•ΰΉ‰ΰΈ™ ({ym})",
185
+ "pl_monthly_ts": "ΰΈ‡ΰΈšΰΈΰΈ³ΰΉ„ΰΈ£ΰΈ‚ΰΈ²ΰΈ”ΰΈ—ΰΈΈΰΈ™ΰΈ£ΰΈ²ΰΈ’ΰΉ€ΰΈ”ΰΈ·ΰΈ­ΰΈ™",
186
+ "pl_cat_picker": "ΰΈΰΈ£ΰΈ­ΰΈ‡ΰΉ€ΰΈ‰ΰΈžΰΈ²ΰΈ°ΰΈ«ΰΈ‘ΰΈ§ΰΈ”",
187
+ "pl_all_categories": "ทุกหฑวด",
188
+ "pl_amount_axis": "ΰΈˆΰΈ³ΰΈ™ΰΈ§ΰΈ™ (ΰΈšΰΈ²ΰΈ—)",
189
+ # Inventory tab
190
+ "inv_no_data": "ΰΉ„ΰΈ‘ΰΉˆΰΈ‘ΰΈ΅ΰΈ‚ΰΉ‰ΰΈ­ΰΈ‘ΰΈΉΰΈ₯ΰΈͺΰΈ΄ΰΈ™ΰΈ„ΰΉ‰ΰΈ²ΰΈ„ΰΈ‡ΰΈ„ΰΈ₯ΰΈ±ΰΈ‡ΰΈͺΰΈ³ΰΈ«ΰΈ£ΰΈ±ΰΈšΰΈ•ΰΈ±ΰΈ§ΰΈΰΈ£ΰΈ­ΰΈ‡ΰΈ›ΰΈ±ΰΈˆΰΈˆΰΈΈΰΈšΰΈ±ΰΈ™",
191
+ "inv_month_picker": "ΰΉ€ΰΈ”ΰΈ·ΰΈ­ΰΈ™",
192
+ "inv_sort_by": "ΰΉ€ΰΈ£ΰΈ΅ΰΈ’ΰΈ‡ΰΈ•ΰΈ²ΰΈ‘",
193
+ "inv_snapshot": "ภาพรวฑΰΈͺΰΈ΄ΰΈ™ΰΈ„ΰΉ‰ΰΈ²ΰΈ„ΰΈ‡ΰΈ„ΰΈ₯ΰΈ±ΰΈ‡ ({ym})",
194
+ "inv_closing_by_branch": "ΰΈ‘ΰΈΉΰΈ₯ΰΈ„ΰΉˆΰΈ²ΰΈ„ΰΈ‡ΰΉ€ΰΈ«ΰΈ₯ΰΈ·ΰΈ­ΰΈ•ΰΈ²ΰΈ‘ΰΈͺΰΈ²ΰΈ‚ΰΈ²",
195
+ # Sign-in screen
196
+ "auth_title": "ΰΉΰΈ”ΰΈŠΰΈšΰΈ­ΰΈ£ΰΉŒΰΈ”ΰΈ„ΰΈ­ΰΈ›ΰΉ€ΰΈ›ΰΈ­ΰΈ£ΰΉŒΰΈΰΈ£ΰΈΈΰΉŠΰΈ›",
197
+ "auth_intro": "ΰΉ€ΰΈ‰ΰΈžΰΈ²ΰΈ°ΰΈͺΰΈ‘ΰΈ²ΰΈŠΰΈ΄ΰΈΰΈ‚ΰΈ­ΰΈ‡ΰΈ­ΰΈ‡ΰΈ„ΰΉŒΰΈΰΈ£ <code>CB-Group</code> ΰΈšΰΈ™ Hugging Face ΰΉ€ΰΈ—ΰΉˆΰΈ²ΰΈ™ΰΈ±ΰΉ‰ΰΈ™ "
198
+ "ΰΉ€ΰΈ‚ΰΉ‰ΰΈ²ΰΈͺΰΈΉΰΉˆΰΈ£ΰΈ°ΰΈšΰΈšΰΈ”ΰΉ‰ΰΈ§ΰΈ’ΰΈšΰΈ±ΰΈΰΈŠΰΈ΅ HF ΰΈ‚ΰΈ­ΰΈ‡ΰΈ„ΰΈΈΰΈ“ΰΉ€ΰΈžΰΈ·ΰΉˆΰΈ­ΰΈ”ΰΈ³ΰΉ€ΰΈ™ΰΈ΄ΰΈ™ΰΈΰΈ²ΰΈ£ΰΈ•ΰΉˆΰΈ­",
199
+ "auth_button": "ΰΉ€ΰΈ‚ΰΉ‰ΰΈ²ΰΈͺΰΈΉΰΉˆΰΈ£ΰΈ°ΰΈšΰΈšΰΈ”ΰΉ‰ΰΈ§ΰΈ’ Hugging Face",
200
+ "auth_no_acct": "ΰΈ’ΰΈ±ΰΈ‡ΰΉ„ΰΈ‘ΰΉˆΰΈ‘ΰΈ΅ΰΈšΰΈ±ΰΈΰΈŠΰΈ΅ HF? ΰΈ‚ΰΈ­ΰΉƒΰΈ«ΰΉ‰ΰΉ€ΰΈˆΰΉ‰ΰΈ²ΰΈ‚ΰΈ­ΰΈ‡ΰΉΰΈ”ΰΈŠΰΈšΰΈ­ΰΈ£ΰΉŒΰΈ”ΰΉ€ΰΈŠΰΈ΄ΰΈΰΈ„ΰΈΈΰΈ“ΰΉ€ΰΈ‚ΰΉ‰ΰΈ² "
201
+ "ΰΈ­ΰΈ‡ΰΈ„ΰΉŒΰΈΰΈ£ <code>CB-Group</code> ΰΈˆΰΈ²ΰΈΰΈ™ΰΈ±ΰΉ‰ΰΈ™",
202
+ "auth_signup": "ΰΈͺΰΈ‘ΰΈ±ΰΈ„ΰΈ£ΰΉ„ΰΈ”ΰΉ‰ΰΈ—ΰΈ΅ΰΉˆΰΈ™ΰΈ΅ΰΉˆ",
203
+ },
204
+ }
205
+
206
+
207
+ def t(key: str, **kwargs) -> str:
208
+ """Return the user-facing string for ``key`` in the current language.
209
+
210
+ Falls back to English if a key is missing in Thai, and falls back to
211
+ the raw key if it's missing in both β€” so a missed translation shows up
212
+ as e.g. ``sm_chart_revenue`` instead of crashing.
213
+ """
214
+ lang = st.session_state.get("_lang", "en")
215
+ s = LANG.get(lang, {}).get(key) or LANG["en"].get(key, key)
216
+ return s.format(**kwargs) if kwargs else s
217
+
218
  # ─────────────────────────────────────────────────────────────────────────────
219
  # Page setup
220
  # ─────────────────────────────────────────────────────────────────────────────
 
466
  f"""
467
  <div style="max-width: 420px; margin: 80px auto 0 auto; text-align: center;">
468
  <div style="font-size: 32px; margin-bottom: 8px;">πŸ”’</div>
469
+ <h2 style="margin: 0 0 8px 0; color: #1E2B3A;">{t("auth_title")}</h2>
470
  <p style="color: #6B7280; margin: 0 0 28px 0; line-height: 1.5;">
471
+ {t("auth_intro")}
 
 
472
  </p>
473
  <a href="{auth_url}" target="_self" style="
474
  display: inline-flex;
 
482
  font-size: 15px;
483
  text-decoration: none;
484
  box-shadow: 0 1px 3px rgba(30, 43, 58, 0.15);
485
+ ">πŸ€— {t("auth_button")}</a>
486
  <p style="color: #6B7280; font-size: 12px; margin-top: 32px;">
487
+ {t("auth_no_acct")}
 
488
  <a href="https://huggingface.co/join" target="_blank"
489
+ style="color: #976A4D;">{t("auth_signup")}</a>.
490
  </p>
491
  </div>
492
  """,
 
750
  secrets_token = os.environ.get("HF_TOKEN") or None
751
 
752
  # Header (we show it early so the loading status is visible even on slow links)
753
+ st.title(t("title"))
754
 
755
 
756
  def _show_diagnostics(exc: "FetchError", source_hint: str) -> None:
 
904
  _branch_defaults = [b for b in _DEFAULT_BRANCHES if b in branches_all] \
905
  or branches_all
906
 
907
+ # Language selector β€” placed BEFORE other widgets so labels switch
908
+ # immediately on the same rerun.
909
+ _lang_label = {"en": "English", "th": "ΰΈ ΰΈ²ΰΈ©ΰΈ²ΰΉ„ΰΈ—ΰΈ’"}
910
+ _lang_default = st.session_state.get("_lang", "en")
911
+ _lang_choice = st.sidebar.radio(
912
+ t("sb_language"),
913
+ options=["en", "th"],
914
+ format_func=lambda code: _lang_label[code],
915
+ horizontal=True,
916
+ index=0 if _lang_default == "en" else 1,
917
+ key="_lang_radio",
918
+ )
919
+ if _lang_choice != st.session_state.get("_lang"):
920
+ st.session_state["_lang"] = _lang_choice
921
+ st.rerun()
922
+ st.sidebar.divider()
923
+
924
+ st.sidebar.title(t("sb_filters"))
925
  sel_restaurants = st.sidebar.multiselect(
926
+ t("sb_restaurant"), restaurants_all, default=_restaurant_defaults
927
  )
928
  sel_branches = st.sidebar.multiselect(
929
+ t("sb_branch"), branches_all, default=_branch_defaults
930
  )
931
 
932
  # Year + date range
 
954
  _default_start, _default_end = min_date, max_date
955
 
956
  sel_dates = st.sidebar.date_input(
957
+ t("sb_date_range"),
958
  value=(_default_start, _default_end),
959
  min_value=min_date,
960
  max_value=max_date,
 
967
  date_from = date_to = None
968
 
969
  st.sidebar.divider()
970
+ st.sidebar.caption(f"{t('sb_source')}: {source_label}")
971
+ st.sidebar.caption(f"{t('sb_sheets_loaded')}: {len(sheets)}")
972
 
973
  # ── Signed-in user badge + sign-out (only shown when OAuth is active) ────────
974
  _oauth_user = st.session_state.get("_oauth_user")
975
  if _oauth_user:
976
  _name = _oauth_user.get("preferred_username") or _oauth_user.get("name") or "user"
977
  st.sidebar.divider()
978
+ st.sidebar.markdown(f"{t('sb_signed_in_as')} **{_name}**")
979
+ if st.sidebar.button(t("sb_sign_out"), use_container_width=True):
980
  # Drop everything OAuth-related and force the sign-in screen on rerun.
981
  for _k in ("_oauth_user", "_oauth_state"):
982
  st.session_state.pop(_k, None)
 
1112
  # Header + KPI tiles
1113
  # ─────────────────────────────────────────────────────────────────────────────
1114
  # (Title already rendered up-top; just print the filtered-period caption.)
1115
+ period_str = f"{date_from} β†’ {date_to}" if date_from else t("all_dates")
1116
  st.markdown(
1117
+ f"<span class='small-caption'>{t('filtered_period')}: <b>{period_str}</b> Β· "
1118
+ f"{t('n_restaurants', n=len(sel_restaurants))}, {t('n_branches', n=len(sel_branches))}</span>",
1119
  unsafe_allow_html=True,
1120
  )
1121
 
 
1138
  # Tabs
1139
  # ─────────────────────────────────────────────────────────────────────────────
1140
  tab_overview, tab_summary, tab_forecast, tab_pl, tab_inv = st.tabs(
1141
+ [t("tab_overview"), t("tab_summary"), t("tab_forecast"),
1142
+ t("tab_pl"), t("tab_inventory")]
1143
  )
1144
 
1145
  # ── Overview ───────────────────────────────────────────────────────────────
 
1147
  left, right = st.columns([2, 1])
1148
 
1149
  with left:
1150
+ st.subheader(t("ov_monthly_revenue_trend"))
1151
  if not kpi_monthly.empty:
1152
  mf = apply_filters(kpi_monthly)
1153
  if not mf.empty:
 
1174
  fig.update_layout(xaxis_title=None, yaxis_title="Revenue (THB)")
1175
  st.plotly_chart(style_plotly(fig, height=420), use_container_width=True)
1176
  else:
1177
+ st.info(t("ov_no_monthly"))
1178
 
1179
  with right:
1180
+ st.subheader(t("ov_channel_mix"))
1181
  if not fact_sales.empty:
1182
  sf = apply_filters(fact_sales)
1183
  # Exclude roll-up rows from the source data β€” "Grand Total" /
 
1207
  )
1208
  st.plotly_chart(style_plotly(fig, height=420), use_container_width=True)
1209
  else:
1210
+ st.info(t("ov_no_channel"))
1211
 
1212
+ st.subheader(t("ov_daytype_perf"))
1213
  if not filtered_daily.empty and "DayType" in filtered_daily.columns:
1214
  order = ["Weekday", "Weekend", "Holiday"]
1215
  daytype = (
 
1234
  color_discrete_map=_DAYTYPE_COLOR,
1235
  text=daytype["Rev_Per_Head"].apply(lambda v: f"ΰΈΏ{v:,.0f}" if pd.notna(v) else ""))
1236
  fig.update_layout(showlegend=False, xaxis_title=None,
1237
+ yaxis_title=t("ov_rev_per_head"))
1238
  fig.update_yaxes(tickformat=",.0f")
1239
  st.plotly_chart(style_plotly(fig, height=320), use_container_width=True)
1240
  with col2:
 
1286
  if "Restaurant" in daily.columns else daily.iloc[0:0]
1287
 
1288
  if monthly.empty and daily.empty:
1289
+ st.info(t("sm_no_data", name=restaurant_name))
1290
  return
1291
 
1292
  # Pre-compute extended monthly DataFrame *once*, reused for both the
 
1391
 
1392
  # ── Trends β€” small charts above the tables ───────────────────────
1393
  if not m.empty:
1394
+ st.markdown(f"**{t('sm_trends')}**")
1395
  mf = m.copy()
1396
  mf["YearMonth"] = (
1397
  mf["Year"].astype(int).astype(str)
 
1413
  textfont=dict(size=9),
1414
  )
1415
  fig.update_yaxes(tickformat=",.0f")
1416
+ fig.update_layout(title=t("sm_chart_revenue"),
1417
  xaxis_title=None, yaxis_title=None)
1418
  st.plotly_chart(style_plotly(fig, height=280), use_container_width=True)
1419
  with tc2:
 
1428
  textfont=dict(size=9),
1429
  )
1430
  fig.update_yaxes(tickformat=",.0f")
1431
+ fig.update_layout(title=t("sm_chart_customers"),
1432
  xaxis_title=None, yaxis_title=None)
1433
  st.plotly_chart(style_plotly(fig, height=280), use_container_width=True)
1434
 
 
1448
  textfont=dict(size=9),
1449
  )
1450
  fig.update_yaxes(ticksuffix="%")
1451
+ fig.update_layout(title=t("sm_chart_cap"),
1452
  xaxis_title=None, yaxis_title=None)
1453
  st.plotly_chart(style_plotly(fig, height=280), use_container_width=True)
1454
  if "%Premium" in mf.columns:
 
1464
  textfont=dict(size=9),
1465
  )
1466
  fig.update_yaxes(ticksuffix="%")
1467
+ fig.update_layout(title=t("sm_chart_premium"),
1468
  xaxis_title=None, yaxis_title=None)
1469
  st.plotly_chart(style_plotly(fig, height=280), use_container_width=True)
1470
 
 
1493
  insidetextanchor="middle",
1494
  )
1495
  fig.update_yaxes(tickformat=",.0f")
1496
+ fig.update_layout(title=t("sm_chart_rounds"),
1497
+ xaxis_title=None, yaxis_title=None)
1498
  st.plotly_chart(style_plotly(fig, height=340), use_container_width=True)
1499
 
1500
  # ── Monthly summary table ────────────────────────────────────────
1501
+ st.markdown(f"**{t('sm_monthly_summary')}**")
1502
  if not m.empty:
1503
  base_cols = [c for c in
1504
  ["Year", "Month", "Branch", "Revenue", "Customers"]
 
1530
  },
1531
  )
1532
  else:
1533
+ st.caption(t("sm_no_monthly_rows"))
1534
 
1535
  # ── Daily detail (collapsed by default β€” can be long) ────────────
1536
  # Mirror the Monthly summary column layout, just with Date instead
1537
  # of Year / Month. Adds %Cap, %Premium and per-round customer
1538
  # columns for Copper Buffet.
1539
+ with st.expander(t("sm_daily_detail"), expanded=False):
1540
  if not daily.empty:
1541
  d = daily.copy().sort_values(["Date", "Branch"], ascending=[True, True])
1542
  if "Revenue" in d.columns and "Customers" in d.columns:
 
1659
  },
1660
  )
1661
  else:
1662
+ st.caption(t("sm_no_daily_rows"))
1663
 
1664
  _render_restaurant_summary("Copper Buffet")
1665
  st.divider()
 
1673
  # service date.
1674
  with tab_forecast:
1675
  if fact_predictions.empty and fact_bookings.empty:
1676
+ st.info(t("fc_no_data"))
 
 
 
1677
  else:
1678
+ st.subheader(t("fc_header"))
1679
+ st.caption(t("fc_caption"))
 
 
 
1680
 
1681
  # Local horizon control β€” the sidebar date range is historical-focused
1682
  # by default (Jan 1 β†’ yesterday), so the forecast tab keeps its own.
1683
  horizon_days = st.slider(
1684
+ t("fc_horizon"),
1685
  min_value=7, max_value=60, value=14, step=1,
1686
  )
1687
  today = pd.Timestamp(_dt.now().date())
 
1721
  avg_pct_booked = (total_booked / total_forecast * 100) if total_forecast > 0 else None
1722
 
1723
  fk1, fk2, fk3 = st.columns(3)
1724
+ fk1.metric(t("fc_kpi_forecast", n=horizon_days), fmt_num(total_forecast))
1725
+ fk2.metric(t("fc_kpi_booked", n=horizon_days), fmt_num(total_booked))
1726
+ fk3.metric(t("fc_kpi_pct_booked"),
1727
  fmt_pct(avg_pct_booked) if avg_pct_booked is not None else "β€”")
1728
 
1729
  # ── Daily outlook table ──────────────────────────────────────────
1730
+ st.markdown(f"**{t('fc_outlook')}**")
1731
  if preds.empty:
1732
+ st.info(t("fc_no_horizon"))
1733
  else:
1734
  out = preds[["Date", "Branch", "DayType", "Prediction",
1735
  "Round_1", "Round_2", "Round_3", "Round_4", "Round_5"]].copy()
 
1766
  )
1767
 
1768
  # ── Forecast trend line β€” predicted customers per day ────────────
1769
+ st.markdown(f"**{t('fc_section_trend')}**")
1770
  if preds.empty:
1771
+ st.caption(t("fc_no_horizon"))
1772
  else:
1773
  fig = px.line(
1774
  preds, x="Date", y="Prediction", color="Branch",
 
1780
  textfont=dict(size=9),
1781
  )
1782
  fig.update_yaxes(tickformat=",.0f")
1783
+ fig.update_layout(title=t("fc_chart_trend", n=horizon_days),
1784
+ xaxis_title=None, yaxis_title=None)
1785
  st.plotly_chart(style_plotly(fig, height=320), use_container_width=True)
1786
 
1787
  # ── Booked seats stacked by round ────────────────────────────────
1788
+ st.markdown(f"**{t('fc_section_bookings')}**")
1789
  if books.empty:
1790
+ st.caption(t("fc_no_bookings"))
1791
  else:
1792
  ROUND_NUM_LABEL = {1: "Breakfast", 2: "Lunch", 3: "Dinner",
1793
  4: "Late Dinner", 5: "Special"}
 
1818
  )
1819
  fig.update_yaxes(tickformat=",.0f")
1820
  fig.update_layout(
1821
+ title=t("fc_chart_booked", n=horizon_days),
1822
+ xaxis_title=None, yaxis_title=None,
 
1823
  )
1824
  st.plotly_chart(style_plotly(fig, height=320), use_container_width=True)
1825
 
 
1827
  with tab_pl:
1828
  pl_filt = apply_filters(fact_pl)
1829
  if pl_filt.empty:
1830
+ st.info(t("pl_no_data"))
1831
  else:
1832
+ # Month picker β€” list every (Year, Month) present in the filtered
1833
+ # data, newest first; default to the most recent one.
1834
+ _pl_ym = (
1835
+ pl_filt[["Date"]].dropna()
1836
+ .assign(_y=lambda d: d["Date"].dt.year,
1837
+ _m=lambda d: d["Date"].dt.month)
1838
+ [["_y", "_m"]].drop_duplicates()
1839
+ .sort_values(["_y", "_m"], ascending=[False, False])
1840
+ )
1841
+ _pl_options = [f"{int(y)}-{int(m):02d}" for y, m in _pl_ym.itertuples(index=False)]
1842
+ if not _pl_options:
1843
+ st.info("No P&L rows for the current filters.")
1844
+ st.stop()
1845
+ ym = st.selectbox(t("pl_month_picker"), _pl_options, index=0, key="pl_month")
1846
+ _y_sel, _m_sel = (int(p) for p in ym.split("-"))
1847
 
1848
+ st.subheader(t("pl_top_subcat_title", ym=ym))
1849
  latest_rows = pl_filt[
1850
+ (pl_filt["Date"].dt.year == _y_sel)
1851
+ & (pl_filt["Date"].dt.month == _m_sel)
1852
  ]
1853
  group_col = "SubCat" if "SubCat" in latest_rows.columns else "Cat"
1854
  agg = (
 
1869
  text="AmountLabel",
1870
  )
1871
  fig.update_traces(textposition="outside", cliponaxis=False)
1872
+ fig.update_layout(yaxis_title=None, xaxis_title=t("pl_amount_axis"), showlegend=True)
1873
  fig.update_xaxes(tickformat=",.0f")
1874
  st.plotly_chart(style_plotly(fig, height=520), use_container_width=True)
1875
 
1876
+ st.subheader(t("pl_monthly_ts"))
1877
  pl_filt = pl_filt.copy()
1878
  pl_filt["YearMonth"] = pl_filt["Date"].dt.to_period("M").astype(str)
1879
+ _all_cats_label = t("pl_all_categories")
1880
+ cats = [_all_cats_label] + sorted(pl_filt["Cat"].dropna().unique().tolist())
1881
+ cat_pick = st.selectbox(t("pl_cat_picker"), cats)
1882
+ ts_src = pl_filt if cat_pick == _all_cats_label else pl_filt[pl_filt["Cat"] == cat_pick]
1883
  ts = (
1884
  ts_src.groupby(["YearMonth", "Restaurant"], as_index=False)["Amount"].sum()
1885
  .sort_values("YearMonth")
 
1900
  with tab_inv:
1901
  inv_filt = apply_filters(fact_inventory)
1902
  if inv_filt.empty:
1903
+ st.info(t("inv_no_data"))
1904
  else:
1905
+ # Month picker β€” list every (Year, Month) present in the filtered
1906
+ # inventory data, newest first; default to the most recent.
1907
+ _inv_ym = (
1908
+ inv_filt[["Date"]].dropna()
1909
+ .assign(_y=lambda d: d["Date"].dt.year,
1910
+ _m=lambda d: d["Date"].dt.month)
1911
+ [["_y", "_m"]].drop_duplicates()
1912
+ .sort_values(["_y", "_m"], ascending=[False, False])
1913
+ )
1914
+ _inv_options = [f"{int(y)}-{int(m):02d}" for y, m in _inv_ym.itertuples(index=False)]
1915
+ if not _inv_options:
1916
+ st.info("No inventory rows for the current filters.")
1917
+ st.stop()
1918
 
1919
+ col1, col2, col3 = st.columns([2, 1, 1])
1920
  with col1:
1921
+ ym = st.selectbox(t("inv_month_picker"), _inv_options, index=0, key="inv_month")
1922
  with col2:
1923
  sort_by = st.selectbox(
1924
+ t("inv_sort_by"),
1925
  ["Value_Closing", "Qty_Closing", "Value_Used", "Qty_Used"],
1926
  index=0,
1927
+ key="inv_sort",
1928
  )
1929
+ with col3:
1930
+ st.markdown(" ") # spacer
1931
+ _y_sel, _m_sel = (int(p) for p in ym.split("-"))
1932
+ st.subheader(t("inv_snapshot", ym=ym))
1933
  latest_rows = inv_filt[
1934
+ (inv_filt["Date"].dt.year == _y_sel)
1935
+ & (inv_filt["Date"].dt.month == _m_sel)
1936
  ]
1937
  ranked = latest_rows[latest_rows[sort_by] > 0].sort_values(sort_by, ascending=False).head(100)
1938
 
 
1958
  )
1959
 
1960
  # Per-branch inventory totals
1961
+ st.subheader(t("inv_closing_by_branch"))
1962
  totals = (
1963
  latest_rows.groupby(["Restaurant", "Branch"], as_index=False)["Value_Closing"]
1964
  .sum()