TIF_E41253317/views/beranda.py

406 lines
15 KiB
Python

"""
views/beranda.py
=================
Halaman utama setelah login (semua role bisa akses). Menampilkan:
- marquee banner (info lonjakan permintaan & hari libur terdekat)
- 4 KPI card ringkas
- grid produk (mini grafik, total peramalan, skenario optimal, tombol detail)
Tidak ada logika manajemen stok di sini -- semua angka murni turunan dari
hasil forecast Prophet (lihat utils/demand_insights.py untuk detail aturan
deteksi lonjakan).
"""
from __future__ import annotations
import datetime
import pandas as pd
import plotly.graph_objects as go
import streamlit as st
from utils import ui_components
from utils.data_loader import get_aggregate_forecast_date_bounds, get_product_list
from utils.demand_insights import detect_peak_days, nearest_holiday
from utils.forecast_helpers import generate_optimal_forecast, summarize_forecast
PRODUCT_ICONS = {
"minyak": "🛢️",
"gula": "🧂",
"tepung": "🌾",
"telur": "🥚",
"sedap goreng": "🍜",
}
PRODUCT_UNITS = {
"minyak": "Kg",
"gula": "Kg",
"tepung": "Kg",
"telur": "Kg",
"sedap goreng": "Pcs",
}
@st.cache_data(show_spinner=False)
def _cached_forecast(_all_results: dict, product: str, start: str, end: str):
"""
Wrapper ber-cache di sekitar generate_optimal_forecast.
Parameter `_all_results` diberi awalan underscore -- ini konvensi
Streamlit supaya argumen tsb TIDAK ikut di-hash untuk menentukan cache
key (isinya objek Prophet yang tidak cocok/tidak perlu di-hash).
Cache key yang efektif jadinya cukup (product, start, end).
"""
entry = _all_results[product]
return generate_optimal_forecast(entry, start, end)
def _period_picker(all_results: dict, products: list[str]) -> tuple[datetime.date, datetime.date]:
st.markdown("###### 📅 Periode peramalan")
preset = st.radio(
"Preset periode",
options=["7 hari", "14 hari", "30 hari", "Kustom"],
horizontal=True,
label_visibility="collapsed",
key="beranda_preset",
)
today = datetime.date.today()
if preset == "Kustom":
# >>> FIX: batas kalender otomatis, hasil IRISAN dari semua produk
# (bukan rentang statis 2026-2030) -- supaya periode yang dipilih
# pasti valid untuk SEMUA produk yang ditampilkan di Beranda.
min_d, max_d = get_aggregate_forecast_date_bounds(all_results, products)
start, end = ui_components.date_range_picker(
st, "Periode kustom", today, today,
key="beranda_custom_period",
min_date=min_d, max_date=max_d,
)
else:
n_hari = {"7 hari": 7, "14 hari": 14, "30 hari": 30}[preset]
start = today
end = today + datetime.timedelta(days=n_hari - 1)
if end < start:
st.error("Tanggal akhir harus setelah atau sama dengan tanggal awal.")
st.stop()
return start, end
def _sparkline(forecast: pd.DataFrame) -> go.Figure:
fig = go.Figure()
yhat_formatted = forecast["yhat"].clip(lower=0).apply(
lambda x: f"{x:,.0f}".replace(",", ".")
)
fig.add_trace(go.Scatter(
x=forecast["ds"], y=forecast["yhat"].clip(lower=0),
mode="lines", marker=dict(size=3),line=dict(width=2, color="#2563eb"),
customdata=yhat_formatted,
hovertemplate="%{x|%d-%m-%Y}: %{customdata}<extra></extra>",
))
fig.update_layout(
height=60, margin=dict(l=0, r=0, t=6, b=6),
xaxis=dict(visible=False), yaxis=dict(visible=False),
plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)",
showlegend=False,
hoverlabel=dict(
font_size=11,
namelength=0,
)
)
return fig
def _go_to_detail(product: str, start: datetime.date, end: datetime.date) -> None:
st.session_state["selected_product"] = product
st.session_state["selected_period"] = (start, end)
st.session_state.pop("detail_product_select", None)
st.session_state.pop("detail_period_select", None)
detail_page = st.session_state.get("_page_detail_peramalan")
if detail_page is not None:
st.switch_page(detail_page)
else:
st.info("Buka menu 'Detail Peramalan' di sidebar untuk melihat produk ini.")
def render() -> None:
ui_components.inject_calendar_id_script()
st.markdown(
"""
<style>
div[data-testid="stMetricValue"] {
font-size: 26px;
white-space: normal;
line-height: 1.25;
overflow-wrap: break-word;
}
div[data-testid="stMetricLabel"] { font-size: 13px; }
div[data-testid="stMetricDelta"] { font-size: 13px; }
div[data-testid="stMetricDelta"] svg { display: none !important; }
div[data-testid="stMetricDelta"] > div { padding: 2px 6px !important; }
hr {
margin: 1rem 0 2rem 0 !important;
}
div[data-testid="stVerticalBlockBorderWrapper"] {
padding: 0.85rem 1rem 0.75rem !important;
}
div[data-testid="stVerticalBlockBorderWrapper"] .stMarkdown p {
font-size: 13.5px !important;
line-height: 1.35 !important;
margin-bottom: 4px !important;
}
div[data-testid="stVerticalBlockBorderWrapper"] [data-testid="stCaptionContainer"] p {
font-size: 12px !important;
line-height: 1.3 !important;
}
div[data-testid="stVerticalBlockBorderWrapper"] button {
font-size: 13px !important;
padding: 0.3rem 0.5rem !important;
}
</style>
""",
unsafe_allow_html=True,
)
all_results = st.session_state.get("all_results", {})
if not all_results:
st.error("Data model belum dimuat.")
return
products = get_product_list(all_results)
st.title("Peramalan Permintaan Produk")
st.caption("UD Aan Jaya Toko")
start, end = _period_picker(all_results, products)
n_hari = (end - start).days + 1
# --- Hitung forecast semua produk untuk periode ini ---------------------
summaries: dict[str, dict] = {}
forecasts: dict[str, pd.DataFrame] = {}
peaks: dict[str, pd.DataFrame] = {}
errors: dict[str, str] = {}
for product in products:
try:
forecast, scenario, _is_log = _cached_forecast(all_results, product, str(start), str(end))
except Exception as e: # noqa: BLE001
# sengaja ditangkap luas: 1 produk gagal (mis. scaler belum ada)
# tidak boleh bikin seluruh halaman Beranda ikut error
errors[product] = str(e)
continue
forecasts[product] = forecast
summary = summarize_forecast(forecast)
summary["scenario"] = scenario
summaries[product] = summary
peaks[product] = detect_peak_days(forecast)
if errors:
with st.expander(f"⚠️ {len(errors)} produk gagal diramal untuk periode ini"):
for p, msg in errors.items():
st.write(f"**{p.title()}**: {msg}")
if not summaries:
st.warning("Tidak ada produk yang berhasil diramal untuk periode ini.")
return
# --- Cari event/hari libur terdekat -------------------------------------
nearest_event = None
for product in forecasts:
scenario_name = all_results[product]["optimal_scenario"]
model = all_results[product]["scenarios"][scenario_name]["best_model"]
ev = nearest_holiday(model, start)
# Kalau ditemukan event (ev) dan tanggal event tersebut lebih dekat dibanding event yang sebelumnya disimpan, maka jadikan event tersebut sebagai nearest_event.
if ev and (nearest_event is None or ev["tanggal"] < nearest_event["tanggal"]):
nearest_event = ev
produk_lonjakan = [p for p, df in peaks.items() if len(df) > 0]
# --- Marquee banner -------------------------------------------------------
# marquee_parts = []
# if nearest_event is not None:
# selisih = (nearest_event["tanggal"].date() - start).days
# if selisih >= 0:
# marquee_parts.append(f"📅 {nearest_event['nama'].replace('_', ' ').title()} dalam {selisih} hari lagi")
# if produk_lonjakan:
# nama = ", ".join(p.title() for p in produk_lonjakan)
# marquee_parts.append(f"🚨 Terdeteksi lonjakan permintaan pada: {nama}")
# # Dipindah ke utils/ui_components.py supaya style marquee konsisten
# # kalau nanti dipakai lagi di halaman lain.
# ui_components.marquee(marquee_parts)
# --- 4 KPI Cards -------------------------------------------------------------
total_semua = sum(s["total"] for s in summaries.values())
top_produk = max(summaries, key=lambda p: summaries[p]["total"])
jumlah_produk = len(summaries)
# st.markdown(
# """
# <style>
# div[data-testid="stMetricValue"] {
# font-size: 26px;
# white-space: normal;
# line-height: 1.25;
# overflow-wrap: break-word;
# }
# div[data-testid="stMetricLabel"] {
# font-size: 13px;
# }
# div[data-testid="stMetricDelta"] {
# font-size: 13px;
# }
# </style>
# """,
# unsafe_allow_html=True,
# )
k1, k2, k3, k4 = st.columns(4)
k1.metric("Total jenis produk", f"{jumlah_produk} produk")
k2.metric(f"Total permintaan ({n_hari} hari)", f"{total_semua:,.0f}".replace(",", ".") + " unit")
k3.metric("Permintaan tinggi", top_produk.title(), f"{summaries[top_produk]['total']:,.0f}".replace(",", ".") + " unit")
if nearest_event is not None:
selisih = (nearest_event["tanggal"].date() - start).days
tgl_str = nearest_event["tanggal"].strftime("%d-%m-%Y")
nama_event = nearest_event["nama"].replace("_", " ").title()
badge_text = f"H-{selisih}" if selisih > 0 else "Berlangsung"
k4.metric(
"Event terdekat",
nama_event,
f"{tgl_str} ({badge_text})"
)
else:
k4.metric("Event terdekat", "-")
# CSS khusus untuk memperlebar badge delta & merapatkan caption tanggal
# st.markdown(
# """
# <style>
# /* Sembunyikan ikon panah SVG pada delta metric */
# div[data-testid="stMetricDelta"] svg {
# display: none !important;
# }
# /* Perlebar ruang (padding) background hijau badge delta */
# div[data-testid="stMetricDelta"] > div {
# padding: 2px 6px !important;
# }
# </style>
# """,
# unsafe_allow_html=True,
# )
st.divider()
# --- Grid produk ---------------------------------------------------------------
cols = st.columns(len(summaries))
single_day = n_hari == 1
# CSS supaya kartu produk tetap rapi & kompak di zoom 100%,
# tanpa perlu user turunkan zoom browser
# st.markdown(
# """
# <style>
# div[data-testid="stVerticalBlockBorderWrapper"] {
# padding: 0.85rem 1rem 0.75rem !important;
# }
# div[data-testid="stVerticalBlockBorderWrapper"] .stMarkdown p {
# font-size: 13.5px !important;
# line-height: 1.35 !important;
# margin-bottom: 4px !important;
# }
# div[data-testid="stVerticalBlockBorderWrapper"] [data-testid="stCaptionContainer"] p {
# font-size: 12px !important;
# line-height: 1.3 !important;
# }
# div[data-testid="stVerticalBlockBorderWrapper"] button {
# font-size: 13px !important;
# padding: 0.3rem 0.5rem !important;
# }
# </style>
# """,
# unsafe_allow_html=True,
# )
for col, product in zip(cols, summaries.keys()):
with col:
with st.container(border=True):
icon = PRODUCT_ICONS.get(product, "📦")
unit = PRODUCT_UNITS.get(product, "unit")
s = summaries[product]
st.markdown(
f"<p style='font-size:15px; font-weight:600; margin:0 0 6px; "
f"white-space:nowrap; overflow:hidden; text-overflow:ellipsis;' "
f"title='{product.title()}'>{icon} {product.title()}</p>",
unsafe_allow_html=True,
)
if single_day:
# variasi Big Number: rentang cuma 1 hari, sparkline tidak
# ada gunanya (cuma 1 titik data)
total_fmt = f"{s['total']:,.0f}".replace(",", ".")
st.markdown(
f"<p style='font-size:26px;font-weight:600;margin:6px 0 0;'>"
f"{total_fmt} {unit}</p>",
unsafe_allow_html=True,
)
else:
st.plotly_chart(
_sparkline(forecasts[product]),
width="stretch",
config={"displayModeBar": False},
key=f"spark_{product}",
)
total_fmt = f"{s['total']:,.0f}".replace(",", ".")
st.markdown(
f"<p style='font-size:12px; font-weight:600; margin:4px 0 2px; "
f"white-space:nowrap; overflow:hidden; text-overflow:ellipsis;'>"
f"{total_fmt} {unit}</p>",
unsafe_allow_html=True,
)
rata_text = f"Rata2: {s['rata_rata_harian']:.1f}".replace(".", ",") + f" {unit}/hari"
st.markdown(
f"<p style='font-size:12px; color:#888; margin:0 0 6px; "
f"white-space:nowrap; overflow:hidden; text-overflow:ellipsis;' "
f"title='{rata_text}'>{rata_text}</p>",
unsafe_allow_html=True,
)
# legenda 🟢 Normal / 🟠 Lonjakan di KPI "Peringatan lonjakan"
# is_lonjakan = product in produk_lonjakan
# badge_bg = "#fff2e0" if is_lonjakan else "#e7f7ee"
# badge_fg = "#b45309" if is_lonjakan else "#1a7f4e"
# badge_text = "🟠 Lonjakan" if is_lonjakan else "🟢 Normal"
# st.markdown(
# f"<span style='display:inline-block; background:{badge_bg}; "
# f"color:{badge_fg}; padding:2px 8px; border-radius:10px; "
# f"font-size:11.5px; font-weight:500; margin:2px 0 2px;'>"
# f"{badge_text}</span>",
# unsafe_allow_html=True,
# )
# PENTING: st.switch_page TIDAK bekerja kalau dipanggil dari
# dalam callback on_click -- harus dipanggil langsung di sini,
# di dalam blok if. Sudah diverifikasi lewat pengujian manual.
if st.button("Lihat detail →", key=f"detail_btn_{product}", width="stretch"):
_go_to_detail(product, start, end)
# st.markdown(
# "<div style='text-align:left; margin-top:-10px;'>"
# "<span style='font-size:14px; color:#8C8D93D1;'>Keterangan warna menunjukkan peramalan pemintaan </span>"
# "<span style='background:#e7f7ee; color:#1a7f4e; padding:2px 9px; "
# "border-radius:10px; font-size:11.5px; font-weight:600; margin-right:6px;'>"
# "🟢 Normal </span>"
# "<span style='font-size:14px; color:#8C8D93D1;'>dan &nbsp;</span>"
# "<span style='background:#fff2e0; color:#b45309; padding:2px 9px; "
# "border-radius:10px; font-size:11.5px; font-weight:600;'>"
# "🟠 Lonjakan</span>"
# "</div>",
# unsafe_allow_html=True,
# )