MIF_E31231708/.history/cv_web/app_20260618021000.py

1982 lines
64 KiB
Python

import base64
import hashlib
import hmac
import json
import os
import time
from html import escape
from pathlib import Path
import sys
from urllib.parse import quote, unquote
from fastapi import FastAPI, Form, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse
BASE_DIR = Path(__file__).resolve().parent
PROJECT_DIR = BASE_DIR.parent
ML_DIR = PROJECT_DIR / "ml"
if str(ML_DIR) not in sys.path:
sys.path.append(str(ML_DIR))
from database import ( # noqa: E402
authenticate_user,
create_user,
delete_prediction_result,
delete_user,
get_database_label,
get_prediction_result,
get_user_by_id,
init_db,
list_prediction_results,
list_users,
save_prediction_result,
update_prediction_result,
update_user,
)
app = FastAPI(title="ConfiVoice Admin Web")
ADMIN_SESSION_COOKIE = "confivoice_admin_session"
ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7
ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local")
@app.on_event("startup")
def startup():
init_db()
@app.middleware("http")
async def require_admin_session(request: Request, call_next):
path = request.url.path.rstrip("/") or "/"
public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"}
if path.startswith("/admin") and path not in public_admin_paths:
user = _get_admin_user_from_request(request)
if user is None:
return RedirectResponse(url="/admin/login", status_code=303)
request.state.admin_user = user
return await call_next(request)
@app.get("/")
def read_root():
return {
"name": "ConfiVoice Admin Web",
"status": "ok",
"admin_page": "/admin",
"database": get_database_label(),
}
@app.get("/admin/login", response_class=HTMLResponse)
def read_admin_login_page(request: Request):
if _get_admin_user_from_request(request):
return RedirectResponse(url="/admin", status_code=303)
return _auth_page(mode="login")
@app.post("/admin/login")
def login_admin(
username: str = Form(...),
password: str = Form(...),
):
user = authenticate_user(username.strip().lower(), password)
if user is None:
return _auth_page(
mode="login",
error_message="Username atau password salah.",
username=username,
)
response = RedirectResponse(url="/admin", status_code=303)
response.set_cookie(
ADMIN_SESSION_COOKIE,
_create_admin_session_token(user),
max_age=ADMIN_SESSION_MAX_AGE,
httponly=True,
samesite="lax",
)
return response
@app.get("/admin/register", response_class=HTMLResponse)
def read_admin_register_page(request: Request):
if _get_admin_user_from_request(request):
return RedirectResponse(url="/admin", status_code=303)
return _auth_page(mode="register")
@app.post("/admin/register", response_class=HTMLResponse)
def register_admin(
full_name: str = Form(...),
username: str = Form(...),
password: str = Form(...),
confirm_password: str = Form(...),
):
full_name = " ".join(full_name.strip().split())
username = username.strip().lower()
password = password.strip()
confirm_password = confirm_password.strip()
if not full_name or not username or not password or not confirm_password:
return _auth_page(
mode="register",
error_message="Lengkapi semua data akun terlebih dahulu.",
full_name=full_name,
username=username,
)
if len(username) < 3:
return _auth_page(
mode="register",
error_message="Username minimal 3 karakter.",
full_name=full_name,
username=username,
)
if len(password) < 6:
return _auth_page(
mode="register",
error_message="Password minimal 6 karakter.",
full_name=full_name,
username=username,
)
if password != confirm_password:
return _auth_page(
mode="register",
error_message="Konfirmasi password belum sama.",
full_name=full_name,
username=username,
)
try:
create_user(full_name, username, password)
except Exception as error:
message = str(error).lower()
if "duplicate" in message or "unique" in message:
error_text = "Username sudah terdaftar."
else:
error_text = "Registrasi gagal. Coba lagi."
return _auth_page(
mode="register",
error_message=error_text,
full_name=full_name,
username=username,
)
return _auth_page(
mode="login",
success_message="Registrasi berhasil. Silakan login.",
username=username,
)
@app.get("/predictions")
def read_predictions(limit: int = 500):
return {
"database": get_database_label(),
"data": list_prediction_results(limit=limit),
}
@app.post("/admin/predictions")
def create_prediction_from_admin(
student_name: str = Form(...),
student_gender: str = Form(""),
predicted_label: str = Form("PD"),
description: str = Form(""),
confidence_percent: float = Form(0),
probability_pd_percent: float = Form(0),
probability_tpd_percent: float = Form(0),
is_valid_audio: str = Form("1"),
error_message: str = Form(""),
audio_duration: float = Form(0),
):
student_name = student_name.strip()
if not student_name:
raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.")
save_prediction_result(
student_name,
_result_payload(
predicted_label=predicted_label,
student_gender=student_gender,
description=description,
confidence_percent=confidence_percent,
probability_pd_percent=probability_pd_percent,
probability_tpd_percent=probability_tpd_percent,
is_valid_audio=is_valid_audio,
error_message=error_message,
audio_duration=audio_duration,
),
)
return _redirect_admin()
@app.get("/admin/predictions/new", response_class=HTMLResponse)
def read_new_prediction_page():
return _html_page(
title="Tambah Data",
active="prediction-new",
content=f"""
<section class="page-head">
<div>
<h1>Tambah Data</h1>
<p>Tambahkan hasil analisis secara manual jika diperlukan.</p>
</div>
</section>
<section class="panel">
{_prediction_form(action="/admin/predictions", submit_label="Tambah Data")}
</section>
""",
)
@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse)
def read_edit_prediction_page(prediction_id: int):
row = get_prediction_result(prediction_id)
if row is None:
raise HTTPException(status_code=404, detail="Data tidak ditemukan.")
return _html_page(
title=f"Edit Data #{prediction_id}",
active="dashboard",
content=f"""
<section class="panel">
<div class="panel-head">
<div>
<h2>Edit Data #{prediction_id}</h2>
<p>Ubah hasil analisis yang tersimpan di admin.</p>
</div>
<a class="button secondary" href="/admin">Kembali</a>
</div>
{_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")}
</section>
""",
)
@app.post("/admin/predictions/{prediction_id}/edit")
def update_prediction_from_admin(
prediction_id: int,
student_name: str = Form(...),
student_gender: str = Form(""),
predicted_label: str = Form("PD"),
description: str = Form(""),
confidence_percent: float = Form(0),
probability_pd_percent: float = Form(0),
probability_tpd_percent: float = Form(0),
is_valid_audio: str = Form("1"),
error_message: str = Form(""),
audio_duration: float = Form(0),
):
student_name = student_name.strip()
if not student_name:
raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.")
updated = update_prediction_result(
prediction_id,
_db_values(
student_name=student_name,
student_gender=student_gender,
predicted_label=predicted_label,
description=description,
confidence_percent=confidence_percent,
probability_pd_percent=probability_pd_percent,
probability_tpd_percent=probability_tpd_percent,
is_valid_audio=is_valid_audio,
error_message=error_message,
audio_duration=audio_duration,
),
)
if not updated:
raise HTTPException(status_code=404, detail="Data tidak ditemukan.")
return _redirect_admin()
@app.post("/admin/predictions/{prediction_id}/delete")
def delete_prediction_from_admin(prediction_id: int):
delete_prediction_result(prediction_id)
return _redirect_admin()
@app.get("/admin/users", response_class=HTMLResponse)
def read_users_page():
users = list_users(limit=500)
return _html_page(
title="Tambah User",
active="users",
content=f"""
<section class="page-head">
<div>
<h1>Tambah User</h1>
<p>Kelola akun guru atau pengguna aplikasi ConfiVoice.</p>
</div>
</section>
<section class="panel">
<div class="panel-head">
<div>
<h2>Tambah User</h2>
<p>Username akan disimpan huruf kecil, password disimpan sebagai hash.</p>
</div>
</div>
{_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)}
</section>
<section class="panel">
<div class="panel-head">
<div>
<h2>CRUD User</h2>
<p>Edit atau hapus user yang sudah terdaftar.</p>
</div>
</div>
{_user_table(users)}
</section>
""",
)
@app.post("/admin/users")
def create_user_from_admin(
full_name: str = Form(...),
username: str = Form(...),
password: str = Form(...),
):
full_name = full_name.strip()
username = username.strip()
password = password.strip()
if not full_name or not username or not password:
raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.")
try:
create_user(full_name, username, password)
except Exception as error:
raise HTTPException(
status_code=400,
detail="Username sudah dipakai atau data user tidak valid.",
) from error
return RedirectResponse(url="/admin/users", status_code=303)
@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse)
def read_edit_user_page(user_id: int):
user = get_user_by_id(user_id)
if user is None:
raise HTTPException(status_code=404, detail="User tidak ditemukan.")
return _html_page(
title=f"Edit User #{user_id}",
active="users",
content=f"""
<section class="panel">
<div class="panel-head">
<div>
<h2>Edit User #{user_id}</h2>
<p>Kosongkan password jika tidak ingin mengganti password.</p>
</div>
<a class="button secondary" href="/admin/users">Kembali</a>
</div>
{_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")}
</section>
""",
)
@app.post("/admin/users/{user_id}/edit")
def update_user_from_admin(
user_id: int,
full_name: str = Form(...),
username: str = Form(...),
password: str = Form(""),
):
full_name = full_name.strip()
username = username.strip()
if not full_name or not username:
raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.")
try:
updated = update_user(user_id, full_name, username, password)
except Exception as error:
raise HTTPException(
status_code=400,
detail="Username sudah dipakai atau data user tidak valid.",
) from error
if not updated:
raise HTTPException(status_code=404, detail="User tidak ditemukan.")
return RedirectResponse(url="/admin/users", status_code=303)
@app.post("/admin/users/{user_id}/delete")
def delete_user_from_admin(user_id: int):
delete_user(user_id)
return RedirectResponse(url="/admin/users", status_code=303)
@app.get("/admin/logout", response_class=HTMLResponse)
def read_logout_page():
response = RedirectResponse(url="/admin/login", status_code=303)
response.delete_cookie(ADMIN_SESSION_COOKIE)
return response
@app.get("/admin/students/{student_name}", response_class=HTMLResponse)
def read_student_predictions_page(student_name: str):
decoded_name = unquote(student_name)
rows = [
row
for row in list_prediction_results(limit=500)
if row["student_name"] == decoded_name
]
total = len(rows)
pd_count = sum(1 for row in rows if row["predicted_label"] == "PD")
tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD")
avg_pd = _average_pd_ratio(rows)
return _html_page(
title=f"Analisis {decoded_name}",
active="dashboard",
content=f"""
<section class="page-head">
<div>
<h1>{escape(decoded_name)}</h1>
<p>Folder hasil analisis siswa/i.</p>
</div>
<a class="button secondary" href="/admin">Kembali</a>
</section>
<section class="stats">
<div class="stat"><span>Total Prediksi</span><strong>{total}</strong></div>
<div class="stat"><span>Percaya Diri</span><strong>{pd_count}</strong></div>
<div class="stat"><span>Tidak Percaya Diri</span><strong>{tpd_count}</strong></div>
<div class="stat"><span>Rata-rata PD</span><strong>{avg_pd * 100:.1f}%</strong></div>
</section>
<section class="panel">
<div class="panel-head">
<div>
<h2>Isi Folder Analisis</h2>
<p>Edit atau hapus hasil prediksi milik siswa/i ini.</p>
</div>
</div>
{_prediction_table(rows)}
</section>
""",
)
@app.get("/admin", response_class=HTMLResponse)
def read_admin_page(
q: str = Query("", alias="q"),
label: str = Query("", alias="label"),
):
rows = list_prediction_results(limit=500)
clean_query = q.strip()
clean_label = label.strip().upper()
if clean_label not in {"PD", "TPD"}:
clean_label = ""
filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label)
total = len(filtered_rows)
pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD")
tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD")
avg_pd = _average_pd_ratio(filtered_rows)
grouped_rows = _group_predictions_by_student(filtered_rows)
return _html_page(
title="Admin ConfiVoice",
active="dashboard",
content=f"""
<section class="page-head">
<div>
<h1>Admin ConfiVoice</h1>
<p>Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.</p>
</div>
</section>
<section class="stats">
<div class="stat"><span>Total Data Tampil</span><strong data-stat="total">{total}</strong></div>
<div class="stat"><span>Percaya Diri</span><strong data-stat="pd">{pd_count}</strong></div>
<div class="stat"><span>Tidak Percaya Diri</span><strong data-stat="tpd">{tpd_count}</strong></div>
<div class="stat"><span>Rata-rata Percaya Diri</span><strong data-stat="avg-pd">{avg_pd * 100:.1f}%</strong></div>
</section>
<section class="panel">
<div class="panel-head">
<div>
<h2>Folder Siswa/i</h2>
<p>Pilih nama siswa/i untuk melihat semua hasil analisisnya.</p>
</div>
</div>
{_dashboard_filter_form(clean_query, clean_label)}
{_student_folder_grid(grouped_rows)}
</section>
""",
)
def _redirect_admin():
return RedirectResponse(url="/admin", status_code=303)
def _create_admin_session_token(user):
payload = {
"id": user["id"],
"username": user["username"],
"exp": int(time.time()) + ADMIN_SESSION_MAX_AGE,
}
payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8")
encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii")
signature = hmac.new(
ADMIN_SESSION_SECRET.encode("utf-8"),
encoded_payload.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return f"{encoded_payload}.{signature}"
def _verify_admin_session_token(token):
try:
encoded_payload, signature = token.split(".", 1)
expected_signature = hmac.new(
ADMIN_SESSION_SECRET.encode("utf-8"),
encoded_payload.encode("utf-8"),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(signature, expected_signature):
return None
payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii")))
if int(payload.get("exp") or 0) < int(time.time()):
return None
return payload
except Exception:
return None
def _get_admin_user_from_request(request):
token = request.cookies.get(ADMIN_SESSION_COOKIE)
if not token:
return None
payload = _verify_admin_session_token(token)
if not payload:
return None
return get_user_by_id(payload.get("id"))
def _password_eye_icon():
return (
'<svg viewBox="0 0 24 24" fill="none" stroke-width="2" '
'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
'<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6z"></path>'
'<circle cx="12" cy="12" r="3"></circle>'
"</svg>"
)
def _password_eye_off_icon():
return (
'<svg viewBox="0 0 24 24" fill="none" stroke-width="2" '
'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
'<path d="M3 3l18 18"></path>'
'<path d="M10.6 10.6A3 3 0 0 0 13.4 13.4"></path>'
'<path d="M9.9 5.2A10.7 10.7 0 0 1 12 5c6.5 0 10 7 10 7a18.7 18.7 0 0 1-3.1 4.2"></path>'
'<path d="M6.1 6.9C3.4 8.7 2 12 2 12s3.5 7 10 7c1.4 0 2.7-.3 3.9-.9"></path>'
"</svg>"
)
def _result_payload(
*,
predicted_label,
student_gender,
description,
confidence_percent,
probability_pd_percent,
probability_tpd_percent,
is_valid_audio,
error_message,
audio_duration,
):
return {
"predicted_label": predicted_label,
"student_gender": _clean_student_gender(student_gender),
"description": description,
"confidence": _percent_to_ratio(confidence_percent),
"probability_pd": _percent_to_ratio(probability_pd_percent),
"probability_tpd": _percent_to_ratio(probability_tpd_percent),
"is_valid_audio": is_valid_audio == "1",
"error_message": error_message.strip() or None,
"audio_quality": {"duration": audio_duration},
"voice_indicators": {},
}
def _db_values(
*,
student_name,
student_gender,
predicted_label,
description,
confidence_percent,
probability_pd_percent,
probability_tpd_percent,
is_valid_audio,
error_message,
audio_duration,
):
return (
student_name,
_clean_student_gender(student_gender),
predicted_label.strip() or None,
description.strip() or None,
_percent_to_ratio(confidence_percent),
_percent_to_ratio(probability_pd_percent),
_percent_to_ratio(probability_tpd_percent),
1 if is_valid_audio == "1" else 0,
error_message.strip() or None,
float(audio_duration or 0),
0,
0,
0,
0,
0,
)
def _percent_to_ratio(value):
return max(0, min(float(value or 0), 100)) / 100
def _ratio_to_percent(value):
return f"{float(value or 0) * 100:.2f}"
def _clean_student_gender(value):
gender = (value or "").strip()
return gender if gender in {"Laki-laki", "Perempuan"} else None
def _student_gender_label(value):
return value or "Belum diisi"
def _pd_probability(row):
return float(row.get("probability_pd") or 0)
def _average_pd_ratio(rows):
return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0
def _filter_prediction_rows(rows, query, label):
query = query.strip().lower()
label = label.strip().upper()
filtered_rows = rows
if query:
filtered_rows = [
row
for row in filtered_rows
if query in (row["student_name"] or "").lower()
]
if label in {"PD", "TPD"}:
filtered_rows = [
row
for row in filtered_rows
if (row["predicted_label"] or "").upper() == label
]
return filtered_rows
def _dashboard_filter_form(query, label):
all_selected = "selected" if not label else ""
pd_selected = "selected" if label == "PD" else ""
tpd_selected = "selected" if label == "TPD" else ""
return f"""
<form class="filter-bar" method="get" action="/admin" data-auto-filter>
<label>
<span>Cari nama siswa/i</span>
<input name="q" value="{escape(query)}" placeholder="Ketik nama siswa/i...">
</label>
<label>
<span>Filter hasil</span>
<select name="label">
<option value="" {all_selected}>Semua</option>
<option value="PD" {pd_selected}>Percaya Diri</option>
<option value="TPD" {tpd_selected}>Tidak Percaya Diri</option>
</select>
</label>
<div class="filter-actions">
<a class="button secondary" href="/admin">Reset</a>
</div>
</form>
"""
def _group_predictions_by_student(rows):
groups = {}
for row in rows:
student_name = row["student_name"] or "Tanpa Nama"
groups.setdefault(student_name, []).append(row)
return sorted(groups.items(), key=lambda item: item[0].lower())
def _student_folder_grid(grouped_rows):
if not grouped_rows:
return '<div class="empty folder-empty" data-folder-empty>Belum ada folder siswa/i.</div>'
cards = []
for student_name, rows in grouped_rows:
total = len(rows)
latest = rows[0]
gender = _student_gender_label(latest.get("student_gender"))
label = latest.get("predicted_label") or "-"
pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD")
tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD")
pd_sum = sum(_pd_probability(row) for row in rows)
labels = " ".join(
sorted({(row.get("predicted_label") or "").upper() for row in rows})
)
latest_pd = _pd_probability(latest) * 100
pd_average = _average_pd_ratio(rows) * 100
folder_url = f"/admin/students/{quote(student_name, safe='')}"
cards.append(
f"""
<a class="student-folder" href="{folder_url}" data-student-name="{escape(student_name.lower())}" data-labels="{escape(labels)}" data-total="{total}" data-pd-count="{pd_count}" data-tpd-count="{tpd_count}" data-pd-sum="{pd_sum:.8f}">
<div class="folder-icon">{escape(student_name[:1].upper() or "?")}</div>
<div class="folder-content">
<strong>{escape(student_name)}</strong>
<span>{escape(gender)} | {total} hasil analisis</span>
<small>Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}%</small>
</div>
</a>
"""
)
return (
f'<div class="folder-grid" data-folder-grid>{"".join(cards)}</div>'
'<div class="empty folder-empty is-hidden" data-filter-empty>'
"Tidak ada folder siswa/i yang sesuai filter."
"</div>"
)
def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"):
row = row or {}
student_name = escape(str(row.get("student_name") or ""))
student_gender = str(row.get("student_gender") or "")
predicted_label = str(row.get("predicted_label") or "PD")
description = escape(str(row.get("description") or ""))
confidence = _ratio_to_percent(row.get("confidence"))
probability_pd = _ratio_to_percent(row.get("probability_pd"))
probability_tpd = _ratio_to_percent(row.get("probability_tpd"))
audio_duration = float(row.get("audio_duration") or 0)
error_message = escape(str(row.get("error_message") or ""))
valid_audio = str(int(row.get("is_valid_audio", 1))) == "1"
pd_selected = "selected" if predicted_label == "PD" else ""
tpd_selected = "selected" if predicted_label == "TPD" else ""
male_selected = "selected" if student_gender == "Laki-laki" else ""
female_selected = "selected" if student_gender == "Perempuan" else ""
valid_selected = "selected" if valid_audio else ""
invalid_selected = "selected" if not valid_audio else ""
return f"""
<form class="form-grid" method="post" action="{escape(action)}">
<label>
<span>Nama siswa/i</span>
<input name="student_name" value="{student_name}" required>
</label>
<label>
<span>Jenis kelamin</span>
<select name="student_gender">
<option value="">Pilih jenis kelamin</option>
<option value="Laki-laki" {male_selected}>Laki-laki</option>
<option value="Perempuan" {female_selected}>Perempuan</option>
</select>
</label>
<label>
<span>Label</span>
<select name="predicted_label">
<option value="PD" {pd_selected}>PD</option>
<option value="TPD" {tpd_selected}>TPD</option>
</select>
</label>
<label>
<span>Confidence (%)</span>
<input name="confidence_percent" type="number" min="0" max="100" step="0.01" value="{confidence}">
</label>
<label>
<span>PD (%)</span>
<input name="probability_pd_percent" type="number" min="0" max="100" step="0.01" value="{probability_pd}">
</label>
<label>
<span>TPD (%)</span>
<input name="probability_tpd_percent" type="number" min="0" max="100" step="0.01" value="{probability_tpd}">
</label>
<label>
<span>Durasi audio (detik)</span>
<input name="audio_duration" type="number" min="0" step="0.01" value="{audio_duration:.2f}">
</label>
<label>
<span>Status audio</span>
<select name="is_valid_audio">
<option value="1" {valid_selected}>Valid</option>
<option value="0" {invalid_selected}>Tidak valid</option>
</select>
</label>
<label class="wide">
<span>Keterangan</span>
<input name="description" value="{description}">
</label>
<label class="wide">
<span>Pesan error audio</span>
<input name="error_message" value="{error_message}">
</label>
<div class="form-actions">
<button class="button" type="submit">{escape(submit_label)}</button>
</div>
</form>
"""
def _prediction_table(rows):
table_rows = []
for row in rows:
valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid"
table_rows.append(
"<tr>"
f"<td>{row['id']}</td>"
f"<td>{escape(str(row['created_at']))}</td>"
f"<td>{escape(row['student_name'])}</td>"
f"<td>{escape(_student_gender_label(row.get('student_gender')))}</td>"
f"<td><span class='badge'>{escape(row['predicted_label'] or '-')}</span></td>"
f"<td>{escape(row['description'] or '-')}</td>"
f"<td>{float(row['confidence'] or 0) * 100:.2f}%</td>"
f"<td>{float(row['probability_pd'] or 0) * 100:.2f}%</td>"
f"<td>{float(row['probability_tpd'] or 0) * 100:.2f}%</td>"
f"<td>{float(row['audio_duration'] or 0):.2f} dtk</td>"
f"<td>{valid_text}</td>"
"<td class='row-actions'>"
f"<a class='small-button' href='/admin/predictions/{row['id']}/edit'>Edit</a>"
f"<form method='post' action='/admin/predictions/{row['id']}/delete' "
f"data-delete-prediction-form data-student-name=\"{escape(row['student_name'])}\" "
f"data-prediction-id=\"{row['id']}\">"
"<button class='small-button danger' type='submit'>Hapus</button>"
"</form>"
"</td>"
"</tr>"
)
body = "\n".join(table_rows) or (
'<tr><td colspan="12" class="empty">Belum ada hasil prediksi.</td></tr>'
)
return f"""
<div class="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>Waktu</th>
<th>Nama siswa/i</th>
<th>Jenis kelamin</th>
<th>Label</th>
<th>Keterangan</th>
<th>Confidence</th>
<th>PD</th>
<th>TPD</th>
<th>Durasi</th>
<th>Status audio</th>
<th>Aksi</th>
</tr>
</thead>
<tbody>{body}</tbody>
</table>
</div>
"""
def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False):
row = row or {}
full_name = escape(str(row.get("full_name") or ""))
username = escape(str(row.get("username") or ""))
required = "required" if password_required else ""
password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti."
return f"""
<form class="form-grid user-form" method="post" action="{escape(action)}">
<label>
<span>Nama lengkap</span>
<input name="full_name" value="{full_name}" required>
</label>
<label>
<span>Username</span>
<input name="username" value="{username}" required>
</label>
<label>
<span>Password</span>
<input name="password" type="password" {required}>
<small>{password_hint}</small>
</label>
<div class="form-actions">
<button class="button" type="submit">{escape(submit_label)}</button>
</div>
</form>
"""
def _user_table(users):
table_rows = []
for user in users:
table_rows.append(
"<tr>"
f"<td>{user['id']}</td>"
f"<td>{escape(str(user['created_at']))}</td>"
f"<td>{escape(user['full_name'])}</td>"
f"<td>{escape(user['username'])}</td>"
"<td class='row-actions'>"
f"<a class='small-button' href='/admin/users/{user['id']}/edit'>Edit</a>"
f"<form method='post' action='/admin/users/{user['id']}/delete' "
f"data-delete-user-form data-user-name=\"{escape(user['full_name'])}\">"
"<button class='small-button danger' type='submit'>Hapus</button>"
"</form>"
"</td>"
"</tr>"
)
body = "\n".join(table_rows) or (
'<tr><td colspan="5" class="empty">Belum ada user terdaftar.</td></tr>'
)
return f"""
<div class="table-wrap">
<table class="user-table">
<thead>
<tr>
<th>ID</th>
<th>Dibuat</th>
<th>Nama lengkap</th>
<th>Username</th>
<th>Aksi</th>
</tr>
</thead>
<tbody>{body}</tbody>
</table>
</div>
"""
def _nav_active(active, name):
return " active" if active == name else ""
def _sidebar(active):
update_open = " open" if active in {"prediction-new", "users"} else ""
return f"""
<aside class="sidebar">
<div class="brand">
<span class="brand-mark">CV</span>
<div>
<strong>ConfiVoice</strong>
<small>Admin Panel</small>
</div>
</div>
<nav class="nav">
<a class="nav-link{_nav_active(active, 'dashboard')}" href="/admin">
<span>Dashboard</span>
</a>
<details class="nav-group"{update_open}>
<summary>Update</summary>
<a class="nav-link{_nav_active(active, 'prediction-new')}" href="/admin/predictions/new">Tambah Data</a>
<a class="nav-link{_nav_active(active, 'users')}" href="/admin/users">Tambah User</a>
</details>
</nav>
<a class="nav-link logout{_nav_active(active, 'logout')}" href="/admin/logout" data-logout-link>Keluar</a>
</aside>
"""
def _auth_page(
*,
mode,
error_message=None,
success_message=None,
full_name="",
username="",
):
is_register = mode == "register"
title = "Daftar" if is_register else "Masuk"
action = "/admin/register" if is_register else "/admin/login"
login_active = "" if is_register else " active"
register_active = " active" if is_register else ""
slider_class = " register" if is_register else ""
error_html = (
f'<div class="auth-alert error">{escape(error_message)}</div>'
if error_message
else ""
)
success_html = (
f'<div class="auth-alert success">{escape(success_message)}</div>'
if success_message
else ""
)
eye_icon = _password_eye_icon()
eye_off_icon = _password_eye_off_icon()
full_name_field = (
f"""
<label>
<span>Nama lengkap</span>
<input name="full_name" value="{escape(full_name)}" required>
</label>
"""
if is_register
else ""
)
confirm_password_field = (
"""
<label>
<span>Konfirmasi password</span>
<span class="password-field">
<input name="confirm_password" type="password" required data-password-input>
<button class="password-toggle" type="button" data-password-toggle aria-label="Tampilkan password" data-eye-icon='{eye_icon}' data-eye-off-icon='{eye_off_icon}'>{eye_icon}</button>
</span>
</label>
"""
if is_register
else ""
)
submit_label = "Daftar" if is_register else "Masuk"
return f"""
<!doctype html>
<html lang="id">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title} ConfiVoice</title>
<style>
:root {{
font-family: Inter, Arial, sans-serif;
color: #0f172a;
background: #081a33;
}}
* {{
box-sizing: border-box;
}}
body {{
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
padding: 22px;
background:
radial-gradient(circle at top right, rgba(147, 197, 253, 0.28), transparent 30rem),
linear-gradient(180deg, #081a33 0%, #0f3a6d 52%, #5eb2f7 100%);
}}
.auth-shell {{
width: min(420px, 100%);
}}
.brand {{
display: grid;
justify-items: center;
gap: 10px;
margin-bottom: 26px;
color: #ffffff;
}}
.brand-mark {{
display: inline-flex;
align-items: center;
justify-content: center;
width: 46px;
height: 46px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.14);
color: #ffffff;
font-weight: 900;
letter-spacing: 0.4px;
}}
.brand strong {{
font-size: 30px;
line-height: 1;
}}
.auth-card {{
padding: 18px;
border: 1px solid #e2e8f0;
border-radius: 20px;
background: #ffffff;
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.22);
}}
.mode-switch {{
position: relative;
display: grid;
grid-template-columns: 1fr 1fr;
height: 46px;
padding: 4px;
border: 1px solid #d6e3f7;
border-radius: 14px;
background: #eaf2ff;
overflow: hidden;
}}
.mode-slider {{
position: absolute;
top: 4px;
bottom: 4px;
left: 4px;
width: calc(50% - 4px);
border-radius: 10px;
background: linear-gradient(135deg, #0d2a52, #2563eb);
box-shadow: 0 8px 18px rgba(16, 42, 86, 0.24);
transition: transform 220ms ease;
}}
.mode-slider.register {{
transform: translateX(100%);
}}
.mode-link {{
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
color: #0f172a;
font-weight: 900;
text-decoration: none;
}}
.mode-link.active {{
color: #ffffff;
}}
h1 {{
margin: 22px 0 6px;
color: #0f172a;
font-size: 24px;
}}
p {{
margin: 0 0 18px;
color: #536179;
}}
form {{
display: grid;
gap: 12px;
}}
label span {{
display: block;
margin-bottom: 6px;
color: #536179;
font-size: 13px;
}}
input {{
width: 100%;
min-height: 46px;
border: 1px solid #d8dfea;
border-radius: 12px;
padding: 12px 14px;
font: inherit;
background: #ffffff;
color: #0f172a;
}}
input:focus {{
border-color: #3b82f6;
outline: 2px solid rgba(59, 130, 246, 0.16);
}}
.password-field {{
position: relative;
display: block;
}}
.password-field input {{
padding-right: 54px;
}}
.password-toggle {{
position: absolute;
top: 50%;
right: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
min-height: 34px;
margin: 0;
padding: 0;
transform: translateY(-50%);
border-radius: 10px;
background: transparent;
color: #102a56;
}}
.password-toggle:hover {{
background: #dbeafe;
}}
.password-toggle svg {{
width: 20px;
height: 20px;
stroke: currentColor;
}}
button {{
min-height: 50px;
border: 0;
border-radius: 14px;
padding: 10px 14px;
font: inherit;
font-weight: 800;
margin-top: 6px;
background: #102a56;
color: #ffffff;
cursor: pointer;
}}
button:hover {{
background: #0d2a52;
}}
.auth-alert {{
margin-bottom: 12px;
padding: 10px 12px;
border-radius: 8px;
line-height: 1.45;
}}
.auth-alert.error {{
background: #fff1f2;
color: #be123c;
border: 1px solid #fecdd3;
}}
.auth-alert.success {{
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}}
@media (max-width: 480px) {{
body {{
padding: 18px;
}}
.auth-card {{
border-radius: 18px;
}}
}}
</style>
</head>
<body>
<main class="auth-shell">
<div class="brand">
<span class="brand-mark">CV</span>
<strong>Confivoice</strong>
</div>
<section class="auth-card">
<nav class="mode-switch" aria-label="Pilih mode autentikasi">
<span class="mode-slider{slider_class}"></span>
<a class="mode-link{login_active}" href="/admin/login">Masuk</a>
<a class="mode-link{register_active}" href="/admin/register">Daftar</a>
</nav>
<h1>{title}</h1>
<p>{'Buat akun admin ConfiVoice.' if is_register else 'Masuk ke pane ConfiVoice.'}</p>
{error_html}
{success_html}
<form method="post" action="{action}">
{full_name_field}
<label>
<span>Username</span>
<input name="username" value="{escape(username)}" required>
</label>
<label>
<span>Password</span>
<span class="password-field">
<input name="password" type="password" required data-password-input>
<button class="password-toggle" type="button" data-password-toggle aria-label="Tampilkan password" data-eye-icon='{eye_icon}' data-eye-off-icon='{eye_off_icon}'>{eye_icon}</button>
</span>
</label>
{confirm_password_field}
<button type="submit">{submit_label}</button>
</form>
</section>
</main>
<script>
document.querySelectorAll('[data-password-toggle]').forEach((button) => {{
button.addEventListener('click', () => {{
const wrapper = button.closest('.password-field');
const input = wrapper?.querySelector('[data-password-input]');
if (!input) return;
const shouldShow = input.type === 'password';
input.type = shouldShow ? 'text' : 'password';
button.innerHTML = shouldShow ? button.dataset.eyeOffIcon : button.dataset.eyeIcon;
button.setAttribute('aria-label', shouldShow ? 'Sembunyikan password' : 'Tampilkan password');
}});
}});
</script>
</body>
</html>
"""
def _html_page(title, content, active="dashboard"):
return f"""
<!doctype html>
<html lang="id">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{escape(title)}</title>
<style>
:root {{
font-family: Inter, Arial, sans-serif;
background: #020817;
color: #f8fbff;
}}
* {{
box-sizing: border-box;
}}
body {{
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top right, rgba(37, 99, 235, 0.26), transparent 32rem),
linear-gradient(135deg, #020817 0%, #071426 58%, #0b1d35 100%);
}}
.app-shell {{
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
min-height: 100vh;
}}
.sidebar {{
position: sticky;
top: 0;
display: flex;
flex-direction: column;
height: 100vh;
padding: 22px 16px;
background: #030b18;
border-right: 1px solid #173456;
color: #dbeafe;
}}
.brand {{
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
}}
.brand-mark {{
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 8px;
background: linear-gradient(135deg, #2563eb, #60a5fa);
color: #ffffff;
font-weight: 800;
}}
.brand strong, .brand small {{
display: block;
}}
.brand small {{
margin-top: 2px;
color: #8db8f5;
font-size: 12px;
}}
.nav {{
display: grid;
gap: 8px;
}}
.nav-link, .nav-group summary {{
display: flex;
align-items: center;
width: 100%;
min-height: 42px;
padding: 10px 12px;
border-radius: 8px;
color: #a8c7f6;
text-decoration: none;
font-weight: 700;
cursor: pointer;
}}
.nav-link:hover, .nav-group summary:hover, .nav-link.active {{
background: #12345b;
color: #ffffff;
}}
.nav-group {{
border-radius: 8px;
}}
.nav-group summary {{
list-style: none;
}}
.nav-group summary::-webkit-details-marker {{
display: none;
}}
.nav-group summary::after {{
content: "v";
margin-left: auto;
font-size: 12px;
}}
.nav-group:not([open]) summary::after {{
content: ">";
}}
.nav-group .nav-link {{
min-height: 38px;
margin-top: 6px;
padding-left: 24px;
font-size: 14px;
font-weight: 600;
}}
.logout {{
margin-top: auto;
color: #fecaca;
}}
.logout:hover, .logout.active {{
background: #571d2b;
color: #ffffff;
}}
main {{
width: 100%;
max-width: 1440px;
padding: 24px;
}}
.page-head {{
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}}
h1, h2 {{
margin: 0 0 6px;
}}
h1 {{
font-size: 28px;
}}
h2 {{
font-size: 20px;
}}
p {{
margin: 0;
color: #9fb3ca;
}}
.stats {{
display: grid;
grid-template-columns: repeat(4, minmax(140px, 1fr));
gap: 12px;
margin-bottom: 16px;
}}
.stat, .panel {{
background: rgba(8, 18, 34, 0.94);
border: 1px solid #1d3558;
border-radius: 8px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.24);
}}
.stat {{
padding: 14px;
}}
.stat span {{
display: block;
color: #8ca6c3;
font-size: 13px;
margin-bottom: 6px;
}}
.stat strong {{
font-size: 24px;
color: #ffffff;
}}
.panel {{
margin-bottom: 16px;
padding: 16px;
}}
.panel-head {{
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}}
.form-grid {{
display: grid;
grid-template-columns: repeat(4, minmax(160px, 1fr));
gap: 12px;
}}
.filter-bar {{
display: grid;
grid-template-columns: minmax(220px, 1fr) minmax(180px, 260px) auto;
gap: 12px;
align-items: end;
margin-bottom: 16px;
padding: 14px;
border: 1px solid #1d3558;
border-radius: 8px;
background: rgba(11, 22, 40, 0.78);
}}
.filter-actions {{
display: flex;
gap: 8px;
}}
.filter-actions .button {{
white-space: nowrap;
}}
label span {{
display: block;
color: #9fb3ca;
font-size: 13px;
margin-bottom: 6px;
}}
small {{
display: block;
margin-top: 5px;
color: #8ca6c3;
font-size: 12px;
}}
input, select {{
width: 100%;
min-height: 40px;
border: 1px solid #24446d;
border-radius: 8px;
padding: 9px 10px;
font: inherit;
background: #0b1628;
color: #f8fbff;
}}
input:focus, select:focus {{
border-color: #60a5fa;
outline: 2px solid rgba(96, 165, 250, 0.18);
}}
.wide {{
grid-column: span 2;
}}
.form-actions {{
display: flex;
align-items: end;
}}
.button, .small-button {{
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 8px;
background: #3b82f6;
color: #ffffff;
font-weight: 700;
text-decoration: none;
cursor: pointer;
}}
.button {{
min-height: 40px;
padding: 9px 14px;
}}
.button:hover, .small-button:hover {{
background: #2563eb;
}}
.button.secondary, .small-button {{
background: #122a4a;
color: #bfdbfe;
border: 1px solid #24446d;
}}
.button.secondary:hover, .small-button:hover {{
background: #173b68;
}}
.small-button {{
min-height: 32px;
padding: 7px 10px;
font-size: 13px;
}}
.small-button.danger {{
background: #421623;
color: #fecaca;
border-color: #7f1d1d;
}}
.small-button.danger:hover {{
background: #5f1b2a;
}}
.button.danger {{
background: #7f1d1d;
color: #ffffff;
}}
.button.danger:hover {{
background: #991b1b;
}}
.table-wrap {{
overflow-x: auto;
border: 1px solid #1d3558;
border-radius: 8px;
}}
table {{
width: 100%;
border-collapse: collapse;
min-width: 1240px;
}}
th, td {{
padding: 12px 14px;
border-bottom: 1px solid #182b49;
text-align: left;
white-space: nowrap;
}}
th {{
background: #0b1b31;
color: #bfdbfe;
font-size: 13px;
text-transform: uppercase;
}}
td {{
color: #e5eefc;
}}
tr:last-child td {{
border-bottom: 0;
}}
.badge {{
display: inline-block;
min-width: 40px;
padding: 4px 8px;
border-radius: 999px;
background: #12345b;
color: #bfdbfe;
text-align: center;
font-weight: 700;
}}
.empty {{
text-align: center;
color: #8ca6c3;
}}
.folder-empty {{
padding: 22px;
border: 1px dashed #24446d;
border-radius: 8px;
}}
.is-hidden {{
display: none !important;
}}
.folder-grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 12px;
}}
.student-folder {{
display: flex;
align-items: center;
gap: 14px;
min-height: 118px;
padding: 16px;
border: 1px solid #24446d;
border-radius: 8px;
background: linear-gradient(135deg, rgba(18, 42, 74, 0.96), rgba(8, 18, 34, 0.96));
color: #f8fbff;
text-decoration: none;
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
}}
.student-folder:hover {{
transform: translateY(-2px);
border-color: #60a5fa;
background: linear-gradient(135deg, rgba(29, 78, 216, 0.34), rgba(8, 18, 34, 0.98));
}}
.folder-icon {{
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 58px;
height: 58px;
border-radius: 8px;
background: #12345b;
color: #93c5fd;
font-size: 24px;
font-weight: 800;
}}
.folder-content {{
display: grid;
gap: 5px;
min-width: 0;
}}
.folder-content strong {{
overflow: hidden;
color: #ffffff;
font-size: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}}
.folder-content span {{
color: #bfdbfe;
}}
.folder-content small {{
margin: 0;
}}
.row-actions {{
display: flex;
align-items: center;
gap: 8px;
}}
.row-actions form {{
margin: 0;
}}
.user-table {{
min-width: 760px;
}}
.logout-panel {{
max-width: 640px;
}}
.logout-panel .button {{
margin-top: 16px;
}}
.modal-backdrop {{
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(2, 8, 23, 0.72);
backdrop-filter: blur(8px);
}}
.modal {{
width: min(420px, 100%);
padding: 22px;
border: 1px solid #24446d;
border-radius: 8px;
background: #081222;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.42);
}}
.modal h2 {{
margin-bottom: 8px;
}}
.modal p {{
line-height: 1.55;
}}
.modal-actions {{
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}}
.modal-actions .button {{
min-width: 88px;
}}
@media (max-width: 980px) {{
.app-shell {{
grid-template-columns: 1fr;
}}
.sidebar {{
position: static;
height: auto;
min-height: 0;
}}
.logout {{
margin-top: 8px;
}}
.form-grid {{
grid-template-columns: repeat(2, minmax(140px, 1fr));
}}
.filter-bar {{
grid-template-columns: 1fr 1fr;
}}
.filter-actions {{
grid-column: span 2;
}}
.wide {{
grid-column: span 2;
}}
}}
@media (max-width: 760px) {{
main {{
padding: 14px;
}}
.page-head {{
align-items: stretch;
flex-direction: column;
}}
.stats {{
grid-template-columns: repeat(2, minmax(120px, 1fr));
}}
.form-grid {{
grid-template-columns: 1fr;
}}
.filter-bar {{
grid-template-columns: 1fr;
}}
.filter-actions {{
grid-column: span 1;
}}
.filter-actions .button {{
flex: 1;
}}
.wide {{
grid-column: span 1;
}}
.panel-head {{
display: block;
}}
.panel-head .button {{
margin-top: 12px;
}}
}}
</style>
</head>
<body>
<div class="app-shell">
{_sidebar(active)}
<main>
{content}
</main>
</div>
<div class="modal-backdrop is-hidden" data-delete-user-modal>
<section class="modal" role="dialog" aria-modal="true" aria-labelledby="delete-user-title">
<h2 id="delete-user-title">Hapus user?</h2>
<p data-delete-user-message>User ini akan dihapus dari database.</p>
<div class="modal-actions">
<button class="button secondary" type="button" data-delete-user-cancel>Batal</button>
<button class="button danger" type="button" data-delete-user-confirm>Hapus</button>
</div>
</section>
</div>
<div class="modal-backdrop is-hidden" data-delete-prediction-modal>
<section class="modal" role="dialog" aria-modal="true" aria-labelledby="delete-prediction-title">
<h2 id="delete-prediction-title">Hapus hasil analisis?</h2>
<p data-delete-prediction-message>Hasil analisis ini akan dihapus dari database.</p>
<div class="modal-actions">
<button class="button secondary" type="button" data-delete-prediction-cancel>Batal</button>
<button class="button danger" type="button" data-delete-prediction-confirm>Hapus</button>
</div>
</section>
</div>
<div class="modal-backdrop is-hidden" data-logout-modal>
<section class="modal" role="dialog" aria-modal="true" aria-labelledby="logout-title">
<h2 id="logout-title">Logout?</h2>
<p>Sesi admin akan ditutup dari web admin ini.</p>
<div class="modal-actions">
<button class="button secondary" type="button" data-logout-cancel>Batal</button>
<a class="button danger" href="/admin/logout" data-logout-confirm>Logout</a>
</div>
</section>
</div>
<script>
const autoFilterForm = document.querySelector('[data-auto-filter]');
if (autoFilterForm) {{
const searchInput = autoFilterForm.querySelector('input[name="q"]');
const labelSelect = autoFilterForm.querySelector('select[name="label"]');
const folders = [...document.querySelectorAll('.student-folder')];
const emptyState = document.querySelector('[data-filter-empty]');
const totalStat = document.querySelector('[data-stat="total"]');
const pdStat = document.querySelector('[data-stat="pd"]');
const tpdStat = document.querySelector('[data-stat="tpd"]');
const avgPdStat = document.querySelector('[data-stat="avg-pd"]');
const applyLocalFilter = () => {{
const query = (searchInput?.value || '').trim().toLowerCase();
const label = (labelSelect?.value || '').trim().toUpperCase();
let visibleCount = 0;
let totalCount = 0;
let pdCount = 0;
let tpdCount = 0;
let pdSum = 0;
folders.forEach((folder) => {{
const studentName = folder.dataset.studentName || '';
const labels = (folder.dataset.labels || '').split(' ');
const matchesName = !query || studentName.includes(query);
const matchesLabel = !label || labels.includes(label);
const shouldShow = matchesName && matchesLabel;
folder.classList.toggle('is-hidden', !shouldShow);
if (shouldShow) {{
visibleCount += 1;
totalCount += Number(folder.dataset.total || 0);
pdCount += Number(folder.dataset.pdCount || 0);
tpdCount += Number(folder.dataset.tpdCount || 0);
pdSum += Number(folder.dataset.pdSum || 0);
}}
}});
emptyState?.classList.toggle('is-hidden', visibleCount !== 0);
if (totalStat) totalStat.textContent = String(totalCount);
if (pdStat) pdStat.textContent = String(pdCount);
if (tpdStat) tpdStat.textContent = String(tpdCount);
if (avgPdStat) {{
avgPdStat.textContent = totalCount ? `${{(pdSum / totalCount * 100).toFixed(1)}}%` : '0.0%';
}}
}};
autoFilterForm.addEventListener('submit', (event) => {{
event.preventDefault();
applyLocalFilter();
}});
searchInput?.addEventListener('input', applyLocalFilter);
labelSelect?.addEventListener('change', applyLocalFilter);
applyLocalFilter();
}}
const deleteUserModal = document.querySelector('[data-delete-user-modal]');
const deleteUserMessage = document.querySelector('[data-delete-user-message]');
const deleteUserCancel = document.querySelector('[data-delete-user-cancel]');
const deleteUserConfirm = document.querySelector('[data-delete-user-confirm]');
let pendingDeleteUserForm = null;
document.querySelectorAll('[data-delete-user-form]').forEach((form) => {{
form.addEventListener('submit', (event) => {{
event.preventDefault();
pendingDeleteUserForm = form;
const userName = form.dataset.userName || 'user ini';
if (deleteUserMessage) {{
deleteUserMessage.textContent = `Yakin ingin menghapus ${{userName}}? Data user akan dihapus dari database.`;
}}
deleteUserModal?.classList.remove('is-hidden');
}});
}});
const closeDeleteUserModal = () => {{
pendingDeleteUserForm = null;
deleteUserModal?.classList.add('is-hidden');
}};
deleteUserCancel?.addEventListener('click', closeDeleteUserModal);
deleteUserModal?.addEventListener('click', (event) => {{
if (event.target === deleteUserModal) {{
closeDeleteUserModal();
}}
}});
deleteUserConfirm?.addEventListener('click', () => {{
const form = pendingDeleteUserForm;
closeDeleteUserModal();
form?.submit();
}});
const deletePredictionModal = document.querySelector('[data-delete-prediction-modal]');
const deletePredictionMessage = document.querySelector('[data-delete-prediction-message]');
const deletePredictionCancel = document.querySelector('[data-delete-prediction-cancel]');
const deletePredictionConfirm = document.querySelector('[data-delete-prediction-confirm]');
let pendingDeletePredictionForm = null;
document.querySelectorAll('[data-delete-prediction-form]').forEach((form) => {{
form.addEventListener('submit', (event) => {{
event.preventDefault();
pendingDeletePredictionForm = form;
const studentName = form.dataset.studentName || 'siswa/i ini';
const predictionId = form.dataset.predictionId || '';
if (deletePredictionMessage) {{
deletePredictionMessage.textContent = `Yakin ingin menghapus hasil analisis ${{studentName}}${{predictionId ? ` dengan ID #${{predictionId}}` : ''}}? Data akan dihapus dari database.`;
}}
deletePredictionModal?.classList.remove('is-hidden');
}});
}});
const closeDeletePredictionModal = () => {{
pendingDeletePredictionForm = null;
deletePredictionModal?.classList.add('is-hidden');
}};
deletePredictionCancel?.addEventListener('click', closeDeletePredictionModal);
deletePredictionModal?.addEventListener('click', (event) => {{
if (event.target === deletePredictionModal) {{
closeDeletePredictionModal();
}}
}});
deletePredictionConfirm?.addEventListener('click', () => {{
const form = pendingDeletePredictionForm;
closeDeletePredictionModal();
form?.submit();
}});
const logoutLink = document.querySelector('[data-logout-link]');
const logoutModal = document.querySelector('[data-logout-modal]');
const logoutCancel = document.querySelector('[data-logout-cancel]');
logoutLink?.addEventListener('click', (event) => {{
event.preventDefault();
logoutModal?.classList.remove('is-hidden');
}});
const closeLogoutModal = () => {{
logoutModal?.classList.add('is-hidden');
}};
logoutCancel?.addEventListener('click', closeLogoutModal);
logoutModal?.addEventListener('click', (event) => {{
if (event.target === logoutModal) {{
closeLogoutModal();
}}
}});
</script>
</body>
</html>
"""