Tambah ringkasan nilai per komponen, rincian tugas ala RTM, auto-save RPS
- Halaman Nilai: tabel "Nilai per Komponen" (rata-rata skor mentah tiap sesi, bisa diedit langsung) berdampingan dengan grid Sub-CPMK yang tetap presisi; kolom persentase ternilai di Hasil Ketercapaian. - Komponen Tugas: sesi bisa menyasar subset Sub-CPMK (RPS tetap batasannya), lengkap dengan field ala RTM (bentuk tugas, deskripsi, metode, luaran, indikator/bobot, jadwal) dan tombol isi-otomatis per bentuk tugas. - RPS: auto-save berkala sebagai draft, tombol ke atas, validasi total bobot komponen 100% saat ditandai selesai, komponen berbobot kosong disembunyikan dari halaman Nilai. - Admin: tampilkan kelas yang tersedia dari data mahasiswa saat mengatur penugasan dosen per kelas. - Perbaikan step="any" pada input nilai (mencegah validasi HTML5 menolak nilai desimal hasil roll-up). - Tambah METODOLOGI_ASESMEN_KOMPONEN.md: dokumentasi metodologi Sub-CPMK vs komponen, termasuk skenario tugas menyasar sebagian Sub-CPMK. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,234 @@
|
|||||||
|
# Metodologi Asesmen: Sub-CPMK vs Komponen
|
||||||
|
|
||||||
|
Dokumen ini menjelaskan **dua sudut pandang nilai** di halaman Input Nilai
|
||||||
|
(`/nilai/<kode>`) — bagaimana keduanya dihitung, bagaimana keduanya berbeda,
|
||||||
|
dan bagaimana pembagian bobot bekerja ketika satu komponen (mis. Tugas) diisi
|
||||||
|
lewat beberapa sesi yang masing-masing menyasar sebagian Sub-CPMK saja.
|
||||||
|
|
||||||
|
Pelengkap `RANCANGAN.md §7` (roll-up CPMK/CPL tingkat prodi) dan halaman
|
||||||
|
`/metodologi` (dokumentasi resmi untuk asesor) — dokumen ini fokus ke lapisan
|
||||||
|
**di dalam satu MK**: dari nilai mentah yang diketik dosen, sampai ke skor
|
||||||
|
Sub-CPMK yang jadi bahan roll-up CPMK/CPL.
|
||||||
|
|
||||||
|
## 1. Dua tampilan, satu sumber data
|
||||||
|
|
||||||
|
| | **Nilai per Mahasiswa** (berdasarkan Sub-CPMK) | **Nilai per Komponen** (berdasarkan komponen) |
|
||||||
|
|---|---|---|
|
||||||
|
| Kolom tabel | Tiap Sub-CPMK (Sub-051-1, Sub-051-2, ...) | Tiap komponen (Kuis, Tugas, UTS, ...) |
|
||||||
|
| Isi sel | Skor Sub-CPMK — presisi, boleh beda-beda dalam satu komponen | Rata-rata skor **mentah** tiap sesi/instrumen komponen itu yang diikuti mahasiswa |
|
||||||
|
| Dipakai untuk roll-up CPMK/CPL? | **Ya** — ini sumber kebenaran satu-satunya | **Tidak** — murni ringkasan & alat edit cepat |
|
||||||
|
| Efek saat diedit | Menulis persis ke Sub-CPMK yang diketik | Menulis nilai yang sama ke **semua** Sub-CPMK komponen itu (menimpa presisi bila sebelumnya beda-beda) |
|
||||||
|
|
||||||
|
Intinya: **Sub-CPMK adalah satuan ukur OBE yang sesungguhnya.** "Komponen" (Kuis,
|
||||||
|
Tugas, UTS, dst.) hanyalah *label pengelompokan instrumen penilaian* — satu
|
||||||
|
komponen bisa mengukur beberapa Sub-CPMK sekaligus, dan satu Sub-CPMK bisa
|
||||||
|
diukur oleh lebih dari satu komponen. "Nilai per Komponen" cuma jendela lain
|
||||||
|
untuk melihat/mengedit data yang sama, dari sudut instrumen bukan dari sudut
|
||||||
|
capaian.
|
||||||
|
|
||||||
|
## 2. Alur data lengkap
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
ISI["Isi Nilai per komponen\n(sesi bernama; Tugas boleh pilih\nsubset Sub-CPMK per sesi)"] --> SESI
|
||||||
|
GRID["Nilai per Mahasiswa\n(ketik langsung per Sub-CPMK)"] --> SK
|
||||||
|
SESI["sesi[komponen]\n= daftar sesi mentah tiap komponen\n(nim → skor, subset Sub-CPMK opsional)"]
|
||||||
|
SESI <--> KOMP["Nilai per Komponen\n(tampil = rata-rata sesi;\nketik = sesi baru 'Input Cepat')"]
|
||||||
|
|
||||||
|
SESI --> H["skor_dari_sesi()\nbagi kontribusi per Sub-CPMK,\ntertimbang bobot_komponen ÷ n_subs_komponen"]
|
||||||
|
H --> SK["Skor Sub-CPMK per mahasiswa\n(mahasiswa[].skor — SUMBER KEBENARAN)"]
|
||||||
|
|
||||||
|
SK --> CPMK["rollup_mk(): Skor CPMK\n= rata-rata Sub-CPMK pendukung"]
|
||||||
|
CPMK --> CPL["Skor CPL (dalam MK)\n= rata-rata CPMK pendukung"]
|
||||||
|
CPMK --> PRODI["rollup_prodi(): CPMK/CPL tingkat prodi\n(lintas semua MK pemikul)"]
|
||||||
|
SK --> AKHIR["Nilai Akhir mahasiswa\n= Σ(bobot_sub × skor_sub) / Σ bobot_sub\n(bobot dari RPS, bagi rata per komponen)"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Dua hal penting dari diagram ini:
|
||||||
|
|
||||||
|
1. **"Nilai per Komponen" bukan jalur data terpisah — ia baca/tulis ke sesi
|
||||||
|
yang sama** yang dipakai `skor_dari_sesi()`. Angka yang **ditampilkan** di
|
||||||
|
situ (rata-rata sesi) tidak dipakai langsung oleh rumus manapun — ia
|
||||||
|
sekadar ringkasan. Tapi begitu diedit (diketik ulang), perubahan itu
|
||||||
|
tercatat sebagai sesi baru "Input Cepat" dan **ikut dihitung** lewat jalur
|
||||||
|
yang sama seperti sesi bernama lain — jadi tetap satu sumber kebenaran,
|
||||||
|
bukan data ganda yang bisa tidak sinkron.
|
||||||
|
2. **"Nilai Akhir" (nilai huruf/angka mahasiswa) dan "Ketercapaian CPMK/CPL"
|
||||||
|
(OBE) adalah dua rumus berbeda** yang sama-sama diturunkan dari skor
|
||||||
|
Sub-CPMK, tapi dengan cara menimbang yang berbeda (lihat §6).
|
||||||
|
|
||||||
|
## 3. Komponen berulang vs sekali-jalan
|
||||||
|
|
||||||
|
| Tipe | Contoh | Bisa berapa sesi? | Sub-CPMK per sesi |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Berulang** | Kuis, Presentasi, Tes Lisan | Banyak — tiap sesi dirata-rata sederhana | Selalu semua Sub-CPMK komponen itu |
|
||||||
|
| **Berulang + subset** | **Tugas** (Observasi/Praktik) | Banyak, tiap sesi = satu tugas bernama | **Bisa dipilih sebagian** — lihat §4 |
|
||||||
|
| **Sekali-jalan** | UTS, UAS | Satu — mengisi lagi menimpa sesi yang sama | Selalu semua Sub-CPMK komponen itu |
|
||||||
|
|
||||||
|
Komponen **Tugas** adalah satu-satunya yang mendukung penyasaran Sub-CPMK per
|
||||||
|
sesi (checklist di form "Isi Nilai"), karena secara alami satu MK sering punya
|
||||||
|
beberapa tugas berbeda yang masing-masing menguji kemampuan (Sub-CPMK) yang
|
||||||
|
berbeda pula — beda dari Kuis/Presentasi yang biasanya satu instrumen menguji
|
||||||
|
semua Sub-CPMK yang sama tiap kali.
|
||||||
|
|
||||||
|
## 4. Sub-CPMK per sesi — bagaimana pembagiannya
|
||||||
|
|
||||||
|
Saat RPS menetapkan komponen Tugas mengukur, misalnya, **4 Sub-CPMK** dengan
|
||||||
|
bobot **20%**, checklist di form "Isi Nilai" **selalu dibatasi ke 4 Sub-CPMK
|
||||||
|
itu saja** (tak bisa menambah Sub-CPMK di luar yang RPS tetapkan) — bawaannya
|
||||||
|
semua tercentang, dosen tinggal **mengurangi** centang sesuai Sub-CPMK mana
|
||||||
|
yang benar-benar diuji tugas tsb.
|
||||||
|
|
||||||
|
**Aturan penggabungan kontribusi** (fungsi `skor_dari_sesi`):
|
||||||
|
|
||||||
|
- Untuk tiap Sub-CPMK di dalam komponen itu, sistem mencari **semua sesi**
|
||||||
|
yang menyasarnya (baik yang eksplisit memilihnya, atau sesi lama yang tak
|
||||||
|
punya pilihan subset sama sekali → dianggap menyasar semua Sub-CPMK
|
||||||
|
komponen).
|
||||||
|
- Sesi-sesi yang relevan itu dirata-rata sederhana dulu **per Sub-CPMK**
|
||||||
|
(bukan rata dulu antar-tugas lalu disebar rata — tiap Sub-CPMK punya rata-
|
||||||
|
ratanya sendiri dari tugas yang benar-benar mengukurnya).
|
||||||
|
- **Bobot komponen (20%) dibagi rata ke SEMUA 4 Sub-CPMK milik komponen itu**
|
||||||
|
(5% tiap Sub-CPMK) — pembagian ini memakai jumlah Sub-CPMK **komponen**,
|
||||||
|
bukan jumlah Sub-CPMK **sesi tertentu**. Bobot per-Sub-CPMK inilah yang
|
||||||
|
dipakai untuk menimbang kalau Sub-CPMK yang sama juga diukur komponen lain
|
||||||
|
(mis. Presentasi turut menilai Sub-052).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph RPS["RPS: Observasi (Praktik/Tugas) — bobot 20%, 4 Sub-CPMK"]
|
||||||
|
direction TB
|
||||||
|
RSub1["Sub-051-1"]
|
||||||
|
RSub2["Sub-051-2"]
|
||||||
|
RSub3["Sub-052-1"]
|
||||||
|
RSub4["Sub-052-2"]
|
||||||
|
end
|
||||||
|
T1["Tugas 1: Analisis Kasus\n(centang: Sub-051-1, Sub-051-2)"] --> RSub1
|
||||||
|
T1 --> RSub2
|
||||||
|
T2["Tugas 2: Proyek Mini\n(centang: Sub-052-1, Sub-052-2)"] --> RSub3
|
||||||
|
T2 --> RSub4
|
||||||
|
T3["Tugas 3: Presentasi Studi Kasus\n(centang: semua 4)"] --> RSub1
|
||||||
|
T3 --> RSub2
|
||||||
|
T3 --> RSub3
|
||||||
|
T3 --> RSub4
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Skenario nyata: tugas menyasar sebagian Sub-CPMK
|
||||||
|
|
||||||
|
Studi kasus dari MK Logika Komputasional — komponen **Observasi (Praktik/
|
||||||
|
Tugas)**, bobot RPS **20%**, mencakup 4 Sub-CPMK: `Sub-051-1`, `Sub-051-2`,
|
||||||
|
`Sub-052-1`, `Sub-052-2`.
|
||||||
|
|
||||||
|
> **Penyederhanaan untuk ilustrasi:** contoh di bawah mengasumsikan keempat
|
||||||
|
> Sub-CPMK itu **hanya** diukur komponen Tugas, supaya mekanisme pembagiannya
|
||||||
|
> kelihatan jelas. Di data MK ini yang sebenarnya, `Sub-051-1/2` juga ikut
|
||||||
|
> diukur komponen **Tes Tulis (UTS)** — lihat §5.1 untuk contoh gabungan
|
||||||
|
> lintas-komponen dengan angka riil.
|
||||||
|
|
||||||
|
**Tiga tugas dibuat sepanjang semester:**
|
||||||
|
|
||||||
|
| Sesi | Sub-CPMK disasar | Nilai per mahasiswa |
|
||||||
|
|---|---|---|
|
||||||
|
| Tugas 1: Analisis Kasus | Sub-051-1, Sub-051-2 | Andi 85 · Budi 92 |
|
||||||
|
| Tugas 2: Proyek Mini | Sub-052-1, Sub-052-2 | Citra 78 · Dedi 88 |
|
||||||
|
| Tugas 3: Presentasi Studi Kasus | Semua 4 Sub-CPMK | Andi 88 · Budi 90 · Citra 82 · Dedi 86 · Eka 79 |
|
||||||
|
|
||||||
|
**Hasil skor per Sub-CPMK** (rata-rata sederhana dari sesi yang relevan tiap
|
||||||
|
Sub-CPMK):
|
||||||
|
|
||||||
|
| Mahasiswa | Sub-051-1 / -2 | Sub-052-1 / -2 | Keterangan |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Andi | (85 + 88) / 2 = **86,5** | 88 (dari Tugas 3 saja) | Ikut Tugas 1 & 3 |
|
||||||
|
| Budi | (92 + 90) / 2 = **91** | 90 (dari Tugas 3 saja) | Ikut Tugas 1 & 3 |
|
||||||
|
| Citra | 82 (dari Tugas 3 saja) | (78 + 82) / 2 = **80** | Ikut Tugas 2 & 3 |
|
||||||
|
| Dedi | 86 (dari Tugas 3 saja) | (88 + 86) / 2 = **87** | Ikut Tugas 2 & 3 |
|
||||||
|
| Eka | 79 (dari Tugas 3 saja) | 79 (dari Tugas 3 saja) | Cuma ikut Tugas 3 |
|
||||||
|
|
||||||
|
Perhatikan: **Sub-051 dan Sub-052 milik satu mahasiswa boleh berbeda** — itu
|
||||||
|
tepat sesuai fakta bahwa Tugas 1 dan Tugas 2 mengukur kemampuan yang berbeda.
|
||||||
|
Ini yang dipakai untuk ketercapaian **CPMK051** dan **CPMK052** — masing-
|
||||||
|
masing presisi sesuai tugas yang benar-benar mengukurnya, bukan dicampur rata.
|
||||||
|
|
||||||
|
**Tampilan "Nilai per Komponen"** (rata-rata mentah tiap tugas yang diikuti,
|
||||||
|
independen dari Sub-CPMK):
|
||||||
|
|
||||||
|
| Mahasiswa | Tugas 1 | Tugas 2 | Tugas 3 | Rata-rata tampil |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Andi | 85 | — | 88 | (85+88)/2 = **86,5** |
|
||||||
|
| Budi | 92 | — | 90 | (92+90)/2 = **91** |
|
||||||
|
| Citra | — | 78 | 82 | (78+82)/2 = **80** |
|
||||||
|
| Dedi | — | 88 | 86 | (88+86)/2 = **87** |
|
||||||
|
| Eka | — | — | 79 | **79** |
|
||||||
|
|
||||||
|
Perhatikan Andi: "Nilai per Komponen"-nya **86,5** kebetulan sama dengan skor
|
||||||
|
Sub-051 (86,5), tapi **berbeda** dari skor Sub-052-nya (88) — karena kolom
|
||||||
|
"Nilai per Komponen" cuma satu angka gabungan dari *seluruh* tugas yang
|
||||||
|
diikuti (Tugas 1 + Tugas 3), tanpa mempedulikan Sub-CPMK mana yang disasar
|
||||||
|
tiap tugas. Pola yang sama berlaku untuk Budi, Citra, dan Dedi. Kalau kolom
|
||||||
|
ini **diketik ulang** (dijadikan override manual), nilai baru itu akan
|
||||||
|
ditulis **rata ke keempat Sub-CPMK** sekaligus — jadi presisi Sub-051 vs
|
||||||
|
Sub-052 akan hilang untuk mahasiswa itu kalau memang sengaja ditimpa.
|
||||||
|
|
||||||
|
### 5.1 Saat Sub-CPMK yang sama diukur >1 komponen (angka riil)
|
||||||
|
|
||||||
|
Di RPS MK ini, `Sub-051-1`/`Sub-051-2` sebenarnya diukur **dua komponen**:
|
||||||
|
Observasi (Praktik/Tugas) — bobot 20%, 4 Sub-CPMK — **dan** Tes Tulis (UTS)
|
||||||
|
— bobot 25%, 6 Sub-CPMK. Andi punya nilai UTS = 80 (sesi tunggal, berlaku ke
|
||||||
|
keenam Sub-CPMK UTS) selain nilai Tugas-nya di atas.
|
||||||
|
|
||||||
|
**Bobot kontribusi tiap komponen ke Sub-051** = bobot komponen ÷ jumlah
|
||||||
|
Sub-CPMK komponen itu (bukan jumlah Sub-CPMK yang disasar sesi tertentu):
|
||||||
|
|
||||||
|
```
|
||||||
|
w(Tugas) = 20% ÷ 4 Sub-CPMK = 5,0%
|
||||||
|
w(UTS) = 25% ÷ 6 Sub-CPMK = 4,1667%
|
||||||
|
```
|
||||||
|
|
||||||
|
**Skor Sub-051 Andi** = rata-rata tertimbang kedua kontribusi itu:
|
||||||
|
|
||||||
|
```
|
||||||
|
rata Tugas (Tugas 1 & 3) = (85 + 88) / 2 = 86,5
|
||||||
|
rata UTS = 80
|
||||||
|
|
||||||
|
skor Sub-051 = (5,0% × 86,5) + (4,1667% × 80)
|
||||||
|
────────────────────────────────
|
||||||
|
5,0% + 4,1667%
|
||||||
|
|
||||||
|
= (4,325 + 3,3333) / 0,091667 = 83,55
|
||||||
|
```
|
||||||
|
|
||||||
|
Ini **persis** angka yang muncul di grid Sub-CPMK aplikasi (83,5455 — beda
|
||||||
|
tipis karena pembulatan). Jadi kalau sebuah Sub-CPMK diukur lebih dari satu
|
||||||
|
komponen, komponen dengan **bobot lebih besar relatif terhadap jumlah
|
||||||
|
Sub-CPMK yang ditanggungnya** akan lebih dominan menentukan skor akhir
|
||||||
|
Sub-CPMK itu — bukan sekadar rata-rata polos antar komponen.
|
||||||
|
|
||||||
|
## 6. Nilai Akhir (huruf mutu) vs Ketercapaian CPL/CPMK (OBE)
|
||||||
|
|
||||||
|
Dua angka yang **kelihatannya mirip** tapi rumusnya beda tujuan:
|
||||||
|
|
||||||
|
| | **Nilai Akhir mahasiswa** | **Ketercapaian CPMK/CPL** |
|
||||||
|
|---|---|---|
|
||||||
|
| Untuk apa | Nilai/huruf mutu mahasiswa (transkrip) | Evaluasi kurikulum (OBE) |
|
||||||
|
| Rumus | Σ(bobot Sub-CPMK × skor mahasiswa) / Σ bobot — bobot dari RPS, dibagi rata per Sub-CPMK dalam tiap komponen | Rata-rata sederhana Sub-CPMK → CPMK → CPL (bagi rata, tanpa bobot RPS) |
|
||||||
|
| Level | Per mahasiswa, per MK | Per angkatan/kelas, lintas MK (tingkat prodi) |
|
||||||
|
| Halaman | Kolom "Nilai Akhir" di form Nilai | Halaman Rekap CPL/CPMK, Dasbor Monitoring |
|
||||||
|
|
||||||
|
Keduanya bersumber dari **skor Sub-CPMK yang sama** — bedanya cuma di cara
|
||||||
|
menimbang saat digulung ke atas.
|
||||||
|
|
||||||
|
## 7. Ringkasan rumus
|
||||||
|
|
||||||
|
```
|
||||||
|
skor Sub-CPMK = rata-rata sesi yang menyasarnya (lintas komponen,
|
||||||
|
tertimbang bobot_komponen ÷ n_subs_komponen)
|
||||||
|
Nilai Akhir = Σ(bobot_sub × skor_sub) / Σ bobot_sub
|
||||||
|
Skor CPMK (dlm MK) = rata-rata skor Sub-CPMK pendukung
|
||||||
|
Skor CPL (dlm MK) = rata-rata skor CPMK pendukung
|
||||||
|
Skor CPMK (prodi) = rata-rata skor CPMK itu lintas semua MK pemikul
|
||||||
|
Skor CPL (prodi) = rata-rata skor CPMK pendukung, lintas prodi
|
||||||
|
Nilai per Komponen = rata-rata skor MENTAH sesi komponen itu yang diikuti
|
||||||
|
mahasiswa (tampilan/edit cepat — tidak dipakai di
|
||||||
|
rumus manapun di atas secara langsung; edit di sini
|
||||||
|
menulis balik ke skor Sub-CPMK secara rata)
|
||||||
|
```
|
||||||
+25
-20
@@ -98,26 +98,20 @@ def skor_dari_sesi(komponen_daftar: list[dict], sesi: dict) -> dict[str, dict[st
|
|||||||
antar-komponen bila satu Sub-CPMK diukur oleh >1 komponen (mis. Kuis dan
|
antar-komponen bila satu Sub-CPMK diukur oleh >1 komponen (mis. Kuis dan
|
||||||
Presentasi sama-sama menilai satu Sub-CPMK) — bobot mengikuti alokasi
|
Presentasi sama-sama menilai satu Sub-CPMK) — bobot mengikuti alokasi
|
||||||
yang sama dengan bobot_sub_dari_rps (bobot komponen dibagi rata ke subnya).
|
yang sama dengan bobot_sub_dari_rps (bobot komponen dibagi rata ke subnya).
|
||||||
|
|
||||||
|
Tiap sesi boleh punya "subs" sendiri (subset Sub-CPMK komponen itu — dipakai
|
||||||
|
komponen Observasi/Tugas untuk rincian ala RTM per tugas bernama); sesi
|
||||||
|
tanpa "subs" (atau kosong) dianggap menyasar semua Sub-CPMK komponen itu
|
||||||
|
(perilaku lama). Dengan begitu satu komponen boleh diisi lewat beberapa
|
||||||
|
sesi yang masing-masing hanya menyasar sebagian Sub-CPMK, tanpa
|
||||||
|
menyamaratakan nilainya ke Sub-CPMK lain yang tak disasar sesi itu.
|
||||||
"""
|
"""
|
||||||
komponen_by_i = {k["i"]: k for k in komponen_daftar}
|
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_nim: dict[str, list[float]] = {}
|
|
||||||
for s in daftar_sesi:
|
|
||||||
for nim, v in (s.get("nilai") or {}).items():
|
|
||||||
if v is not None:
|
|
||||||
per_nim.setdefault(nim, []).append(float(v))
|
|
||||||
per_komponen_avg[i] = {nim: _rata(xs) for nim, xs in per_nim.items()}
|
|
||||||
|
|
||||||
kontribusi_per_sub: dict[str, list[tuple[float, dict]]] = {}
|
kontribusi_per_sub: dict[str, list[tuple[float, dict]]] = {}
|
||||||
for i, avg_map in per_komponen_avg.items():
|
for i, k in komponen_by_i.items():
|
||||||
k = komponen_by_i[i]
|
daftar_sesi = (sesi or {}).get(i) or []
|
||||||
|
if not daftar_sesi:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
bobot = float(k.get("bobot") or 0)
|
bobot = float(k.get("bobot") or 0)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -125,6 +119,15 @@ def skor_dari_sesi(komponen_daftar: list[dict], sesi: dict) -> dict[str, dict[st
|
|||||||
n_subs = len(k["subs"]) or 1
|
n_subs = len(k["subs"]) or 1
|
||||||
w = bobot / n_subs if bobot else 1.0
|
w = bobot / n_subs if bobot else 1.0
|
||||||
for sub in k["subs"]:
|
for sub in k["subs"]:
|
||||||
|
relevan = [s for s in daftar_sesi if sub in (s.get("subs") or k["subs"])]
|
||||||
|
if not relevan:
|
||||||
|
continue
|
||||||
|
per_nim: dict[str, list[float]] = {}
|
||||||
|
for s in relevan:
|
||||||
|
for nim, v in (s.get("nilai") or {}).items():
|
||||||
|
if v is not None:
|
||||||
|
per_nim.setdefault(nim, []).append(float(v))
|
||||||
|
avg_map = {nim: _rata(xs) for nim, xs in per_nim.items()}
|
||||||
kontribusi_per_sub.setdefault(sub, []).append((w, avg_map))
|
kontribusi_per_sub.setdefault(sub, []).append((w, avg_map))
|
||||||
|
|
||||||
out: dict[str, dict[str, float]] = {}
|
out: dict[str, dict[str, float]] = {}
|
||||||
@@ -192,9 +195,11 @@ def rollup_mk(peta: dict, nilai_data: dict, ambang: float) -> dict:
|
|||||||
|
|
||||||
cpl: dict[str, dict] = {}
|
cpl: dict[str, dict] = {}
|
||||||
for cp in peta["cpls"]:
|
for cp in peta["cpls"]:
|
||||||
skor = _rata([v["skor"] for v in cpmk.values()
|
pendukung = [v for v in cpmk.values() if cp["kode"] in v["cpl"]]
|
||||||
if cp["kode"] in v["cpl"] and v["skor"] is not None])
|
ternilai = [v for v in pendukung if v["skor"] is not None]
|
||||||
cpl[cp["kode"]] = {"skor": skor}
|
skor = _rata([v["skor"] for v in ternilai])
|
||||||
|
cpl[cp["kode"]] = {"skor": skor, "n_cpmk": len(pendukung),
|
||||||
|
"n_cpmk_ternilai": len(ternilai)}
|
||||||
|
|
||||||
return {"sub": sub, "cpmk": cpmk, "cpl": cpl,
|
return {"sub": sub, "cpmk": cpmk, "cpl": cpl,
|
||||||
"mode": nilai_data.get("mode", "agregat")}
|
"mode": nilai_data.get("mode", "agregat")}
|
||||||
|
|||||||
+171
-16
@@ -525,8 +525,29 @@ async def simpan_rps(request: Request, kode: str):
|
|||||||
}
|
}
|
||||||
aksi = form.get("aksi") or "draft"
|
aksi = form.get("aksi") or "draft"
|
||||||
status = "selesai" if aksi == "selesai" else "draft"
|
status = "selesai" if aksi == "selesai" else "draft"
|
||||||
|
|
||||||
|
# Komponen boleh kosong (MK tak memakainya, mis. tak ada proyek/Tugas),
|
||||||
|
# tapi total bobot komponen yang DIISI wajib tepat 100% sebelum RPS bisa
|
||||||
|
# ditandai selesai — kalau belum, tetap disimpan (tak menghapus isian)
|
||||||
|
# tapi statusnya dipaksa draft.
|
||||||
|
total_bobot_g = 0.0
|
||||||
|
for k in komponen:
|
||||||
|
if k["bobot"]:
|
||||||
|
try:
|
||||||
|
total_bobot_g += float(k["bobot"])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
pesan_tambahan = ""
|
||||||
|
if status == "selesai" and abs(total_bobot_g - 100) > 0.01:
|
||||||
|
status = "draft"
|
||||||
|
pesan_tambahan = (
|
||||||
|
f"+—+disimpan+sebagai+draft:+total+bobot+komponen+G+harus+tepat+100%25+"
|
||||||
|
f"(saat+ini+{total_bobot_g:g}%25)+sebelum+bisa+ditandai+selesai"
|
||||||
|
)
|
||||||
|
|
||||||
db.simpan_rps(kode, data, status, request.state.user["nama"])
|
db.simpan_rps(kode, data, status, request.state.user["nama"])
|
||||||
return RedirectResponse(f"/rps/{kode}?pesan=Tersimpan+sebagai+{status}.", status_code=303)
|
return RedirectResponse(
|
||||||
|
f"/rps/{kode}?pesan=Tersimpan+sebagai+{status}{pesan_tambahan}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/rps/{kode}/unduh")
|
@app.get("/rps/{kode}/unduh")
|
||||||
@@ -602,12 +623,23 @@ def _grid_mahasiswa(tersimpan: dict | None, terdaftar: list[dict],
|
|||||||
return grid
|
return grid
|
||||||
|
|
||||||
|
|
||||||
|
IDX_TUGAS = next(i for i, k in enumerate(KOMPONEN_DEFAULT)
|
||||||
|
if k["nama"] == "Observasi (Praktik/Tugas)")
|
||||||
|
|
||||||
|
|
||||||
def _komponen_daftar(kode: str) -> list[dict]:
|
def _komponen_daftar(kode: str) -> list[dict]:
|
||||||
"""Komponen penilaian RPS yang sudah dikaitkan ke Sub-CPMK (bagian G) —
|
"""Komponen penilaian RPS yang sudah dikaitkan ke Sub-CPMK (bagian G) —
|
||||||
dipakai untuk tombol isi-cepat per komponen di halaman nilai. 'ulang'
|
dipakai untuk tombol isi-cepat per komponen di halaman nilai. 'ulang'
|
||||||
menandai komponen yang boleh diisi berkali-kali sepanjang semester (Kuis,
|
menandai komponen yang boleh diisi berkali-kali sepanjang semester (Kuis,
|
||||||
Tugas, Presentasi, Tes Lisan) — rata-rata otomatis antar sesi — versus
|
Tugas, Presentasi, Tes Lisan) — rata-rata otomatis antar sesi — versus
|
||||||
komponen sekali-jalan (UTS/UAS)."""
|
komponen sekali-jalan (UTS/UAS). 'tugas' menandai komponen Observasi
|
||||||
|
(Praktik/Tugas) — satu-satunya yang menawarkan rincian ala RTM (deskripsi
|
||||||
|
+ checklist Sub-CPMK per sesi) di form isi nilainya.
|
||||||
|
|
||||||
|
Komponen dengan bobot kosong dianggap TAK DIPAKAI MK ini (mis. tak ada
|
||||||
|
proyek/Tugas) dan disembunyikan sepenuhnya dari halaman nilai — bukan
|
||||||
|
error, itu memang boleh terjadi (lihat validasi 100% saat RPS ditandai
|
||||||
|
selesai di simpan_rps)."""
|
||||||
rps = db.ambil_rps(kode)
|
rps = db.ambil_rps(kode)
|
||||||
if not rps:
|
if not rps:
|
||||||
return []
|
return []
|
||||||
@@ -615,27 +647,62 @@ def _komponen_daftar(kode: str) -> list[dict]:
|
|||||||
sesi_semua = ((tersimpan["data"].get("sesi") or {}) if tersimpan else {})
|
sesi_semua = ((tersimpan["data"].get("sesi") or {}) if tersimpan else {})
|
||||||
out = []
|
out = []
|
||||||
for idx, k in enumerate(_normalisasi_komponen(rps["data"].get("komponen", []))):
|
for idx, k in enumerate(_normalisasi_komponen(rps["data"].get("komponen", []))):
|
||||||
if not k["subs"]:
|
if not k["subs"] or not k["bobot"]:
|
||||||
continue
|
continue
|
||||||
out.append({"i": idx, "nama": k["nama"], "bobot": k["bobot"],
|
key = str(idx)
|
||||||
|
out.append({"i": key, "nama": k["nama"], "bobot": k["bobot"],
|
||||||
"subs": k["subs"], "n_subs": len(k["subs"]),
|
"subs": k["subs"], "n_subs": len(k["subs"]),
|
||||||
"ulang": KOMPONEN_DEFAULT[idx]["ulang"],
|
"ulang": KOMPONEN_DEFAULT[idx]["ulang"],
|
||||||
"n_sesi": len(sesi_semua.get(str(idx), []))})
|
"tugas": idx == IDX_TUGAS,
|
||||||
|
"n_sesi": len(sesi_semua.get(key, []))})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _ambil_komponen(kode: str, i: int) -> dict | None:
|
def _ambil_komponen(kode: str, i: str) -> dict | None:
|
||||||
rps = db.ambil_rps(kode)
|
rps = db.ambil_rps(kode)
|
||||||
if not rps:
|
if not rps:
|
||||||
return None
|
return None
|
||||||
komponen_list = _normalisasi_komponen(rps["data"].get("komponen", []))
|
komponen_list = _normalisasi_komponen(rps["data"].get("komponen", []))
|
||||||
if i < 0 or i >= len(komponen_list) or not komponen_list[i]["subs"]:
|
try:
|
||||||
|
idx = int(i)
|
||||||
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
k = dict(komponen_list[i])
|
if (idx < 0 or idx >= len(komponen_list)
|
||||||
k["ulang"] = KOMPONEN_DEFAULT[i]["ulang"]
|
or not komponen_list[idx]["subs"] or not komponen_list[idx]["bobot"]):
|
||||||
|
return None
|
||||||
|
k = dict(komponen_list[idx])
|
||||||
|
k["ulang"] = KOMPONEN_DEFAULT[idx]["ulang"]
|
||||||
|
k["tugas"] = idx == IDX_TUGAS
|
||||||
return k
|
return k
|
||||||
|
|
||||||
|
|
||||||
|
def _tabel_komponen(grid: list[dict], komponen_daftar: list[dict],
|
||||||
|
sesi_semua: dict) -> list[dict]:
|
||||||
|
"""Ringkasan nilai per KOMPONEN (mis. Tugas, UTS, UAS) per mahasiswa —
|
||||||
|
rata-rata skor MENTAH tiap sesi/tugas komponen itu yang mahasiswa ybs
|
||||||
|
ikuti (bukan lewat Sub-CPMK), sehingga mis. "Tugas" tetap tampil sebagai
|
||||||
|
satu angka gabungan meski tiap tugas menyasar Sub-CPMK berbeda-beda —
|
||||||
|
berbeda dari grid Sub-CPMK di bawah yang presisi per Sub-CPMK. Bisa
|
||||||
|
diedit langsung dari sini (lihat aksi='komponen' di simpan_nilai); angka
|
||||||
|
yang diketik menimpa SEMUA Sub-CPMK komponen itu untuk mahasiswa tsb
|
||||||
|
(override manual, terpisah dari presisi per-tugas per Sub-CPMK)."""
|
||||||
|
out = []
|
||||||
|
for m in grid:
|
||||||
|
nilai = {}
|
||||||
|
for k in komponen_daftar:
|
||||||
|
daftar_sesi = sesi_semua.get(k["i"]) or []
|
||||||
|
xs = [s["nilai"][m["nim"]] for s in daftar_sesi
|
||||||
|
if m["nim"] in (s.get("nilai") or {})]
|
||||||
|
# tak ada sesi tercatat untuk komponen ini (mis. Sub-CPMK-nya
|
||||||
|
# diisi langsung lewat grid Sub-CPMK, bukan per komponen) —
|
||||||
|
# dikosongkan, bukan ditebak dari skor Sub-CPMK (subnya bisa
|
||||||
|
# dipakai bersama komponen lain, jadi tak bisa dipastikan sumbernya)
|
||||||
|
nilai[k["i"]] = (sum(xs) / len(xs)) if xs else None
|
||||||
|
out.append({"nim": m["nim"], "nama": m["nama"], "kelas": m.get("kelas"),
|
||||||
|
"nilai": nilai})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _konteks_nilai(request, kode, pesan="", peringatan=None):
|
def _konteks_nilai(request, kode, pesan="", peringatan=None):
|
||||||
peta = db.peta_mk(kode)
|
peta = db.peta_mk(kode)
|
||||||
tersimpan = db.ambil_dok("nilai", kode)
|
tersimpan = db.ambil_dok("nilai", kode)
|
||||||
@@ -652,12 +719,15 @@ def _konteks_nilai(request, kode, pesan="", peringatan=None):
|
|||||||
kelas_dosen = _kelas_dosen(request.state.user, kode)
|
kelas_dosen = _kelas_dosen(request.state.user, kode)
|
||||||
terdaftar = db.mahasiswa_mk(kode)
|
terdaftar = db.mahasiswa_mk(kode)
|
||||||
grid = _grid_mahasiswa(tersimpan, terdaftar, kelas_dosen)
|
grid = _grid_mahasiswa(tersimpan, terdaftar, kelas_dosen)
|
||||||
|
komponen_daftar = _komponen_daftar(kode)
|
||||||
|
sesi_semua = (tersimpan["data"].get("sesi") or {}) if tersimpan else {}
|
||||||
return render(
|
return render(
|
||||||
request, "form_nilai.html",
|
request, "form_nilai.html",
|
||||||
{"peta": peta, "tersimpan": tersimpan, "hasil": hasil, "ambang": ambang,
|
{"peta": peta, "tersimpan": tersimpan, "hasil": hasil, "ambang": ambang,
|
||||||
"akhir": akhir, "tahun": db.tahun_aktif(), "pesan": pesan,
|
"akhir": akhir, "tahun": db.tahun_aktif(), "pesan": pesan,
|
||||||
"grid": grid, "kelas_dosen": kelas_dosen,
|
"grid": grid, "kelas_dosen": kelas_dosen,
|
||||||
"komponen_daftar": _komponen_daftar(kode),
|
"komponen_daftar": komponen_daftar,
|
||||||
|
"tabel_komponen": _tabel_komponen(grid, komponen_daftar, sesi_semua) if komponen_daftar else [],
|
||||||
"peringatan": peringatan or []},
|
"peringatan": peringatan or []},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -674,7 +744,7 @@ def form_nilai(request: Request, kode: str, pesan: str = ""):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/nilai/{kode}/komponen/{i}", response_class=HTMLResponse)
|
@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 = ""):
|
def form_nilai_komponen(request: Request, kode: str, i: str, sesi_id: int = -1, pesan: 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)
|
||||||
@@ -685,10 +755,11 @@ def form_nilai_komponen(request: Request, kode: str, i: int, sesi_id: int = -1,
|
|||||||
komponen = _ambil_komponen(kode, i)
|
komponen = _ambil_komponen(kode, i)
|
||||||
if not komponen:
|
if not komponen:
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
f"/nilai/{kode}?pesan=Komponen+belum+dikaitkan+ke+Sub-CPMK+di+RPS+bagian+G.",
|
f"/nilai/{kode}?pesan=Komponen+ini+belum+dipakai+MK+ini+"
|
||||||
|
"(Sub-CPMK+dan/atau+bobotnya+belum+diisi+di+RPS+bagian+G).",
|
||||||
status_code=303)
|
status_code=303)
|
||||||
tersimpan = db.ambil_dok("nilai", kode)
|
tersimpan = db.ambil_dok("nilai", kode)
|
||||||
daftar_sesi = ((tersimpan["data"].get("sesi") or {}) if tersimpan else {}).get(str(i), [])
|
daftar_sesi = ((tersimpan["data"].get("sesi") or {}) if tersimpan else {}).get(i, [])
|
||||||
|
|
||||||
edit = None
|
edit = None
|
||||||
if not komponen["ulang"] and daftar_sesi:
|
if not komponen["ulang"] and daftar_sesi:
|
||||||
@@ -703,6 +774,13 @@ def form_nilai_komponen(request: Request, kode: str, i: int, sesi_id: int = -1,
|
|||||||
label_awal = (edit or {}).get("label") or (
|
label_awal = (edit or {}).get("label") or (
|
||||||
komponen["nama"] if not komponen["ulang"]
|
komponen["nama"] if not komponen["ulang"]
|
||||||
else f"{komponen['nama']} {len(daftar_sesi) + 1}")
|
else f"{komponen['nama']} {len(daftar_sesi) + 1}")
|
||||||
|
subs_terpilih = set((edit or {}).get("subs") or komponen["subs"])
|
||||||
|
uraian = {s["kode"]: s["uraian"] for s in peta["subs"]}
|
||||||
|
subs_pilihan = [{"kode": s, "uraian": uraian.get(s, s)} for s in komponen["subs"]]
|
||||||
|
# Rincian ala RTM (khusus komponen Tugas) — semua opsional, per sesi/tugas
|
||||||
|
rtm = {kunci: (edit or {}).get(kunci, "") for kunci in
|
||||||
|
("bentuk_tugas", "deskripsi", "metode_pengerjaan", "luaran",
|
||||||
|
"indikator_bobot", "jadwal", "lain_lain", "rujukan")}
|
||||||
|
|
||||||
kelas_dosen = _kelas_dosen(request.state.user, kode)
|
kelas_dosen = _kelas_dosen(request.state.user, kode)
|
||||||
grid = _grid_mahasiswa(tersimpan, db.mahasiswa_mk(kode), kelas_dosen)
|
grid = _grid_mahasiswa(tersimpan, db.mahasiswa_mk(kode), kelas_dosen)
|
||||||
@@ -713,12 +791,13 @@ def form_nilai_komponen(request: Request, kode: str, i: int, sesi_id: int = -1,
|
|||||||
request, "form_nilai_komponen.html",
|
request, "form_nilai_komponen.html",
|
||||||
{"peta": peta, "komponen": komponen, "i": i, "grid": grid,
|
{"peta": peta, "komponen": komponen, "i": i, "grid": grid,
|
||||||
"daftar_sesi": daftar_sesi, "edit_id": edit_id, "label_awal": label_awal,
|
"daftar_sesi": daftar_sesi, "edit_id": edit_id, "label_awal": label_awal,
|
||||||
|
"rtm": rtm, "subs_pilihan": subs_pilihan, "subs_terpilih": subs_terpilih,
|
||||||
"tahun": db.tahun_aktif(), "pesan": pesan},
|
"tahun": db.tahun_aktif(), "pesan": pesan},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/nilai/{kode}/komponen/{i}")
|
@app.post("/nilai/{kode}/komponen/{i}")
|
||||||
async def simpan_nilai_komponen(request: Request, kode: str, i: int):
|
async def simpan_nilai_komponen(request: Request, kode: str, i: 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)
|
||||||
@@ -761,17 +840,29 @@ async def simpan_nilai_komponen(request: Request, kode: str, i: int):
|
|||||||
lama_data = (dict(lama["data"]) if lama and lama["data"].get("mode") == "per_mahasiswa"
|
lama_data = (dict(lama["data"]) if lama and lama["data"].get("mode") == "per_mahasiswa"
|
||||||
else {"mode": "per_mahasiswa", "mahasiswa": []})
|
else {"mode": "per_mahasiswa", "mahasiswa": []})
|
||||||
sesi_semua = {k: list(v) for k, v in (lama_data.get("sesi") or {}).items()}
|
sesi_semua = {k: list(v) for k, v in (lama_data.get("sesi") or {}).items()}
|
||||||
daftar_sesi = list(sesi_semua.get(str(i), []))
|
daftar_sesi = list(sesi_semua.get(i, []))
|
||||||
|
|
||||||
|
subs_dipilih = form.getlist("subs")
|
||||||
|
|
||||||
entri = {"label": label, "at": datetime.now(db.WIB).strftime("%Y-%m-%d %H:%M"),
|
entri = {"label": label, "at": datetime.now(db.WIB).strftime("%Y-%m-%d %H:%M"),
|
||||||
"by": updated_by, "nilai": nilai}
|
"by": updated_by, "nilai": nilai}
|
||||||
|
# rincian ala RTM (khusus komponen Tugas) — semua opsional, dokumentasi saja
|
||||||
|
for kunci in ("bentuk_tugas", "deskripsi", "metode_pengerjaan", "luaran",
|
||||||
|
"indikator_bobot", "jadwal", "lain_lain", "rujukan"):
|
||||||
|
nilai_kunci = (form.get(kunci) or "").strip()
|
||||||
|
if nilai_kunci:
|
||||||
|
entri[kunci] = nilai_kunci
|
||||||
|
if subs_dipilih and set(subs_dipilih) != set(komponen["subs"]):
|
||||||
|
# subset Sub-CPMK khusus sesi ini (ala RTM) — dibiarkan kosong (berarti
|
||||||
|
# menyasar semua Sub-CPMK komponen, perilaku lama) bila semua tercentang
|
||||||
|
entri["subs"] = [s for s in subs_dipilih if s in komponen["subs"]]
|
||||||
if not komponen["ulang"] and daftar_sesi:
|
if not komponen["ulang"] and daftar_sesi:
|
||||||
daftar_sesi[0] = entri # sekali-jalan: selalu menimpa sesi yang sama
|
daftar_sesi[0] = entri # sekali-jalan: selalu menimpa sesi yang sama
|
||||||
elif 0 <= edit_id < len(daftar_sesi):
|
elif 0 <= edit_id < len(daftar_sesi):
|
||||||
daftar_sesi[edit_id] = entri # mengedit sesi tertentu
|
daftar_sesi[edit_id] = entri # mengedit sesi tertentu
|
||||||
else:
|
else:
|
||||||
daftar_sesi.append(entri) # sesi baru
|
daftar_sesi.append(entri) # sesi baru
|
||||||
sesi_semua[str(i)] = daftar_sesi
|
sesi_semua[i] = daftar_sesi
|
||||||
|
|
||||||
komponen_daftar = _komponen_daftar(kode)
|
komponen_daftar = _komponen_daftar(kode)
|
||||||
skor_terhitung = hitung.skor_dari_sesi(komponen_daftar, sesi_semua)
|
skor_terhitung = hitung.skor_dari_sesi(komponen_daftar, sesi_semua)
|
||||||
@@ -821,6 +912,70 @@ async def simpan_nilai(request: Request, kode: str):
|
|||||||
request, kode,
|
request, kode,
|
||||||
pesan="Seluruh nilai tersimpan MK ini dihapus — silakan mulai dari awal.")
|
pesan="Seluruh nilai tersimpan MK ini dihapus — silakan mulai dari awal.")
|
||||||
|
|
||||||
|
if aksi == "komponen":
|
||||||
|
kelas_dosen = _kelas_dosen(request.state.user, kode)
|
||||||
|
nim_diizinkan = _nim_diizinkan(kode, kelas_dosen)
|
||||||
|
komponen_daftar = _komponen_daftar(kode)
|
||||||
|
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()}
|
||||||
|
nama_map = {mm["nim"]: mm["nama"] for mm in db.mahasiswa_mk(kode)}
|
||||||
|
n_diubah = 0
|
||||||
|
i = 0
|
||||||
|
while f"nim_{i}" in form:
|
||||||
|
nim = (form.get(f"nim_{i}") or "").strip()
|
||||||
|
if nim and (nim_diizinkan is None or nim in nim_diizinkan):
|
||||||
|
for k in komponen_daftar:
|
||||||
|
v = (form.get(f"komp_{i}_{k['i']}") or "").strip()
|
||||||
|
if not v:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
x = float(v)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if not (0 <= x <= 100):
|
||||||
|
continue
|
||||||
|
# Simpan sebagai sesi "Input Cepat" (satu per komponen,
|
||||||
|
# ditimpa/diperbarui tiap kali) — supaya sumbernya sama
|
||||||
|
# dengan alur "Isi Nilai" dan bisa dihitung ulang seragam
|
||||||
|
# lewat skor_dari_sesi (lihat _tabel_komponen).
|
||||||
|
daftar_sesi = sesi_semua.setdefault(k["i"], [])
|
||||||
|
cepat = next((s for s in daftar_sesi if s.get("cepat")), None)
|
||||||
|
if cepat is None:
|
||||||
|
cepat = {"label": "Input Cepat", "cepat": True, "nilai": {}}
|
||||||
|
daftar_sesi.append(cepat)
|
||||||
|
cepat["nilai"][nim] = x
|
||||||
|
cepat["at"] = datetime.now(db.WIB).strftime("%Y-%m-%d %H:%M")
|
||||||
|
cepat["by"] = updated_by
|
||||||
|
n_diubah += 1
|
||||||
|
i += 1
|
||||||
|
if not n_diubah:
|
||||||
|
return _konteks_nilai(request, kode,
|
||||||
|
peringatan=["Tidak ada nilai komponen yang diubah."])
|
||||||
|
|
||||||
|
skor_terhitung = hitung.skor_dari_sesi(komponen_daftar, sesi_semua)
|
||||||
|
urut = [dict(mm, skor=dict(mm.get("skor") or {})) for mm in lama_data.get("mahasiswa", [])]
|
||||||
|
per_nim = {mm["nim"]: mm for mm in urut if mm.get("nim")}
|
||||||
|
for nim, subskor in skor_terhitung.items():
|
||||||
|
mm = per_nim.get(nim)
|
||||||
|
if mm is None:
|
||||||
|
mm = {"nim": nim, "nama": nama_map.get(nim, ""), "skor": {}}
|
||||||
|
urut.append(mm)
|
||||||
|
per_nim[nim] = mm
|
||||||
|
mm["skor"].update(subskor)
|
||||||
|
|
||||||
|
riwayat = lama_data.get("sumber") or []
|
||||||
|
if isinstance(riwayat, str):
|
||||||
|
riwayat = [riwayat]
|
||||||
|
riwayat.append(f"input per komponen ({updated_by})")
|
||||||
|
data = {"mode": "per_mahasiswa", "mahasiswa": urut, "sesi": sesi_semua,
|
||||||
|
"sumber": riwayat[-8:]}
|
||||||
|
db.simpan_dok("nilai", kode, data, "selesai", updated_by)
|
||||||
|
return _konteks_nilai(
|
||||||
|
request, kode,
|
||||||
|
pesan=f"{n_diubah} nilai komponen tersimpan.")
|
||||||
|
|
||||||
if aksi == "grid":
|
if aksi == "grid":
|
||||||
kelas_dosen = _kelas_dosen(request.state.user, kode)
|
kelas_dosen = _kelas_dosen(request.state.user, kode)
|
||||||
nim_diizinkan = _nim_diizinkan(kode, kelas_dosen)
|
nim_diizinkan = _nim_diizinkan(kode, kelas_dosen)
|
||||||
|
|||||||
@@ -48,6 +48,11 @@
|
|||||||
{% if mk.kode in p.mk %}checked{% endif %}> {{ mk.kode }} — {{ mk.nama }}</label>
|
{% if mk.kode in p.mk %}checked{% endif %}> {{ mk.kode }} — {{ mk.nama }}</label>
|
||||||
<input type="text" name="kelas_{{ mk.kode }}" placeholder="kelas (kosong = semua)"
|
<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">
|
value="{{ p.kelas_map.get(mk.kode, '') }}" style="width:150px;margin:0 0 4px 22px;font-size:12px">
|
||||||
|
{% if mhs.get(mk.kode) and mhs[mk.kode].kelas %}
|
||||||
|
<span style="color:var(--grey);font-size:11px">kelas tersedia (dari data mahasiswa): {{ mhs[mk.kode].kelas|join(', ') }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="color:var(--grey);font-size:11px;font-style:italic">belum ada data mahasiswa/kelas untuk MK ini — impor dulu di bagian C</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<p class="cari-kosong" style="display:none;color:var(--grey);font-size:13px;margin:4px 0">Tidak ada MK yang cocok.</p>
|
<p class="cari-kosong" style="display:none;color:var(--grey);font-size:13px;margin:4px 0">Tidak ada MK yang cocok.</p>
|
||||||
|
|||||||
@@ -7,9 +7,7 @@
|
|||||||
|
|
||||||
<h1>Input Nilai — {{ peta.nama }} <span style="color:var(--grey)">({{ peta.kode }})</span></h1>
|
<h1>Input Nilai — {{ peta.nama }} <span style="color:var(--grey)">({{ peta.kode }})</span></h1>
|
||||||
|
|
||||||
<p class="keterangan">Daftar mahasiswa mengikuti impor kaprodi. Apa pun cara
|
<p class="keterangan">Skor per Sub-CPMK digulung ke CPMK lalu CPL (rincian di halaman <a href="/metodologi">Metodologi</a>).</p>
|
||||||
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 %}
|
{% if kelas_dosen is not none %}
|
||||||
<div class="info">Anda hanya mengampu kelas <b>{{ kelas_dosen|join(', ') }}</b> pada MK ini —
|
<div class="info">Anda hanya mengampu kelas <b>{{ kelas_dosen|join(', ') }}</b> pada MK ini —
|
||||||
@@ -20,13 +18,7 @@
|
|||||||
{% for w in peringatan %}<div class="peringatan">{{ w }}</div>{% endfor %}
|
{% for w in peringatan %}<div class="peringatan">{{ w }}</div>{% endfor %}
|
||||||
|
|
||||||
{% if komponen_daftar %}
|
{% 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>
|
<h2 class="bagian">ISI PER KOMPONEN</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">
|
<table class="grid" style="max-width:760px">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -76,14 +68,54 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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">
|
||||||
|
<table class="grid">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:44px" class="tengah">No.</th>
|
||||||
|
<th style="width:112px">NIM</th>
|
||||||
|
<th style="min-width:150px">Nama</th>
|
||||||
|
{% for k in komponen_daftar %}<th class="tengah" style="font-size:11px;min-width:90px">{{ k.nama }}</th>{% endfor %}
|
||||||
|
<th class="tengah" style="width:80px">Nilai Akhir</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in tabel_komponen %}
|
||||||
|
{% set ri = loop.index0 %}
|
||||||
|
<tr>
|
||||||
|
<td class="tengah">{{ loop.index }}
|
||||||
|
<input type="hidden" name="nim_{{ ri }}" value="{{ r.nim }}">
|
||||||
|
<input type="hidden" name="nama_{{ ri }}" value="{{ r.nama }}"></td>
|
||||||
|
<td>{{ r.nim }}</td>
|
||||||
|
<td>{{ r.nama }}</td>
|
||||||
|
{% for k in komponen_daftar %}
|
||||||
|
<td class="isian"><input type="number" name="komp_{{ ri }}_{{ k.i }}"
|
||||||
|
min="0" max="100" step="any"
|
||||||
|
value="{{ '%g'|format(r.nilai[k.i]) if r.nilai[k.i] is not none else '' }}"></td>
|
||||||
|
{% endfor %}
|
||||||
|
<td class="tengah"><b>{{ '%.1f'|format(akhir.get(r.nim)) if akhir.get(r.nim) is not none else '—' }}</b></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 Nilai Komponen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if grid %}
|
{% if grid %}
|
||||||
{% set per_mhs = tersimpan and tersimpan.data.mode == 'per_mahasiswa' %}
|
{% 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)">(ketik langsung di sel, 0–100; kolom Nilai Akhir & baris rata-rata dihitung dari data tersimpan)</span></h2>
|
<h2 class="bagian">NILAI PER MAHASISWA <span style="font-weight:normal;font-style:italic;color:var(--grey)">— nilai mahasiswa berdasarkan Sub-CPMK</span></h2>
|
||||||
<p class="keterangan">
|
|
||||||
Daftar mahasiswa dari impor kaprodi. Kosongkan sel yang belum dinilai;
|
|
||||||
<b>Simpan Nilai</b> menyimpan persis isi tabel. Nilai Akhir = tertimbang bobot
|
|
||||||
komponen RPS; angkanya diperbarui setelah disimpan.
|
|
||||||
</p>
|
|
||||||
<form method="post" action="/nilai/{{ peta.kode }}">
|
<form method="post" action="/nilai/{{ peta.kode }}">
|
||||||
<div class="gulir" style="max-height:480px;overflow-y:auto">
|
<div class="gulir" style="max-height:480px;overflow-y:auto">
|
||||||
{% set ada_kelas = grid|selectattr('kelas')|list|length > 0 %}
|
{% set ada_kelas = grid|selectattr('kelas')|list|length > 0 %}
|
||||||
@@ -111,7 +143,7 @@
|
|||||||
{% if ada_kelas %}<td class="tengah">{{ m.kelas or '—' }}</td>{% endif %}
|
{% if ada_kelas %}<td class="tengah">{{ m.kelas or '—' }}</td>{% endif %}
|
||||||
{% for s in peta.subs %}
|
{% for s in peta.subs %}
|
||||||
<td class="isian"><input type="number" name="skor_{{ i }}_{{ s.kode }}"
|
<td class="isian"><input type="number" name="skor_{{ i }}_{{ s.kode }}"
|
||||||
min="0" max="100" step="0.01"
|
min="0" max="100" step="any"
|
||||||
value="{{ '%g'|format(m.skor[s.kode]) if s.kode in m.skor else '' }}"></td>
|
value="{{ '%g'|format(m.skor[s.kode]) if s.kode in m.skor else '' }}"></td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<td class="tengah"><b>{{ '%.1f'|format(akhir.get(m.nim)) if akhir.get(m.nim) is not none else '—' }}</b></td>
|
<td class="tengah"><b>{{ '%.1f'|format(akhir.get(m.nim)) if akhir.get(m.nim) is not none else '—' }}</b></td>
|
||||||
@@ -147,16 +179,17 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if hasil %}
|
{% 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>
|
<h2 class="bagian">HASIL KETERCAPAIAN MK <span style="font-weight:normal;font-style:italic;color:var(--grey)">(ambang {{ ambang|round(0)|int }})</span></h2>
|
||||||
<div class="gulir">
|
<div class="gulir">
|
||||||
<table class="grid">
|
<table class="grid">
|
||||||
<thead><tr><th>CPMK</th><th class="tengah">Skor</th><th class="tengah">Sub Ternilai</th><th>CPL Didukung</th></tr></thead>
|
<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>
|
<tbody>
|
||||||
{% for kode_c, v in hasil.cpmk.items() %}
|
{% for kode_c, v in hasil.cpmk.items() %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ kode_c }}</td>
|
<td>{{ kode_c }}</td>
|
||||||
<td class="tengah">{% if v.skor is not none %}<b>{{ '%.2f'|format(v.skor) }}</b>{% else %}—{% endif %}</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">{{ 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>
|
||||||
<td>{{ v.cpl|join(', ') }}</td>
|
<td>{{ v.cpl|join(', ') }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -165,12 +198,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="gulir" style="margin-top:10px">
|
<div class="gulir" style="margin-top:10px">
|
||||||
<table class="grid">
|
<table class="grid">
|
||||||
<thead><tr><th>CPL (dalam MK ini)</th><th class="tengah">Skor</th><th class="tengah">Status</th></tr></thead>
|
<thead><tr><th>CPL</th><th class="tengah">Skor</th><th class="tengah">% CPMK Ternilai</th><th class="tengah">Status</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for kode_c, v in hasil.cpl.items() %}
|
{% for kode_c, v in hasil.cpl.items() %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ kode_c }}</td>
|
<td>{{ kode_c }}</td>
|
||||||
<td class="tengah">{% if v.skor is not none %}<b>{{ '%.2f'|format(v.skor) }}</b>{% else %}—{% endif %}</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">
|
<td class="tengah">
|
||||||
{% if v.skor is none %}<span class="stamp stamp-draft">BELUM TERUKUR</span>
|
{% if v.skor is none %}<span class="stamp stamp-draft">BELUM TERUKUR</span>
|
||||||
{% elif v.skor >= ambang %}<span class="stamp stamp-ok">TERCAPAI</span>
|
{% elif v.skor >= ambang %}<span class="stamp stamp-ok">TERCAPAI</span>
|
||||||
|
|||||||
@@ -28,12 +28,13 @@
|
|||||||
|
|
||||||
{% if komponen.ulang and daftar_sesi %}
|
{% if komponen.ulang and daftar_sesi %}
|
||||||
<h2 class="bagian">SESI TERSIMPAN</h2>
|
<h2 class="bagian">SESI TERSIMPAN</h2>
|
||||||
<table class="grid" style="max-width:560px">
|
<table class="grid" style="max-width:{{ '900px' if komponen.tugas else '560px' }}">
|
||||||
<thead><tr><th>Sesi</th><th class="tengah" style="width:120px">Mahasiswa Terisi</th><th style="width:60px"></th></tr></thead>
|
<thead><tr><th>Sesi</th>{% if komponen.tugas %}<th>Sub-CPMK</th><th style="width:130px">Jadwal</th>{% endif %}<th class="tengah" style="width:120px">Mahasiswa Terisi</th><th style="width:60px"></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for s in daftar_sesi %}
|
{% for s in daftar_sesi %}
|
||||||
<tr{% if loop.index0 == edit_id %} style="background:var(--blue-fill)"{% endif %}>
|
<tr{% if loop.index0 == edit_id %} style="background:var(--blue-fill)"{% endif %}>
|
||||||
<td>{{ s.label }}</td>
|
<td>{{ s.label }}{% if komponen.tugas and s.deskripsi %}<br><span style="font-size:12px;color:var(--grey);font-style:italic">{{ s.deskripsi }}</span>{% endif %}</td>
|
||||||
|
{% if komponen.tugas %}<td>{{ (s.subs or komponen.subs)|join(', ') }}</td><td>{{ s.jadwal or '—' }}</td>{% endif %}
|
||||||
<td class="tengah">{{ (s.nilai or {})|length }}</td>
|
<td class="tengah">{{ (s.nilai or {})|length }}</td>
|
||||||
<td><a href="/nilai/{{ peta.kode }}/komponen/{{ i }}?sesi_id={{ loop.index0 }}">Edit</a></td>
|
<td><a href="/nilai/{{ peta.kode }}/komponen/{{ i }}?sesi_id={{ loop.index0 }}">Edit</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -46,12 +47,55 @@
|
|||||||
<form method="post" action="/nilai/{{ peta.kode }}/komponen/{{ i }}">
|
<form method="post" action="/nilai/{{ peta.kode }}/komponen/{{ i }}">
|
||||||
<input type="hidden" name="edit_id" value="{{ edit_id }}">
|
<input type="hidden" name="edit_id" value="{{ edit_id }}">
|
||||||
{% if komponen.ulang %}
|
{% if komponen.ulang %}
|
||||||
<label style="display:block;margin-bottom:10px">Nama sesi
|
<label style="display:block;margin-bottom:10px">{{ 'Judul Tugas' if komponen.tugas else 'Nama sesi' }}
|
||||||
<input type="text" name="label" value="{{ label_awal }}" style="display:block;margin-top:4px;max-width:280px">
|
<input type="text" name="label" value="{{ label_awal }}" style="display:block;margin-top:4px;max-width:480px">
|
||||||
</label>
|
</label>
|
||||||
{% else %}
|
{% else %}
|
||||||
<input type="hidden" name="label" value="{{ label_awal }}">
|
<input type="hidden" name="label" value="{{ label_awal }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if komponen.tugas %}
|
||||||
|
<div style="margin-bottom:10px">
|
||||||
|
<div>Sub Capaian Pembelajaran Mata Kuliah <span style="font-weight:normal;color:var(--grey)">(bawaan semua tercentang, sesuai yang ditentukan RPS untuk komponen ini — kurangi bila tugas lain akan menyasar sisanya; bobot komponen ini tetap {{ komponen.bobot }}%)</span></div>
|
||||||
|
<div class="pilih-sub">
|
||||||
|
{% for s in subs_pilihan %}
|
||||||
|
<label><input type="checkbox" name="subs" value="{{ s.kode }}" {% if s.kode in subs_terpilih %}checked{% endif %}>{{ s.kode }} — {{ s.uraian }}</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label style="display:block;margin-bottom:10px">Bentuk Tugas
|
||||||
|
<span style="display:flex;align-items:center;gap:8px;margin-top:4px">
|
||||||
|
<select name="bentuk_tugas" id="bentuk_tugas" onchange="toggleIsiOtomatis()" style="max-width:280px">
|
||||||
|
<option value="">— pilih —</option>
|
||||||
|
{% for opsi in ["Diskusi", "PjBL", "CBL", "Esai", "Tes"] %}
|
||||||
|
<option value="{{ opsi }}" {% if rtm.bentuk_tugas == opsi %}selected{% endif %}>{{ opsi }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="button" class="tombol garis" id="tombol_isi_otomatis" onclick="isiOtomatisTugas()" {% if not rtm.bentuk_tugas %}hidden{% endif %}>Isi Otomatis</button>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label style="display:block;margin-bottom:10px">Deskripsi Tugas <span style="font-weight:normal;color:var(--grey)">(opsional — soal cerita, esai, kasus, atau proyek yang harus diselesaikan mahasiswa)</span>
|
||||||
|
<textarea name="deskripsi" id="rtm_deskripsi" rows="4" style="display:block;margin-top:4px;max-width:760px;width:100%">{{ rtm.deskripsi }}</textarea>
|
||||||
|
</label>
|
||||||
|
<label style="display:block;margin-bottom:10px">Metode Pengerjaan Tugas <span style="font-weight:normal;color:var(--grey)">(opsional — langkah-langkah pengerjaan)</span>
|
||||||
|
<textarea name="metode_pengerjaan" id="rtm_metode" rows="3" style="display:block;margin-top:4px;max-width:760px;width:100%">{{ rtm.metode_pengerjaan }}</textarea>
|
||||||
|
</label>
|
||||||
|
<label style="display:block;margin-bottom:10px">Bentuk dan Format Luaran <span style="font-weight:normal;color:var(--grey)">(opsional)</span>
|
||||||
|
<textarea name="luaran" id="rtm_luaran" rows="2" style="display:block;margin-top:4px;max-width:760px;width:100%">{{ rtm.luaran }}</textarea>
|
||||||
|
</label>
|
||||||
|
<label style="display:block;margin-bottom:10px">Indikator, Kriteria dan Bobot Penilaian <span style="font-weight:normal;color:var(--grey)">(opsional — dokumentasi rubrik; bobot komponen di aplikasi tetap {{ komponen.bobot }}%, tak dihitung dari sini)</span>
|
||||||
|
<textarea name="indikator_bobot" id="rtm_indikator" rows="4" style="display:block;margin-top:4px;max-width:760px;width:100%">{{ rtm.indikator_bobot }}</textarea>
|
||||||
|
</label>
|
||||||
|
<label style="display:block;margin-bottom:10px">Jadwal Pelaksanaan <span style="font-weight:normal;color:var(--grey)">(opsional)</span>
|
||||||
|
<input type="text" name="jadwal" id="rtm_jadwal" value="{{ rtm.jadwal }}" style="display:block;margin-top:4px;max-width:480px">
|
||||||
|
</label>
|
||||||
|
<label style="display:block;margin-bottom:10px">Lain-lain <span style="font-weight:normal;color:var(--grey)">(opsional)</span>
|
||||||
|
<textarea name="lain_lain" rows="2" style="display:block;margin-top:4px;max-width:760px;width:100%">{{ rtm.lain_lain }}</textarea>
|
||||||
|
</label>
|
||||||
|
<label style="display:block;margin-bottom:10px">Daftar Rujukan <span style="font-weight:normal;color:var(--grey)">(opsional)</span>
|
||||||
|
<textarea name="rujukan" rows="2" style="display:block;margin-top:4px;max-width:760px;width:100%">{{ rtm.rujukan }}</textarea>
|
||||||
|
</label>
|
||||||
|
{% endif %}
|
||||||
|
<h2 class="bagian">NILAI MAHASISWA</h2>
|
||||||
<div class="gulir" style="max-height:480px;overflow-y:auto">
|
<div class="gulir" style="max-height:480px;overflow-y:auto">
|
||||||
<table class="grid">
|
<table class="grid">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -71,7 +115,7 @@
|
|||||||
<td>{{ m.nim }}</td>
|
<td>{{ m.nim }}</td>
|
||||||
<td>{{ m.nama }}</td>
|
<td>{{ m.nama }}</td>
|
||||||
<td class="isian"><input type="number" name="nilai_{{ loop.index0 }}"
|
<td class="isian"><input type="number" name="nilai_{{ loop.index0 }}"
|
||||||
min="0" max="100" step="0.01"
|
min="0" max="100" step="any"
|
||||||
value="{{ '%g'|format(m.nilai_awal) if m.nilai_awal is not none else '' }}"></td>
|
value="{{ '%g'|format(m.nilai_awal) if m.nilai_awal is not none else '' }}"></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -84,4 +128,67 @@
|
|||||||
<button class="tombol" type="submit">Simpan Sesi Ini</button>
|
<button class="tombol" type="submit">Simpan Sesi Ini</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{% if komponen.tugas %}
|
||||||
|
<script>
|
||||||
|
const BENTUK_TEMPLATE = {
|
||||||
|
Diskusi: {
|
||||||
|
deskripsi: 'Diskusikan {SUBCPMK} melalui studi kasus/topik terkait materi ini. Kelompok kecil (3-5 orang) menganalisis permasalahan dan menyusun rekomendasi.',
|
||||||
|
metode: '1) Baca materi & bahan diskusi;\n2) Diskusi kelompok;\n3) Susun poin-poin kesimpulan;\n4) Presentasikan hasil diskusi.',
|
||||||
|
luaran: 'Notulen/rangkuman diskusi kelompok dalam bentuk dokumen tertulis (maks. 3 halaman).',
|
||||||
|
indikator: 'Ketepatan analisis (30%)\nKeaktifan berdiskusi (30%)\nKejelasan kesimpulan (20%)\nKerja sama kelompok (20%)\nBobot: 100%',
|
||||||
|
jadwal: '1 minggu',
|
||||||
|
},
|
||||||
|
PjBL: {
|
||||||
|
deskripsi: 'Kerjakan proyek yang menghasilkan solusi nyata terkait {SUBCPMK}, mulai dari perencanaan hingga penyerahan hasil akhir.',
|
||||||
|
metode: '1) Identifikasi masalah;\n2) Rencanakan solusi;\n3) Kerjakan proyek secara bertahap;\n4) Uji & perbaiki;\n5) Serahkan hasil akhir beserta laporan.',
|
||||||
|
luaran: 'Produk/prototipe beserta laporan tertulis yang menjelaskan proses pengerjaan.',
|
||||||
|
indikator: 'Perencanaan (20%)\nProses pengerjaan (30%)\nKualitas hasil akhir (30%)\nLaporan (20%)\nBobot: 100%',
|
||||||
|
jadwal: '2-3 minggu',
|
||||||
|
},
|
||||||
|
CBL: {
|
||||||
|
deskripsi: 'Analisis studi kasus terkait {SUBCPMK}; identifikasi permasalahan dan usulkan solusi berbasis konsep yang dipelajari.',
|
||||||
|
metode: '1) Baca & pahami kasus;\n2) Identifikasi masalah inti;\n3) Terapkan konsep untuk analisis;\n4) Rumuskan solusi;\n5) Tulis laporan.',
|
||||||
|
luaran: 'Laporan analisis kasus tertulis, memuat identifikasi masalah, analisis, dan solusi yang diusulkan.',
|
||||||
|
indikator: 'Ketepatan identifikasi masalah (25%)\nKedalaman analisis (35%)\nKetepatan solusi (25%)\nSistematika laporan (15%)\nBobot: 100%',
|
||||||
|
jadwal: '1-2 minggu',
|
||||||
|
},
|
||||||
|
Esai: {
|
||||||
|
deskripsi: 'Tulis esai mengenai {SUBCPMK} sepanjang 800-1200 kata, disertai argumen dan referensi yang relevan.',
|
||||||
|
metode: '1) Tentukan tesis/argumen utama;\n2) Kumpulkan referensi pendukung;\n3) Susun kerangka esai;\n4) Tulis draf;\n5) Revisi & finalisasi.',
|
||||||
|
luaran: 'Esai tertulis (800-1200 kata) dalam format dokumen (PDF/Word).',
|
||||||
|
indikator: 'Ketepatan argumen (30%)\nKedalaman analisis (30%)\nSistematika penulisan (20%)\nPenggunaan referensi (20%)\nBobot: 100%',
|
||||||
|
jadwal: '1 minggu',
|
||||||
|
},
|
||||||
|
Tes: {
|
||||||
|
deskripsi: 'Kerjakan soal-soal tes tertulis terkait {SUBCPMK} sesuai instruksi yang diberikan.',
|
||||||
|
metode: '1) Baca instruksi & soal dengan teliti;\n2) Kerjakan sesuai alokasi waktu;\n3) Periksa kembali jawaban sebelum dikumpulkan.',
|
||||||
|
luaran: 'Lembar jawaban tes (tertulis/daring).',
|
||||||
|
indikator: 'Ketepatan jawaban per butir soal, dijumlah sesuai bobot masing-masing.\nBobot: 100%',
|
||||||
|
jadwal: '1x pertemuan (sesuai jadwal tes)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function isiOtomatisTugas() {
|
||||||
|
const bentuk = document.getElementById('bentuk_tugas').value;
|
||||||
|
const tmpl = BENTUK_TEMPLATE[bentuk];
|
||||||
|
if (!tmpl) return;
|
||||||
|
const checked = Array.from(document.querySelectorAll('.pilih-sub input[name="subs"]:checked')).map(cb => cb.value);
|
||||||
|
const subcpmk = checked.length ? checked.join(', ') : 'Sub-CPMK terkait';
|
||||||
|
const isi = (id, teks) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el && !el.value.trim()) el.value = teks.replace('{SUBCPMK}', subcpmk);
|
||||||
|
};
|
||||||
|
isi('rtm_deskripsi', tmpl.deskripsi);
|
||||||
|
isi('rtm_metode', tmpl.metode);
|
||||||
|
isi('rtm_luaran', tmpl.luaran);
|
||||||
|
isi('rtm_indikator', tmpl.indikator);
|
||||||
|
isi('rtm_jadwal', tmpl.jadwal);
|
||||||
|
}
|
||||||
|
function toggleIsiOtomatis() {
|
||||||
|
const bentuk = document.getElementById('bentuk_tugas').value;
|
||||||
|
document.getElementById('tombol_isi_otomatis').hidden = !bentuk;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -4,10 +4,12 @@
|
|||||||
<div class="nav-baris">
|
<div class="nav-baris">
|
||||||
<div>MK ini: <b>RPS</b> · <a href="/nilai/{{ peta.kode }}">Input Nilai</a> · <a href="/portofolio/{{ peta.kode }}">Portofolio</a></div>
|
<div>MK ini: <b>RPS</b> · <a href="/nilai/{{ peta.kode }}">Input Nilai</a> · <a href="/portofolio/{{ peta.kode }}">Portofolio</a></div>
|
||||||
<div>
|
<div>
|
||||||
{% if status == 'selesai' %}<span class="stamp stamp-ok">SELESAI</span>
|
<span id="status-stamp">
|
||||||
{% elif status == 'draft' %}<span class="stamp stamp-draft">DRAFT</span>
|
{% if status == 'selesai' %}<span class="stamp stamp-ok">SELESAI</span>
|
||||||
{% else %}<span class="stamp stamp-no">BELUM</span>{% endif %}
|
{% elif status == 'draft' %}<span class="stamp stamp-draft">DRAFT</span>
|
||||||
{% if updated_at %}<span style="color:var(--grey);font-size:13px"> · disimpan {{ updated_at }}{% if updated_by %} oleh {{ updated_by }}{% endif %}</span>{% endif %}
|
{% else %}<span class="stamp stamp-no">BELUM</span>{% endif %}
|
||||||
|
</span>
|
||||||
|
<span id="status-waktu" style="color:var(--grey);font-size:13px">{% if updated_at %} · disimpan {{ updated_at }}{% if updated_by %} oleh {{ updated_by }}{% endif %}{% endif %}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -34,7 +36,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="post" action="/rps/{{ peta.kode }}">
|
<form method="post" action="/rps/{{ peta.kode }}" id="form-rps">
|
||||||
<fieldset {% if not boleh_edit %}disabled{% endif %} style="border:none;padding:0;margin:0">
|
<fieldset {% if not boleh_edit %}disabled{% endif %} style="border:none;padding:0;margin:0">
|
||||||
|
|
||||||
<h2 class="bagian">A. IDENTITAS MATA KULIAH</h2>
|
<h2 class="bagian">A. IDENTITAS MATA KULIAH</h2>
|
||||||
@@ -213,6 +215,7 @@
|
|||||||
<div class="aksi-baris">
|
<div class="aksi-baris">
|
||||||
{% if boleh_edit %}
|
{% if boleh_edit %}
|
||||||
<span style="color:var(--grey);font-size:13px">Disimpan atas nama <b>{{ user.nama }}</b></span>
|
<span style="color:var(--grey);font-size:13px">Disimpan atas nama <b>{{ user.nama }}</b></span>
|
||||||
|
<span id="autosave-status" style="color:var(--grey);font-size:13px;margin-left:10px"></span>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<button class="tombol garis" type="submit" name="aksi" value="draft">Simpan Draft</button>
|
<button class="tombol garis" type="submit" name="aksi" value="draft">Simpan Draft</button>
|
||||||
<button class="tombol selesai" type="submit" name="aksi" value="selesai">Simpan & Tandai Selesai</button>
|
<button class="tombol selesai" type="submit" name="aksi" value="selesai">Simpan & Tandai Selesai</button>
|
||||||
@@ -224,6 +227,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div style="text-align:center;margin-top:16px">
|
||||||
|
<button type="button" class="tombol garis" onclick="window.scrollTo({top:0, behavior:'smooth'})">▲ Ke Atas</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const SUB_URAIAN = {{ sub_uraian | tojson | safe }};
|
const SUB_URAIAN = {{ sub_uraian | tojson | safe }};
|
||||||
|
|
||||||
@@ -283,5 +290,59 @@
|
|||||||
}
|
}
|
||||||
document.addEventListener('input', segarkan);
|
document.addEventListener('input', segarkan);
|
||||||
segarkan();
|
segarkan();
|
||||||
|
|
||||||
|
// Auto-save berkala (selalu sebagai draft — tak pernah menandai "selesai"
|
||||||
|
// otomatis) supaya isian tak hilang kalau sinyal/listrik tiba-tiba putus.
|
||||||
|
const BOLEH_EDIT = {{ boleh_edit | tojson | safe }};
|
||||||
|
const NAMA_USER = {{ user.nama | tojson | safe }};
|
||||||
|
if (BOLEH_EDIT) {
|
||||||
|
let timerAutosave = null;
|
||||||
|
let sedangMenyimpan = false;
|
||||||
|
let perluSimpanLagi = false;
|
||||||
|
|
||||||
|
function waktuSekarang() {
|
||||||
|
const d = new Date();
|
||||||
|
const p = n => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function simpanOtomatis() {
|
||||||
|
if (sedangMenyimpan) { perluSimpanLagi = true; return; }
|
||||||
|
sedangMenyimpan = true;
|
||||||
|
const status = document.getElementById('autosave-status');
|
||||||
|
if (status) status.textContent = 'Menyimpan otomatis…';
|
||||||
|
try {
|
||||||
|
const form = document.getElementById('form-rps');
|
||||||
|
const data = new FormData(form);
|
||||||
|
data.set('aksi', 'draft');
|
||||||
|
const res = await fetch(form.action, { method: 'POST', body: data });
|
||||||
|
const jam = new Date().toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
if (status) {
|
||||||
|
status.textContent = res.ok
|
||||||
|
? `Tersimpan otomatis ${jam}`
|
||||||
|
: 'Auto-save gagal — klik Simpan Draft manual.';
|
||||||
|
}
|
||||||
|
if (res.ok) {
|
||||||
|
// label stempel "DRAFT · disimpan ... oleh ..." di atas halaman
|
||||||
|
// dirender sekali saat page-load — perbarui juga di sini supaya
|
||||||
|
// tak kelihatan basi walau auto-save baru saja jalan di belakang layar.
|
||||||
|
const stamp = document.getElementById('status-stamp');
|
||||||
|
const waktu = document.getElementById('status-waktu');
|
||||||
|
if (stamp) stamp.innerHTML = '<span class="stamp stamp-draft">DRAFT</span>';
|
||||||
|
if (waktu) waktu.textContent = ` · disimpan ${waktuSekarang()} oleh ${NAMA_USER}`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (status) status.textContent = 'Auto-save gagal (offline?) — klik Simpan Draft manual.';
|
||||||
|
} finally {
|
||||||
|
sedangMenyimpan = false;
|
||||||
|
if (perluSimpanLagi) { perluSimpanLagi = false; jadwalkanAutosave(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function jadwalkanAutosave() {
|
||||||
|
clearTimeout(timerAutosave);
|
||||||
|
timerAutosave = setTimeout(simpanOtomatis, 3000);
|
||||||
|
}
|
||||||
|
document.addEventListener('input', jadwalkanAutosave);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user