Tambah asesmen per komponen, batasi Dasbor/Rekap ke kaprodi, penugasan per kelas

- Halaman nilai: tombol isi-cepat per komponen penilaian (bagian G RPS) yang
  menyalin nilai ke seluruh Sub-CPMK terkait; komponen berulang (Kuis, Tugas,
  Presentasi, Tes Lisan) mendukung banyak sesi dengan rata-rata otomatis,
  komponen sekali-jalan (UTS/UAS) mengedit sesi yang sama. Sub-CPMK yang
  diukur >1 komponen digulung rata tertimbang bobot (hitung.skor_dari_sesi).
- Hapus jalur unggah Excel & mode "ketik rata-rata kelas" (agregat) dari
  input nilai — proses diseragamkan: hanya grid manual + tombol komponen,
  keduanya bersumber dari daftar mahasiswa impor kaprodi.
- Dasbor Monitoring, Rekap CPL/CPMK, dan Riwayat kini khusus kaprodi (dosen
  tidak lagi melihat rollup lintas-MK yang bukan miliknya).
- Penugasan dosen ke MK bisa dibatasi per kelas (kolom kelas baru di tabel
  penugasan, migrasi otomatis & aman untuk DB yang sudah berjalan) — dosen
  MK dengan banyak kelas paralel hanya melihat & menilai kelasnya sendiri;
  hapus nilai MK penuh dikunci untuk dosen yang dibatasi kelas.
