classquota / ui_components.py
muhalwan's picture
Revised version
6a0a429
from typing import Dict
def get_color(value: float, thresholds: tuple = (50, 25)) -> str:
high, low = thresholds
if value >= high:
return "#4ade80"
elif value >= low:
return "#fb923c"
else:
return "#f87171"
def get_diff_color(value: float) -> str:
return "#4ade80" if value >= 0 else "#f87171"
# Card Components
def metric_card(title: str, value: str, color: str, subtitle: str = "") -> str:
subtitle_html = (
f'<div style="font-size: 11px; color: #9ca3af; margin-top: 4px;">{subtitle}</div>'
if subtitle
else ""
)
return f"""
<div style="background: #1e293b; padding: 20px; border-radius: 12px; border-left: 4px solid {color};">
<div style="font-size: 12px; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px;">{title}</div>
<div style="font-size: 28px; font-weight: 700; color: {color};">{value}</div>
{subtitle_html}
</div>
"""
def info_row(label: str, value: str, color: str = "#fff", border: bool = True) -> str:
border_style = "border-bottom: 1px solid #334155;" if border else ""
return f"""
<div style="display: flex; justify-content: space-between; padding: 12px 0; {border_style}">
<span style="color: #9ca3af;">{label}</span>
<span style="font-weight: 600; color: {color};">{value}</span>
</div>
"""
def info_card(title: str, rows: list) -> str:
rows_html = "".join(rows)
return f"""
<div style="background: #1e293b; padding: 20px; border-radius: 12px;">
<h4 style="margin: 0 0 16px 0; color: #fff; font-size: 14px; font-weight: 600;">{title}</h4>
{rows_html}
</div>
"""
# Summary Templates
def build_validation_summary(data: Dict) -> str:
"""Build summary HTML for validation mode (when actual data exists)."""
year = data["year"]
semester_name = data["semester_name"]
class_capacity = data["class_capacity"]
data_source = data.get("data_source", "kalkulasi")
# Metrics
class_accuracy_pct = data.get("class_accuracy_pct", 0)
class_within_one_pct = data.get("class_within_one_pct", 0)
total_classes = data.get("total_classes", 0)
comparison_mae = data.get("comparison_mae", 0)
comparison_rmse = data.get("comparison_rmse", 0)
total_for_class_accuracy = data.get("total_for_class_accuracy", 0)
# Enrollment metrics
total_actual = data.get("total_actual", 0)
total_predicted = data.get("total_predicted", 0)
accuracy_pct = data.get("accuracy_pct", 0)
class_matches = data.get("class_matches", 0)
class_within_one = data.get("class_within_one", 0)
# Colors
class_accuracy_color = get_color(class_accuracy_pct)
diff_color = get_diff_color(total_predicted - total_actual)
return f"""
<div style="padding: 24px;">
<div style="margin-bottom: 24px;">
<h2 style="margin: 0 0 8px 0; color: #fff; font-size: 24px; font-weight: 600;">{
year
} Semester {semester_name}</h2>
<p style="color: #9ca3af; margin: 0; font-size: 14px;">Validasi prediksi terhadap data aktual | Kapasitas per kelas: {
class_capacity
} mahasiswa | Sumber kelas aktual: {data_source}</p>
</div>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px;">
{
metric_card(
"Akurasi Kelas",
f"{class_accuracy_pct:.1f}%",
class_accuracy_color,
f"±1 kelas: {class_within_one_pct:.1f}%",
)
}
{metric_card("Total Kelas Prediksi", str(total_classes), "#60a5fa")}
{
metric_card(
"MAE / RMSE", f"{comparison_mae:.1f} / {comparison_rmse:.1f}", "#a78bfa"
)
}
{metric_card("MK Divalidasi", str(total_for_class_accuracy), "#fb923c")}
</div>
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px;">
{
info_card(
"Ringkasan Enrollment",
[
info_row("Total Aktual", str(int(total_actual))),
info_row("Total Prediksi", str(int(total_predicted))),
info_row(
"Selisih",
f"{int(total_predicted - total_actual):+d}",
diff_color,
border=False,
),
],
)
}
{
info_card(
f"Akurasi Prediksi Kelas (dari {data_source})",
[
info_row(
"Kelas Tepat",
f"{class_matches}/{total_for_class_accuracy}",
"#4ade80",
),
info_row(
"Selisih ±1 Kelas",
f"{class_within_one}/{total_for_class_accuracy}",
"#60a5fa",
),
info_row("Akurasi Enrollment", f"{accuracy_pct:.1f}%", border=False),
],
)
}
</div>
</div>
"""
def build_no_match_summary(data: Dict) -> str:
year = data["year"]
semester_name = data["semester_name"]
metrics = data.get("metrics", {"mae": 0, "rmse": 0})
total_to_open = data.get("total_to_open", 0)
total_classes = data.get("total_classes", 0)
return f"""
<div style="padding: 24px;">
<div style="margin-bottom: 24px;">
<h2 style="margin: 0 0 8px 0; color: #fff; font-size: 24px; font-weight: 600;">{year} Semester {semester_name}</h2>
<p style="color: #9ca3af; margin: 0; font-size: 14px;">Data semester ada, tetapi tidak ditemukan MK pilihan yang cocok</p>
</div>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px;">
{metric_card("MAE (Backtest)", f"{metrics['mae']:.2f}", "#60a5fa")}
{metric_card("RMSE (Backtest)", f"{metrics['rmse']:.2f}", "#a78bfa")}
{metric_card("MK Dibuka", str(total_to_open), "#4ade80")}
{metric_card("Total Kelas", str(total_classes), "#fb923c")}
</div>
</div>
"""
def build_future_prediction_summary(data: Dict) -> str:
year = data["year"]
semester_name = data["semester_name"]
class_capacity = data["class_capacity"]
metrics = data.get("metrics", {"mae": 0, "rmse": 0})
total_to_open = data.get("total_to_open", 0)
total_classes = data.get("total_classes", 0)
total_predicted_students = data.get("total_predicted_students", 0)
total_capacity = data.get("total_capacity", 0)
avg_utilization = (
(total_predicted_students / total_capacity * 100) if total_capacity > 0 else 0
)
return f"""
<div style="padding: 24px;">
<div style="margin-bottom: 24px;">
<h2 style="margin: 0 0 8px 0; color: #fff; font-size: 24px; font-weight: 600;">{
year
} Semester {semester_name}</h2>
<p style="color: #9ca3af; margin: 0; font-size: 14px;">Prediksi masa depan berdasarkan tren historis | Kapasitas per kelas: {
class_capacity
} mahasiswa</p>
</div>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px;">
{metric_card("MK Dibuka", str(total_to_open), "#4ade80")}
{metric_card("Total Kelas Dibuka", str(total_classes), "#60a5fa")}
{metric_card("Prediksi Mahasiswa", str(total_predicted_students), "#a78bfa")}
{metric_card("Total Kuota", str(total_capacity), "#fb923c")}
</div>
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px;">
{
info_card(
"Backtest Metrics",
[
info_row("MAE", f"{metrics['mae']:.2f}"),
info_row("RMSE", f"{metrics['rmse']:.2f}", border=False),
],
)
}
{
info_card(
"Kapasitas Info",
[
info_row("Kapasitas/Kelas", f"{class_capacity} mhs"),
info_row("Avg Utilization", f"{avg_utilization:.1f}%", border=False),
],
)
}
</div>
</div>
"""
def build_prediction_summary(data: Dict) -> str:
has_actual_data = data.get("has_actual_data", False)
if has_actual_data:
if "comparison_mae" in data:
return build_validation_summary(data)
else:
return build_no_match_summary(data)
else:
return build_future_prediction_summary(data)
def build_multi_year_summary(data: Dict) -> str:
year = data["year"]
years_ahead = data["years_ahead"]
semester_name = data["semester_name"]
class_capacity = data["class_capacity"]
first_year_classes = data["first_year_classes"]
last_year_classes = data["last_year_classes"]
growth_classes = data["growth_classes"]
growth_students = data["growth_students"]
growth_class_color = get_diff_color(growth_classes)
growth_student_color = get_diff_color(growth_students)
return f"""
<div style="padding: 24px;">
<div style="margin-bottom: 24px;">
<h2 style="margin: 0 0 8px 0; color: #fff; font-size: 24px; font-weight: 600;">Proyeksi {years_ahead} Tahun ke Depan - Semester {semester_name}</h2>
<p style="color: #9ca3af; margin: 0; font-size: 14px;">Forecasting kebutuhan kelas {year} - {year + years_ahead} | Kapasitas per kelas: {class_capacity} mahasiswa</p>
</div>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px;">
{metric_card(f"Kelas ({year})", str(first_year_classes), "#4ade80")}
{metric_card(f"Kelas ({year + years_ahead})", str(last_year_classes), "#60a5fa")}
{metric_card("Pertumbuhan Kelas", f"{growth_classes:+d}", growth_class_color)}
{metric_card("Pertumbuhan Mhs", f"{growth_students:+d}", growth_student_color)}
</div>
</div>
"""
# Placeholder Templates
def placeholder_card(title: str, subtitle: str) -> str:
return f"""
<div style="padding: 60px 40px; text-align: center; background: #1e293b; border-radius: 12px;">
<h3 style="color: #fff; margin: 0 0 8px 0; font-size: 18px; font-weight: 600;">{title}</h3>
<p style="color: #9ca3af; margin: 0; font-size: 14px;">{subtitle}</p>
</div>
"""
def get_prediction_placeholder() -> str:
return placeholder_card(
"Pilih tahun dan semester",
"Klik Generate Predictions untuk melihat rekomendasi jumlah kelas",
)
def get_forecast_placeholder() -> str:
return placeholder_card(
"Proyeksi Multi-Tahun", "Lihat tren kebutuhan kelas beberapa tahun ke depan"
)
# Data Info Component
def build_data_info(data: Dict) -> str:
if "error" in data:
return f"<p style='color: #f87171;'>{data['error']}</p>"
return f"""
<div style="padding: 8px 0;">
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px;">
<div style="background: #1e293b; padding: 16px; border-radius: 8px; text-align: center;">
<div style="font-size: 11px; color: #9ca3af; margin-bottom: 6px;">Total MK</div>
<div style="font-size: 20px; font-weight: 700; color: #fff;">{data["total_courses"]}</div>
</div>
<div style="background: #1e293b; padding: 16px; border-radius: 8px; text-align: center;">
<div style="font-size: 11px; color: #9ca3af; margin-bottom: 6px;">MK Pilihan</div>
<div style="font-size: 20px; font-weight: 700; color: #4ade80;">{data["elective_courses"]}</div>
</div>
<div style="background: #1e293b; padding: 16px; border-radius: 8px; text-align: center;">
<div style="font-size: 11px; color: #9ca3af; margin-bottom: 6px;">Kapasitas/Kelas</div>
<div style="font-size: 20px; font-weight: 700; color: #60a5fa;">{data["class_capacity"]}</div>
</div>
<div style="background: #1e293b; padding: 16px; border-radius: 8px; text-align: center;">
<div style="font-size: 11px; color: #9ca3af; margin-bottom: 6px;">Tahun Data</div>
<div style="font-size: 20px; font-weight: 700; color: #fb923c;">{data["year_min"]}-{data["year_max"]}</div>
</div>
</div>
</div>
"""