""" ui_components.py ================= Komponen UI kecil yang dipakai berulang di beberapa halaman (marquee, badge status, header seksi), supaya gaya visual (warna, spacing) konsisten tanpa copy-paste HTML/CSS di setiap views/*.py. Modul ini sengaja tidak menyimpan state apa pun -- murni fungsi yang menampilkan atau mengembalikan markup, mirip komponen "dumb" di React. Tidak ada dependensi ke utils/data_loader.py atau lainnya, supaya modul ini bisa dipakai di halaman mana pun tanpa risiko circular import. """ from __future__ import annotations import datetime import streamlit as st PRIMARY_COLOR = "#2563eb" _BADGE_COLORS = { "ok": ("#dcfce7", "#166534"), # hijau -- sukses / optimal "warn": ("#fef3c7", "#92400e"), # kuning -- perhatian "error": ("#fee2e2", "#991b1b"), # merah -- gagal / invalid "neutral": ("#e5e7eb", "#374151"), # abu-abu -- info netral } _BULAN_ID = { 1: "Jan", 2: "Feb", 3: "Mar", 4: "Apr", 5: "Mei", 6: "Jun", 7: "Jul", 8: "Agt", 9: "Sep", 10: "Okt", 11: "Nov", 12: "Des", } def strip_spasi_username(widget_key: str) -> None: """ Callback untuk parameter on_change di st.text_input(key=widget_key) pada kolom Username. Streamlit tidak bisa mencegah karakter tertentu diketik secara real-time (tidak ada event per-keystroke di Python), tapi callback ini otomatis jalan begitu kolom kehilangan fokus / user menekan Enter -- efeknya spasi yang sempat diketik langsung hilang tanpa perlu submit form dulu untuk melihat pesan error. Contoh pakai: st.text_input( "Username", key="tu_username", on_change=strip_spasi_username, args=("tu_username",), ) """ if widget_key in st.session_state: st.session_state[widget_key] = st.session_state[widget_key].replace(" ", "") def inject_global_css() -> None: """ CSS yang berlaku untuk SELURUH aplikasi, dipanggil SEKALI dari app.py (setelah st.set_page_config), bukan diulang-ulang di tiap halaman. Isinya: sembunyikan toggle "show password" bawaan browser (Edge/IE) supaya tidak dobel dengan toggle mata bawaan Streamlit. Sebelumnya fix ini cuma ditulis di dalam render_login_form(), jadi cuma berlaku di halaman login -- begitu ada form password baru (Edit Profil, Tambah/Edit User di Manajemen User), bug icon mata dobel muncul lagi di situ karena CSS-nya tidak ikut kepakai. Dengan dipindah ke sini dan dipanggil sekali di app.py, otomatis berlaku di SEMUA input password di seluruh halaman, termasuk yang muncul di dalam st.dialog (popup). """ st.markdown( """ """, unsafe_allow_html=True, ) # --------------------------------------------------------------------------- # Marquee (banner berjalan) # --------------------------------------------------------------------------- def marquee(parts: list[str], separator: str = "   ┃   ", speed_seconds: int = 20) -> None: """ Banner berjalan satu baris (dipakai di atas halaman Beranda untuk info lonjakan permintaan & hari libur terdekat). Kalau `parts` kosong, tidak menampilkan apa pun (tidak ada div kosong yang tetap makan tempat di layout). """ if not parts: return text = separator.join(parts) st.markdown( f"""
{text}
""", unsafe_allow_html=True, ) # --------------------------------------------------------------------------- # Badge status # --------------------------------------------------------------------------- def status_badge(label: str, status: str = "neutral") -> str: """ Bangun 1 badge HTML kecil (pill). Return string HTML mentah -- pemanggil tetap harus lewat st.markdown(..., unsafe_allow_html=True) atau menyisipkannya ke dalam blok HTML yang lebih besar. Parameters ---------- status : str Salah satu dari 'ok' (hijau), 'warn' (kuning), 'error' (merah), atau 'neutral' (abu-abu, default kalau nilai tidak dikenali). """ bg, fg = _BADGE_COLORS.get(status, _BADGE_COLORS["neutral"]) return ( f"" f"{label}" ) def optimal_scenario_badge(scenario_name: str) -> str: """Shortcut: badge hijau berlabel nama skenario optimal (dipakai di tabel evaluasi).""" return status_badge(f"⭐ {scenario_name}", status="ok") def render_badge(label: str, status: str = "neutral") -> None: """Versi status_badge() yang langsung dirender (bukan cuma return string).""" st.markdown(status_badge(label, status), unsafe_allow_html=True) # --------------------------------------------------------------------------- # Header seksi & KPI card # --------------------------------------------------------------------------- def section_header(icon: str, title: str, caption: str | None = None) -> None: """Header seksi konsisten: ikon + judul (markdown h5) + caption opsional di bawahnya.""" st.markdown(f"##### {icon} {title}") if caption: st.caption(caption) def kpi_card(label: str, value: str, delta: str | None = None) -> None: """ Wrapper tipis di atas st.metric. Ada sebagai fungsi terpisah (bukan langsung st.metric di views/*.py) supaya kalau nanti style KPI card mau diseragamkan lebih jauh (mis. ganti ke st.container dengan border custom), cukup ubah di satu tempat. """ st.metric(label, value, delta) # --------------------------------------------------------------------------- # Date-range picker (1 field, ikon kalender bawaan Streamlit) # --------------------------------------------------------------------------- def inject_calendar_id_script(): """ Menyisipkan skrip untuk menerjemahkan teks bawaan di widget kalender Streamlit menjadi Bahasa Indonesia yang rapi. """ st.html( """ """ ) def format_tanggal_id(d: datetime.date) -> str: """'2026-08-04' -> '04 Agt 2026' (format singkat ala Indonesia).""" return f"{d.day:02d} {_BULAN_ID[d.month]} {d.year}" def date_range_picker( container, label: str, start: datetime.date, end: datetime.date, key: str, min_date: datetime.date | None = None, # >>> FIX: opsional, batas per-produk max_date: datetime.date | None = None, # >>> FIX: opsional, batas per-produk ): # inject_calendar_id_script() # Batas rentang DEFAULT (dipakai kalau pemanggil tidak kasih min_date/ # max_date sendiri -- menjaga kompatibilitas dengan pemanggilan lama). DEFAULT_MIN_FORECAST_DATE = datetime.date(2026, 1, 1) DEFAULT_MAX_FORECAST_DATE = datetime.date(2030, 12, 31) # >>> FIX: kalau pemanggil kasih min_date/max_date eksplisit (mis. hasil # dari data_loader.get_forecast_date_bounds() per produk), itu yang # dipakai. Kalau tidak, fallback ke rentang default lama. lower_bound = min_date if min_date is not None else DEFAULT_MIN_FORECAST_DATE upper_bound = max_date if max_date is not None else DEFAULT_MAX_FORECAST_DATE # Sesuaikan start & end bawaan agar tetap di dalam rentang valid produk ini val_start = max(min(start, upper_bound), lower_bound) val_end = max(min(end, upper_bound), lower_bound) date_range = container.date_input( label, value=(val_start, val_end), min_value=lower_bound, # >>> FIX: sebelumnya MIN_FORECAST_DATE hardcode max_value=upper_bound, # >>> FIX: sebelumnya MAX_FORECAST_DATE hardcode key=key, format="DD-MM-YYYY", ) if isinstance(date_range, tuple) and len(date_range) == 2: new_start, new_end = date_range else: new_start, new_end = val_start, val_end return new_start, new_end # get_forecast_date_bounds -> itu soal data & logika bisnis — "kapan sebenarnya boleh diramal, secara matematis/bisnis". Tidak peduli sama sekali soal tampilan. # date_range_picker -> itu soal UI/tampilan — "bagaimana caranya kasih widget kalender yang enak dipakai user, rata tengah, dst". Tidak peduli soal kenapa rentang tanggalnya segitu.