406 lines
15 KiB
Python
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 </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,
|
|
# ) |