271 lines
10 KiB
Python
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 = " ┃ ", 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. |