1982 lines
64 KiB
Python
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>
|
|
"""
|