Tambah Analisis Proses Asesmen, Kelompok Capaian, riwayat nilai terstruktur, dan perbaikan bug CPL

- Portofolio: section "Analisis Proses Asesmen" (dropdown tertutup default) —
  distribusi huruf mutu, kelompok capaian (mean ± SD), Sub-CPMK terlemah/terkuat,
  kesenjangan rencana vs realisasi, mahasiswa berisiko, tren lintas tahun ajaran
- Portofolio: Nomor Dokumen & Tanggal Terbit terisi otomatis; placeholder contoh
  di Realisasi Pembelajaran dan Kendala & Tindak Lanjut; tombol "Isi Otomatis"
  merangkai draf Simpulan dari analisis + realisasi + kendala
- Nilai: tabel Kelompok Capaian (Atas/Tengah/Bawah, shading warna) dipindah ke
  dropdown "Detail Tambahan" bersama Skala Poin/Rincian Bobot/Hasil Ketercapaian;
  tabel utama tak lagi ada scroll vertikal internal
- Nilai: riwayat pengisian dicatat terstruktur (waktu, dosen, aksi, komponen),
  tanpa batas 8 entri, dengan ringkasan per komponen + dropdown Log Lengkap —
  mendukung akuntabilitas team teaching
- Fix bug data: seed kurikulum menyimpan 'cpl' CPMK sebagai string tunggal,
  bukan list, sehingga pecah per huruf saat di-join() di tabel Hasil
  Ketercapaian/Rekap (mis. "CPL-03" -> "C, P, L, -, 0, 3"); dinormalisasi di
  db.py (runtime) dan tools/extract_seed.py (agar re-seed berikutnya benar)
- Hover tooltip deskripsi lengkap di kode Sub-CPMK/CPMK/CPL (halaman Nilai
  dan Rekap CPL/CPMK)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Power BI Dev
