TIF_E41253317/utils/ui_components.py

271 lines
10 KiB
Python

"""
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(
"""
<style>
input::-ms-reveal,
input::-ms-clear {
display: none;
}
</style>
""",
unsafe_allow_html=True,
)
# ---------------------------------------------------------------------------
# Marquee (banner berjalan)
# ---------------------------------------------------------------------------
def marquee(parts: list[str], separator: str = " &nbsp; ┃ &nbsp; ", 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"""
<div style="overflow:hidden;white-space:nowrap;background:#fef3c7;
border-radius:8px;padding:6px 0;margin-bottom:16px;">
<div style="display:inline-block;padding-left:100%;
animation:ui_marquee {speed_seconds}s linear infinite;
color:#92400e;font-size:13px;">
{text}
</div>
</div>
<style>
@keyframes ui_marquee {{
from {{ transform: translateX(0); }}
to {{ transform: translateX(-100%); }}
}}
</style>
""",
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"<span style='background:{bg};color:{fg};padding:2px 10px;"
f"border-radius:999px;font-size:12px;font-weight:600;white-space:nowrap;'>"
f"{label}</span>"
)
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(
"""
<script>
const observer = new MutationObserver((mutations) => {
// Terjemahkan instruksi di bawah kalender
document.querySelectorAll('div, span, p').forEach(el => {
if (el.textContent.trim() === 'Choose a date range') {
el.textContent = 'Pilih rentang tanggal';
}
if (el.textContent.trim() === 'None') {
el.textContent = 'Tidak ada';
}
});
// Terjemahkan nama bulan
const monthMap = {
'January': 'Januari', 'February': 'Februari', 'March': 'Maret',
'April': 'April', 'May': 'Mei', 'June': 'Juni',
'July': 'Juli', 'August': 'Agustus', 'September': 'September',
'October': 'Oktober', 'November': 'November', 'December': 'Desember'
};
// Terjemahkan nama hari singkat
const dayMap = {
'Su': 'Min', 'Mo': 'Sen', 'Tu': 'Sel',
'We': 'Rab', 'Th': 'Kam', 'Fr': 'Jum', 'Sa': 'Sab'
};
document.querySelectorAll('button, div').forEach(el => {
const text = el.textContent.trim();
if (monthMap[text]) {
el.textContent = monthMap[text];
}
if (dayMap[text]) {
el.textContent = dayMap[text];
}
});
});
observer.observe(document.body, { childList: true, subtree: true });
</script>
"""
)
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.