MIF_E31231708/.history/cv_web/app_20260618015004.py

1427 lines
46 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>
<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>
"""
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">Keluar</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>
"""