2026-08-08 00:09:57 +07:00
parent af369883f3
commit 5d7ab16733
8 changed files with 491 additions and 37 deletions
+62 -8
View File
@@ -58,8 +58,8 @@
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 perubahan:
{{ tersimpan.data.sumber if tersimpan.data.sumber is string else tersimpan.data.sumber|join(' · ') }}.{% endif %}
{% if riwayat_ringkas %}<br>Ringkasan pengisian — team teaching:
{% for k in riwayat_ringkas %}{{ k.komponen }}: {% for d in k.per_dosen %}{{ d.n }}&times; {{ d.nama }}{{ ', ' if not loop.last }}{% endfor %}{{ ' · ' if not loop.last }}{% endfor %}.{% 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.')">
<button class="tombol garis" type="submit" name="aksi" value="hapus"
@@ -68,13 +68,34 @@
</div>
{% endif %}
{% if riwayat %}
<details class="cara-alt" style="margin:16px 0">
<summary style="font-size:13px">Log Lengkap Pengisian Nilai ({{ riwayat|length }} entri)</summary>
<div class="gulir" style="margin-top:8px;max-height:320px;overflow-y:auto">
<table class="grid">
<thead><tr><th style="width:140px">Waktu</th><th>Dosen</th><th>Aksi</th><th style="width:140px">Komponen</th></tr></thead>
<tbody>
{% for r in riwayat %}
<tr>
<td>{{ r.at or '—' }}</td>
<td>{{ r.by or '—' }}</td>
<td>{{ r.aksi }}</td>
<td>{{ r.komponen or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</details>
{% endif %}
{% if grid and tabel_komponen and komponen_daftar %}
<h2 class="bagian">NILAI PER KOMPONEN <span style="font-weight:normal;font-style:italic;color:var(--grey)">— nilai mahasiswa berdasarkan komponen</span></h2>
<p class="keterangan">Angka = rata-rata skor mentah tiap sesi/tugas komponen itu yang diikuti mahasiswa (bukan lewat Sub-CPMK). Ketik untuk menimpa —
ditulis rata ke semua Sub-CPMK komponen itu; rincian per Sub-CPMK tetap presisi di grid bawah.</p>
<form method="post" action="/nilai/{{ peta.kode }}">
<input type="hidden" name="aksi" value="komponen">
<div class="gulir" style="max-height:480px;overflow-y:auto">
<div class="gulir">
<table class="grid">
<thead>
<tr>
@@ -120,7 +141,7 @@
{% set per_mhs = tersimpan and tersimpan.data.mode == 'per_mahasiswa' %}
<h2 class="bagian">NILAI PER MAHASISWA <span style="font-weight:normal;font-style:italic;color:var(--grey)">— nilai mahasiswa berdasarkan Sub-CPMK</span></h2>
<form method="post" action="/nilai/{{ peta.kode }}">
<div class="gulir" style="max-height:480px;overflow-y:auto">
<div class="gulir">
{% set ada_kelas = grid|selectattr('kelas')|list|length > 0 %}
{% set n_kiri = 4 if ada_kelas else 3 %}
<table class="grid">
@@ -187,11 +208,40 @@
</form>
{% endif %}
{% if kelompok or tabel_poin or bobot_komponen or hasil %}
<details class="cara-alt" style="margin-top:26px;border:1px solid #c9c9c9;padding:10px 14px">
<summary>DETAIL TAMBAHAN — Kelompok Capaian, Skala Poin, Rincian Bobot &amp; Hasil Ketercapaian</summary>
{% if kelompok %}
<h2 class="bagian">KELOMPOK CAPAIAN — ATAS / TENGAH / BAWAH <span style="font-weight:normal;font-style:italic;color:var(--grey)">(acuan norma: mean &plusmn; SD kelas ini, bukan ambang kelulusan)</span></h2>
<p class="keterangan">Batas: Atas &gt; {{ '%.1f'|format(kelompok.batas_atas) }} · Bawah &lt; {{ '%.1f'|format(kelompok.batas_bawah) }} — untuk melihat sebaran capaian relatif dalam kelas ini, pelengkap tabel Hasil Ketercapaian (acuan ambang tetap) di bawah.</p>
<div class="gulir">
<table class="grid" style="max-width:540px">
<thead><tr><th style="width:44px" class="tengah">No.</th><th style="width:112px">NIM</th><th>Nama</th><th class="tengah" style="width:90px">Nilai Akhir</th><th class="tengah" style="width:80px">Kelompok</th></tr></thead>
<tbody>
{% set ns = namespace(no=0) %}
{% for label, kelas, warna, daftar in [('Atas', 'kelompok-atas', '#205020', kelompok.atas), ('Tengah', 'kelompok-tengah', '#7a5c00', kelompok.tengah), ('Bawah', 'kelompok-bawah', 'var(--red)', kelompok.bawah)] %}
{% for m in daftar %}
{% set ns.no = ns.no + 1 %}
<tr class="{{ kelas }}">
<td class="tengah">{{ ns.no }}</td>
<td>{{ m.nim }}</td>
<td>{{ m.nama }}</td>
<td class="tengah">{{ '%.1f'|format(m.akhir) }}</td>
<td class="tengah" style="color:{{ warna }};font-weight:bold">{{ label }}</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if tabel_poin %}
{% set ada_kelas_p = tabel_poin|selectattr('kelas')|list|length > 0 %}
{% set n_kiri_p = 4 if ada_kelas_p else 3 %}
<h2 class="bagian">NILAI PER MAHASISWA — SKALA POIN <span style="font-weight:normal;font-style:italic;color:var(--grey)">— nilai yang sama, ditampilkan sebagai poin yang didapat dari total 100 (lihat rincian bobot di bawah); hanya tampilan, bukan isian</span></h2>
<div class="gulir" style="max-height:480px;overflow-y:auto">
<div class="gulir">
<table class="grid">
<thead>
<tr>
@@ -278,7 +328,7 @@
{% for s in peta.subs %}
{% set info = hasil.sub.get(s.kode) %}
<tr>
<td>{{ s.kode }}</td>
<td><span class="kode-hover" title="{{ s.uraian }}">{{ s.kode }}</span></td>
<td class="tengah">{% if info and info.rata is not none %}<b>{{ '%.1f'|format(info.rata) }}</b>{% else %}—{% endif %}</td>
<td class="tengah">100</td>
<td class="tengah">
@@ -298,8 +348,9 @@
<thead><tr><th>CPMK</th><th class="tengah">Skor</th><th class="tengah">Sub Ternilai</th><th class="tengah">% Ternilai</th><th>CPL Didukung</th></tr></thead>
<tbody>
{% for kode_c, v in hasil.cpmk.items() %}
{% set cm = peta.cpmks|selectattr('kode', 'equalto', kode_c)|first %}
<tr>
<td>{{ kode_c }}</td>
<td><span class="kode-hover" title="{{ cm.desk if cm else '' }}">{{ kode_c }}</span></td>
<td class="tengah">{% if v.skor is not none %}<b>{{ '%.2f'|format(v.skor) }}</b>{% else %}—{% endif %}</td>
<td class="tengah">{{ v.n_ternilai }}/{{ v.n_sub }}</td>
<td class="tengah">{{ '%.0f'|format(100 * v.n_ternilai / v.n_sub) ~ '%' if v.n_sub else '—' }}</td>
@@ -314,8 +365,9 @@
<thead><tr><th>CPL</th><th class="tengah">Skor</th><th class="tengah">% CPMK Ternilai</th><th class="tengah">Status</th></tr></thead>
<tbody>
{% for kode_c, v in hasil.cpl.items() %}
{% set cp = peta.cpls|selectattr('kode', 'equalto', kode_c)|first %}
<tr>
<td>{{ kode_c }}</td>
<td><span class="kode-hover" title="{{ cp.desk if cp else '' }}">{{ kode_c }}</span></td>
<td class="tengah">{% if v.skor is not none %}<b>{{ '%.2f'|format(v.skor) }}</b>{% else %}—{% endif %}</td>
<td class="tengah">{{ '%.0f'|format(100 * v.n_cpmk_ternilai / v.n_cpmk) ~ '%' if v.n_cpmk else '—' }}</td>
<td class="tengah">
@@ -329,4 +381,6 @@
</table>
</div>
{% endif %}
</details>
{% endif %}
{% endblock %}
+139 -4
View File
@@ -30,6 +30,90 @@
<div class="info">RPS &#10003; dan Nilai &#10003; tersedia — tabel korelasi, penilaian, evaluasi ketercapaian, dan statistik akan diisi otomatis di dokumen. Anda hanya melengkapi bagian di bawah.</div>
{% endif %}
{% if analisis %}
<details class="cara-alt" style="margin-top:26px;border:1px solid #c9c9c9;padding:10px 14px">
<summary>ANALISIS PROSES ASESMEN {% if analisis.berisiko %}<span style="color:var(--red);font-weight:normal;font-size:13px">— {{ analisis.berisiko|length }} mahasiswa berisiko</span>{% endif %}</summary>
<p class="keterangan">Dihitung otomatis dari RPS + Nilai tersimpan — bahan diskusi sebelum menulis Realisasi &amp; Simpulan di bawah. Hanya tampil di layar, tidak ikut ke dokumen unduhan.</p>
<div class="perhatian-kisi">
<div>
<p class="keterangan" style="margin-top:0">Distribusi huruf mutu{% if analisis.statistik %} ({{ analisis.statistik.n }} mahasiswa){% endif %}</p>
{% if analisis.statistik %}
<table class="grid">
<thead><tr><th>Huruf</th><th class="tengah">Jumlah</th><th class="tengah">%</th></tr></thead>
<tbody>
{% for h, n in analisis.statistik.frekuensi.items() %}
<tr><td>{{ h }}</td><td class="tengah">{{ n }}</td>
<td class="tengah">{{ '%.0f'|format(100 * n / analisis.statistik.n) }}%</td></tr>
{% endfor %}
</tbody>
</table>
<p class="keterangan">Rata-rata {{ '%.1f'|format(analisis.statistik.mean) }} · median {{ '%.1f'|format(analisis.statistik.median) }} · rentang {{ '%.1f'|format(analisis.statistik.min) }}&ndash;{{ '%.1f'|format(analisis.statistik.max) }}</p>
{% if analisis.kelompok %}
<p class="keterangan">Kelompok capaian (mean &plusmn; SD, batas {{ '%.1f'|format(analisis.kelompok.batas_bawah) }}&ndash;{{ '%.1f'|format(analisis.kelompok.batas_atas) }}):
<b style="color:#205020">Atas {{ analisis.kelompok.atas|length }}</b> ·
Tengah {{ analisis.kelompok.tengah|length }} ·
<b style="color:var(--red)">Bawah {{ analisis.kelompok.bawah|length }}</b>{% if analisis.kelompok.bawah %} ({{ analisis.kelompok.bawah|map(attribute='nama')|join(', ') }}){% endif %}</p>
{% endif %}
{% else %}
<p class="keterangan">Nilai masih mode agregat (rata-rata kelas) — distribusi huruf per mahasiswa tidak bisa dihitung.</p>
{% endif %}
</div>
<div>
<p class="keterangan" style="margin-top:0">Sub-CPMK terlemah &amp; terkuat</p>
{% if analisis.sub_urut %}
<table class="grid">
<thead><tr><th>Sub-CPMK</th><th class="tengah">Skor</th><th class="tengah">Lulus %</th></tr></thead>
<tbody>
{% for s in analisis.sub_urut %}
<tr><td>{{ s.kode }} — {{ s.uraian }}</td>
<td class="tengah"{% if s.rata < analisis.ambang %} style="color:var(--red)"{% endif %}>{{ '%.1f'|format(s.rata) }}</td>
<td class="tengah">{{ '%.0f'|format(s.lulus_pct) if s.lulus_pct is not none else '—' }}{% if s.lulus_pct is not none %}%{% endif %}</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="keterangan">Belum ada Sub-CPMK ternilai.</p>
{% endif %}
</div>
</div>
{% if analisis.kesenjangan %}
<p class="keterangan" style="margin-top:14px"><b>Kesenjangan rencana vs realisasi:</b> {{ analisis.kesenjangan|length }} Sub-CPMK diberi bobot di RPS tapi belum ada nilai masuk — {{ analisis.kesenjangan|join(', ') }}.</p>
{% endif %}
{% if analisis.berisiko %}
<p class="keterangan" style="margin-top:14px">Mahasiswa berisiko — &ge;2 Sub-CPMK di bawah ambang ({{ analisis.ambang|int }}), atau nilai akhir sendiri di bawahnya</p>
<table class="grid">
<thead><tr><th>NIM</th><th>Nama</th><th class="tengah">Sub-CPMK di bawah ambang</th><th class="tengah">Nilai Akhir</th></tr></thead>
<tbody>
{% for m in analisis.berisiko %}
<tr><td>{{ m.nim }}</td><td>{{ m.nama }}</td>
<td class="tengah">{{ m.n_dibawah }}/{{ m.total_sub }}</td>
<td class="tengah" style="color:var(--red)">{{ '%.1f'|format(m.akhir) if m.akhir is not none else '—' }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if analisis.tren %}
<p class="keterangan" style="margin-top:14px">Tren MK ini lintas tahun ajaran — apakah capaian membaik atau tidak</p>
<table class="grid">
<thead><tr><th>Tahun Ajaran</th><th class="tengah">Rata-rata</th><th class="tengah">% Kelulusan</th><th class="tengah">n Mahasiswa</th></tr></thead>
<tbody>
{% for t in analisis.tren %}
<tr{% if t.tahun == tahun %} class="total"{% endif %}>
<td>{{ t.tahun }}</td>
<td class="tengah">{{ '%.1f'|format(t.mean) if t.mean is not none else '—' }}</td>
<td class="tengah">{{ '%.0f'|format(t.lulus_pct) if t.lulus_pct is not none else '—' }}{% if t.lulus_pct is not none %}%{% endif %}</td>
<td class="tengah">{{ t.n if t.n is not none else '—' }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</details>
{% endif %}
<form method="post" action="/portofolio/{{ peta.kode }}">
<h2 class="bagian">IDENTITAS DOKUMEN (LEMBAR PENGESAHAN)</h2>
<table class="grid">
@@ -49,24 +133,34 @@
<h2 class="bagian">REALISASI PEMBELAJARAN</h2>
<p class="keterangan">Ceritakan pelaksanaan nyata semester ini: penyesuaian jadwal/materi/metode terhadap rencana di RPS, kehadiran, dan hal penting lain.</p>
<textarea name="realisasi" rows="6">{{ f.realisasi }}</textarea>
<textarea name="realisasi" rows="6" placeholder="Contoh:&#10;- Materi minggu 56 (rekursi) tertunda 1 minggu karena libur nasional, digabung jadi 1 pertemuan&#10;- Praktikum awalnya direncanakan luring, dialihkan ke daring minggu 34 karena lab dalam perbaikan&#10;- Rata-rata kehadiran mahasiswa, jumlah pertemuan efektif dari 16 minggu rencana&#10;- Metode yang berubah di lapangan (mis. tugas kelompok jadi individu karena jumlah mahasiswa sedikit)">{{ f.realisasi }}</textarea>
<h2 class="bagian">KENDALA &amp; TINDAK LANJUT</h2>
{% set contoh_kendala = [
"Banyak mahasiswa kesulitan di konsep rekursi (Sub-CPMK X)",
"Partisipasi diskusi daring rendah",
"Jadwal praktikum bentrok dengan MK lain"] %}
{% set contoh_tindak = [
"Ditambah sesi responsi/tutorial tambahan minggu 9",
"Kuis singkat wajib tiap sesi untuk dorong keterlibatan",
"Diusulkan penyesuaian jadwal ke prodi semester depan"] %}
<table class="grid">
<thead><tr><th style="width:50px" class="tengah">No.</th><th>Kendala</th><th>Tindak Lanjut</th></tr></thead>
<tbody>
{% for k in f.kendala[:5] %}
<tr>
<td class="tengah">{{ loop.index }}</td>
<td class="isian"><textarea name="kendala_{{ loop.index }}" rows="2">{{ k.kendala }}</textarea></td>
<td class="isian"><textarea name="tindak_{{ loop.index }}" rows="2">{{ k.tindak }}</textarea></td>
<td class="isian"><textarea name="kendala_{{ loop.index }}" rows="2" placeholder="{{ contoh_kendala[loop.index0] if loop.index0 < contoh_kendala|length else '' }}">{{ k.kendala }}</textarea></td>
<td class="isian"><textarea name="tindak_{{ loop.index }}" rows="2" placeholder="{{ contoh_tindak[loop.index0] if loop.index0 < contoh_tindak|length else '' }}">{{ k.tindak }}</textarea></td>
</tr>
{% endfor %}
</tbody>
</table>
<h2 class="bagian">SIMPULAN HASIL EVALUASI</h2>
<p class="keterangan">Statistik nilai, frekuensi huruf mutu, dan tabel ketercapaian CPL/CPMK dihitung dan dicetak otomatis; tuliskan simpulan naratifnya di sini.</p>
<p class="keterangan">Statistik nilai, frekuensi huruf mutu, dan tabel ketercapaian CPL/CPMK dihitung dan dicetak otomatis; tuliskan simpulan naratifnya di sini.
{% if boleh_edit %}<button type="button" class="tombol garis" style="padding:2px 10px;font-size:12px" onclick="isiSimpulanOtomatis()">Isi Otomatis</button>
merangkai draf dari Analisis Proses Asesmen, Realisasi Pembelajaran, dan Kendala &amp; Tindak Lanjut di atas — cek &amp; lengkapi sebelum menyimpan.{% endif %}</p>
<textarea name="simpulan" rows="4">{{ f.simpulan }}</textarea>
<div class="aksi-baris">
@@ -80,4 +174,45 @@
{% endif %}
</div>
</form>
<script>
const SIMPULAN_SARAN = {% if analisis %}{{ analisis.simpulan_saran | tojson | safe }}{% else %}null{% endif %};
// ambil kalimat pertama (maks `maks` karakter) dari teks bebas — dipakai
// untuk mengutip cuplikan Realisasi/Kendala ke dalam draf Simpulan
function ringkas(teks, maks) {
maks = maks || 160;
const kalimat = teks.match(/^.*?[.!?](?=\s|$)/);
let dasar = kalimat ? kalimat[0].trim() : teks.trim();
if (dasar.length > maks) dasar = teks.trim().slice(0, maks).trim() + '…';
return /[.!?…]$/.test(dasar) ? dasar : dasar + '.';
}
function isiSimpulanOtomatis() {
const bagian = [];
if (SIMPULAN_SARAN) bagian.push(SIMPULAN_SARAN);
const elRealisasi = document.querySelector('[name="realisasi"]');
const realisasi = elRealisasi ? elRealisasi.value.trim() : '';
if (realisasi) bagian.push('Dari sisi pelaksanaan pembelajaran: ' + ringkas(realisasi));
const kendalaTeks = [];
document.querySelectorAll('[name^="kendala_"]').forEach(el => {
const v = el.value.trim();
if (v) kendalaTeks.push(v);
});
if (kendalaTeks.length) {
const label = kendalaTeks.length > 1
? kendalaTeks.length + ' kendala tercatat, utamanya'
: 'Kendala utama yang tercatat';
bagian.push(label + ': ' + ringkas(kendalaTeks[0])
+ ' Tindak lanjutnya sudah direncanakan pada tabel Kendala & Tindak Lanjut di atas.');
}
if (!bagian.length) {
bagian.push('Belum ada data Analisis Proses Asesmen, Realisasi Pembelajaran, atau Kendala untuk dirangkai — isi bagian-bagian tersebut dulu, lalu klik Isi Otomatis lagi.');
}
document.querySelector('textarea[name="simpulan"]').value = bagian.join(' ');
}
</script>
{% endblock %}
+3 -3
View File
@@ -30,7 +30,7 @@
<tbody>
{% for c in cpl_urut %}
<tr>
<td class="tengah"><b>{{ c.kode }}</b></td>
<td class="tengah"><b class="kode-hover" title="{{ c.desk }}">{{ c.kode }}</b></td>
<td>{{ c.desk[:130] }}{% if c.desk|length > 130 %}…{% endif %}</td>
<td class="tengah">{% if c.skor is not none %}<b>{{ '%.2f'|format(c.skor) }}</b>{% else %}—{% endif %}</td>
<td class="tengah">
@@ -66,8 +66,8 @@
<tbody>
{% for c in cpmk_urut %}
<tr>
<td class="tengah">{{ c.kode }}</td>
<td class="tengah">{{ c.cpl|join(', ') }}</td>
<td class="tengah"><span class="kode-hover" title="{{ desk_cpmk.get(c.kode, '') }}">{{ c.kode }}</span></td>
<td class="tengah">{% for cp in c.cpl %}<span class="kode-hover" title="{{ desk_cpl.get(cp, '') }}">{{ cp }}</span>{{ ', ' if not loop.last }}{% endfor %}</td>
<td class="tengah">{% if c.skor is not none %}<b>{{ '%.2f'|format(c.skor) }}</b>{% else %}—{% endif %}</td>
<td class="tengah">{{ c.n_setor }}/{{ c.n_pemikul }}</td>
<td>{% if c.mk_terlemah %}{{ nama_mk.get(c.mk_terlemah[0], c.mk_terlemah[0]) }} ({{ '%.1f'|format(c.mk_terlemah[1]) }}){% else %}—{% endif %}</td>