Tambah peran Dosen MK/RPS+MK, komponen penilaian baku, dan bantu isi RPS

- Peran dosen dipecah jadi dosen_mk (nilai & portofolio saja, RPS baca-only)
  dan dosen_rps_mk (bisa edit RPS juga); migrasi otomatis dari skema lama.
- Rekap Bobot Penilaian RPS pakai 6 komponen teknik asesmen baku prodi;
  total wajib tepat 100%, komponen individual boleh dikosongkan.
- Dropdown & daftar Sub-CPMK tak lagi terpotong teksnya.
- Bagian F (Rencana Mingguan): tombol "Isi Otomatis" per baris (termasuk
  usulan Bobot) yang muncul setelah Sub-CPMK dipilih, plus panel contoh
  teks bantu per kolom.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Power BI Dev
2026-07-21 08:45:56 +07:00
parent 9be6bd03e3
commit a45b08b00e
7 changed files with 175 additions and 45 deletions
+36 -1
View File
@@ -105,6 +105,39 @@ def get_conn() -> sqlite3.Connection:
return conn return conn
def _migrasi_role_pengguna(conn: sqlite3.Connection) -> None:
"""Skema lama hanya kenal role 'dosen'; pecah jadi dosen_mk/dosen_rps_mk.
Akun 'dosen' lama dipetakan ke 'dosen_rps_mk' agar hak akses (termasuk
edit RPS) yang sudah mereka miliki tidak berkurang oleh migrasi ini.
"""
row = conn.execute(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='pengguna'"
).fetchone()
if not row or "dosen_rps_mk" in row["sql"]:
return
conn.execute("ALTER TABLE pengguna RENAME TO pengguna_lama")
conn.execute(
"""CREATE TABLE pengguna (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
nama TEXT NOT NULL,
role TEXT NOT NULL
CHECK (role IN ('kaprodi','dosen_mk','dosen_rps_mk')),
pw_hash TEXT NOT NULL,
dibuat TEXT NOT NULL
)"""
)
conn.execute(
"""INSERT INTO pengguna (id, username, nama, role, pw_hash, dibuat)
SELECT id, username, nama,
CASE WHEN role='dosen' THEN 'dosen_rps_mk' ELSE role END,
pw_hash, dibuat
FROM pengguna_lama"""
)
conn.execute("DROP TABLE pengguna_lama")
def init_db() -> None: def init_db() -> None:
with get_conn() as conn: with get_conn() as conn:
for tabel in ("rps", "portofolio", "nilai"): for tabel in ("rps", "portofolio", "nilai"):
@@ -141,11 +174,13 @@ def init_db() -> None:
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
nama TEXT NOT NULL, nama TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('kaprodi','dosen')), role TEXT NOT NULL
CHECK (role IN ('kaprodi','dosen_mk','dosen_rps_mk')),
pw_hash TEXT NOT NULL, pw_hash TEXT NOT NULL,
dibuat TEXT NOT NULL dibuat TEXT NOT NULL
)""" )"""
) )
_migrasi_role_pengguna(conn)
conn.execute( conn.execute(
"""CREATE TABLE IF NOT EXISTS sesi ( """CREATE TABLE IF NOT EXISTS sesi (
token TEXT PRIMARY KEY, token TEXT PRIMARY KEY,
+4 -1
View File
@@ -218,7 +218,10 @@ def build_rps(d: dict) -> bytes:
r.bold = True r.bold = True
r.font.size = Pt(11) r.font.size = Pt(11)
komponen = d.get("komponen", []) komponen = [
k for k in d.get("komponen", [])
if k.get("subs") or str(k.get("bobot") or "").strip() not in ("", "0", "0.0")
]
tk = doc.add_table(rows=1 + max(len(komponen), 1), cols=4) tk = doc.add_table(rows=1 + max(len(komponen), 1), cols=4)
_grid_borders(tk) _grid_borders(tk)
for ci, judul in enumerate( for ci, judul in enumerate(
+61 -27
View File
@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Aplikasi Penyusunan RPS & Portofolio — Prodi Informatika UNTAN.""" """Aplikasi Penyusunan RPS & Portofolio — Prodi Informatika UNTAN."""
import json
import pathlib import pathlib
import re import re
@@ -17,6 +18,9 @@ BASE = pathlib.Path(__file__).resolve().parent
app = FastAPI(title="RPS & Portofolio — Informatika UNTAN") app = FastAPI(title="RPS & Portofolio — Informatika UNTAN")
app.mount("/static", StaticFiles(directory=str(BASE / "static")), name="static") app.mount("/static", StaticFiles(directory=str(BASE / "static")), name="static")
templates = Jinja2Templates(directory=str(BASE / "templates")) templates = Jinja2Templates(directory=str(BASE / "templates"))
templates.env.filters["tojson"] = (
lambda v: json.dumps(v, ensure_ascii=False).replace("</", "<\\/")
)
db.init_db() db.init_db()
db.kurikulum() db.kurikulum()
@@ -46,9 +50,19 @@ def render(request: Request, nama: str, ctx: dict):
return templates.TemplateResponse(request, nama, ctx) return templates.TemplateResponse(request, nama, ctx)
def boleh_kelola(user: dict, kode: str) -> bool: def boleh_rps(user: dict, kode: str) -> bool:
"""Pengampu MK ini atau kaprodi — syarat edit & akses nilai.""" """Kaprodi, atau dosen RPS+MK yang ditugaskan ke MK ini — syarat edit RPS."""
return user["role"] == "kaprodi" or kode in db.mk_diampu(user["id"]) if user["role"] == "kaprodi":
return True
return user["role"] == "dosen_rps_mk" and kode in db.mk_diampu(user["id"])
def boleh_mk(user: dict, kode: str) -> bool:
"""Kaprodi, atau dosen (MK / RPS+MK) yang ditugaskan ke MK ini —
syarat edit nilai & portofolio."""
if user["role"] == "kaprodi":
return True
return user["role"] in ("dosen_mk", "dosen_rps_mk") and kode in db.mk_diampu(user["id"])
def _masuk(user_id: int) -> RedirectResponse: def _masuk(user_id: int) -> RedirectResponse:
@@ -147,7 +161,8 @@ async def admin_buat_pengguna(request: Request):
form = await request.form() form = await request.form()
nip = _rapikan_nip(form.get("nip") or "") nip = _rapikan_nip(form.get("nip") or "")
nama = (form.get("nama") or "").strip() nama = (form.get("nama") or "").strip()
role = "kaprodi" if form.get("role") == "kaprodi" else "dosen" role_in = form.get("role")
role = role_in if role_in in ("kaprodi", "dosen_mk", "dosen_rps_mk") else "dosen_mk"
if not nip or not nama: if not nip or not nama:
return RedirectResponse( return RedirectResponse(
"/admin?pesan=Nama+dan+NIP+wajib+diisi.", status_code=303) "/admin?pesan=Nama+dan+NIP+wajib+diisi.", status_code=303)
@@ -243,15 +258,33 @@ async def admin_impor_mahasiswa(request: Request):
return RedirectResponse(f"/admin?pesan={pesan}", status_code=303) return RedirectResponse(f"/admin?pesan={pesan}", status_code=303)
# bobot default komponen — dari sheet `Asessement` draft kurikulum (Tabel 25) # 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 = [ KOMPONEN_DEFAULT = [
{"nama": "Kuis", "subs": [], "bobot": 15}, {"nama": "Partisipasi (Kuis)", "subs": [], "bobot": 10},
{"nama": "Ujian Tengah Semester (UTS)", "subs": [], "bobot": 20}, {"nama": "Observasi (Praktik/Tugas)", "subs": [], "bobot": 20},
{"nama": "Ujian Akhir Semester (UAS)", "subs": [], "bobot": 30}, {"nama": "Unjuk Kerja (Presentasi)", "subs": [], "bobot": 10},
{"nama": "Tugas / Praktikum / Proyek", "subs": [], "bobot": 35}, {"nama": "Tes Tulis (UTS)", "subs": [], "bobot": 25},
{"nama": "Tes Tulis (UAS)", "subs": [], "bobot": 25},
{"nama": "Tes Lisan (Tugas Kelompok)", "subs": [], "bobot": 10},
] ]
def _normalisasi_komponen(tersimpan: list[dict]) -> list[dict]:
"""Selaraskan komponen tersimpan ke struktur baku KOMPONEN_DEFAULT (cocokkan
berdasar nama; RPS lama dengan nama komponen berbeda kehilangan isian itu)."""
by_nama = {k.get("nama"): k for k in tersimpan}
out = []
for default in KOMPONEN_DEFAULT:
ada = by_nama.get(default["nama"])
out.append({
"nama": default["nama"],
"subs": ada.get("subs", []) if ada else [],
"bobot": ada.get("bobot", "") if ada else "",
})
return out
def minggu_default() -> list[dict]: def minggu_default() -> list[dict]:
rows = [] rows = []
for w in range(1, 17): for w in range(1, 17):
@@ -289,7 +322,7 @@ def beranda(request: Request, semua: int = 0):
n_total = len(daftar) n_total = len(daftar)
mk_saya = db.mk_diampu(request.state.user["id"]) mk_saya = db.mk_diampu(request.state.user["id"])
# dosen: bawaan hanya MK yang diampu; ?semua=1 menampilkan seluruh MK # dosen: bawaan hanya MK yang diampu; ?semua=1 menampilkan seluruh MK
hanya_saya = (request.state.user["role"] == "dosen" hanya_saya = (request.state.user["role"] != "kaprodi"
and bool(mk_saya) and not semua) and bool(mk_saya) and not semua)
if hanya_saya: if hanya_saya:
daftar = [m for m in daftar if m["kode"] in mk_saya] daftar = [m for m in daftar if m["kode"] in mk_saya]
@@ -324,6 +357,7 @@ def form_rps(request: Request, kode: str, pesan: str = ""):
if tersimpan: if tersimpan:
f = tersimpan["data"] f = tersimpan["data"]
f.setdefault("referensi_teks", "\n".join(f.get("referensi", []))) f.setdefault("referensi_teks", "\n".join(f.get("referensi", [])))
f["komponen"] = _normalisasi_komponen(f.get("komponen", []))
ctx_status = tersimpan ctx_status = tersimpan
else: else:
f = form_kosong() f = form_kosong()
@@ -339,17 +373,19 @@ def form_rps(request: Request, kode: str, pesan: str = ""):
{"peta": peta, "f": f, "status": ctx_status["status"], {"peta": peta, "f": f, "status": ctx_status["status"],
"updated_at": ctx_status["updated_at"], "updated_by": ctx_status["updated_by"], "updated_at": ctx_status["updated_at"], "updated_by": ctx_status["updated_by"],
"tahun": db.tahun_aktif(), "pesan": pesan, "tahun": db.tahun_aktif(), "pesan": pesan,
"boleh_edit": boleh_kelola(request.state.user, kode), "boleh_edit": boleh_rps(request.state.user, kode),
"tahun_lain": db.tahun_rps_lain(kode) if not tersimpan else []}, "tahun_lain": db.tahun_rps_lain(kode) if not tersimpan else [],
"sub_uraian": {s["kode"]: s["uraian"] for s in peta["subs"]}},
) )
@app.post("/rps/{kode}/salin") @app.post("/rps/{kode}/salin")
async def salin_rps(request: Request, kode: str): async def salin_rps(request: Request, kode: str):
"""Duplikasi RPS dari tahun ajaran lain ke tahun aktif (status: draft).""" """Duplikasi RPS dari tahun ajaran lain ke tahun aktif (status: draft)."""
if not boleh_kelola(request.state.user, kode): if not boleh_rps(request.state.user, kode):
return RedirectResponse( return RedirectResponse(
f"/rps/{kode}?pesan=Anda+bukan+pengampu+MK+ini.", status_code=303) f"/rps/{kode}?pesan=Anda+tidak+memiliki+hak+edit+RPS+untuk+MK+ini.",
status_code=303)
form = await request.form() form = await request.form()
dari = form.get("dari") or "" dari = form.get("dari") or ""
sumber = db.ambil_rps(kode, tahun=dari) if dari else None sumber = db.ambil_rps(kode, tahun=dari) if dari else None
@@ -371,9 +407,10 @@ async def simpan_rps(request: Request, kode: str):
peta = db.peta_mk(kode) peta = db.peta_mk(kode)
if not peta: if not peta:
return RedirectResponse("/", status_code=303) return RedirectResponse("/", status_code=303)
if not boleh_kelola(request.state.user, kode): if not boleh_rps(request.state.user, kode):
return RedirectResponse( return RedirectResponse(
f"/rps/{kode}?pesan=Anda+bukan+pengampu+MK+ini.", status_code=303) f"/rps/{kode}?pesan=Anda+tidak+memiliki+hak+edit+RPS+untuk+MK+ini.",
status_code=303)
form = await request.form() form = await request.form()
identitas = { identitas = {
@@ -396,12 +433,9 @@ async def simpan_rps(request: Request, kode: str):
"bobot": (form.get(f"m{w}_bobot") or "").strip(), "bobot": (form.get(f"m{w}_bobot") or "").strip(),
}) })
komponen = [] komponen = []
for i in range(1, len(KOMPONEN_DEFAULT) + 1): for i, default in enumerate(KOMPONEN_DEFAULT, 1):
nama = (form.get(f"k{i}_nama") or "").strip()
if not nama:
continue
komponen.append({ komponen.append({
"nama": nama, "nama": default["nama"],
"subs": [s for s in form.getlist(f"k{i}_subs")], "subs": [s for s in form.getlist(f"k{i}_subs")],
"bobot": (form.get(f"k{i}_bobot") or "").strip(), "bobot": (form.get(f"k{i}_bobot") or "").strip(),
}) })
@@ -540,7 +574,7 @@ def _gabung_nilai(lama: dict, baru: list[dict], sumber: str, oleh: str) -> dict:
def form_nilai(request: Request, kode: str, pesan: str = ""): def form_nilai(request: Request, kode: str, pesan: str = ""):
if not db.peta_mk(kode): if not db.peta_mk(kode):
return RedirectResponse("/", status_code=303) return RedirectResponse("/", status_code=303)
if not boleh_kelola(request.state.user, kode): if not boleh_mk(request.state.user, kode):
return RedirectResponse( return RedirectResponse(
f"/rps/{kode}?pesan=Nilai+mahasiswa+hanya+dibuka+untuk+pengampu+MK+dan+kaprodi.", f"/rps/{kode}?pesan=Nilai+mahasiswa+hanya+dibuka+untuk+pengampu+MK+dan+kaprodi.",
status_code=303) status_code=303)
@@ -552,7 +586,7 @@ def template_nilai(request: Request, kode: str):
peta = db.peta_mk(kode) peta = db.peta_mk(kode)
if not peta: if not peta:
return RedirectResponse("/", status_code=303) return RedirectResponse("/", status_code=303)
if not boleh_kelola(request.state.user, kode): if not boleh_mk(request.state.user, kode):
return RedirectResponse(f"/rps/{kode}", status_code=303) return RedirectResponse(f"/rps/{kode}", status_code=303)
isi = nilai_xlsx.buat_template(peta, db.tahun_aktif(), db.mahasiswa_mk(kode)) isi = nilai_xlsx.buat_template(peta, db.tahun_aktif(), db.mahasiswa_mk(kode))
return Response( return Response(
@@ -568,7 +602,7 @@ async def simpan_nilai(request: Request, kode: str):
peta = db.peta_mk(kode) peta = db.peta_mk(kode)
if not peta: if not peta:
return RedirectResponse("/", status_code=303) return RedirectResponse("/", status_code=303)
if not boleh_kelola(request.state.user, kode): if not boleh_mk(request.state.user, kode):
return RedirectResponse( return RedirectResponse(
f"/rps/{kode}?pesan=Nilai+mahasiswa+hanya+dibuka+untuk+pengampu+MK+dan+kaprodi.", f"/rps/{kode}?pesan=Nilai+mahasiswa+hanya+dibuka+untuk+pengampu+MK+dan+kaprodi.",
status_code=303) status_code=303)
@@ -705,7 +739,7 @@ def form_porto(request: Request, kode: str, pesan: str = ""):
"status": tersimpan["status"] if tersimpan else "", "status": tersimpan["status"] if tersimpan else "",
"updated_at": tersimpan["updated_at"] if tersimpan else "", "updated_at": tersimpan["updated_at"] if tersimpan else "",
"updated_by": tersimpan["updated_by"] if tersimpan else "", "updated_by": tersimpan["updated_by"] if tersimpan else "",
"boleh_edit": boleh_kelola(request.state.user, kode), "boleh_edit": boleh_mk(request.state.user, kode),
"ada_rps": bool(rps), "ada_nilai": bool(nilai)}, "ada_rps": bool(rps), "ada_nilai": bool(nilai)},
) )
@@ -714,7 +748,7 @@ def form_porto(request: Request, kode: str, pesan: str = ""):
async def simpan_porto(request: Request, kode: str): async def simpan_porto(request: Request, kode: str):
if not db.peta_mk(kode): if not db.peta_mk(kode):
return RedirectResponse("/", status_code=303) return RedirectResponse("/", status_code=303)
if not boleh_kelola(request.state.user, kode): if not boleh_mk(request.state.user, kode):
return RedirectResponse( return RedirectResponse(
f"/portofolio/{kode}?pesan=Anda+bukan+pengampu+MK+ini.", status_code=303) f"/portofolio/{kode}?pesan=Anda+bukan+pengampu+MK+ini.", status_code=303)
form = await request.form() form = await request.form()
@@ -810,7 +844,7 @@ def _bahan_porto(kode: str) -> dict | None:
@app.get("/portofolio/{kode}/unduh") @app.get("/portofolio/{kode}/unduh")
def unduh_porto(request: Request, kode: str): def unduh_porto(request: Request, kode: str):
# dokumen memuat matriks nilai per mahasiswa — batasi pengampu + kaprodi # dokumen memuat matriks nilai per mahasiswa — batasi pengampu + kaprodi
if not boleh_kelola(request.state.user, kode): if not boleh_mk(request.state.user, kode):
return RedirectResponse( return RedirectResponse(
f"/portofolio/{kode}?pesan=Unduhan+portofolio+memuat+nilai+mahasiswa+-+hanya+pengampu+dan+kaprodi.", f"/portofolio/{kode}?pesan=Unduhan+portofolio+memuat+nilai+mahasiswa+-+hanya+pengampu+dan+kaprodi.",
status_code=303) status_code=303)
+9 -6
View File
@@ -5,15 +5,17 @@
{% if pesan %}<div class="info">{{ pesan }}</div>{% endif %} {% if pesan %}<div class="info">{{ pesan }}</div>{% endif %}
<h2 class="bagian">A. Akun Pengguna &amp; Penugasan Mata Kuliah</h2> <h2 class="bagian">A. Akun Pengguna &amp; Penugasan Mata Kuliah</h2>
<p class="keterangan">Dosen hanya dapat mengedit RPS/portofolio dan melihat nilai <p class="keterangan"><b>Dosen RPS + MK</b> dapat mengedit RPS, nilai, dan portofolio
mahasiswa pada MK yang ditugaskan kepadanya. Kaprodi dapat mengakses semuanya.</p> 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>
<div class="gulir"> <div class="gulir">
<table class="grid"> <table class="grid">
<thead> <thead>
<tr> <tr>
<th style="width:170px">Nama</th> <th style="width:170px">Nama</th>
<th style="width:160px">NIP</th> <th style="width:160px">NIP</th>
<th style="width:90px">Peran</th> <th style="width:130px">Peran</th>
<th>MK yang Diampu</th> <th>MK yang Diampu</th>
<th style="width:340px">Tindakan</th> <th style="width:340px">Tindakan</th>
</tr> </tr>
@@ -23,7 +25,7 @@
<tr> <tr>
<td>{{ p.nama }}</td> <td>{{ p.nama }}</td>
<td>{{ p.username }}</td> <td>{{ p.username }}</td>
<td>{{ 'Kaprodi' if p.role == 'kaprodi' else 'Dosen' }}</td> <td>{{ {'kaprodi': 'Kaprodi', 'dosen_rps_mk': 'Dosen RPS + MK', 'dosen_mk': 'Dosen MK'}[p.role] }}</td>
<td> <td>
{% if p.role == 'kaprodi' %} {% if p.role == 'kaprodi' %}
<i style="color:var(--grey)">semua MK</i> <i style="color:var(--grey)">semua MK</i>
@@ -71,8 +73,9 @@
<label>Nama lengkap <input type="text" name="nama" required style="width:230px"></label> <label>Nama lengkap <input type="text" name="nama" required style="width:230px"></label>
<label>NIP <input type="text" name="nip" required style="width:180px"></label> <label>NIP <input type="text" name="nip" required style="width:180px"></label>
<label>Peran <label>Peran
<select name="role" style="width:110px"> <select name="role" style="width:150px">
<option value="dosen" selected>Dosen</option> <option value="dosen_mk" selected>Dosen MK</option>
<option value="dosen_rps_mk">Dosen RPS + MK</option>
<option value="kaprodi">Kaprodi</option> <option value="kaprodi">Kaprodi</option>
</select> </select>
</label> </label>
+1 -1
View File
@@ -36,7 +36,7 @@
{% if user.role == 'kaprodi' %}{{ tautan('/admin', 'Kelola Akun & Mahasiswa') }}{% endif %} {% if user.role == 'kaprodi' %}{{ tautan('/admin', 'Kelola Akun & Mahasiswa') }}{% endif %}
</span> </span>
<span class="menu-kanan"> <span class="menu-kanan">
<b>{{ user.nama }}</b> — {{ 'Kaprodi' if user.role == 'kaprodi' else 'Dosen' }} <b>{{ user.nama }}</b> — {{ {'kaprodi': 'Kaprodi', 'dosen_rps_mk': 'Dosen RPS + MK', 'dosen_mk': 'Dosen MK'}[user.role] }}
· <a href="/keluar">Keluar</a> · <a href="/keluar">Keluar</a>
</span> </span>
</nav> </nav>
+1 -1
View File
@@ -8,7 +8,7 @@
<div><b id="kpi-porto">{{ st_porto.values()|selectattr('status', 'equalto', 'selesai')|list|length }}</b> <span>portofolio selesai</span> <span class="kpi-pct" id="pct-porto"></span></div> <div><b id="kpi-porto">{{ st_porto.values()|selectattr('status', 'equalto', 'selesai')|list|length }}</b> <span>portofolio selesai</span> <span class="kpi-pct" id="pct-porto"></span></div>
</div> </div>
{% if user.role == 'dosen' %} {% if user.role != 'kaprodi' %}
{% if hanya_saya %} {% if hanya_saya %}
<div class="info" style="margin-top:0">Menampilkan <b>{{ daftar|length }} MK yang Anda ampu</b>. <div class="info" style="margin-top:0">Menampilkan <b>{{ daftar|length }} MK yang Anda ampu</b>.
RPS mata kuliah lain tetap terbuka baca — <a href="/?semua=1">tampilkan seluruh {{ n_total }} MK</a>.</div> RPS mata kuliah lain tetap terbuka baca — <a href="/?semua=1">tampilkan seluruh {{ n_total }} MK</a>.</div>
+63 -8
View File
@@ -16,8 +16,10 @@
{% if pesan %}<div class="info">{{ pesan }}</div>{% endif %} {% if pesan %}<div class="info">{{ pesan }}</div>{% endif %}
{% if not boleh_edit %} {% if not boleh_edit %}
<div class="info">Anda bukan pengampu MK ini — form terbuka <b>hanya untuk dibaca</b>. <div class="info">Anda tidak memiliki hak edit RPS untuk MK ini — form terbuka
Hubungi kaprodi bila penugasan MK Anda perlu diubah.</div> <b>hanya untuk dibaca</b>. Ini karena Anda bukan pengampu MK ini, atau peran Anda
<b>Dosen MK</b> (hanya mengelola nilai &amp; portofolio). Hubungi kaprodi bila ini
perlu diubah.</div>
{% endif %} {% endif %}
{% if tahun_lain and boleh_edit %} {% if tahun_lain and boleh_edit %}
@@ -97,7 +99,23 @@
<textarea name="media" rows="2" placeholder="mis. LMS (e-learning UNTAN), LCD proyektor, perangkat lunak pendukung.">{{ f.media }}</textarea> <textarea name="media" rows="2" placeholder="mis. LMS (e-learning UNTAN), LCD proyektor, perangkat lunak pendukung.">{{ f.media }}</textarea>
<h2 class="bagian">F. RENCANA MINGGUAN <span id="total-bobot-f" style="font-weight:normal">— total bobot: <b>0</b>%</span></h2> <h2 class="bagian">F. RENCANA MINGGUAN <span id="total-bobot-f" style="font-weight:normal">— total bobot: <b>0</b>%</span></h2>
<p class="keterangan">Minggu 8 = UTS, minggu 16 = UAS. Total bobot seluruh minggu harus 100 %.</p> <p class="keterangan">Minggu 8 = UTS, minggu 16 = UAS. Total bobot seluruh minggu harus 100 %.
{% if boleh_edit %}Pilih Sub-CPMK lalu klik <b>Isi Otomatis</b> untuk mengisi kolom kosong dengan teks
bantu (termasuk usulan Bobot — UTS 20%, UAS 30%, minggu biasa 3,5% — sesuaikan lagi agar totalnya
pas 100%).{% endif %}</p>
{% if boleh_edit %}
<details style="margin:0 0 10px">
<summary style="cursor:pointer;color:var(--blue);font-size:13px">Lihat contoh teks bantu per kolom</summary>
<div class="info" style="margin-top:6px;line-height:1.7">
<b>Indikator</b> — turunan Sub-CPMK, mis. "Mahasiswa mampu menjelaskan …"; "Ketepatan dan kelengkapan dalam …"<br>
<b>Kriteria &amp; Teknik</b> — mis. "Rubrik holistik; tes tertulis" / "Ketepatan jawaban; unjuk kerja" / "Kelengkapan tugas; observasi"<br>
<b>Tatap Muka</b> — mis. "Ceramah, diskusi, tanya jawab [TM: 2x50 menit]" / "Studi kasus &amp; presentasi kelompok [TM: 2x50 menit]"<br>
<b>Daring</b> — mis. "Membaca modul &amp; kuis di LMS [BT+BM: 2x60 menit]" / "Diskusi forum LMS [BT+BM: 2x60 menit]"<br>
<b>Kegiatan Pembelajaran</b> — mis. "Mahasiswa menyimak paparan dosen, berdiskusi, dan mengerjakan latihan terkait materi minggu ini."<br>
<b>Materi [Pustaka]</b> — rujuk nomor pustaka di Bagian H, mis. "Bab 3 (Pustaka Utama #1)"
</div>
</details>
{% endif %}
<div class="gulir"> <div class="gulir">
<table class="grid" id="tabel-f"> <table class="grid" id="tabel-f">
<thead> <thead>
@@ -122,14 +140,20 @@
<tr class="{% if m.ke == 8 %}uts{% elif m.ke == 16 %}uas{% endif %}"> <tr class="{% if m.ke == 8 %}uts{% elif m.ke == 16 %}uas{% endif %}">
<td class="tengah">{{ m.ke }}</td> <td class="tengah">{{ m.ke }}</td>
<td class="isian"> <td class="isian">
<select name="m{{ m.ke }}_sub"> <select name="m{{ m.ke }}_sub" {% if boleh_edit %}onchange="document.getElementById('btn-isi-m{{ m.ke }}').style.display = this.value ? '' : 'none'"{% endif %}>
<option value="">— pilih —</option> <option value="">— pilih —</option>
{% for s in peta.subs %} {% for s in peta.subs %}
<option value="{{ s.kode }}" {% if m.sub == s.kode %}selected{% endif %}>{{ s.kode }} — {{ s.uraian[:60] }}{% if s.uraian|length > 60 %}…{% endif %}</option> <option value="{{ s.kode }}" {% if m.sub == s.kode %}selected{% endif %}>{{ s.kode }} — {{ s.uraian }}</option>
<option disabled>──────────────────────</option>
{% endfor %} {% endfor %}
<option value="__uts__" {% if m.sub == '__uts__' %}selected{% endif %}>Sub-CPMK minggu 17 (UTS)</option> <option value="__uts__" {% if m.sub == '__uts__' %}selected{% endif %}>Sub-CPMK minggu 17 (UTS)</option>
<option value="__uas__" {% if m.sub == '__uas__' %}selected{% endif %}>Sub-CPMK minggu 915 (UAS)</option> <option value="__uas__" {% if m.sub == '__uas__' %}selected{% endif %}>Sub-CPMK minggu 915 (UAS)</option>
</select> </select>
{% if boleh_edit %}
<button type="button" id="btn-isi-m{{ m.ke }}" class="tombol garis"
style="padding:1px 6px;font-size:10px;margin-top:4px;{% if not m.sub %}display:none{% endif %}"
onclick="isiOtomatis({{ m.ke }})" title="Isi kolom kosong dengan teks bantu dari Sub-CPMK terpilih">Isi Otomatis</button>
{% endif %}
</td> </td>
<td class="isian"><textarea name="m{{ m.ke }}_indikator" rows="2">{{ m.indikator }}</textarea></td> <td class="isian"><textarea name="m{{ m.ke }}_indikator" rows="2">{{ m.indikator }}</textarea></td>
<td class="isian"><textarea name="m{{ m.ke }}_kriteria" rows="2">{{ m.kriteria }}</textarea></td> <td class="isian"><textarea name="m{{ m.ke }}_kriteria" rows="2">{{ m.kriteria }}</textarea></td>
@@ -145,7 +169,10 @@
</div> </div>
<h2 class="bagian">G. REKAP BOBOT PENILAIAN <span id="total-bobot-g" style="font-weight:normal">— total: <b>0</b>%</span></h2> <h2 class="bagian">G. REKAP BOBOT PENILAIAN <span id="total-bobot-g" style="font-weight:normal">— total: <b>0</b>%</span></h2>
<p class="keterangan">Centang Sub-CPMK yang dinilai tiap komponen. Setiap Sub-CPMK harus tercakup minimal satu komponen agar CPMK/CPL-nya terukur.</p> <p class="keterangan">Enam komponen teknik asesmen baku prodi. Bobot boleh dikosongkan untuk
komponen yang tidak dipakai MK ini (opsional), tapi total seluruh komponen yang diisi harus
tepat 100%. Centang Sub-CPMK yang dinilai tiap komponen; setiap Sub-CPMK harus tercakup minimal
satu komponen agar CPMK/CPL-nya terukur.</p>
<div class="gulir"> <div class="gulir">
<table class="grid"> <table class="grid">
<thead> <thead>
@@ -155,11 +182,11 @@
{% for k in f.komponen %} {% for k in f.komponen %}
{% set ki = loop.index %} {% set ki = loop.index %}
<tr> <tr>
<td class="isian"><input type="text" name="k{{ ki }}_nama" value="{{ k.nama }}"></td> <td>{{ k.nama }}</td>
<td> <td>
<div class="pilih-sub"> <div class="pilih-sub">
{% for s in peta.subs %} {% for s in peta.subs %}
<label><input type="checkbox" class="sub-k" name="k{{ ki }}_subs" value="{{ s.kode }}" {% if s.kode in k.subs %}checked{% endif %}>{{ s.kode }} — {{ s.uraian[:70] }}{% if s.uraian|length > 70 %}…{% endif %}</label> <label><input type="checkbox" class="sub-k" name="k{{ ki }}_subs" value="{{ s.kode }}" {% if s.kode in k.subs %}checked{% endif %}>{{ s.kode }} — {{ s.uraian }}</label>
{% endfor %} {% endfor %}
</div> </div>
</td> </td>
@@ -194,6 +221,34 @@
</form> </form>
<script> <script>
const SUB_URAIAN = {{ sub_uraian | tojson | safe }};
function isiOtomatis(mg) {
const sel = document.querySelector(`select[name="m${mg}_sub"]`);
const nilai = sel ? sel.value : '';
const isi = (nama, teks) => {
const el = document.querySelector(`[name="m${mg}_${nama}"]`);
if (el && !el.value.trim()) el.value = teks;
};
if (SUB_URAIAN[nilai]) {
const uraian = SUB_URAIAN[nilai];
isi('indikator', 'Mahasiswa ' + uraian.charAt(0).toLowerCase() + uraian.slice(1));
} else if (nilai === '__uts__') {
isi('indikator', 'Mahasiswa menguasai capaian Sub-CPMK minggu 17.');
} else if (nilai === '__uas__') {
isi('indikator', 'Mahasiswa menguasai capaian Sub-CPMK minggu 915.');
} else {
return; // belum pilih Sub-CPMK — tak ada dasar untuk mengisi
}
isi('kriteria', 'Ketepatan dan kelengkapan jawaban/tugas sesuai rubrik penilaian.');
isi('metode', 'Ceramah, diskusi, dan tanya jawab [TM: 2x50 menit]');
isi('daring', 'Membaca materi & berdiskusi di LMS [BT+BM: 2x60 menit]');
isi('kegiatan', 'Mahasiswa menyimak penjelasan dosen, berdiskusi, dan mengerjakan latihan terkait materi minggu ini.');
// bobot: usulan awal (UTS/UAS lazim lebih besar) — total tetap harus disesuaikan ke 100%
isi('bobot', mg == 8 ? '20' : mg == 16 ? '30' : '3.5');
segarkan();
}
function hitungTotal(kelas, idLabel) { function hitungTotal(kelas, idLabel) {
let t = 0; let t = 0;
document.querySelectorAll(kelas).forEach(el => { t += parseFloat(el.value) || 0; }); document.querySelectorAll(kelas).forEach(el => { t += parseFloat(el.value) || 0; });