Spaces:
Running
Running
Support Thai language
Browse files- 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;">
|
| 286 |
<p style="color: #6B7280; margin: 0 0 28px 0; line-height: 1.5;">
|
| 287 |
-
|
| 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 |
-
">π€
|
| 304 |
<p style="color: #6B7280; font-size: 12px; margin-top: 32px;">
|
| 305 |
-
|
| 306 |
-
you to the <code>CB-Group</code> org, then
|
| 307 |
<a href="https://huggingface.co/join" target="_blank"
|
| 308 |
-
style="color: #976A4D;">
|
| 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("
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 727 |
sel_restaurants = st.sidebar.multiselect(
|
| 728 |
-
"
|
| 729 |
)
|
| 730 |
sel_branches = st.sidebar.multiselect(
|
| 731 |
-
"
|
| 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 |
-
"
|
| 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"
|
| 773 |
-
st.sidebar.caption(f"
|
| 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"
|
| 781 |
-
if st.sidebar.button("
|
| 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 "
|
| 918 |
st.markdown(
|
| 919 |
-
f"<span class='small-caption'>
|
| 920 |
-
f"{len(sel_restaurants)}
|
| 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 |
-
["
|
|
|
|
| 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("
|
| 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("
|
| 979 |
|
| 980 |
with right:
|
| 981 |
-
st.subheader("
|
| 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("
|
| 1012 |
|
| 1013 |
-
st.subheader("
|
| 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=
|
| 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(
|
| 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("**
|
| 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=
|
| 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="
|
| 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="
|
| 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="
|
| 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=
|
| 1298 |
-
xaxis_title=None, yaxis_title=
|
| 1299 |
st.plotly_chart(style_plotly(fig, height=340), use_container_width=True)
|
| 1300 |
|
| 1301 |
# ββ Monthly summary table ββββββββββββββββββββββββββββββββββββββββ
|
| 1302 |
-
st.markdown("**
|
| 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("
|
| 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("
|
| 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("
|
| 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("
|
| 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 |
-
|
| 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(
|
| 1532 |
-
fk2.metric(
|
| 1533 |
-
fk3.metric("
|
| 1534 |
fmt_pct(avg_pct_booked) if avg_pct_booked is not None else "β")
|
| 1535 |
|
| 1536 |
# ββ Daily outlook table ββββββββββββββββββββββββββββββββββββββββββ
|
| 1537 |
-
st.markdown("**
|
| 1538 |
if preds.empty:
|
| 1539 |
-
st.info("
|
| 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("**
|
| 1577 |
if preds.empty:
|
| 1578 |
-
st.caption("
|
| 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=
|
| 1591 |
-
xaxis_title=None, yaxis_title=
|
| 1592 |
st.plotly_chart(style_plotly(fig, height=320), use_container_width=True)
|
| 1593 |
|
| 1594 |
# ββ Booked seats stacked by round ββββββββββββββββββββββββββββββββ
|
| 1595 |
-
st.markdown("**
|
| 1596 |
if books.empty:
|
| 1597 |
-
st.caption("
|
| 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=
|
| 1629 |
-
xaxis_title=None, yaxis_title=
|
| 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(
|
| 1639 |
else:
|
| 1640 |
-
|
| 1641 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1642 |
|
| 1643 |
-
st.subheader(
|
| 1644 |
latest_rows = pl_filt[
|
| 1645 |
-
(pl_filt["Date"].dt.year ==
|
| 1646 |
-
& (pl_filt["Date"].dt.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=
|
| 1668 |
fig.update_xaxes(tickformat=",.0f")
|
| 1669 |
st.plotly_chart(style_plotly(fig, height=520), use_container_width=True)
|
| 1670 |
|
| 1671 |
-
st.subheader("
|
| 1672 |
pl_filt = pl_filt.copy()
|
| 1673 |
pl_filt["YearMonth"] = pl_filt["Date"].dt.to_period("M").astype(str)
|
| 1674 |
-
|
| 1675 |
-
|
| 1676 |
-
|
|
|
|
| 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("
|
| 1698 |
else:
|
| 1699 |
-
|
| 1700 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1701 |
|
| 1702 |
-
col1, col2 = st.columns(2)
|
| 1703 |
with col1:
|
| 1704 |
-
st.
|
| 1705 |
with col2:
|
| 1706 |
sort_by = st.selectbox(
|
| 1707 |
-
"
|
| 1708 |
["Value_Closing", "Qty_Closing", "Value_Used", "Qty_Used"],
|
| 1709 |
index=0,
|
|
|
|
| 1710 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1711 |
latest_rows = inv_filt[
|
| 1712 |
-
(inv_filt["Date"].dt.year ==
|
| 1713 |
-
& (inv_filt["Date"].dt.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("
|
| 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()
|