1417 lines
45 KiB
Python
1417 lines
45 KiB
Python
from html import escape
|
|
from pathlib import Path
|
|
import sys
|
|
from urllib.parse import quote, unquote
|
|
|
|
from fastapi import FastAPI, Form, HTTPException, Query
|
|
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
|
|
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")
|
|
|
|
|
|
@app.on_event("startup")
|
|
def startup():
|
|
init_db()
|
|
|
|
|
|
@app.get("/")
|
|
def read_root():
|
|
return {
|
|
"name": "ConfiVoice Admin Web",
|
|
"status": "ok",
|
|
"admin_page": "/admin",
|
|
"database": get_database_label(),
|
|
}
|
|
|
|
|
|
@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():
|
|
return _html_page(
|
|
title="Logout",
|
|
active="logout",
|
|
content="""
|
|
<section class="panel logout-panel">
|
|
<h1>Logout</h1>
|
|
<p>Sesi admin ditutup. Saat ini web admin belum memakai session login khusus, jadi halaman ini menjadi pintu keluar visual.</p>
|
|
<a class="button" href="/admin">Kembali ke Dashboard</a>
|
|
</section>
|
|
""",
|
|
)
|
|
|
|
|
|
@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 _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' "
|
|
"onsubmit=\"return confirm('Hapus data ini?');\">"
|
|
"<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">Logout</a>
|
|
</aside>
|
|
"""
|
|
|
|
|
|
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>
|
|
<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();
|
|
}});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|