This commit is contained in:
Power BI Dev
2026-07-21 12:44:57 +07:00
parent a45b08b00e
commit afb9e72ba2
8 changed files with 490 additions and 274 deletions
+38 -7
View File
@@ -138,6 +138,15 @@ def _migrasi_role_pengguna(conn: sqlite3.Connection) -> None:
conn.execute("DROP TABLE pengguna_lama")
def _migrasi_kelas_penugasan(conn: sqlite3.Connection) -> None:
"""Skema lama penugasan hanya (user_id, mk_kode) — tambah kolom kelas
(kosong = seluruh kelas MK ini, dipakai dosen_rps_mk; terisi = dosen_mk
yang hanya mengampu kelas tertentu)."""
kolom = {r["name"] for r in conn.execute("PRAGMA table_info(penugasan)")}
if "kelas" not in kolom:
conn.execute("ALTER TABLE penugasan ADD COLUMN kelas TEXT NOT NULL DEFAULT ''")
def init_db() -> None:
with get_conn() as conn:
for tabel in ("rps", "portofolio", "nilai"):
@@ -195,6 +204,7 @@ def init_db() -> None:
PRIMARY KEY (user_id, mk_kode)
)"""
)
_migrasi_kelas_penugasan(conn)
conn.execute(
"""CREATE TABLE IF NOT EXISTS mahasiswa (
npm TEXT NOT NULL,
@@ -391,12 +401,15 @@ def daftar_pengguna() -> list[dict]:
"ORDER BY role, nama"
).fetchall()
tugas = conn.execute(
"SELECT user_id, mk_kode FROM penugasan ORDER BY mk_kode"
"SELECT user_id, mk_kode, kelas FROM penugasan ORDER BY mk_kode"
).fetchall()
per_user: dict[int, list[str]] = {}
per_user_kode: dict[int, list[str]] = {}
per_user_kelas: dict[int, dict[str, str]] = {}
for t in tugas:
per_user.setdefault(t["user_id"], []).append(t["mk_kode"])
return [{**dict(r), "mk": per_user.get(r["id"], [])} for r in rows]
per_user_kode.setdefault(t["user_id"], []).append(t["mk_kode"])
per_user_kelas.setdefault(t["user_id"], {})[t["mk_kode"]] = t["kelas"] or ""
return [{**dict(r), "mk": per_user_kode.get(r["id"], []),
"kelas_map": per_user_kelas.get(r["id"], {})} for r in rows]
def hapus_pengguna(user_id: int) -> None:
@@ -413,12 +426,14 @@ def ubah_password(user_id: int, pw_hash: str) -> None:
conn.execute("DELETE FROM sesi WHERE user_id=?", (user_id,))
def set_penugasan(user_id: int, kode_list: list[str]) -> None:
def set_penugasan(user_id: int, penugasan: list[tuple[str, str]]) -> None:
"""penugasan = [(mk_kode, kelas), ...] — kelas '' berarti seluruh kelas MK
ini; boleh CSV ('A,B') bila dosen mengampu beberapa kelas MK yang sama."""
with get_conn() as conn:
conn.execute("DELETE FROM penugasan WHERE user_id=?", (user_id,))
conn.executemany(
"INSERT OR IGNORE INTO penugasan (user_id, mk_kode) VALUES (?,?)",
[(user_id, k) for k in kode_list],
"INSERT OR IGNORE INTO penugasan (user_id, mk_kode, kelas) VALUES (?,?,?)",
[(user_id, k, kelas) for k, kelas in penugasan],
)
@@ -430,6 +445,22 @@ def mk_diampu(user_id: int) -> set[str]:
return {r["mk_kode"] for r in rows}
def kelas_diampu(user_id: int, mk_kode: str) -> set[str] | None:
"""Kelas yang diizinkan dosen ini untuk MK tsb. None = seluruh kelas
(tak dibatasi); set kosong = tidak ditugaskan sama sekali ke MK ini."""
with get_conn() as conn:
row = conn.execute(
"SELECT kelas FROM penugasan WHERE user_id=? AND mk_kode=?",
(user_id, mk_kode),
).fetchone()
if not row:
return set()
kelas = (row["kelas"] or "").strip()
if not kelas:
return None
return {k.strip() for k in kelas.split(",") if k.strip()}
def pengampu_per_mk() -> dict[str, list[str]]:
"""{mk_kode: [nama dosen, ...]} — untuk kolom pengampu di beranda."""
with get_conn() as conn:
+50
View File
@@ -91,6 +91,56 @@ def bobot_sub_dari_rps(rps_data: dict | None) -> dict[str, float]:
return bobot
def skor_dari_sesi(komponen_daftar: list[dict], sesi: dict) -> dict[str, dict[str, float]]:
"""npm -> {sub: skor}, dihitung dari log sesi input per-komponen (tombol
'isi per komponen' di halaman nilai): rata sederhana antar sesi dalam
komponen yang sama (mis. beberapa kali Kuis), lalu rata tertimbang bobot
antar-komponen bila satu Sub-CPMK diukur oleh >1 komponen (mis. Kuis dan
Presentasi sama-sama menilai satu Sub-CPMK) — bobot mengikuti alokasi
yang sama dengan bobot_sub_dari_rps (bobot komponen dibagi rata ke subnya).
"""
komponen_by_i = {k["i"]: k for k in komponen_daftar}
per_komponen_avg: dict[int, dict[str, float]] = {}
for i_str, daftar_sesi in (sesi or {}).items():
try:
i = int(i_str)
except (TypeError, ValueError):
continue
if i not in komponen_by_i or not daftar_sesi:
continue
per_npm: dict[str, list[float]] = {}
for s in daftar_sesi:
for npm, v in (s.get("nilai") or {}).items():
if v is not None:
per_npm.setdefault(npm, []).append(float(v))
per_komponen_avg[i] = {npm: _rata(xs) for npm, xs in per_npm.items()}
kontribusi_per_sub: dict[str, list[tuple[float, dict]]] = {}
for i, avg_map in per_komponen_avg.items():
k = komponen_by_i[i]
try:
bobot = float(k.get("bobot") or 0)
except (TypeError, ValueError):
bobot = 0
n_subs = len(k["subs"]) or 1
w = bobot / n_subs if bobot else 1.0
for sub in k["subs"]:
kontribusi_per_sub.setdefault(sub, []).append((w, avg_map))
out: dict[str, dict[str, float]] = {}
for sub, kontribusi in kontribusi_per_sub.items():
npms: set[str] = set()
for _, avg_map in kontribusi:
npms |= avg_map.keys()
for npm in npms:
pasangan = [(w, avg_map[npm]) for w, avg_map in kontribusi if npm in avg_map]
total_w = sum(w for w, _ in pasangan)
nilai = (sum(w * v for w, v in pasangan) / total_w) if total_w \
else _rata([v for _, v in pasangan])
out.setdefault(npm, {})[sub] = nilai
return out
def nilai_akhir_mahasiswa(nilai_data: dict, bobot_sub: dict[str, float]) -> list[dict]:
"""Nilai akhir tertimbang bobot Sub-CPMK per mahasiswa (mode per_mahasiswa)."""
out = []
+250 -111
View File
@@ -3,6 +3,7 @@
import json
import pathlib
import re
from datetime import datetime
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response
@@ -65,6 +66,21 @@ def boleh_mk(user: dict, kode: str) -> bool:
return user["role"] in ("dosen_mk", "dosen_rps_mk") and kode in db.mk_diampu(user["id"])
def _kelas_dosen(user: dict, kode: str) -> set[str] | None:
"""Kelas yang dibatasi untuk dosen ini pada MK ini. None = tak dibatasi
(kaprodi, atau penugasan tanpa kelas spesifik — seluruh kelas MK)."""
if user["role"] == "kaprodi":
return None
return db.kelas_diampu(user["id"], kode)
def _npm_diizinkan(kode: str, kelas_dosen: set[str] | None) -> set[str] | None:
"""NPM yang boleh ditulis dosen ini. None = tak dibatasi (semua NPM MK ini)."""
if kelas_dosen is None:
return None
return {m["npm"] for m in db.mahasiswa_mk(kode) if m.get("kelas") in kelas_dosen}
def _masuk(user_id: int) -> RedirectResponse:
token = auth.token_baru()
db.buat_sesi(user_id, token)
@@ -208,7 +224,8 @@ async def admin_penugasan(request: Request, uid: int):
return tolak
form = await request.form()
kode_valid = {m["kode"] for m in db.daftar_mk()}
pilihan = [k for k in form.getlist("mk") if k in kode_valid]
dipilih = [k for k in form.getlist("mk") if k in kode_valid]
pilihan = [(k, (form.get(f"kelas_{k}") or "").strip()) for k in dipilih]
db.set_penugasan(uid, pilihan)
p = db.pengguna_by_id(uid)
return RedirectResponse(
@@ -261,12 +278,12 @@ async def admin_impor_mahasiswa(request: Request):
# komponen penilaian tetap — teknik asesmen baku prodi; bobot boleh 0 (opsional,
# tak dipakai MK ybs) tapi total seluruh komponen tak boleh melebihi 100%.
KOMPONEN_DEFAULT = [
{"nama": "Partisipasi (Kuis)", "subs": [], "bobot": 10},
{"nama": "Observasi (Praktik/Tugas)", "subs": [], "bobot": 20},
{"nama": "Unjuk Kerja (Presentasi)", "subs": [], "bobot": 10},
{"nama": "Tes Tulis (UTS)", "subs": [], "bobot": 25},
{"nama": "Tes Tulis (UAS)", "subs": [], "bobot": 25},
{"nama": "Tes Lisan (Tugas Kelompok)", "subs": [], "bobot": 10},
{"nama": "Partisipasi (Kuis)", "subs": [], "bobot": 10, "ulang": True},
{"nama": "Observasi (Praktik/Tugas)", "subs": [], "bobot": 20, "ulang": True},
{"nama": "Unjuk Kerja (Presentasi)", "subs": [], "bobot": 10, "ulang": True},
{"nama": "Tes Tulis (UTS)", "subs": [], "bobot": 25, "ulang": False},
{"nama": "Tes Tulis (UAS)", "subs": [], "bobot": 25, "ulang": False},
{"nama": "Tes Lisan (Tugas Kelompok)", "subs": [], "bobot": 10, "ulang": True},
]
@@ -508,21 +525,13 @@ def unduh_rps(kode: str):
# ═════════════════════════ TAHAP 2: NILAI & PENGUKURAN ═════════════════════════
def _konteks_nilai(request, kode, pesan="", peringatan=None):
peta = db.peta_mk(kode)
tersimpan = db.ambil_dok("nilai", kode)
pengaturan = db.ambil_pengaturan()
ambang = float(pengaturan["ambang"])
hasil, akhir = None, {}
if tersimpan:
hasil = hitung.rollup_mk(peta, tersimpan["data"], ambang)
if tersimpan["data"].get("mode") == "per_mahasiswa":
rps = db.ambil_rps(kode)
bobot_sub = hitung.bobot_sub_dari_rps(rps["data"] if rps else None)
akhir = {a["npm"]: a["akhir"]
for a in hitung.nilai_akhir_mahasiswa(tersimpan["data"], bobot_sub)}
# grid input langsung: gabungan daftar impor kaprodi + nilai tersimpan
terdaftar = db.mahasiswa_mk(kode)
def _grid_mahasiswa(tersimpan: dict | None, terdaftar: list[dict],
kelas_dosen: set[str] | None = None) -> list[dict]:
"""Gabungan daftar impor kaprodi + nilai tersimpan (mahasiswa yang sudah
punya nilai tapi tak lagi terdaftar tetap ditampilkan, tak hilang).
kelas_dosen membatasi hasil ke kelas yang diampu dosen ini (None = semua
kelas); mahasiswa dari kelas lain, termasuk entri tak-terdaftar yang
kelasnya tak diketahui, disaring keluar demi privasi nilai kelas lain."""
skor_map, nama_map = {}, {}
if tersimpan and tersimpan["data"].get("mode") == "per_mahasiswa":
for m in tersimpan["data"]["mahasiswa"]:
@@ -537,39 +546,71 @@ def _konteks_nilai(request, kode, pesan="", peringatan=None):
if npm not in ada:
grid.append({"npm": npm, "nama": nama_map.get(npm, ""), "kelas": "",
"skor": sk})
if kelas_dosen is not None:
grid = [g for g in grid if g["kelas"] in kelas_dosen]
return grid
def _komponen_daftar(kode: str) -> list[dict]:
"""Komponen penilaian RPS yang sudah dikaitkan ke Sub-CPMK (bagian G) —
dipakai untuk tombol isi-cepat per komponen di halaman nilai. 'ulang'
menandai komponen yang boleh diisi berkali-kali sepanjang semester (Kuis,
Tugas, Presentasi, Tes Lisan) — rata-rata otomatis antar sesi — versus
komponen sekali-jalan (UTS/UAS)."""
rps = db.ambil_rps(kode)
if not rps:
return []
tersimpan = db.ambil_dok("nilai", kode)
sesi_semua = ((tersimpan["data"].get("sesi") or {}) if tersimpan else {})
out = []
for idx, k in enumerate(_normalisasi_komponen(rps["data"].get("komponen", []))):
if not k["subs"]:
continue
out.append({"i": idx, "nama": k["nama"], "bobot": k["bobot"],
"subs": k["subs"], "n_subs": len(k["subs"]),
"ulang": KOMPONEN_DEFAULT[idx]["ulang"],
"n_sesi": len(sesi_semua.get(str(idx), []))})
return out
def _ambil_komponen(kode: str, i: int) -> dict | None:
rps = db.ambil_rps(kode)
if not rps:
return None
komponen_list = _normalisasi_komponen(rps["data"].get("komponen", []))
if i < 0 or i >= len(komponen_list) or not komponen_list[i]["subs"]:
return None
k = dict(komponen_list[i])
k["ulang"] = KOMPONEN_DEFAULT[i]["ulang"]
return k
def _konteks_nilai(request, kode, pesan="", peringatan=None):
peta = db.peta_mk(kode)
tersimpan = db.ambil_dok("nilai", kode)
pengaturan = db.ambil_pengaturan()
ambang = float(pengaturan["ambang"])
hasil, akhir = None, {}
if tersimpan:
hasil = hitung.rollup_mk(peta, tersimpan["data"], ambang)
if tersimpan["data"].get("mode") == "per_mahasiswa":
rps = db.ambil_rps(kode)
bobot_sub = hitung.bobot_sub_dari_rps(rps["data"] if rps else None)
akhir = {a["npm"]: a["akhir"]
for a in hitung.nilai_akhir_mahasiswa(tersimpan["data"], bobot_sub)}
kelas_dosen = _kelas_dosen(request.state.user, kode)
terdaftar = db.mahasiswa_mk(kode)
grid = _grid_mahasiswa(tersimpan, terdaftar, kelas_dosen)
return render(
request, "form_nilai.html",
{"peta": peta, "tersimpan": tersimpan, "hasil": hasil, "ambang": ambang,
"akhir": akhir, "tahun": db.tahun_aktif(), "pesan": pesan,
"terdaftar": terdaftar, "grid": grid,
"grid": grid, "kelas_dosen": kelas_dosen,
"komponen_daftar": _komponen_daftar(kode),
"peringatan": peringatan or []},
)
def _gabung_nilai(lama: dict, baru: list[dict], sumber: str, oleh: str) -> dict:
"""Gabungkan unggahan ke data per-mahasiswa tersimpan (team teaching):
sel terisi memperbarui, sel kosong tidak menghapus nilai dosen lain,
mahasiswa yang tidak ada di file dibiarkan utuh."""
urut = [dict(m, skor=dict(m.get("skor") or {}))
for m in lama.get("mahasiswa", [])]
per_npm = {m["npm"]: m for m in urut if m.get("npm")}
for m in baru:
ada = per_npm.get(m.get("npm"))
if ada is None:
urut.append(m)
if m.get("npm"):
per_npm[m["npm"]] = m
else:
if m.get("nama"):
ada["nama"] = m["nama"]
ada["skor"].update(m.get("skor") or {})
riwayat = lama.get("sumber") or []
if isinstance(riwayat, str):
riwayat = [riwayat]
riwayat.append(f"{sumber} ({oleh})")
return {"mode": "per_mahasiswa", "mahasiswa": urut, "sumber": riwayat[-8:]}
@app.get("/nilai/{kode}", response_class=HTMLResponse)
def form_nilai(request: Request, kode: str, pesan: str = ""):
if not db.peta_mk(kode):
@@ -581,22 +622,131 @@ def form_nilai(request: Request, kode: str, pesan: str = ""):
return _konteks_nilai(request, kode, pesan)
@app.get("/nilai/{kode}/template.xlsx")
def template_nilai(request: Request, kode: str):
@app.get("/nilai/{kode}/komponen/{i}", response_class=HTMLResponse)
def form_nilai_komponen(request: Request, kode: str, i: int, sesi_id: int = -1, pesan: str = ""):
peta = db.peta_mk(kode)
if not peta:
return RedirectResponse("/", status_code=303)
if not boleh_mk(request.state.user, kode):
return RedirectResponse(f"/rps/{kode}", status_code=303)
isi = nilai_xlsx.buat_template(peta, db.tahun_aktif(), db.mahasiswa_mk(kode))
return Response(
content=isi,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition":
f'attachment; filename="Template_Nilai_{kode}.xlsx"'},
return RedirectResponse(
f"/rps/{kode}?pesan=Nilai+mahasiswa+hanya+dibuka+untuk+pengampu+MK+dan+kaprodi.",
status_code=303)
komponen = _ambil_komponen(kode, i)
if not komponen:
return RedirectResponse(
f"/nilai/{kode}?pesan=Komponen+belum+dikaitkan+ke+Sub-CPMK+di+RPS+bagian+G.",
status_code=303)
tersimpan = db.ambil_dok("nilai", kode)
daftar_sesi = ((tersimpan["data"].get("sesi") or {}) if tersimpan else {}).get(str(i), [])
edit = None
if not komponen["ulang"] and daftar_sesi:
# komponen sekali-jalan (UTS/UAS) sudah punya sesi — selalu edit sesi itu
edit, edit_id = daftar_sesi[0], 0
elif 0 <= sesi_id < len(daftar_sesi):
edit, edit_id = daftar_sesi[sesi_id], sesi_id
else:
edit_id = len(daftar_sesi) # sesi baru
nilai_awal = (edit or {}).get("nilai", {})
label_awal = (edit or {}).get("label") or (
komponen["nama"] if not komponen["ulang"]
else f"{komponen['nama']} {len(daftar_sesi) + 1}")
kelas_dosen = _kelas_dosen(request.state.user, kode)
grid = _grid_mahasiswa(tersimpan, db.mahasiswa_mk(kode), kelas_dosen)
for m in grid:
m["nilai_awal"] = nilai_awal.get(m["npm"])
return render(
request, "form_nilai_komponen.html",
{"peta": peta, "komponen": komponen, "i": i, "grid": grid,
"daftar_sesi": daftar_sesi, "edit_id": edit_id, "label_awal": label_awal,
"tahun": db.tahun_aktif(), "pesan": pesan},
)
@app.post("/nilai/{kode}/komponen/{i}")
async def simpan_nilai_komponen(request: Request, kode: str, i: int):
peta = db.peta_mk(kode)
if not peta:
return RedirectResponse("/", status_code=303)
if not boleh_mk(request.state.user, kode):
return RedirectResponse(
f"/rps/{kode}?pesan=Nilai+mahasiswa+hanya+dibuka+untuk+pengampu+MK+dan+kaprodi.",
status_code=303)
komponen = _ambil_komponen(kode, i)
if not komponen:
return RedirectResponse(f"/nilai/{kode}", status_code=303)
form = await request.form()
updated_by = request.state.user["nama"]
label = (form.get("label") or komponen["nama"]).strip()
try:
edit_id = int(form.get("edit_id") or -1)
except ValueError:
edit_id = -1
npm_diizinkan = _npm_diizinkan(kode, _kelas_dosen(request.state.user, kode))
nilai = {}
j = 0
while f"npm_{j}" in form:
npm = (form.get(f"npm_{j}") or "").strip()
v = (form.get(f"nilai_{j}") or "").strip()
if npm and v and (npm_diizinkan is None or npm in npm_diizinkan):
try:
x = float(v)
except ValueError:
x = None
if x is not None and 0 <= x <= 100:
nilai[npm] = x
j += 1
if not nilai:
return RedirectResponse(
f"/nilai/{kode}/komponen/{i}?pesan=Tidak+ada+nilai+yang+diisi.",
status_code=303)
lama = db.ambil_dok("nilai", kode)
lama_data = (dict(lama["data"]) if lama and lama["data"].get("mode") == "per_mahasiswa"
else {"mode": "per_mahasiswa", "mahasiswa": []})
sesi_semua = {k: list(v) for k, v in (lama_data.get("sesi") or {}).items()}
daftar_sesi = list(sesi_semua.get(str(i), []))
entri = {"label": label, "at": datetime.now(db.WIB).strftime("%Y-%m-%d %H:%M"),
"by": updated_by, "nilai": nilai}
if not komponen["ulang"] and daftar_sesi:
daftar_sesi[0] = entri # sekali-jalan: selalu menimpa sesi yang sama
elif 0 <= edit_id < len(daftar_sesi):
daftar_sesi[edit_id] = entri # mengedit sesi tertentu
else:
daftar_sesi.append(entri) # sesi baru
sesi_semua[str(i)] = daftar_sesi
komponen_daftar = _komponen_daftar(kode)
skor_terhitung = hitung.skor_dari_sesi(komponen_daftar, sesi_semua)
nama_map = {npm: m["nama"] for m in db.mahasiswa_mk(kode) for npm in [m["npm"]]}
urut = [dict(m, skor=dict(m.get("skor") or {})) for m in lama_data.get("mahasiswa", [])]
per_npm = {m["npm"]: m for m in urut if m.get("npm")}
for npm, subskor in skor_terhitung.items():
m = per_npm.get(npm)
if m is None:
m = {"npm": npm, "nama": nama_map.get(npm, ""), "skor": {}}
urut.append(m)
per_npm[npm] = m
m["skor"].update(subskor)
riwayat = lama_data.get("sumber") or []
if isinstance(riwayat, str):
riwayat = [riwayat]
riwayat.append(f"komponen {komponen['nama']}{label} ({updated_by})")
data = {"mode": "per_mahasiswa", "mahasiswa": urut, "sesi": sesi_semua,
"sumber": riwayat[-8:]}
db.simpan_dok("nilai", kode, data, "selesai", updated_by)
pesan = f"Nilai+{label.replace(' ', '+')}+tersimpan+untuk+{len(nilai)}+mahasiswa."
return RedirectResponse(f"/nilai/{kode}?pesan={pesan}", status_code=303)
@app.post("/nilai/{kode}")
async def simpan_nilai(request: Request, kode: str):
peta = db.peta_mk(kode)
@@ -609,21 +759,28 @@ async def simpan_nilai(request: Request, kode: str):
form = await request.form()
aksi = form.get("aksi")
updated_by = request.state.user["nama"]
kode_sub = {s["kode"] for s in peta["subs"]}
if aksi == "hapus":
if _kelas_dosen(request.state.user, kode) is not None:
return _konteks_nilai(request, kode, peringatan=[
"Anda hanya diampukan sebagian kelas MK ini — menghapus seluruh nilai "
"hanya bisa dilakukan kaprodi atau dosen yang mengampu seluruh kelas."])
db.hapus_dok("nilai", kode)
return _konteks_nilai(
request, kode,
pesan="Seluruh nilai tersimpan MK ini dihapus — silakan mulai dari awal.")
if aksi == "grid":
# input langsung: menyimpan persis isi tabel yang tampil
kelas_dosen = _kelas_dosen(request.state.user, kode)
npm_diizinkan = _npm_diizinkan(kode, kelas_dosen)
mahasiswa = []
i = 0
while f"npm_{i}" in form:
npm = (form.get(f"npm_{i}") or "").strip()
nama = (form.get(f"nama_{i}") or "").strip()
if npm_diizinkan is not None and npm not in npm_diizinkan:
i += 1
continue
skor = {}
for s in peta["subs"]:
v = (form.get(f"skor_{i}_{s['kode']}") or "").strip()
@@ -642,67 +799,40 @@ async def simpan_nilai(request: Request, kode: str):
return _konteks_nilai(request, kode,
peringatan=["Tidak ada nilai yang diisi."])
lama = db.ambil_dok("nilai", kode)
riwayat = (lama["data"].get("sumber") if lama else None) or []
lama_data = lama["data"] if lama else {}
riwayat = lama_data.get("sumber") or []
if isinstance(riwayat, str):
riwayat = [riwayat]
riwayat.append(f"input langsung ({updated_by})")
data = {"mode": "per_mahasiswa", "mahasiswa": mahasiswa,
"sumber": riwayat[-8:]}
if kelas_dosen is None:
# dosen tak dibatasi kelas: tabel yang tampil = seluruh MK,
# jadi menyimpan persis isi tabel memang dimaksudkan sebagai pengganti
data = {"mode": "per_mahasiswa", "mahasiswa": mahasiswa, "sumber": riwayat[-8:]}
else:
# dosen dibatasi kelas: tabel yang tampil cuma kelasnya — gabungkan
# ke data lama, jangan sampai menghapus nilai kelas lain
urut = [dict(m, skor=dict(m.get("skor") or {}))
for m in lama_data.get("mahasiswa", [])]
per_npm = {m["npm"]: m for m in urut if m.get("npm")}
for m in mahasiswa:
ada = per_npm.get(m["npm"])
if ada is None:
urut.append(m)
per_npm[m["npm"]] = m
else:
ada["nama"] = m["nama"] or ada.get("nama", "")
ada["skor"] = m["skor"]
data = {"mode": "per_mahasiswa", "mahasiswa": urut, "sumber": riwayat[-8:]}
if lama_data.get("sesi"):
# pertahankan log sesi per-komponen — grid ini hanya koreksi manual
# di atasnya, bukan pengganti sumber datanya
data["sesi"] = lama_data["sesi"]
db.simpan_dok("nilai", kode, data, "selesai", updated_by)
return _konteks_nilai(
request, kode,
pesan=f"Nilai {len(mahasiswa)} mahasiswa tersimpan (input langsung).")
if aksi == "unggah":
berkas = form.get("berkas")
if berkas is None or not getattr(berkas, "filename", ""):
return _konteks_nilai(request, kode,
peringatan=["Pilih file .xlsx terlebih dahulu."])
isi = await berkas.read()
try:
mahasiswa, peringatan = nilai_xlsx.parse_unggahan(isi, kode_sub)
except Exception:
return _konteks_nilai(request, kode,
peringatan=["File tidak terbaca sebagai .xlsx."])
if not mahasiswa:
return _konteks_nilai(request, kode, peringatan=peringatan)
lama = db.ambil_dok("nilai", kode)
if lama and lama["data"].get("mode") == "per_mahasiswa":
data = _gabung_nilai(lama["data"], mahasiswa, berkas.filename, updated_by)
pesan = (f"Unggahan {berkas.filename} digabung: {len(mahasiswa)} mahasiswa "
f"diperbarui, total {len(data['mahasiswa'])} mahasiswa tersimpan.")
else:
data = {"mode": "per_mahasiswa", "mahasiswa": mahasiswa,
"sumber": [f"{berkas.filename} ({updated_by})"]}
pesan = f"Nilai {len(mahasiswa)} mahasiswa tersimpan dari {berkas.filename}."
db.simpan_dok("nilai", kode, data, "selesai", updated_by)
return _konteks_nilai(request, kode, pesan=pesan, peringatan=peringatan)
# aksi == 'agregat' — ketik rata-rata per Sub-CPMK
lama = db.ambil_dok("nilai", kode)
if lama and lama["data"].get("mode") == "per_mahasiswa":
return _konteks_nilai(request, kode, peringatan=[
"Data per-mahasiswa sudah tersimpan — menyimpan rata-rata akan membuang "
'rinciannya. Bila memang ingin beralih, hapus dulu lewat tombol "Mulai Ulang".'])
agregat = {}
for s in peta["subs"]:
v = (form.get(f"rata_{s['kode']}") or "").strip()
if v:
try:
x = float(v)
except ValueError:
continue
if 0 <= x <= 100:
agregat[s["kode"]] = x
if not agregat:
return _konteks_nilai(request, kode,
peringatan=["Tidak ada rata-rata yang diisi."])
data = {"mode": "agregat", "agregat": agregat}
db.simpan_dok("nilai", kode, data, "selesai", updated_by)
return _konteks_nilai(
request, kode,
pesan=f"Rata-rata {len(agregat)} Sub-CPMK tersimpan (mode BUKTI AGREGAT).",
)
return _konteks_nilai(request, kode, peringatan=["Aksi tidak dikenali."])
# ═════════════════════════ TAHAP 2: PORTOFOLIO ═════════════════════════
@@ -866,6 +996,9 @@ def unduh_porto(request: Request, kode: str):
@app.get("/rekap", response_class=HTMLResponse)
def rekap(request: Request):
tolak = _bukan_kaprodi(request)
if tolak:
return tolak
hasil = hitung.rollup_prodi()
cpl_urut = sorted(hasil["cpl"].values(), key=lambda c: c["kode"])
cpmk_urut = sorted(hasil["cpmk"].values(), key=lambda c: c["kode"])
@@ -881,6 +1014,9 @@ def rekap(request: Request):
def pantau(request: Request):
"""Dasbor monitoring prodi: ketercapaian CPL/CPMK semester berjalan,
kelengkapan proses dokumen, dan daftar tindak lanjut untuk kaprodi."""
tolak = _bukan_kaprodi(request)
if tolak:
return tolak
hasil = hitung.rollup_prodi()
daftar = db.daftar_mk()
nama_mk = {m["kode"]: m["nama"] for m in daftar}
@@ -962,6 +1098,9 @@ async def simpan_metodologi(request: Request):
@app.get("/rekap/riwayat", response_class=HTMLResponse)
def rekap_riwayat(request: Request):
"""Tren ketercapaian CPL lintas tahun ajaran."""
tolak = _bukan_kaprodi(request)
if tolak:
return tolak
daftar_tahun = db.daftar_tahun()
per_tahun = {t: hitung.rollup_prodi(t) for t in daftar_tahun}
kode_cpl = sorted(db.kurikulum()["cpl"].keys())
-96
View File
@@ -10,45 +10,6 @@ BIRU = "1F4E79"
BIRU_MUDA = "D6E4F0"
def buat_template(peta: dict, tahun: str, mahasiswa: list[dict] | None = None) -> bytes:
"""Template xlsx: kolom NPM, Nama, Kelas, lalu satu kolom per Sub-CPMK.
Bila daftar mahasiswa (impor kaprodi) tersedia, baris NPM/Nama/Kelas
sudah terisi — dosen tinggal mengisi skornya.
"""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Nilai"
subs = peta["subs"]
ws.append([f"NILAI PER SUB-CPMK — {peta['nama']} ({peta['kode']}) — {tahun}"])
ws.append(["Petunjuk: isi skor 0100 per mahasiswa per Sub-CPMK. "
"Jangan mengubah baris kode Sub-CPMK."])
ws.append([])
header = ["NPM", "Nama Mahasiswa", "Kelas"] + [s["kode"] for s in subs]
ws.append(header)
uraian = ["", "", ""] + [s["uraian"][:80] for s in subs]
ws.append(uraian)
for m in mahasiswa or []:
ws.append([m["npm"], m["nama"], m.get("kelas", "")])
for ci, _ in enumerate(header, 1):
c = ws.cell(row=4, column=ci)
c.font = Font(bold=True, color="FFFFFF")
c.fill = PatternFill("solid", fgColor=BIRU)
c.alignment = Alignment(horizontal="center")
u = ws.cell(row=5, column=ci)
u.font = Font(italic=True, size=8)
u.alignment = Alignment(wrap_text=True, vertical="top")
ws.column_dimensions[get_column_letter(ci)].width = (
14 if ci == 1 else 28 if ci == 2 else 9 if ci == 3 else 14)
ws.freeze_panes = "D6"
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def template_mahasiswa(daftar_mk: list[dict], tahun: str) -> bytes:
"""Template impor daftar mahasiswa: NPM | Nama | Kode MK | Kelas."""
wb = openpyxl.Workbook()
@@ -148,60 +109,3 @@ def parse_mahasiswa(isi: bytes, kode_mk_valid: set[str]) -> tuple[list[dict], li
if not baris:
peringatan.append("Tidak ada baris kontrak yang terbaca.")
return baris, peringatan
def parse_unggahan(isi: bytes, kode_sub_valid: set[str]) -> tuple[list[dict], list[str]]:
"""Baca xlsx nilai → (mahasiswa, peringatan).
Baris header dicari otomatis: baris yang memuat 'NPM'. Kolom Sub-CPMK
dicocokkan dengan kode yang valid untuk MK tsb.
"""
wb = openpyxl.load_workbook(io.BytesIO(isi), read_only=True, data_only=True)
ws = wb.active
rows = list(ws.iter_rows(values_only=True))
header_i, kolom_sub = None, {}
for i, row in enumerate(rows):
vals = [str(v).strip() if v is not None else "" for v in row]
if "NPM" in vals:
header_i = i
for ci, v in enumerate(vals):
if v in kode_sub_valid:
kolom_sub[ci] = v
npm_i = vals.index("NPM")
nama_i = npm_i + 1
break
if header_i is None or not kolom_sub:
return [], ["Header tidak dikenali: pastikan memakai template dari aplikasi "
"(ada kolom NPM dan kode Sub-CPMK)."]
mahasiswa, peringatan = [], []
for row in rows[header_i + 1:]:
vals = list(row)
npm = str(vals[npm_i]).strip() if npm_i < len(vals) and vals[npm_i] is not None else ""
nama = str(vals[nama_i]).strip() if nama_i < len(vals) and vals[nama_i] is not None else ""
skor = {}
for ci, sub in kolom_sub.items():
v = vals[ci] if ci < len(vals) else None
if v is None or v == "":
continue
try:
x = float(v)
except (TypeError, ValueError):
continue
if 0 <= x <= 100:
skor[sub] = x
else:
peringatan.append(f"{npm or nama}: nilai {sub} = {x} di luar 0100, dilewati.")
if skor and (npm or nama):
mahasiswa.append({"npm": npm, "nama": nama, "skor": skor})
if not mahasiswa:
peringatan.append("Tidak ada baris nilai yang terbaca.")
hilang = kode_sub_valid - {s for m in mahasiswa for s in m["skor"]}
if mahasiswa and hilang:
peringatan.append(
f"{len(hilang)} Sub-CPMK tanpa nilai sama sekali: "
+ ", ".join(sorted(hilang)[:8]) + ("" if len(hilang) > 8 else "")
)
return mahasiswa, peringatan
+9 -2
View File
@@ -8,7 +8,10 @@
<p class="keterangan"><b>Dosen RPS + MK</b> dapat mengedit RPS, nilai, dan portofolio
pada MK yang ditugaskan. <b>Dosen MK</b> hanya dapat mengedit nilai &amp; portofolio
pada MK yang ditugaskan — halaman RPS terbuka hanya untuk dibaca. Kaprodi dapat
mengakses semuanya.</p>
mengakses semuanya. Kosongkan kolom kelas bila dosen mengampu <b>seluruh kelas</b>
MK itu; isi (mis. "A" atau "A,B") bila dosen hanya boleh melihat &amp; menilai
mahasiswa kelas tertentu — cocok untuk MK dengan beberapa kelas paralel yang
diampu dosen berbeda.</p>
<div class="gulir">
<table class="grid">
<thead>
@@ -30,7 +33,9 @@
{% if p.role == 'kaprodi' %}
<i style="color:var(--grey)">semua MK</i>
{% else %}
{{ p.mk|join(', ') if p.mk else '—' }}
{% if p.mk %}
{% for kode in p.mk %}{{ kode }}{% if p.kelas_map.get(kode) %} (kelas {{ p.kelas_map[kode] }}){% endif %}{{ ', ' if not loop.last }}{% endfor %}
{% else %}—{% endif %}
<details style="margin-top:6px">
<summary style="cursor:pointer;color:var(--blue);font-size:13px">Ubah penugasan…</summary>
<form method="post" action="/admin/pengguna/{{ p.id }}/tugas">
@@ -38,6 +43,8 @@
{% for mk in daftar %}
<label><input type="checkbox" name="mk" value="{{ mk.kode }}"
{% if mk.kode in p.mk %}checked{% endif %}> {{ mk.kode }} — {{ mk.nama }}</label>
<input type="text" name="kelas_{{ mk.kode }}" placeholder="kelas (kosong = semua)"
value="{{ p.kelas_map.get(mk.kode, '') }}" style="width:150px;margin:0 0 4px 22px;font-size:12px">
{% endfor %}
</div>
<button class="tombol garis" type="submit" style="padding:4px 12px;font-size:13px">Simpan Penugasan</button>
+5 -3
View File
@@ -28,9 +28,11 @@
<nav class="menu-baris">
<span class="menu-kiri">
{{ tautan('/', 'Beranda') }}
{{ tautan('/pantau', 'Dasbor Monitoring') }}
{{ tautan('/rekap', 'Rekap CPL/CPMK') }}
{{ tautan('/rekap/riwayat', 'Riwayat') }}
{% if user.role == 'kaprodi' %}
{{ tautan('/pantau', 'Dasbor Monitoring') }}
{{ tautan('/rekap', 'Rekap CPL/CPMK') }}
{{ tautan('/rekap/riwayat', 'Riwayat') }}
{% endif %}
{{ tautan('/metodologi', 'Metodologi') }}
{{ tautan('/dashboard', 'Pemetaan CPMK') }}
{% if user.role == 'kaprodi' %}{{ tautan('/admin', 'Kelola Akun & Mahasiswa') }}{% endif %}
+51 -55
View File
@@ -7,20 +7,66 @@
<h1>Input Nilai — {{ peta.nama }} <span style="color:var(--grey)">({{ peta.kode }})</span></h1>
<p class="keterangan">Apa pun cara inputnya, pengukuran memakai <b>satu metodologi
yang sama</b>: skor per Sub-CPMK digulung ke CPMK lalu CPL
(rincian di halaman <a href="/metodologi">Metodologi</a>). Pilih <b>satu</b> cara
di bawah — menyimpan lewat cara mana pun menggantikan data nilai MK ini.</p>
<p class="keterangan">Daftar mahasiswa mengikuti impor kaprodi. Apa pun cara
inputnya, pengukuran memakai <b>satu metodologi yang sama</b>: skor per Sub-CPMK
digulung ke CPMK lalu CPL (rincian di halaman <a href="/metodologi">Metodologi</a>).</p>
{% if kelas_dosen is not none %}
<div class="info">Anda hanya mengampu kelas <b>{{ kelas_dosen|join(', ') }}</b> pada MK ini —
daftar &amp; tombol di bawah cuma menampilkan mahasiswa kelas tersebut.</div>
{% endif %}
{% if pesan %}<div class="info">{{ pesan }}</div>{% endif %}
{% for w in peringatan %}<div class="peringatan">{{ w }}</div>{% endfor %}
{% if komponen_daftar %}
<h2 class="bagian">ISI PER KOMPONEN PENILAIAN <span style="font-weight:normal;font-style:italic;color:var(--grey)">(cara cepat — nilai per mahasiswa digabung ke Sub-CPMK yang diukur komponen itu)</span></h2>
<p class="keterangan">
Komponen <b>berulang</b> (Kuis, Tugas, Presentasi, Tes Lisan) boleh diisi berkali-kali
sepanjang semester — tiap sesi dirata-rata otomatis. Komponen <b>sekali-jalan</b>
(UTS, UAS) hanya satu sesi; mengisi lagi memperbarui sesi yang sama, bukan menambah.
Bila satu instrumen (mis. UTS dengan beberapa kelompok soal) perlu nilai berbeda
antar Sub-CPMK, isi lewat grid per Sub-CPMK di bawah.</p>
<table class="grid" style="max-width:760px">
<thead>
<tr>
<th>Komponen</th>
<th class="tengah" style="width:90px">Sub-CPMK</th>
<th class="tengah" style="width:70px">Bobot</th>
<th class="tengah" style="width:150px">Status</th>
<th class="tengah" style="width:110px"></th>
</tr>
</thead>
<tbody>
{% for k in komponen_daftar %}
<tr>
<td>{{ k.nama }}<br><span style="font-size:12px;color:var(--grey)">{{ 'boleh diisi berkali-kali' if k.ulang else 'sekali-jalan' }}</span></td>
<td class="tengah">{{ k.n_subs }}</td>
<td class="tengah">{% if k.bobot %}{{ k.bobot }}%{% else %}—{% endif %}</td>
<td class="tengah">
{% if k.ulang %}
{% if k.n_sesi %}<span class="stamp stamp-ok">{{ k.n_sesi }} SESI</span>{% else %}<span class="stamp stamp-draft">BELUM ADA SESI</span>{% endif %}
{% else %}
{% if k.n_sesi %}<span class="stamp stamp-ok">SUDAH DIISI</span>{% else %}<span class="stamp stamp-draft">BELUM DIISI</span>{% endif %}
{% endif %}
</td>
<td class="tengah">
<a class="tombol garis" style="padding:4px 12px;font-size:13px" href="/nilai/{{ peta.kode }}/komponen/{{ k.i }}">
{{ 'Sesi Baru' if k.ulang else ('Edit' if k.n_sesi else 'Isi') }}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if tersimpan %}
<div class="info">
Data tersimpan: mode
<b>{% if tersimpan.data.mode == 'per_mahasiswa' %}PER MAHASISWA ({{ tersimpan.data.mahasiswa|length }} mahasiswa){% else %}BUKTI AGREGAT (rata-rata kelas){% endif %}</b>
— terakhir disimpan {{ tersimpan.updated_at }}{% if tersimpan.updated_by %} oleh {{ tersimpan.updated_by }}{% endif %}.
{% if tersimpan.data.sumber %}<br>Riwayat unggahan:
{% if tersimpan.data.sumber %}<br>Riwayat perubahan:
{{ tersimpan.data.sumber if tersimpan.data.sumber is string else tersimpan.data.sumber|join(' · ') }}.{% endif %}
<form method="post" action="/nilai/{{ peta.kode }}" style="display:inline;margin-left:8px"
onsubmit="return confirm('Hapus SELURUH nilai tersimpan MK ini untuk {{ tahun }}? Tindakan ini tidak bisa dibatalkan.')">
@@ -100,56 +146,6 @@
</form>
{% endif %}
<details class="cara-alt"{% if not grid %} open{% endif %}>
<summary>{{ 'ALTERNATIF — ' if grid }}UNGGAH EXCEL PER MAHASISWA <span style="font-weight:normal;font-style:italic;color:var(--grey)">(cocok bila nilai sudah direkap di Excel, atau team teaching mengisi terpisah)</span></summary>
<p class="keterangan">
Unduh template berkolom Sub-CPMK MK ini, isi nilai 0100 per mahasiswa, lalu unggah kembali.
{% if terdaftar %}Template sudah berisi <b>{{ terdaftar|length }} mahasiswa</b> yang
mengontrak MK ini (dari daftar impor kaprodi).
{% else %}<b>Daftar mahasiswa MK ini belum diimpor kaprodi</b> — template kosong,
isi NPM &amp; nama secara manual.{% endif %}
<br><b>Team teaching:</b> tiap dosen cukup mengunggah bagiannya sendiri — kolom
Sub-CPMK yang diampunya (mis. sebelum/sesudah UTS) atau baris mahasiswa kelasnya.
Unggahan <b>digabung</b>: sel terisi memperbarui nilai, sel kosong tidak menghapus
nilai yang sudah diunggah dosen lain.
</p>
<div class="aksi-baris" style="border-top:none;margin-top:0;padding-top:0">
<a class="tombol garis" href="/nilai/{{ peta.kode }}/template.xlsx">Unduh Template Excel</a>
</div>
<form method="post" action="/nilai/{{ peta.kode }}" enctype="multipart/form-data">
<div class="aksi-baris" style="border-top:none">
<input type="file" name="berkas" accept=".xlsx" style="width:auto">
<button class="tombol" type="submit" name="aksi" value="unggah">Unggah &amp; Hitung</button>
</div>
</form>
</details>
<details class="cara-alt"{% if tersimpan and tersimpan.data.mode == 'agregat' %} open{% endif %}>
<summary>ALTERNATIF — KETIK RATA-RATA KELAS PER SUB-CPMK <span style="font-weight:normal;font-style:italic;color:var(--grey)">(hanya bila nilai per mahasiswa tidak tersedia; ditandai "bukti agregat" pada rekap)</span></summary>
<form method="post" action="/nilai/{{ peta.kode }}">
<div class="gulir" style="max-height:420px;overflow-y:auto">
<table class="grid">
<thead><tr><th style="width:120px">Sub-CPMK</th><th>Uraian</th><th class="tengah" style="width:130px">Rata-rata (0100)</th></tr></thead>
<tbody>
{% for s in peta.subs %}
<tr>
<td class="tengah">{{ s.kode }}</td>
<td>{{ s.uraian[:110] }}{% if s.uraian|length > 110 %}…{% endif %}</td>
<td class="isian"><input type="number" name="rata_{{ s.kode }}" min="0" max="100" step="0.01"
value="{% if tersimpan and tersimpan.data.mode == 'agregat' %}{{ tersimpan.data.agregat.get(s.kode, '') }}{% endif %}"></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="aksi-baris">
<span style="color:var(--grey);font-size:13px">Disimpan atas nama <b>{{ user.nama }}</b></span>
<span class="spacer"></span>
<button class="tombol garis" type="submit" name="aksi" value="agregat">Simpan Rata-rata</button>
</div>
</form>
</details>
{% if hasil %}
<h2 class="bagian">HASIL KETERCAPAIAN MK INI <span style="font-weight:normal;font-style:italic;color:var(--grey)">(ambang {{ ambang|round(0)|int }}; roll-up bagi rata)</span></h2>
<div class="gulir">
+87
View File
@@ -0,0 +1,87 @@
{% extends "base.html" %}
{% block judul %}Nilai {{ komponen.nama }} — {{ peta.nama }} — Informatika UNTAN{% endblock %}
{% block isi %}
<div class="nav-baris">
<div>MK ini: <a href="/rps/{{ peta.kode }}">RPS</a> · <a href="/nilai/{{ peta.kode }}">Input Nilai</a> · <b>{{ komponen.nama }}</b> · <a href="/portofolio/{{ peta.kode }}">Portofolio</a></div>
</div>
<h1>Isi Nilai — {{ komponen.nama }} <span style="color:var(--grey)">({{ peta.nama }})</span></h1>
<p class="keterangan">
Nilai di sini digabung ke Sub-CPMK yang diukur komponen ini:
{% for s in komponen.subs %}<b>{{ s }}</b>{{ ', ' if not loop.last }}{% endfor %}
{% if komponen.bobot %}(bobot komponen {{ komponen.bobot }}%){% endif %}.
{% if komponen.ulang %}
Komponen ini <b>boleh diisi berkali-kali</b> (mis. beberapa kali kuis sepanjang
semester) — nilai tiap sesi dirata-rata otomatis per Sub-CPMK. Bila satu Sub-CPMK
juga diukur komponen lain, hasil akhirnya dirata tertimbang bobot antar-komponen.
{% else %}
Komponen ini <b>hanya satu sesi per semester</b> — menyimpan lagi memperbarui
(menimpa) sesi yang sudah ada, bukan menambah sesi baru.
{% endif %}
Jika mahasiswa perlu nilai berbeda antar Sub-CPMK dalam komponen yang sama
(mis. UTS dengan beberapa kelompok soal), isi lewat
<a href="/nilai/{{ peta.kode }}">grid per Sub-CPMK</a> di halaman utama nilai, bukan di sini.
</p>
{% if pesan %}<div class="info">{{ pesan }}</div>{% endif %}
{% if komponen.ulang and daftar_sesi %}
<h2 class="bagian">SESI TERSIMPAN</h2>
<table class="grid" style="max-width:560px">
<thead><tr><th>Sesi</th><th class="tengah" style="width:120px">Mahasiswa Terisi</th><th style="width:60px"></th></tr></thead>
<tbody>
{% for s in daftar_sesi %}
<tr{% if loop.index0 == edit_id %} style="background:var(--blue-fill)"{% endif %}>
<td>{{ s.label }}</td>
<td class="tengah">{{ (s.nilai or {})|length }}</td>
<td><a href="/nilai/{{ peta.kode }}/komponen/{{ i }}?sesi_id={{ loop.index0 }}">Edit</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<h2 class="bagian">{{ 'EDIT SESI' if edit_id < daftar_sesi|length else 'SESI BARU' }} {{ label_awal }}</h2>
<form method="post" action="/nilai/{{ peta.kode }}/komponen/{{ i }}">
<input type="hidden" name="edit_id" value="{{ edit_id }}">
{% if komponen.ulang %}
<label style="display:block;margin-bottom:10px">Nama sesi
<input type="text" name="label" value="{{ label_awal }}" style="display:block;margin-top:4px;max-width:280px">
</label>
{% else %}
<input type="hidden" name="label" value="{{ label_awal }}">
{% endif %}
<div class="gulir" style="max-height:480px;overflow-y:auto">
<table class="grid">
<thead>
<tr>
<th style="width:44px" class="tengah">No.</th>
<th style="width:112px">NPM</th>
<th style="min-width:150px">Nama</th>
<th class="tengah" style="width:140px">Nilai (0100)</th>
</tr>
</thead>
<tbody>
{% for m in grid %}
<tr>
<td class="tengah">{{ loop.index }}
<input type="hidden" name="npm_{{ loop.index0 }}" value="{{ m.npm }}">
<input type="hidden" name="nama_{{ loop.index0 }}" value="{{ m.nama }}"></td>
<td>{{ m.npm }}</td>
<td>{{ m.nama }}</td>
<td class="isian"><input type="number" name="nilai_{{ loop.index0 }}"
min="0" max="100" step="0.01"
value="{{ '%g'|format(m.nilai_awal) if m.nilai_awal is not none else '' }}"></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="aksi-baris">
<span style="color:var(--grey);font-size:13px">Disimpan atas nama <b>{{ user.nama }}</b></span>
<span class="spacer"></span>
<button class="tombol" type="submit">Simpan Sesi Ini</button>
</div>
</form>
{% endblock %}