From c9c92f82d33ca837ad666a5dcee910cc09cdfd19 Mon Sep 17 00:00:00 2001 From: Johann Hoffmann Date: Thu, 6 Aug 2026 10:33:46 +0200 Subject: [PATCH] WIP refactor --- application/config/noten.php | 37 +- .../controllers/api/frontend/v1/Noten.php | 958 ++++++------- .../api/frontend/v1/stv/Grades.php | 87 +- .../models/education/Lvgesamtnote_model.php | 2 +- .../benotungstool/lvgesamtnoteverwalten.php | 2 +- public/css/Cis4/Benotungstool.css | 60 +- public/js/api/factory/noten.js | 11 +- .../Cis/Benotungstool/Benotungstool.js | 1260 +++++++---------- .../Cis/Benotungstool/notenRules.js | 57 +- system/phrasesupdate.php | 160 +++ .../e2e/specs/api/noten/noten.bulk.cy.js | 41 +- .../e2e/specs/api/noten/noten.frist.cy.js | 4 +- .../api/noten/noten.pruefungsantritte.cy.js | 340 +++-- .../api/noten/noten.pruefungstermin.cy.js | 259 ++-- tests/cypress/support/api/notenApi.js | 29 +- .../cypress/support/helpers/notenScenario.js | 31 +- .../cypress/support/helpers/notenTestData.js | 44 +- 17 files changed, 1715 insertions(+), 1667 deletions(-) diff --git a/application/config/noten.php b/application/config/noten.php index 7bdfce1ad..63ad9d911 100644 --- a/application/config/noten.php +++ b/application/config/noten.php @@ -2,22 +2,51 @@ if (!defined('BASEPATH')) exit('No direct script access allowed'); -// 'entschuldigt' & 'noch nicht eingetragen' -> wirken sich nicht auf Antritte aus +// Noten die keinen Prüfungsantritt verbrauchen. Fallback-PKs; massgeblich sind die per Bezeichnung +// aufgelösten Noten (NOTEN_OHNE_ANTRITT_BEZEICHNUNGEN), da die PKs je Installation abweichen. $config['NOTEN_OHNE_ANTRITT'] = [9, 17]; // tbl_note pk -$config['NOTEN_OCCURANCE_LIMIT_MAP'] = [17 => 1]; // across the 4 fixed antritte only one can be entschuldigt +// 'Nicht beurteilt' ist eine Altlast: formal eine Note, inhaltlich keine Leistungsfeststellung. +// Wird weiterhin häufig vergeben und darf daher keinen Antritt kosten. +$config['NOTEN_OHNE_ANTRITT_BEZEICHNUNGEN'] = ['Noch nicht eingetragen', 'entschuldigt', 'Nicht beurteilt']; + +// Anrechnungsnoten: die Leistung wurde vorab anerkannt, der/die Studierende ist zwar Teil der +// Lehrveranstaltung, tritt aber zu keiner Prüfung an. Solange die ZEUGNISNOTE eine davon ist, sind +// für diese Lehrveranstaltung keine Prüfungen möglich. Auflösung wie oben über die Bezeichnung, +// die PKs sind nur Fallback. +$config['NOTEN_ANRECHNUNG_BEZEICHNUNGEN'] = ['angerechnet', 'intern angerechnet']; +$config['NOTEN_ANRECHNUNG'] = [6, 16]; // tbl_note pk + +$config['NOTEN_OCCURANCE_LIMIT_MAP'] = [17 => 1]; // über alle Antritte hinweg nur ein entschuldigt // tbl_note pk of the 'entschuldigt' note. An entschuldigt Termin is preserved as its own dated // entry when a new pruefung of the same type is created (instead of being overwritten). $config['NOTE_ENTSCHULDIGT'] = 17; +// Maximale Anzahl zählender Prüfungsantritte, die IN DIESEM TOOL eingetragen werden können +// (inkl. erstem Antritt). null = aus den Alt-Flags CIS_GESAMTNOTE_PRUEFUNG_TERMIN2/TERMIN3 +// ableiten (1 + je aktiviertem Termin), damit bestehende Installationen unverändert laufen. +// Der abschliessende Antritt (kommPruef) wird woanders eingetragen und zählt hier nicht mit. +$config['CIS_GESAMTNOTE_MAX_ANTRITTE'] = null; + +// Prüfungstypen die den Verlauf abschliessen: danach ist kein weiterer Antritt möglich. Das ist +// die einzige Stelle, an der der Typ eines Termins noch eine Bedeutung trägt. +$config['PRUEFUNG_TERMINAL_TYPEN'] = ['kommPruef', 'zusKommPruef']; + +// Spaltenaufteilung der Prüfungen in der Notentabelle: +// 'antritt' - eine Spalte je Antrittsnummer, Datum steht in der Zelle (robust bei Einzelterminen) +// 'datum' - eine Spalte je Prüfungsdatum (kompakt, wenn ganze Jahrgänge am selben Tag antreten) +// Nur die Vorgabe; im Tool umschaltbar und pro Benutzer gespeichert. +$config['CIS_GESAMTNOTE_PRUEFUNGSSPALTEN'] = 'antritt'; + +// Bei der Notenübernahme durch die Assistenz den ersten Antritt als eigene Prüfung anlegen. +$config['CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME'] = true; + // availability of the two Benotungstool import flows. When both are true they are shown as // separate buttons/dialogs. $config['CIS_GESAMTNOTE_PRUEFUNGSIMPORT'] = true; // dated import that creates a pruefung per row $config['CIS_GESAMTNOTE_NOTENIMPORT'] = false; // classic note-only import (uid + note, no date) -$config['SHOW_BENOTUNGSDATUM_ON_NOTENVORSCHLAG_UEBERNAHME'] = true; - // Noteneintragungsfrist (Prüfungsordnung §1): grade/pruefung entry is only permitted up to this // deadline. The month/day below is applied to the studiensemester's year: // Sommersemester (SSyyyy) -> deadline in the SAME calendar year (default 15th November) diff --git a/application/controllers/api/frontend/v1/Noten.php b/application/controllers/api/frontend/v1/Noten.php index d2dc0c8ef..c5a60a58f 100644 --- a/application/controllers/api/frontend/v1/Noten.php +++ b/application/controllers/api/frontend/v1/Noten.php @@ -47,6 +47,7 @@ class Noten extends FHCAPI_Controller $this->load->library('AuthLib', null, 'AuthLib'); $this->load->library('PhrasesLib'); + $this->load->library('PruefungsverlaufLib', null, 'VerlaufLib'); // Loads LogLib with different debug trace levels to get data of the job that extends this class // It also specify parameters to set database fields @@ -90,7 +91,7 @@ class Noten extends FHCAPI_Controller public function getCisConfig() { // resolved from tbl_note (Bezeichnung) with config fallback -> single source of truth - $special = $this->resolveSpecialNotes(); + $special = $this->VerlaufLib->getSpecialNotes(); $NOTEN_OHNE_ANTRITT = $special['ohneAntritt']; $NOTEN_OCCURANCE_LIMIT_MAP = $special['limitMap']; $NOTE_ENTSCHULDIGT = $special['entschuldigt']; @@ -106,14 +107,13 @@ class Noten extends FHCAPI_Controller // only relevant in punkte calculation in backend // 'CIS_GESAMTNOTE_GEWICHTUNG' => CIS_GESAMTNOTE_GEWICHTUNG, - // enables the first in-tool retake (Termin2). - // Used in the maxAntritte calculation. - 'CIS_GESAMTNOTE_PRUEFUNG_TERMIN2' => CIS_GESAMTNOTE_PRUEFUNG_TERMIN2, + // Maximale Anzahl zählender Antritte in diesem Tool. Serverseitig abgeleitet (explizite + // Konfiguration oder Alt-Flags TERMIN2/TERMIN3); der Client rechnet das nicht mehr nach. + 'CIS_GESAMTNOTE_MAX_ANTRITTE' => $this->VerlaufLib->getMaxAntritte(), + + // Vorgabe der Spaltenaufteilung ('antritt' | 'datum'), im Tool umschaltbar + 'CIS_GESAMTNOTE_PRUEFUNGSSPALTEN' => $this->config->item('CIS_GESAMTNOTE_PRUEFUNGSSPALTEN'), - // legacy: enables a SECOND in-tool retake (Termin3) for other installations whose ruleset - // uses three explicit Nachprüfungs-Termine. Each enabled retake type raises the maxAntritte cap. - 'CIS_GESAMTNOTE_PRUEFUNG_TERMIN3' => CIS_GESAMTNOTE_PRUEFUNG_TERMIN3, - // used to toggle availability of kommPruef type pruefungen 'CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF' => CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF, @@ -142,9 +142,6 @@ class Noten extends FHCAPI_Controller // pk of the 'entschuldigt' note; used to preserve excused Termine on new pruefung creation 'NOTE_ENTSCHULDIGT' => $NOTE_ENTSCHULDIGT, - // show the Benotungsdatum of the first Antritt in the "Ursprüngliche Zeugnisnote" column - 'SHOW_BENOTUNGSDATUM_ON_NOTENVORSCHLAG_UEBERNAHME' => $this->config->item('SHOW_BENOTUNGSDATUM_ON_NOTENVORSCHLAG_UEBERNAHME'), - // Noteneintragungsfrist window (enforced server-side; also surfaced so the UI can hint at it) 'CIS_GESAMTNOTE_NOTENEINTRAGUNGSFRIST' => $this->config->item('CIS_GESAMTNOTE_NOTENEINTRAGUNGSFRIST') ) @@ -429,7 +426,34 @@ class Noten extends FHCAPI_Controller $pruefungen = $this->LePruefungModel->getPruefungenByLvStudiensemester($lv_id, $sem_kurzbz); $pruefungenData = getData($pruefungen); - $this->terminateWithSuccess(array($studentenData, $pruefungenData, DOMAIN, $grades, $anwresult)); + // Prüfungsverlauf je Student serverseitig ableiten: Position, Antrittsnummer, ob ein Termin + // einen Antritt verbraucht und ob noch einer möglich ist. Der Client wertet das nur noch aus, + // damit die Regeln nicht in zwei Implementierungen auseinanderlaufen. + $proStudent = []; + foreach($pruefungenData ?: [] as $p) { + $proStudent[$p->student_uid][] = $p; + } + + // Zeugnisnoten kommen bereits mit der Studentenliste (tbl_zeugnisnote.note) - keine + // Extra-Abfrage je Zeile nötig + $zeugnisnoten = []; + foreach($studentenData as $s) $zeugnisnoten[$s->uid] = $s->note; + + $pruefungenAbgeleitet = []; + foreach(array_unique(array_merge($student_uids, array_keys($proStudent))) as $uid) { + $lvNote = isset($grades[$uid]) ? ($grades[$uid]['note_lv'] ?? null) : null; + $verlauf = $this->VerlaufLib->buildVerlauf($proStudent[$uid] ?? [], $lvNote, $zeugnisnoten[$uid] ?? null); + + foreach($verlauf->pruefungen as $p) $pruefungenAbgeleitet[] = $p; + + if(isset($grades[$uid])) { + $grades[$uid]['verlauf'] = $this->verlaufSummary( + $verlauf, false, $this->getLvGesamtnoteRow($lv_id, $uid, $sem_kurzbz) !== null + ); + } + } + + $this->terminateWithSuccess(array($studentenData, $pruefungenAbgeleitet, DOMAIN, $grades, $anwresult)); } /** @@ -550,6 +574,14 @@ class Noten extends FHCAPI_Controller if(hasData($res)) { $lvgesamtnote = getData($res)[0]; $ret[] = array('uid' => $note->uid, 'freigabedatum' => $lvgesamtnote->freigabedatum, 'benotungsdatum' => $lvgesamtnote->benotungsdatum); + + // Mit der Freigabe wird die Note verbindlich - genau hier entsteht der + // erste Prüfungstermin. Das Anlegen einer Prüfung legt nie zusätzlich + // einen zweiten Termin an. + $this->upsertErstantritt( + $lv_id, $lvgesamtnote->student_uid, $sem_kurzbz, + $lvgesamtnote->note, $lvgesamtnote->punkte, $lvgesamtnote->freigabedatum + ); } } @@ -659,9 +691,9 @@ class Noten extends FHCAPI_Controller * expects 'datum', 'lva_id', 'student_uid', 'note' * Inserts or updates a pruefung for lva & student_uid at given datum (YYYY-MM-DD). When creating a new * Pruefung, sets the provided (Prüfungs-) Note. - * Updates the LvGesamtnote of student. - * Can return 1 or 2 Prüfungen, since the original grade before the first prüfung is being saved as "Termin1" when - * a "Termin2" is being created. + * Updates the LvGesamtnote of student (niemals die Zeugnisnote - die Übernahme passiert in der STV). + * Can return 1 or 2 Prüfungen: fehlt der erste Antritt als eigener Datensatz (Altdaten), wird er + * beim Anlegen einer Wiederholung aus der ursprünglichen LV-Note nachgetragen. */ public function saveStudentPruefung() { // einzelne pruefung speichern $result = $this->getPostJSON(); @@ -681,7 +713,6 @@ class Noten extends FHCAPI_Controller $pruefung_id = property_exists($result, 'pruefung_id') ? $result->pruefung_id : null; $stsem = $result->sem_kurzbz; - $typ = $result->typ; $this->assertLvAccess($lva_id, $stsem); @@ -712,91 +743,71 @@ class Noten extends FHCAPI_Controller $note = getData($result)[0]->note; } + $result = $this->savePruefungFuerStudent($pruefung_id, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum); + + // Validierungs- und Schreibfehler kommen als lokalisierte Meldung zurück + if(is_string($result)) $this->terminateWithError($result, 'general'); + + $savedPruefung = $result['savedPruefung'] ?? []; + $savedPruefungData = count($savedPruefung) > 0 ? $savedPruefung[0] : null; + $lvgesamtnote = $result['lvgesamtnote'] ?? null; + + // eine Aktion = ein Termin, daher kein zusätzlicher Datensatz mehr in der Antwort + $this->terminateWithSuccess(array($savedPruefungData, $lvgesamtnote, $result['verlauf'] ?? null)); + } + + /** + * Kern einer Prüfungseingabe für EINEN Studenten: prüfen, LV-Note schreiben, dann den Termin. + * + * Einzige Stelle, an der das passiert - der Dialog aus der Tabelle (saveStudentPruefung) und die + * Bulk-Endpunkte verwenden sie gleichermassen. Beide Wege tun damit exakt dasselbe, der Bulk-Weg + * nur für mehrere Studenten auf einmal. Die Zeugnisnote bleibt unberührt, deren Übernahme + * passiert in der Studierendenverwaltung. + * + * @return array|string ['savedPruefung' => [...], 'lvgesamtnote' => stdClass, 'verlauf' => [...]] + * oder eine lokalisierte Fehlermeldung + */ + private function savePruefungFuerStudent($pruefung_id, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum) + { // validate the edit before any write: the date must stay between the neighbouring exams and, - // once a later/higher pruefung exists, the grade may no longer be changed (only the date). + // once a later pruefung exists, the grade may no longer be changed (only the date). // only applies when editing an existing record ($pruefung_id set) $editError = $this->validatePruefungEdit($student_uid, $lva_id, $stsem, $note, $datum, $pruefung_id); - if($editError !== null) { - $this->terminateWithError($editError, 'general'); - } + if($editError !== null) return $editError; // for a NEW attempt (no pruefung_id) enforce the add rules server-side // maxAntritte calc, chronological order, occurrence limit for entschuldigt if($pruefung_id === null || $pruefung_id === '') { $addError = $this->validatePruefungAdd($student_uid, $lva_id, $stsem, $note, $datum); - if($addError !== null) { - $this->terminateWithError($addError, 'general'); - } + if($addError !== null) return $addError; } - - $this->load->model('education/Lehrveranstaltung_model', 'LehrveranstaltungModel'); - $this->load->model('organisation/Studiengang_model', 'StudiengangModel'); - - $res = $this->LehrveranstaltungModel->load($lva_id); - if(isError($res) || !hasData($res)) { - $this->terminateWithError($this->p->t('benotungstool', 'noValidLvFoundForId', [$lva_id])); + // fehlt die lehreinheit_id (der Client liefert sie nicht immer), serverseitig auflösen - + // sonst scheitert das Insert stillschweigend am NOT NULL + if(!$lehreinheit_id) { + $resLe = $this->LehrveranstaltungModel->getLeByStudent($student_uid, $stsem, $lva_id); + if(!isError($resLe) && hasData($resLe)) $lehreinheit_id = current(getData($resLe))->lehreinheit_id; } - $studiengang_kz = getData($res)[0]->studiengang_kz; - $res = $this->StudiengangModel->load($studiengang_kz); - if(isError($res) || !hasData($res)) { - $this->terminateWithError($this->p->t('benotungstool', 'noValidStudiengangFoundForId', [$studiengang_kz])); - } - + $jetzt = date("Y-m-d H:i:s"); - $result = $this->LvgesamtnoteModel->getLvGesamtNoten($lva_id, $student_uid, $stsem); + // ungefiltert: eine noch nicht freigegebene LV-Note existiert bereits und muss aktualisiert + // statt neu angelegt werden (sonst Verstoss gegen den Primärschlüssel) + $bestehendeLvNote = $this->getLvGesamtnoteRow($lva_id, $student_uid, $stsem); - $origLvNote = null; - $origLvPunkte = null; - $origBenotungsdatum = null; - -// // TODO: check if savePruefungstermin wont throw an error before updating/inserting lv note - // todo: also check if typ is wrong but another would be available? -// if(!$this->ableToSavePruefungstermin($typ)) - - if(!isError($result) && !hasData($result)) { - - $id = $this->LvgesamtnoteModel->insert( - array( - 'student_uid' => $student_uid, - 'lehrveranstaltung_id' => $lva_id, - 'studiensemester_kurzbz' => $stsem, - 'note' => $note, - 'punkte' => $punkte, - 'mitarbeiter_uid' => getAuthUID(), - 'benotungsdatum' => $jetzt, - 'freigabedatum' => null, - 'freigabevon_uid' => null, - 'bemerkung' => null, - 'updateamum' => null, - 'updatevon' => null, - 'insertamum' => $jetzt, - 'insertvon' => getAuthUID() - ) - ); - if($id) { - $res = $this->LvgesamtnoteModel->load($id->retval); - if(hasData($res)) $lvgesamtnote = getData($res)[0]; - } + // ursprüngliche Note festhalten, bevor sie überschrieben wird: sie entscheidet, ob die + // LV-Note als impliziter erster Antritt zählt + $origLvNote = $bestehendeLvNote ? $bestehendeLvNote->note : null; + $lvgesamtnote = $bestehendeLvNote; - $this->logLib->logInfoDB(array('saveStudentPruefung insert lvnote', $res, array( - 'insertvon' => getAuthUID(), - 'insertamum' => date('Y-m-d H:i:s') - ), getAuthUID(), getAuthPersonId(), $student_uid, $lva_id, $stsem, $note,$punkte, $jetzt)); + if($bestehendeLvNote === null) { + $lvgesamtnote = $this->createLvGesamtnote($lva_id, $student_uid, $stsem, $note, $punkte); - } - else if(!isError($result) && hasData($result)) - { - $lvgesamtnote = getData($result)[0]; - - $orig = getData($result)[0]; - $origLvNote = $lvgesamtnote->note; - $origLvPunkte = $lvgesamtnote->punkte; - $origBenotungsdatum = $lvgesamtnote->benotungsdatum; - + $this->logLib->logInfoDB(array('pruefung: lvnote angelegt', $student_uid, $lva_id, $stsem, + $note, $punkte, getAuthUID(), getAuthPersonId())); + } else { $id = $this->LvgesamtnoteModel->update( - [$lvgesamtnote->student_uid, $lvgesamtnote->studiensemester_kurzbz, $lvgesamtnote->lehrveranstaltung_id], + [$bestehendeLvNote->student_uid, $bestehendeLvNote->studiensemester_kurzbz, $bestehendeLvNote->lehrveranstaltung_id], array( 'note' => $note, 'punkte' => $punkte, @@ -810,42 +821,43 @@ class Noten extends FHCAPI_Controller $res = $this->LvgesamtnoteModel->load($id->retval); if(hasData($res)) $lvgesamtnote = getData($res)[0]; } - - $this->logLib->logInfoDB(array('saveStudentPruefung update lvnote', $res, array( - 'updatevon' => getAuthUID(), - 'updateamum' => date('Y-m-d H:i:s') - ), getAuthUID(), getAuthPersonId(), $student_uid, $lva_id, $stsem, $note,$punkte, $jetzt)); + + $this->logLib->logInfoDB(array('pruefung: lvnote aktualisiert', $student_uid, $lva_id, $stsem, + $note, $punkte, getAuthUID(), getAuthPersonId())); } - - // save pruefung after updating lvnote, since pruefungspunkte get loaded by lv punkte - $pruefungenChanged = $this->savePruefungstermin($typ, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum, $origLvNote, $origLvPunkte, $origBenotungsdatum); - $savedPruefung = $pruefungenChanged['savedPruefung'] ?? null; - $extraPruefung = $pruefungenChanged['extraPruefung'] ?? null; + // save pruefung after updating lvnote, since pruefungspunkte get loaded by lv punkte + $pruefungenChanged = $this->savePruefungstermin($pruefung_id, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum, $origLvNote); + if(is_string($pruefungenChanged)) return $pruefungenChanged; - $savedPruefungData = count($savedPruefung) > 0 ? $savedPruefung[0] : null; - $extraPruefungData = count($extraPruefung) > 0 ? $extraPruefung[0] : null; - - $this->terminateWithSuccess(array($savedPruefungData, $lvgesamtnote, $extraPruefungData)); + $pruefungenChanged['lvgesamtnote'] = $lvgesamtnote; + + return $pruefungenChanged; } /** - * private helper method to update/insert pruefungstermine + * Legt einen Prüfungstermin an oder aktualisiert einen bestehenden. + * + * Termintyp-agnostisch: die Position eines Antritts ergibt sich aus dem Verlauf (Datum bzw. + * Reihenfolge), nicht aus pruefungstyp_kurzbz. Der Alt-Typ wird beim Schreiben nur noch als + * abgeleitete Rückwärtskompatibilitäts-Projektion mitgeschrieben (siehe PruefungsverlaufLib), + * damit bestehende Auswertungen weiterlaufen; zurückgelesen wird er nie. + * + * @param int $pruefung_id gesetzt = genau diesen Datensatz bearbeiten, null = neuer Antritt + * @return array|string geänderte Prüfungen oder eine lokalisierte Fehlermeldung */ - private function savePruefungstermin($typ, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum, $origLvNote = null, $origLvPunkte = null, $origBenotungsdatum = null) + private function savePruefungstermin($pruefung_id, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum, $origLvNote = null) { + // allocating pruefungen before lv note is forbidden (ungefiltert: auch eine noch nicht + // freigegebene LV-Note zählt als vorhanden, siehe getLvGesamtnoteRow) + if($this->getLvGesamtnoteRow($lva_id, $student_uid, $stsem) === null) { + return $this->p->t('benotungstool', 'c4keineLvNoteEingetragen'); + } - // extra check if the student has lvnote and a zeugnisnote in the relevant lva - $resultLV = $this->LvgesamtnoteModel->getLvGesamtNoten($lva_id, $student_uid, $stsem); - $lvgesamtnoteData = getData($resultLV); -// $this->addMeta('lvgesamtnoteData', $lvgesamtnoteData); - - // allocating pruefungen before lv note is forbidden - if($lvgesamtnoteData == null) return $this->p->t('benotungstool', 'c4keineLvNoteEingetragen'); + $pruefungen = $this->VerlaufLib->getPruefungen($student_uid, $lva_id, $stsem); $status = []; - - // TODO: config here? + // send $grades reference to moodle addon Events::trigger( 'getEntschuldigungsStatusForStudentOnDate', @@ -858,261 +870,151 @@ class Noten extends FHCAPI_Controller 'datum' => $datum ] ); - + if(count($status) > 0 && $status[0] == true) { - $entschuldigtNote = $this->resolveSpecialNotes()['entschuldigt']; + $entschuldigtNote = $this->VerlaufLib->getSpecialNotes()['entschuldigt']; // the occurrence limit was validated on the PRE-override note, so re-check it here: only // apply the auto-excuse if it would not exceed the allowed number of excused Termine - if($entschuldigtNote !== null) { - $allRes = $this->LePruefungModel->getPruefungenByUidTypLvStudiensemester($student_uid, null, $lva_id, $stsem); - $existing = (!isError($allRes) && hasData($allRes)) ? getData($allRes) : []; - if(!$this->exceedsNoteOccuranceLimit($existing, $entschuldigtNote, null)) { - $note = $entschuldigtNote; - } + if($entschuldigtNote !== null + && !$this->VerlaufLib->ueberschreitetNotenLimit($pruefungen, $entschuldigtNote, $pruefung_id)) { + $note = $entschuldigtNote; } } - + $jetzt = date("Y-m-d H:i:s"); - + $pruefungenChanged = []; - - if($typ == "Termin2" && defined('CIS_GESAMTNOTE_PRUEFUNG_TERMIN2') && CIS_GESAMTNOTE_PRUEFUNG_TERMIN2) - { - - // Wenn eine Nachprüfung angelegt wird, wird zuerst eine Pruefung mit 1. Termin angelegt welche für die ursprüngliche Note - // vor den Prüfungsantritten zählt - - $result1 = $this->LePruefungModel->getPruefungenByUidTypLvStudiensemester($student_uid, "Termin1", $lva_id, $stsem); - - // if there is a termin 1 entry already do nothing - if(!isError($result1) && hasData($result1)) { - } else if(!isError($result1) && !hasData($result1)) { - // new entry termin1 - - $resultLV = $this->LvgesamtnoteModel->getLvGesamtNoteVorschlag($lva_id, $student_uid, $stsem); - - // update Termin1 note - if ($origLvNote !== null) { - $pr_note = $origLvNote; - $pr_punkte = $origLvPunkte; - $benotungsdatum = $origBenotungsdatum ?? $jetzt; - } - else if (hasData($resultLV)) - { - $lvgesamtnote = getData($resultLV)[0]; - $pr_note = $lvgesamtnote->note; - $pr_punkte = $lvgesamtnote->punkte; - $benotungsdatum = $lvgesamtnote->benotungsdatum; - } - else if(!hasData($resultLV))// set Termin1 note to "noch nicht eingetragen" - { - $this->load->model('education/Note_model', 'NoteModel'); - $result = $this->NoteModel->getNochNichtEingetragenNote(); - $pr_note = getData($result)[0]->note; - $pr_punkte = ''; - $benotungsdatum = $jetzt; - } - - $id = $this->LePruefungModel->insert( - array( - 'lehreinheit_id' => $lehreinheit_id, - 'student_uid' => $student_uid, - 'mitarbeiter_uid' => getAuthUID(), - 'note' => $pr_note, - 'punkte' => $pr_punkte, - 'pruefungstyp_kurzbz' => "Termin1", - 'datum' => $benotungsdatum, - 'anmerkung' => "", - 'insertamum' => $jetzt, - 'insertvon' => getAuthUID(), - 'updateamum' => null, - 'updatevon' => null, - 'ext_id' => null - ) - ); - if($id) { - $res = $this->LePruefungModel->load($id->retval); - if(hasData($res)) $pruefungenChanged['extraPruefung'] = getData($res); - } - - $this->logLib->logInfoDB(array('termin1 created',$res, getAuthUID(), getAuthPersonId())); - + // Bearbeitung: der adressierte Datensatz wird geändert, seine Position im Verlauf ergibt + // sich danach wieder aus dem Datum. Kein Typwechsel, keine Neuanlage. + if($pruefung_id !== null && $pruefung_id !== '') { + $id = $this->LePruefungModel->update( + $pruefung_id, + array( + 'updateamum' => $jetzt, + 'updatevon' => getAuthUID(), + 'note' => $note, + 'punkte' => $punkte, + 'datum' => $datum, + 'anmerkung' => "" + ) + ); + if($id) { + $res = $this->LePruefungModel->load($id->retval); + if(hasData($res)) $pruefungenChanged['savedPruefung'] = getData($res); } - + $this->logLib->logInfoDB(array('pruefung updated', $res, getAuthUID(), getAuthPersonId())); - - // Die Pruefung wird als Termin2 eingetragen. - // Ein bestehender "entschuldigt"-Termin2 bleibt erhalten: in diesem Fall wird die neue - // Pruefung als eigener Datensatz angelegt statt den entschuldigten zu ueberschreiben. - $result2 = $this->LePruefungModel->getPruefungenByUidTypLvStudiensemester($student_uid, "Termin2", $lva_id, $stsem); - - $termin2 = null; - if(!isError($result2) && hasData($result2)) { - foreach(getData($result2) as $t2) { - if(!$this->isEntschuldigtNote($t2->note)) { $termin2 = $t2; break; } - } + if(!isset($pruefungenChanged['savedPruefung'])) { + return $this->p->t('benotungstool', 'c4pruefungNichtGespeichert', [$student_uid]); } - if($termin2 !== null) { - // update existing (non-excused) Termin2 - $id = $this->LePruefungModel->update( - $termin2->pruefung_id, - array( - 'updateamum' => $jetzt, - 'updatevon' => getAuthUID(), - 'note' => $note, - 'punkte' => $punkte, - 'datum' => $datum, - 'anmerkung' => "" - ) - ); - if($id) { - $res = $this->LePruefungModel->load($id->retval); - if(hasData($res)) $pruefungenChanged['savedPruefung'] = getData($res); - } - - $this->logLib->logInfoDB(array('termin2 updated',$res, getAuthUID(), getAuthPersonId())); - - - } else if(!isError($result2)) { - // no editable Termin2 (none yet, or only an entschuldigt one) -> insert new, excused stays - - $id = $this->LePruefungModel->insert( - array( - 'lehreinheit_id' => $lehreinheit_id, - 'student_uid' => $student_uid, - 'mitarbeiter_uid' => getAuthUID(), - 'note' => $note, - 'punkte' => $punkte, - 'pruefungstyp_kurzbz' => $typ, - 'datum' => $datum, - 'anmerkung' => "", - 'insertamum' => $jetzt, - 'insertvon' => getAuthUID(), - 'updateamum' => null, - 'updatevon' => null, - 'ext_id' => null - ) - ); - if($id) { - $res = $this->LePruefungModel->load($id->retval); - if(hasData($res)) $pruefungenChanged['savedPruefung'] = getData($res); - } - - $this->logLib->logInfoDB(array('termin2 inserted',$res, getAuthUID(), getAuthPersonId())); - - } - - } - else if($typ == "Termin3" && defined('CIS_GESAMTNOTE_PRUEFUNG_TERMIN3') && CIS_GESAMTNOTE_PRUEFUNG_TERMIN3) - { - // same entschuldigt-preservation handling as Termin2 - $result3 = $this->LePruefungModel->getPruefungenByUidTypLvStudiensemester($student_uid, "Termin3", $lva_id, $stsem); - - $termin3 = null; - if(!isError($result3) && hasData($result3)) { - foreach(getData($result3) as $t3) { - if(!$this->isEntschuldigtNote($t3->note)) { $termin3 = $t3; break; } - } - } - - if($termin3 !== null) { - // update existing (non-excused) Termin3 - $id = $this->LePruefungModel->update( - $termin3->pruefung_id, - array( - 'updateamum' => $jetzt, - 'updatevon' => getAuthUID(), - 'note' => $note, - 'punkte' => $punkte, - 'datum' => $datum, - 'anmerkung' => "" - ) - ); - if($id) { - $res = $this->LePruefungModel->load($id->retval); - if(hasData($res)) $pruefungenChanged['savedPruefung'] = getData($res); - } - - $this->logLib->logInfoDB(array('termin3 updated',$res, getAuthUID(), getAuthPersonId())); - - } else if(!isError($result3)) { - // no editable Termin3 (none yet, or only an entschuldigt one) -> insert new, excused stays - - $id = $this->LePruefungModel->insert( - array( - 'lehreinheit_id' => $lehreinheit_id, - 'student_uid' => $student_uid, - 'mitarbeiter_uid' => getAuthUID(), - 'note' => $note, - 'punkte' => $punkte, - 'pruefungstyp_kurzbz' => $typ, - 'datum' => $datum, - 'anmerkung' => "", - 'insertamum' => $jetzt, - 'insertvon' => getAuthUID(), - 'updateamum' => null, - 'updatevon' => null, - 'ext_id' => null - ) - ); - if($id) { - $res = $this->LePruefungModel->load($id->retval); - if(hasData($res)) $pruefungenChanged['savedPruefung'] = getData($res); - } - - $this->logLib->logInfoDB(array('termin3 inserted',$res, getAuthUID(), getAuthPersonId())); - - } + $pruefungenChanged['verlauf'] = $this->buildVerlaufSummary($student_uid, $lva_id, $stsem); + return $pruefungenChanged; } - else { - $this->terminateWithError($this->p->t('benotungstool', 'wrongPruefungType', [$student_uid, $typ]), 'general'); + + $verlauf = $this->VerlaufLib->buildVerlauf($pruefungen, $origLvNote); + $rolle = $verlauf->naechsteRolle; + + // Eine Aktion legt genau EINEN Termin an. Der erste Antritt entsteht nicht beiläufig hier, + // sondern bei der Notenfreigabe (upsertErstantritt) - dort wird die Note verbindlich. + + $typ = ($rolle === PruefungsverlaufLib::ROLLE_ERSTANTRITT) + ? $this->VerlaufLib->legacyTypFuerAntritt(1) + : $this->VerlaufLib->legacyTypFuerWiederholung($verlauf); + + $id = $this->LePruefungModel->insert( + array( + 'lehreinheit_id' => $lehreinheit_id, + 'student_uid' => $student_uid, + 'mitarbeiter_uid' => getAuthUID(), + 'note' => $note, + 'punkte' => $punkte, + 'pruefungstyp_kurzbz' => $typ, + 'datum' => $datum, + 'anmerkung' => "", + 'insertamum' => $jetzt, + 'insertvon' => getAuthUID(), + 'updateamum' => null, + 'updatevon' => null, + 'ext_id' => null + ) + ); + if($id) { + $res = $this->LePruefungModel->load($id->retval); + if(hasData($res)) $pruefungenChanged['savedPruefung'] = getData($res); } - + + $this->logLib->logInfoDB(array('pruefung inserted ('.$rolle.')', $res, getAuthUID(), getAuthPersonId())); + + // Schlägt das Insert fehl (zB fehlende lehreinheit_id), darf hier KEIN Erfolg zurückkommen: + // der Client wertet die Antwort sonst als angelegt und zeigt einen leeren Verlauf an. + if(!isset($pruefungenChanged['savedPruefung'])) { + return $this->p->t('benotungstool', 'c4pruefungNichtGespeichert', [$student_uid]); + } + + $pruefungenChanged['verlauf'] = $this->buildVerlaufSummary($student_uid, $lva_id, $stsem); return $pruefungenChanged; } /** - * ranking of the pruefung attempt types, used to detect whether a "höhere" - * (later attempt) pruefung exists for a student + * Verlaufskennzahlen und die abgeleiteten Termine eines Studenten für den Client. Wird nach + * jeder Änderung mitgeliefert, damit die Oberfläche die Regeln nicht selbst nachrechnet und + * ihre Zeilen einfach aus dem Serverstand neu aufbauen kann. */ - private function pruefungAttemptRank($typ) + private function buildVerlaufSummary($student_uid, $lva_id, $stsem) { - switch($typ) { - case 'Termin1': return 1; - case 'Termin2': return 2; - case 'Termin3': return 3; - case 'kommPruef': return 4; - default: return 0; - } + $lvNote = $this->getLvGesamtnoteRow($lva_id, $student_uid, $stsem); + $verlauf = $this->VerlaufLib->getVerlauf( + $student_uid, $lva_id, $stsem, + $lvNote ? $lvNote->note : null, + $this->getZeugnisnote($lva_id, $student_uid, $stsem) + ); + + return $this->verlaufSummary($verlauf, true, $lvNote !== null); } /** - * whether a note is the configured 'entschuldigt' note. Such Termine are preserved as their - * own dated entry instead of being overwritten when a new pruefung of the same type is created. + * @param bool $withPruefungen Termine mitliefern (Schreibpfade) oder nur die Kennzahlen + * @return array */ - private function isEntschuldigtNote($note) + private function verlaufSummary($verlauf, $withPruefungen = false, $hatLvNote = null) { - $entschuldigt = $this->resolveSpecialNotes()['entschuldigt']; - return $entschuldigt !== null && $note == $entschuldigt; + $summary = array( + 'antrittCount' => $verlauf->antrittCount, + 'maxAntritte' => $verlauf->maxAntritte, + 'canAdd' => $verlauf->canAdd, + 'terminal' => $verlauf->terminal, + 'erstantrittMoeglich' => $verlauf->erstantrittMoeglich, + 'naechsteRolle' => $verlauf->naechsteRolle, + // Zeugnisnote ist eine Anrechnung -> Zeile wird angezeigt, ist aber nicht auswählbar und + // bekommt keine Prüfungen + 'angerechnet' => $verlauf->angerechnet, + // ob überhaupt eine LV-Note existiert - ungefiltert, also inklusive noch nicht + // freigegebener. Die Oberfläche kennt über lv_note nur die freigegebene. + 'hatLvNote' => $hatLvNote + ); + + if($withPruefungen) $summary['pruefungen'] = $verlauf->pruefungen; + + return $summary; } /** - * Validates an edit to an existing Termin2/Termin3 pruefung. Returns a localized error - * string if the edit is not allowed, or null if it is. Mirrors the frontend guards so a - * disallowed edit cannot be forced through the API. + * Validates an edit to an existing pruefung. Returns a localized error string if the edit is + * not allowed, or null if it is. Mirrors the frontend guards so a disallowed edit cannot be + * forced through the API. * * Rules: * - The new $datum must stay strictly between the dates of the chronologically adjacent * pruefungen so the attempt order is preserved. - * - Once a later-dated or higher-attempt pruefung already exists the grade may no longer be - * changed (only the datum may be corrected within the bounds above). + * - Once a later attempt exists the grade may no longer be changed (only the datum may be + * corrected within the bounds above). * - * Only guards EDITS: $pruefung_id identifies the record being edited; when it is null (a new - * attempt is being added) nothing is restricted here (adds are validated client-side). + * Only guards EDITS: $pruefung_id identifies the record being edited; when it is null a new + * attempt is being added and validatePruefungAdd applies instead. * * @param string $student_uid * @param int $lva_id @@ -1126,43 +1028,44 @@ class Noten extends FHCAPI_Controller { if($pruefung_id === null || $pruefung_id === '') return null; // add, not an edit - $result = $this->LePruefungModel->getPruefungenByUidTypLvStudiensemester($student_uid, null, $lva_id, $stsem); - if(isError($result) || !hasData($result)) return null; + // Anrechnung: die Leistung wurde vorab anerkannt, an bestehenden Terminen wird nichts geändert + $zeugnisNote = $this->getZeugnisnote($lva_id, $student_uid, $stsem); + if($this->VerlaufLib->istAnrechnungsnote($zeugnisNote)) { + return $this->p->t('benotungstool', 'c4angerechnetKeinePruefung', [$student_uid]); + } - $pruefungen = getData($result); + $pruefungen = $this->VerlaufLib->getPruefungen($student_uid, $lva_id, $stsem); + if(count($pruefungen) === 0) return null; + + $verlauf = $this->VerlaufLib->buildVerlauf($pruefungen, null, $zeugnisNote); // the record being edited $current = null; - foreach($pruefungen as $p) { + foreach($verlauf->pruefungen as $p) { if($p->pruefung_id == $pruefung_id) { $current = $p; break; } } if($current === null) return null; - // only the changeable resit grades are guarded - if($current->pruefungstyp_kurzbz !== 'Termin2' && $current->pruefungstyp_kurzbz !== 'Termin3') return null; + // abschliessende Termine (kommPruef) werden in einem anderen Tool gepflegt + if($current->terminal) return null; - $currentRank = $this->pruefungAttemptRank($current->pruefungstyp_kurzbz); $currentDate = substr((string)$current->datum, 0, 10); $new = substr((string)$newDatum, 0, 10); - // chronological bounds from the immediate date-neighbours + detect later/higher pruefung - $lower = null; $upper = null; $hasLaterOrHigher = false; - foreach($pruefungen as $p) { + // chronologische Grenzen aus den unmittelbaren Datums-Nachbarn + $lower = null; $upper = null; + foreach($verlauf->pruefungen as $p) { if($p->pruefung_id == $current->pruefung_id) continue; $d = substr((string)$p->datum, 0, 10); - if($d !== '') { - if($d < $currentDate) { if($lower === null || $d > $lower) $lower = $d; } - elseif($d > $currentDate) { if($upper === null || $d < $upper) $upper = $d; } - } + if($d === '') continue; - if($d > $currentDate || $this->pruefungAttemptRank($p->pruefungstyp_kurzbz) > $currentRank) { - $hasLaterOrHigher = true; - } + if($d < $currentDate) { if($lower === null || $d > $lower) $lower = $d; } + elseif($d > $currentDate) { if($upper === null || $d < $upper) $upper = $d; } } - // grade is locked once a later/higher pruefung exists - if($hasLaterOrHigher && $newNote != $current->note) { + // grade is locked once a later attempt exists + if($this->VerlaufLib->hatSpaeterenTermin($verlauf, $current->pruefung_id) && $newNote != $current->note) { return $this->p->t('benotungstool', 'pruefungNoteLocked', [$student_uid]); } @@ -1172,7 +1075,7 @@ class Noten extends FHCAPI_Controller } // an edit may also change the note (e.g. to 'entschuldigt') -> keep the occurrence limit - if($this->exceedsNoteOccuranceLimit($pruefungen, $newNote, $current->pruefung_id)) { + if($this->VerlaufLib->ueberschreitetNotenLimit($verlauf->pruefungen, $newNote, $current->pruefung_id)) { return $this->p->t('benotungstool', 'noteOccuranceLimitReached', [$student_uid]); } @@ -1181,10 +1084,11 @@ class Noten extends FHCAPI_Controller /** * Validates ADDING a new pruefung (attempt). Returns an error string if the add is not - * allowed, or null if it is.. + * allowed, or null if it is. * * Rules (Prüfungsordnung §1): - * - Rule A: the number of counting Prüfungsantritte may not exceed the configured maximum + * - Rule A: die Zahl der zählenden Prüfungsantritte darf das Maximum nicht überschreiten, und + * nach einem abschliessenden Termin ist kein weiterer Antritt mehr möglich * - Rule B: attempts are taken in chronological order * - Rule C: a note carrying an occurrence limit (entschuldigt) may not exceed it. * @@ -1197,17 +1101,24 @@ class Noten extends FHCAPI_Controller */ private function validatePruefungAdd($student_uid, $lva_id, $stsem, $note, $datum) { - $result = $this->LePruefungModel->getPruefungenByUidTypLvStudiensemester($student_uid, null, $lva_id, $stsem); - $pruefungen = (!isError($result) && hasData($result)) ? getData($result) : []; + // Anrechnung: wer die Lehrveranstaltung angerechnet bekommen hat, tritt zu keiner Prüfung an + $zeugnisNote = $this->getZeugnisnote($lva_id, $student_uid, $stsem); + if($this->VerlaufLib->istAnrechnungsnote($zeugnisNote)) { + return $this->p->t('benotungstool', 'c4angerechnetKeinePruefung', [$student_uid]); + } + + $pruefungen = $this->VerlaufLib->getPruefungen($student_uid, $lva_id, $stsem); // current LV note (the implicit first Antritt), read before it gets mutated by the caller - $lvNote = null; - $resLv = $this->LvgesamtnoteModel->getLvGesamtNoten($lva_id, $student_uid, $stsem); - if(!isError($resLv) && hasData($resLv)) $lvNote = getData($resLv)[0]->note; + $lvRow = $this->getLvGesamtnoteRow($lva_id, $student_uid, $stsem); + $lvNote = $lvRow ? $lvRow->note : null; - // Rule A: max Antritte - if($this->computeAntrittCount($pruefungen, $lvNote) >= $this->computeMaxAntritte()) { - return $this->p->t('benotungstool', 'maxAntritteReached', [$student_uid, $this->computeMaxAntritte()]); + $verlauf = $this->VerlaufLib->buildVerlauf($pruefungen, $lvNote, $zeugnisNote); + + // Rule A: max Antritte. Der erste Antritt materialisiert nur die ohnehin als Antritt gezählte + // LV-Note und erhöht die Zahl der Antritte daher nicht. + if($verlauf->naechsteRolle !== PruefungsverlaufLib::ROLLE_ERSTANTRITT && !$verlauf->canAdd) { + return $this->p->t('benotungstool', 'maxAntritteReached', [$student_uid, $verlauf->maxAntritte]); } // Rule B: no new attempt on/before an existing dated attempt @@ -1219,131 +1130,14 @@ class Noten extends FHCAPI_Controller } } - // Rule C: occurrence limit (e.g. only one 'entschuldigt' across the fixed Antritte) - if($this->exceedsNoteOccuranceLimit($pruefungen, $note, null)) { + // Rule C: occurrence limit (e.g. only one 'entschuldigt' across the Antritte) + if($this->VerlaufLib->ueberschreitetNotenLimit($pruefungen, $note, null)) { return $this->p->t('benotungstool', 'noteOccuranceLimitReached', [$student_uid]); } return null; } - /** - * Number of counting Prüfungsantritte for a student, mirroring the frontend getAntrittCountStudent: - * a kommPruef caps the count at 4, notes in NOTEN_OHNE_ANTRITT (entschuldigt / noch nicht - * eingetragen) do not count, and when no pruefung counts yet the original LV note counts as the - * first Antritt (unless it is a non-lehre note such as 'angerechnet'). - */ - private function computeAntrittCount($pruefungen, $lvNote) - { - $notenOhneAntritt = $this->resolveSpecialNotes()['ohneAntritt']; - - foreach($pruefungen as $p) { - if($p->pruefungstyp_kurzbz == 'kommPruef') return 4; - } - - $count = 0; - foreach($pruefungen as $p) { - if(!in_array($p->note, $notenOhneAntritt)) $count++; - } - - if($count === 0 && $lvNote !== null && $this->isLehreNote($lvNote)) return 1; - - return $count; - } - - /** - * Maximum number of counting Prüfungsantritte enterable IN THIS TOOL, mirroring the frontend - * maxAntrittCount: the original note (always 1) plus each enabled in-tool retake type (Termin2 and - * the legacy Termin3). kommPruef is the terminal attempt, entered elsewhere, and is intentionally - * not part of this cap. For our installation (Termin2 on, Termin3 off) this is 2; excused Termine do - * not count (see computeAntrittCount), which is what lets Termin2 occur twice within the cap. - */ - private function computeMaxAntritte() - { - $max = 1; - if(defined('CIS_GESAMTNOTE_PRUEFUNG_TERMIN2') && CIS_GESAMTNOTE_PRUEFUNG_TERMIN2) $max++; - if(defined('CIS_GESAMTNOTE_PRUEFUNG_TERMIN3') && CIS_GESAMTNOTE_PRUEFUNG_TERMIN3) $max++; - return $max; - } - - /** - * Whether the given note is flagged as a 'lehre' note in tbl_note. - */ - private function isLehreNote($note) - { - $res = $this->NoteModel->load($note); - if(isError($res) || !hasData($res)) return false; - return (bool) getData($res)[0]->lehre; - } - - /** - * Single source of truth for the "special" note PKs (entschuldigt & noch nicht eingetragen). - * tbl_note is resolved by Bezeichnung (install-independent) and takes precedence; the hardcoded - * config PKs (NOTE_ENTSCHULDIGT / NOTEN_OHNE_ANTRITT) are only a fallback. This removes the second - * source of truth so the entschuldigt-preservation and Antritt-counting logic can't disagree with - * the Bezeichnung lookups on installs whose PKs differ. Cached per request. - * - * @return array{entschuldigt: mixed, ohneAntritt: array, limitMap: array} - */ - private function resolveSpecialNotes() - { - static $cache = null; - if($cache !== null) return $cache; - - $cfgEntschuldigt = $this->config->item('NOTE_ENTSCHULDIGT'); - $cfgOhneAntritt = $this->config->item('NOTEN_OHNE_ANTRITT'); - $cfgLimitMap = $this->config->item('NOTEN_OCCURANCE_LIMIT_MAP'); - if(!is_array($cfgOhneAntritt)) $cfgOhneAntritt = []; - if(!is_array($cfgLimitMap)) $cfgLimitMap = []; - - // resolve by Bezeichnung, fall back to the config PKs - $resEnt = $this->NoteModel->getEntschuldigtNote(); - $entschuldigt = (!isError($resEnt) && hasData($resEnt)) ? getData($resEnt)[0]->note : $cfgEntschuldigt; - - $resNn = $this->NoteModel->getNochNichtEingetragenNote(); - $nochNicht = (!isError($resNn) && hasData($resNn)) ? getData($resNn)[0]->note : ($cfgOhneAntritt[0] ?? null); - - $ohneAntritt = array_values(array_filter([$nochNicht, $entschuldigt], function($v){ return $v !== null; })); - - // re-key the configured entschuldigt limit onto the resolved PK (keep any other entries as-is) - $limitMap = []; - foreach($cfgLimitMap as $k => $v) { - $limitMap[($k == $cfgEntschuldigt) ? $entschuldigt : $k] = $v; - } - - $cache = [ - 'entschuldigt' => $entschuldigt, - 'ohneAntritt' => $ohneAntritt, - 'limitMap' => $limitMap - ]; - return $cache; - } - - /** - * Check for configured occurrence limit (NOTEN_OCCURANCE_LIMIT_MAP) among the given pruefungen. - * $excludePruefungId skips the record currently being edited - * Noten without a configured limit never exceed. - */ - private function exceedsNoteOccuranceLimit($pruefungen, $note, $excludePruefungId = null) - { - $limitMap = $this->resolveSpecialNotes()['limitMap']; - if(!is_array($limitMap)) return false; - - $limit = null; - foreach($limitMap as $limitNote => $limitVal) { - if($limitNote == $note) { $limit = $limitVal; break; } - } - if($limit === null) return false; - - $count = 0; - foreach($pruefungen as $p) { - if($excludePruefungId !== null && $p->pruefung_id == $excludePruefungId) continue; - if($p->note == $note) $count++; - } - - return ($count + 1) > $limit; - } - /** * Enforces the Noteneintragungsfrist (Prüfungsordnung §1) for grade/pruefung entry. Terminates the * request with an error when the deadline for the given studiensemester has passed. Does nothing @@ -1568,22 +1362,22 @@ class Noten extends FHCAPI_Controller /** * POST METHOD - * expects 'uids', 'datum' - * Bulk variant of saveStudentPruefung, used when creating a new Prüfung for several students. Always sets note to - * "noch nicht eingetragen" for the created Prüfung. + * expects 'uids', 'datum', optional 'note'/'punkte' + * Bulk variant of saveStudentPruefung, used when creating a new Prüfung for several students. + * Ohne ausgewählte Note wird "noch nicht eingetragen" gesetzt. */ public function createPruefungen() { - $result = $this->getPostJSON(); + $payload = $this->getPostJSON(); - if(!property_exists($result, 'uids') || !property_exists($result, 'datum')) { + if(!property_exists($payload, 'uids') || !property_exists($payload, 'datum')) { $this->terminateWithError($this->p->t('global', 'missingParameters'), 'general'); } - $uids = $result->uids; - $datum = $result->datum; - $lva_id = $result->lva_id; + $uids = $payload->uids; + $datum = $payload->datum; + $lva_id = $payload->lva_id; - $stsem = $result->sem_kurzbz; + $stsem = $payload->sem_kurzbz; $this->assertLvAccess($lva_id, $stsem); @@ -1592,25 +1386,28 @@ class Noten extends FHCAPI_Controller $ret = []; - $this->load->model('education/Note_model', 'NoteModel'); - $result = $this->NoteModel->getNochNichtEingetragenNote(); - $note = getData($result)[0]->note; + $note = property_exists($payload, 'note') ? $payload->note : null; + $punkte = property_exists($payload, 'punkte') ? $payload->punkte : null; + // Punkteeingabe hat Vorrang: die Note kommt dann aus dem Notenschlüssel + if(CIS_GESAMTNOTE_PUNKTE && $punkte !== null && $punkte !== '' && $punkte >= 0) { + $resNote = $this->NotenschluesselaufteilungModel->getNote($punkte, $lva_id, $stsem); + $note = $this->getDataOrTerminateWithError($resNote); + } + + // ohne Auswahl bleibt der Termin unbenotet + if($note === null || $note === '') { + $resNote = $this->NoteModel->getNochNichtEingetragenNote(); + $note = getData($resNote)[0]->note; + $punkte = null; + } + + // identisch zum Dialog aus der Tabelle, nur für mehrere Studenten: derselbe Kern schreibt + // LV-Note und Termin. Fehler kommen je Zeile als lokalisierte Meldung zurück. foreach ($uids as $student) { - $student_uid = $student->uid; - $typ = $student->typ; - $punkte = null; // new pruefungen never have punkte, - - $lehreinheit_id = $student->lehreinheit_id; - - // server-side add guards (max Antritte, chronological order, occurrence limit) - $addError = $this->validatePruefungAdd($student_uid, $lva_id, $stsem, $note, $datum); - if($addError !== null) { - $ret[$student->uid] = $addError; // string result is surfaced per-student in the UI - continue; - } - - $ret[$student->uid] = $this->savePruefungstermin($typ, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum); + $ret[$student->uid] = $this->savePruefungFuerStudent( + null, $student->uid, $lva_id, $stsem, $student->lehreinheit_id, $note, $punkte, $datum + ); } $this->logLib->logInfoDB(array('createPruefungen',$ret, getAuthUID(), getAuthPersonId())); @@ -1651,22 +1448,11 @@ class Noten extends FHCAPI_Controller // $this->addMeta($pruefung->uid."note", $pruefung->note); } - $student_uid = $pruefung->uid; - $typ = $pruefung->typ; - $note = $pruefung->note; // TODO: parameterize for import maybe - $datum = $pruefung->datum; - $punkte = $pruefung->punkte; - - $lehreinheit_id = $pruefung->lehreinheit_id; - - // server-side add guards (max Antritte, chronological order, occurrence limit) - $addError = $this->validatePruefungAdd($student_uid, $lv_id, $sem_kurzbz, $note, $datum); - if($addError !== null) { - $ret[$student_uid] = $addError; // string result is surfaced per-student in the UI - continue; - } - - $ret[$student_uid] = $this->savePruefungstermin($typ, $student_uid, $lv_id, $sem_kurzbz, $lehreinheit_id, $note, $punkte, $datum); + // identisch zum Dialog aus der Tabelle, nur je Importzeile + $ret[$pruefung->uid] = $this->savePruefungFuerStudent( + null, $pruefung->uid, $lv_id, $sem_kurzbz, $pruefung->lehreinheit_id, + $pruefung->note, $pruefung->punkte, $pruefung->datum + ); } $this->logLib->logInfoDB(array('savePruefungenBulk',$ret, getAuthUID(), getAuthPersonId())); @@ -1674,6 +1460,138 @@ class Noten extends FHCAPI_Controller $this->terminateWithSuccess($ret); } + /** + * Aktuelle LV-Gesamtnote eines Studenten oder null. In den Bulk-Pfaden ist sie zugleich die + * ursprüngliche Note, weil dort - anders als in saveStudentPruefung - vorher nichts + * überschrieben wird. + */ + /** + * Legt den ersten Prüfungstermin an oder aktualisiert ihn (Upsert). + * + * Aufgerufen bei der Notenfreigabe: erst dort wird die Note verbindlich, deshalb entsteht genau + * dann der erste Antritt - und nicht beiläufig beim Anlegen einer Prüfung. Datiert auf das + * Freigabedatum. + * + * Greift nur, solange höchstens ein Termin existiert: sobald Wiederholungen erfasst sind, führt + * die LV-Note die zuletzt erreichte Note und dürfte den ersten Antritt nicht mehr überschreiben. + * Bei einer Anrechnung passiert nichts. + * + * @return void + */ + private function upsertErstantritt($lva_id, $student_uid, $stsem, $note, $punkte, $datum) + { + if($this->VerlaufLib->istAnrechnungsnote($this->getZeugnisnote($lva_id, $student_uid, $stsem))) return; + + $pruefungen = $this->VerlaufLib->getPruefungen($student_uid, $lva_id, $stsem); + if(count($pruefungen) > 1) return; + + $jetzt = date("Y-m-d H:i:s"); + + if(count($pruefungen) === 1) { + $this->LePruefungModel->update( + $pruefungen[0]->pruefung_id, + array( + 'note' => $note, + 'punkte' => $punkte, + 'datum' => $datum, + 'updateamum' => $jetzt, + 'updatevon' => getAuthUID() + ) + ); + + $this->logLib->logInfoDB(array('erstantritt aktualisiert (freigabe)', $student_uid, getAuthUID(), getAuthPersonId())); + return; + } + + // lehreinheit_id serverseitig auflösen statt sie vom Client zu übernehmen + $resLe = $this->LehrveranstaltungModel->getLeByStudent($student_uid, $stsem, $lva_id); + if(isError($resLe) || !hasData($resLe)) return; + $le = current(getData($resLe)); + + $this->LePruefungModel->insert( + array( + 'lehreinheit_id' => $le->lehreinheit_id, + 'student_uid' => $student_uid, + 'mitarbeiter_uid' => getAuthUID(), + 'note' => $note, + 'punkte' => $punkte, + 'pruefungstyp_kurzbz' => $this->VerlaufLib->legacyTypFuerAntritt(1), + 'datum' => $datum, + 'anmerkung' => "", + 'insertamum' => $jetzt, + 'insertvon' => getAuthUID(), + 'updateamum' => null, + 'updatevon' => null, + 'ext_id' => null + ) + ); + + $this->logLib->logInfoDB(array('erstantritt angelegt (freigabe)', $student_uid, getAuthUID(), getAuthPersonId())); + } + + /** + * Zeugnisnote eines Studenten in einer LV, oder null. Anrechnungen werden dort geführt (nicht in + * der LV-Note) und entscheiden darüber, ob überhaupt Prüfungen möglich sind. + * + * @return mixed|null + */ + private function getZeugnisnote($lva_id, $student_uid, $stsem) + { + $this->load->model('education/Zeugnisnote_model', 'ZeugnisnoteModel'); + + $res = $this->ZeugnisnoteModel->load([ + 'studiensemester_kurzbz' => $stsem, + 'student_uid' => $student_uid, + 'lehrveranstaltung_id' => $lva_id + ]); + + return (!isError($res) && hasData($res)) ? getData($res)[0]->note : null; + } + + private function getLvGesamtnoteRow($lva_id, $student_uid, $stsem) + { + // ACHTUNG: getLvGesamtNoten filtert auf 'freigabedatum < NOW()' und liefert daher NUR + // freigegebene Noten. Für die Frage "existiert eine LV-Note?" ist das falsch - eine gerade + // erst angelegte Note hat noch kein Freigabedatum und wäre unsichtbar (Endlos-Fehler + // "keine LV-Note eingetragen" bzw. doppelter Insert). Deshalb hier der ungefilterte Zugriff. + $res = $this->LvgesamtnoteModel->getLvGesamtNoteVorschlag($lva_id, $student_uid, $stsem); + return (!isError($res) && hasData($res)) ? getData($res)[0] : null; + } + + /** + * Legt eine LV-Gesamtnote an. Die Zeugnisnote bleibt unberührt - dieses Tool liest sie nur, + * die Übernahme in das Zeugnis passiert in der Studierendenverwaltung. + * + * @return stdClass|null + */ + private function createLvGesamtnote($lva_id, $student_uid, $stsem, $note, $punkte) + { + $jetzt = date("Y-m-d H:i:s"); + + $id = $this->LvgesamtnoteModel->insert( + array( + 'student_uid' => $student_uid, + 'lehrveranstaltung_id' => $lva_id, + 'studiensemester_kurzbz' => $stsem, + 'note' => $note, + 'punkte' => $punkte, + 'mitarbeiter_uid' => getAuthUID(), + 'benotungsdatum' => $jetzt, + 'freigabedatum' => null, + 'freigabevon_uid' => null, + 'bemerkung' => null, + 'updateamum' => null, + 'updatevon' => null, + 'insertamum' => $jetzt, + 'insertvon' => getAuthUID() + ) + ); + if(!$id) return null; + + $res = $this->LvgesamtnoteModel->load($id->retval); + return hasData($res) ? getData($res)[0] : null; + } + private function getAnwesenheiten($prestudent_ids, $lv_id, $sem_kurzbz) { $anwesenheiten = []; diff --git a/application/controllers/api/frontend/v1/stv/Grades.php b/application/controllers/api/frontend/v1/stv/Grades.php index 128316d2b..abc417bca 100644 --- a/application/controllers/api/frontend/v1/stv/Grades.php +++ b/application/controllers/api/frontend/v1/stv/Grades.php @@ -41,6 +41,9 @@ class Grades extends FHCAPI_Controller // Load Libraries $this->load->library('VariableLib', ['uid' => getAuthUID()]); + $this->load->library('PruefungsverlaufLib', null, 'VerlaufLib'); + + $this->load->config('noten'); // Load Phrases $this->loadPhrases([ @@ -355,23 +358,24 @@ class Grades extends FHCAPI_Controller $data['lehrveranstaltung_id'] = $lehrveranstaltung_id; $data['student_uid'] = $student_uid; $data['studiensemester_kurzbz'] = $studiensemester_kurzbz; - - $this->ZeugnisnoteModel->insert($data); - if (defined('FAS_PRUEFUNG_BEI_NOTENEINGABE_ANLEGEN') - && FAS_PRUEFUNG_BEI_NOTENEINGABE_ANLEGEN) { - $result = $this->addTestsForGrade( - $studiensemester_kurzbz, - $student_uid, - $lehrveranstaltung_id, - $teacherGrade->note, - $teacherGrade->punkte - ); - $this->getDataOrTerminateWithError($result); - } + $this->ZeugnisnoteModel->insert($data); + } + + // Der erste Antritt wird bereits bei der Übernahme als eigene Prüfung angelegt, damit der + // Prüfungsverlauf von Anfang an vollständig ist und das Benotungstool ihn nicht erst beim + // Anlegen einer Wiederholung nachtragen muss. Läuft bewusst in BEIDEN Zweigen (auch beim + // Überschreiben einer bestehenden Zeugnisnote) und ist idempotent. + if ($this->config->item('CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME')) { + $result = $this->addErstantrittForGrade( + $studiensemester_kurzbz, + $student_uid, + $lehrveranstaltung_id, + $teacherGrade + ); + $this->getDataOrTerminateWithError($result); } - $this->terminateWithSuccess(true); } @@ -491,20 +495,32 @@ class Grades extends FHCAPI_Controller } /** - * Helper function that adds tests for a student - * (Entries in lehre.tbl_pruefung) + * Legt bei der Notenübernahme den ersten Prüfungsantritt als eigenen Datensatz an + * (Eintrag in lehre.tbl_pruefung). * - * @param string $studiensemester_kurzbz - * @param string $student_uid - * @param integer $lehrveranstaltung_id - * @param integer $note - * @param numeric $punkte + * Idempotent: existiert für den Studenten in dieser LV bereits ein Termin, passiert nichts - + * die Übernahme darf beliebig oft laufen, ohne den Verlauf zu verfälschen. + * + * Datiert auf das Freigabedatum der LV-Note: das ist der Zeitpunkt, zu dem die + * Leistungsfeststellung verbindlich wurde. Das Benotungsdatum ändert sich bei jeder Korrektur, + * der Übernahmezeitpunkt ist reine Verwaltungsarbeit - beide taugen nicht als Termindatum. + * + * @param string $studiensemester_kurzbz + * @param string $student_uid + * @param integer $lehrveranstaltung_id + * @param stdClass $teacherGrade die übernommene LV-Gesamtnote * * @return stdClass */ - protected function addTestsForGrade($studiensemester_kurzbz, $student_uid, $lehrveranstaltung_id, $note, $punkte) + protected function addErstantrittForGrade($studiensemester_kurzbz, $student_uid, $lehrveranstaltung_id, $teacherGrade) { $this->load->model('education/Lehrveranstaltung_model', 'LehrveranstaltungModel'); + $this->load->model('education/LePruefung_model', 'LePruefungModel'); + + // bereits ein Termin vorhanden -> der Verlauf beginnt schon, nichts nachzutragen + $vorhanden = $this->VerlaufLib->getPruefungen($student_uid, $lehrveranstaltung_id, $studiensemester_kurzbz); + if (count($vorhanden) > 0) + return success(null); // Get Lehreinheit $result = $this->LehrveranstaltungModel->getLeByStudent($student_uid, $studiensemester_kurzbz, $lehrveranstaltung_id); @@ -515,19 +531,24 @@ class Grades extends FHCAPI_Controller return error($this->p->t("stv", "grades_error_lehreinheit_id")); $le = current(getData($result)); + $datum = $teacherGrade->freigabedatum ?: ($teacherGrade->benotungsdatum ?: date('Y-m-d')); + $now = date('Y-m-d H:i:s'); + // Prepare - $this->load->model('education/LePruefung_model', 'LePruefungModel'); $data = [ "student_uid" => $student_uid, "lehreinheit_id" => $le->lehreinheit_id, - "datum" => date('Y-m-d'), - "pruefungstyp_kurzbz" => "Termin1", - "note" => $note + "mitarbeiter_uid" => $teacherGrade->mitarbeiter_uid, + "datum" => $datum, + "pruefungstyp_kurzbz" => $this->VerlaufLib->legacyTypFuerAntritt(1), + "note" => $teacherGrade->note, + "insertamum" => $now, + "insertvon" => getAuthUID() ]; if (defined('CIS_GESAMTNOTE_PUNKTE') && CIS_GESAMTNOTE_PUNKTE) - $data["punkte"] = $punkte; - + $data["punkte"] = $teacherGrade->punkte; + // Get Anwesenheit $this->load->model('education/Anwesenheit_model', 'AnwesenheitModel'); $result = $this->AnwesenheitModel->loadAnwesenheitStudiensemester($studiensemester_kurzbz, $student_uid, $lehrveranstaltung_id); @@ -535,7 +556,8 @@ class Grades extends FHCAPI_Controller return $result; $anwesenheit = getData($result); - if ($anwesenheit && (float)current($anwesenheit)->prozent < FAS_ANWESENHEIT_ROT) { + if (defined('FAS_PRUEFUNG_BEI_NOTENEINGABE_ANLEGEN') && FAS_PRUEFUNG_BEI_NOTENEINGABE_ANLEGEN + && $anwesenheit && (float)current($anwesenheit)->prozent < FAS_ANWESENHEIT_ROT) { // Get Anwesenheitsbefreiung $this->load->model('person/Benutzerfunktion_model', 'BenutzerfunktionModel'); $result = $this->BenutzerfunktionModel->getBenutzerFunktionByUidInStdsem($student_uid, $studiensemester_kurzbz, 'awbefreit'); @@ -545,7 +567,10 @@ class Grades extends FHCAPI_Controller $anwesenheitsbefreit = hasData($result); - // Wenn nicht Anwesenheitsbefreit und Anwesenheit unter einem bestimmten Prozentsatz fällt dann wird ein Pruefungsantritt abgezogen + // Bei zu geringer Anwesenheit ohne Befreiung wird ein zusätzlicher Termin + // 'Nicht beurteilt' vor dem eigentlichen Antritt festgehalten. ACHTUNG: diese Note + // zählt nicht mehr als Antritt (NOTEN_OHNE_ANTRITT_BEZEICHNUNGEN) - der frühere + // "Antritt abziehen"-Effekt entfällt damit, der Eintrag ist reine Dokumentation. if (!$anwesenheitsbefreit) { $data2 = $data; $data2["note"] = 7; @@ -556,8 +581,6 @@ class Grades extends FHCAPI_Controller if (isError($result)) return $result; - - $data["pruefungstyp_kurzbz"] = "Termin2"; } } diff --git a/application/models/education/Lvgesamtnote_model.php b/application/models/education/Lvgesamtnote_model.php index 44645e75b..5afb07e40 100644 --- a/application/models/education/Lvgesamtnote_model.php +++ b/application/models/education/Lvgesamtnote_model.php @@ -55,7 +55,7 @@ class Lvgesamtnote_model extends DB_Model $params = [$student_uid, $studiensemester_kurzbz]; if ($lehrveranstaltung_id) { - $qry .= "AND campus.tbl_lvgesamtnote.lehrveranstaltung_id = ?"; + $qry .= " AND campus.tbl_lvgesamtnote.lehrveranstaltung_id = ?"; $params[] = $lehrveranstaltung_id; } diff --git a/cis/private/lehre/benotungstool/lvgesamtnoteverwalten.php b/cis/private/lehre/benotungstool/lvgesamtnoteverwalten.php index 553e3f03c..3b3689144 100644 --- a/cis/private/lehre/benotungstool/lvgesamtnoteverwalten.php +++ b/cis/private/lehre/benotungstool/lvgesamtnoteverwalten.php @@ -1824,7 +1824,7 @@ $htmlstring .= " "; -if ($anzahlChanged > 0) +if (isset($anzahlChanged) && $anzahlChanged > 0) echo '
' . $p->t('benotungstool/geaenderteNotenVorhanden') . '
'; echo $htmlstring; diff --git a/public/css/Cis4/Benotungstool.css b/public/css/Cis4/Benotungstool.css index 83a026d0e..225cfadf8 100644 --- a/public/css/Cis4/Benotungstool.css +++ b/public/css/Cis4/Benotungstool.css @@ -62,9 +62,63 @@ transform: translateY(-50%) rotate(180deg); } -.pruefung-badge { +/* + * Prueufungszelle: feste Spuren fuer Note | Datum | Aktion. + * Vorher war das eine zentrierte Flex-Zeile, dadurch verschoben laengere Notenbezeichnungen + * ("Nicht Genuegend" vs "Genuegend") Datum und Button - die Zellen standen von Zeile zu Zeile + * nicht mehr untereinander. Mit festen Spurbreiten fluchten sie immer. + */ +.pruefung-cell { position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) 6.5rem; /* Note | Aktion */ + align-items: center; + column-gap: 0.5rem; + height: 100%; padding-left: 32px; /* badge width (20) + its left offset (6) + gap (6) */ + padding-right: 6px; + text-align: left; +} + +/* Spaltenmodus 'antritt': das Datum steht in der Zelle, nicht im Spaltenkopf */ +.pruefung-cell.mit-datum { + grid-template-columns: minmax(0, 1fr) 4.75rem 6.5rem; /* Note | Datum | Aktion */ +} + +/* + * Zellen ohne Aktion (abschliessender Termin, wird in einem anderen Tool gepflegt): die Spur fuer + * den Button entfaellt, der Platz gehoert der Notenbezeichnung. + */ +.pruefung-cell.ohne-aktion { + grid-template-columns: minmax(0, 1fr); /* Note */ +} + +.pruefung-cell.ohne-aktion.mit-datum { + grid-template-columns: minmax(0, 1fr) 4.75rem; /* Note | Datum */ +} + +.pruefung-cell .pruefung-note { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pruefung-cell .pruefung-datum { + font-size: 0.75rem; + white-space: nowrap; + text-align: right; + opacity: 0.7; +} + +/* immer in der letzten Spur, auch wenn Note und Datum fehlen (leere Zelle mit "Hinzufuegen") */ +.pruefung-cell .pruefung-action { + grid-column: -2 / -1; + display: flex; + justify-content: flex-end; +} + +.pruefung-cell .pruefung-action .btn { + white-space: nowrap; } .pruefung-badge::before { @@ -87,4 +141,6 @@ .pruefung-badge.attempt-t1::before { background: #0072B2; } .pruefung-badge.attempt-t2::before { background: #E69F00; } .pruefung-badge.attempt-t3::before { background: #CC79A7; } -.pruefung-badge.attempt-k::before { background: #D55E00; } \ No newline at end of file +.pruefung-badge.attempt-k::before { background: #D55E00; } +/* Termin ohne verbrauchten Antritt (entschuldigt, nicht beurteilt) - traegt keine Nummer */ +.pruefung-badge.attempt-none::before { background: #767676; } \ No newline at end of file diff --git a/public/js/api/factory/noten.js b/public/js/api/factory/noten.js index 964cc5e73..55278964d 100644 --- a/public/js/api/factory/noten.js +++ b/public/js/api/factory/noten.js @@ -49,18 +49,21 @@ export default { params: { lv_id, sem_kurzbz, student_uid, note, punkte } }; }, - saveStudentPruefung(student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, typ, pruefung_id = null){ + // kein Termintyp mehr: welchen Antritt die Prüfung darstellt, leitet der Server aus dem + // bestehenden Prüfungsverlauf des Studenten in dieser LV und diesem Semester ab + saveStudentPruefung(student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, pruefung_id = null){ return { method: 'post', url: '/api/frontend/v1/Noten/saveStudentPruefung', - params: { student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, typ, pruefung_id } + params: { student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, pruefung_id } }; }, - createPruefungen(uids, datum, lva_id, sem_kurzbz){ + // note/punkte optional: ohne Auswahl wird der Termin als "noch nicht eingetragen" angelegt + createPruefungen(uids, datum, lva_id, sem_kurzbz, note = null, punkte = null){ return { method: 'post', url: '/api/frontend/v1/Noten/createPruefungen', - params: { uids, datum, lva_id, sem_kurzbz } + params: { uids, datum, lva_id, sem_kurzbz, note, punkte } }; }, saveNotenvorschlagBulk(lv_id, sem_kurzbz, noten) { diff --git a/public/js/components/Cis/Benotungstool/Benotungstool.js b/public/js/components/Cis/Benotungstool/Benotungstool.js index 0b727ff88..e4f8a3b7c 100644 --- a/public/js/components/Cis/Benotungstool/Benotungstool.js +++ b/public/js/components/Cis/Benotungstool/Benotungstool.js @@ -64,10 +64,15 @@ export const Benotungstool = { selectedPruefungNote: null, selectedPruefungDate: new Date(), // v-model for pruefung edit datepicker selectedPruefungPunkte: null, - pruefungNoteLocked: false, // grade read-only when a later/higher pruefung exists (date stays editable) + pruefungNoteLocked: false, // grade read-only when a later pruefung exists (date stays editable) pruefungDateMin: null, pruefungDateMax: null, distinctPruefungsDates: null, + // Spaltenaufteilung der Termine: 'antritt' (je Antrittsnummer) oder 'datum' (je Prüfungsdatum). + // null = Vorgabe aus der Konfiguration; die Wahl des Benutzers wird lokal gemerkt. + pruefungsspalten: localStorage.getItem('notenToolPruefungsspalten'), + // Studenten der aktuellen Anlage, für die noch keine LV-Note existiert -> sie entsteht mit + // der Prüfung und wird vorher bestätigt pruefungStudent: null, pruefung: null, password: '', @@ -453,25 +458,15 @@ export const Benotungstool = { } }, validatePruefungBulk(pruefungen) { - // need to check pruefungen for validity in respect to the students nr of antritte - // pruefungsdatum will be validated aswell so we dont get a termin 3 chronologically before - // a termin 2 which is totally possible in the old tool + // Reihenfolge und Antrittsgrenze vorab prüfen, damit offensichtlich unmögliche Zeilen gar + // nicht erst gesendet werden. Eine fehlende LV-Note ist kein Hindernis: liegt für den + // Studenten noch keine Leistungsfeststellung vor, entsteht sie mit der importierten Note. const validatedPruefungen = [] pruefungen.forEach( p => { const student = this.studenten.find(s => s.uid === p.uid) - // if a student doesnt have a lv_note entered definitely dont allow to create pruefung - if(!student.lv_note) { - this.$fhcAlert.alertWarning('Student ' + student.uid + ' hat noch keine LV-Note eingetragen! Es wird keine Prüfung angelegt.') - return - } - - // note: a missing Zeugnisnote is intentionally allowed here so that a dated import can - // create the first attempt for not-yet-graded students (Termin1 falls back to - // "noch nicht eingetragen" server-side). Only the LV-note is mandatory. - // check if student antrittCount is too high already - if(student.hoechsterAntritt >= this.maxAntrittCount) { + if(!this.canAddPruefung(student)) { this.$fhcAlert.alertWarning('Student ' + student.uid + ' hat bereits ' + student.hoechsterAntritt + ' Prüfungsantritte abgelegt. Die Zeile wurde übersprungen.') return } @@ -571,9 +566,7 @@ export const Benotungstool = { } } - const typ = this.getPruefungstypForStudentByAntritt(student) - - pruefungbulk.push({uid: student.uid, datum: dateStr, note, punkte, typ, lehreinheit_id: student.lehreinheit_id, dateObj}) + pruefungbulk.push({uid: student.uid, datum: dateStr, note, punkte, lehreinheit_id: student.lehreinheit_id, dateObj}) }, saveNotenBulk(notenbulk) { this.loading = true @@ -622,7 +615,7 @@ export const Benotungstool = { let errorList = '' Object.keys(res.data ?? {}).forEach(uid => { const entry = res.data[uid] - if(!entry?.savedPruefung && !entry?.extraPruefung) { + if(!entry?.savedPruefung) { errorList += entry + '\n' } }) @@ -643,179 +636,290 @@ export const Benotungstool = { handleAddNewPruefungenResponse(res, uids) { // in case we reload when changing lva_id or stsem to always consider local storage layout this.colLayoutRestored = false; - + const pruefungen = res.data uids.forEach(entry => { const rowResult = pruefungen[entry.uid] - const saved = rowResult?.savedPruefung?.[0] - const extra = rowResult?.extraPruefung?.[0] const student = this.studenten.find(s => s.uid == entry.uid) if(!student) return // a per-row backend rejection returns a localized error string (or produced nothing) -> - // skip it here; callers surface those messages to the user separately - if(!saved) return + // skip it here; callers surface those messages to the user separately. + // savedPruefung ist der Erfolgsnachweis, nicht verlauf: letzterer beschreibt nur den + // Stand und wäre auch bei einem fehlgeschlagenen Insert vorhanden. + if(!rowResult?.savedPruefung || !rowResult?.verlauf) return - // check for extra pruefung (termin1) to add before - if(extra) { - student["Termin1"] = extra - student.pruefungen.push(extra) // push to pruefungen array for antritt evaluation - } - - this.correctOldTerminTypenForStudent(student, saved) - - if(!this.distinctPruefungsDates.includes(saved.datum)) { - this.insertSortedDate(this.distinctPruefungsDates, saved.datum) - } - - // add pruefung to pruefungen array - student.pruefungen.push(saved) - - // add pruefung to student via its datum as a field - student[saved.datum] = saved - - // usually should be in order naturally, just to be save - student.pruefungen.sort((p1, p2) => { - if(p1.datum > p2.datum) { - return 1 - } else if (p1.datum < p2.datum) { - return -1 - } else { - return 0 - } - }) - - // recalculate student antritte - student.hoechsterAntritt = this.getAntrittCountStudent(student) - this.recalculateSelectable(student) - this.reformatStudentRow(student) + // gleiche Nachbereitung wie im Dialog aus der Tabelle: erst LV-Note, dann Verlauf + this.applyLvGesamtnote(student, rowResult.lvgesamtnote) + this.applyVerlauf(student, rowResult.verlauf) }) - // add col to table - const cols = [...this.notenTableOptions.columns.slice(0, -1)]; - let kommCol = null - if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) kommCol = this.notenTableOptions.columns[this.notenTableOptions.columns.length - 1]; - - if(this.distinctPruefungsDates.length) { - cols.push({ - title: this.$capitalize(this.$p.t('benotungstool/c4originalZnote')), - field: "Termin1", - formatter: this.pruefungFormatter, - titleFormatter: this.pruefungTitleFormatter, - topCalc: this.terminCalcFunc, - topCalcFormatter: this.terminCalcFormatter, - hozAlign:"center", - widthGrow: 1, - minWidth: 200, - width: 250, - visible: true, - tooltip: false - }) - } - - this.distinctPruefungsDates.forEach((date)=>{ - const dateparts = date.split('-') - const titledate = `${dateparts[2]}.${dateparts[1]}.${dateparts[0]}` - - cols.push({ - title: titledate,//this.$p.t('benotungstool/pruefungNr', [index+1]), - field: date, - formatter: this.pruefungFormatter, - titleFormatter: this.pruefungTitleFormatter, - topCalc: this.terminCalcFunc, - topCalcFormatter: this.terminCalcFormatter, - hozAlign:"center", - widthGrow: 1, - minWidth: 200, - width: 250, - visible: true, - tooltip: false - }) - }) - - if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) cols.push(kommCol) // keep kommPruef Col as last - // redraw table - - - // preemptively do the persistence part of columns already here so we get the merged definition before - // tabulator internal go to war with vue reactives - let colsUsed = null - const saved = this.loadState(); - if (saved && saved.columns) { - // new columns map for lookup - const colMap = new Map(cols.map(c => [c.field, c])); - - const restoredCols = []; - - // add columns in the SAVED order, applying saved width/visibility - saved.columns.forEach(savedCol => { - const originalDef = colMap.get(savedCol.field); - if (originalDef) { - restoredCols.push({ - ...originalDef, // Keep formatters, calcs, etc. - width: savedCol.width, - visible: savedCol.visible - }); - colMap.delete(savedCol.field); // Remove so we don't double-add - } - }); - - // append any NEW columns aka pruefungs cols - colMap.forEach((def) => { - restoredCols.push(def); - }); - - colsUsed = restoredCols; - this.colLayoutRestored = true; - } - this.loading = false - - const colsFinal = colsUsed ?? cols - this.$refs.notenTable.tabulator.setColumns(colsFinal) - this.$refs.notenTable.tabulator.setData(this.studenten); - this.$refs.notenTable.tabulator.redraw(true); + + // Scrollstand halten: nach einer Bulk-Anlage soll der Benutzer seine Zeilen weiter sehen + this.preserveScroll(() => { + this.applyPruefungColumns() + const loaded = this.$refs.notenTable.tabulator.setData(this.studenten); + this.$refs.notenTable.tabulator.redraw(true); + return loaded + }) + }, + /** + * Zeile aus dem vom Server gelieferten Prüfungsverlauf neu aufbauen. Positionen, Antrittszahl + * und Grenzen kommen ausschliesslich von dort - der Client führt keine eigene Buchhaltung über + * Termine, damit die Regeln nicht in zwei Implementierungen auseinanderlaufen. + */ + /** + * LV-Note einer Zeile aus der Serverantwort übernehmen: Note, Punkte und Freigabestatus. + * Beide Eingabewege (Dialog aus der Tabelle und Sammelanlage) verwenden dasselbe, sonst + * zeigt die Tabelle nach einer Sammelanlage die alte Note. + */ + applyLvGesamtnote(student, lvgesamtnote) { + if(!student || !lvgesamtnote) return + + student.lv_note = lvgesamtnote.note + student.freigabedatum = this.parseDate(lvgesamtnote.freigabedatum) + student.benotungsdatum = this.parseDate(lvgesamtnote.benotungsdatum) + student.freigegeben = this.checkFreigabe(student.freigabedatum, student.benotungsdatum, student.uid) + + if(this.config?.CIS_GESAMTNOTE_PUNKTE) student.punkte = lvgesamtnote.punkte + + // teilnoten ist die Quelle für changedNoten (Zähler und Liste der Freigabe) + if(this.teilnoten && this.teilnoten[student.uid]) { + this.teilnoten[student.uid].note_lv = lvgesamtnote.note + this.teilnoten[student.uid].punkte_lv = lvgesamtnote.punkte + this.teilnoten[student.uid]['freigabedatum'] = lvgesamtnote.freigabedatum + this.teilnoten[student.uid]['benotungsdatum'] = lvgesamtnote.benotungsdatum + } + + this.changedNotenCounter++ // computed changedNoten neu auswerten + }, + applyVerlauf(student, verlauf) { + if(!verlauf) return + + // die Kennzahlen vollständig übernehmen. Nicht einzeln abschreiben: fehlende Felder + // (angerechnet, hatLvNote) sind danach undefined und die Oberfläche urteilt falsch, + // bis die Lehrveranstaltung neu geladen wird. + student.verlauf = {...verlauf} + delete student.verlauf.pruefungen + + student.pruefungen = [] + delete student['kommPruef'] + + ;(verlauf.pruefungen ?? []).forEach(p => { + p.dateObj = this.parseISODate(p.datum) + // der abschliessende Termin steht in einer eigenen, immer letzten Spalte + if(p.terminal) student['kommPruef'] = p + else student.pruefungen.push(p) + }) + + // die flache Liste über alle Studenten mitziehen: sie entscheidet, ob Notenvorschlag und + // Punkte noch bearbeitbar sind (siehe getColumnsDefinition), und war bisher bis zum + // nächsten Laden veraltet + this.pruefungen = (this.pruefungen ?? []).filter(p => p.student_uid !== student.uid) + ;(verlauf.pruefungen ?? []).forEach(p => this.pruefungen.push(p)) + + this.syncDistinctPruefungsDates() + this.indexPruefungen(student) + + student.hoechsterAntritt = this.getAntrittCountStudent(student) + this.recalculateSelectable(student) + this.reformatStudentRow(student) + }, + /** Datumsspalten aus allen Zeilen neu ableiten (nur im Spaltenmodus 'datum' sichtbar). */ + syncDistinctPruefungsDates() { + const dates = new Set() + this.studenten?.forEach(s => (s.pruefungen ?? []).forEach(p => dates.add(p.datum))) + this.distinctPruefungsDates = [...dates].sort() + }, + /** Feldname der Spalte, in der ein Termin dargestellt wird. */ + pruefungField(pruefung, index) { + if(pruefung.terminal) return 'kommPruef' + return this.pruefungsspaltenModus === 'antritt' ? ('antritt_' + (index + 1)) : pruefung.datum + }, + /** + * Termine als Spaltenwerte an der Zeile ablegen. Vorherige Zuordnungen werden zuvor entfernt, + * damit ein Moduswechsel oder ein korrigiertes Prüfungsdatum keine verwaiste Spalte hinterlässt. + */ + indexPruefungen(student) { + (student._pruefungFields ?? []).forEach(f => { delete student[f] }) + + const fields = [] + student.pruefungen.forEach((p, i) => { + const field = this.pruefungField(p, i) + student[field] = p + fields.push(field) + }) + + student._pruefungFields = fields + }, + /** einheitliche Definition einer Prüfungsspalte, unabhängig vom Spaltenmodus */ + pruefungColumnDef(field, title) { + // Die Zelle hat feste Spuren (siehe .pruefung-cell): Badge 32 + Note + Aktion 6.5rem, + // im Antrittsmodus zusätzlich das Datum 4.75rem. Die Mindestbreite muss die festen + // Spuren plus lesbaren Notentext fassen, sonst wird die Note sofort abgeschnitten. + const minWidth = this.pruefungsspaltenModus === 'antritt' ? 320 : 250 + + return { + title, + field, + formatter: this.pruefungFormatter, + titleFormatter: this.pruefungTitleFormatter, + sorter: this.pruefungSorter, + topCalc: this.terminCalcFunc, + topCalcFormatter: this.terminCalcFormatter, + hozAlign: "center", + widthGrow: 1, + minWidth, + width: minWidth, + visible: true, + tooltip: false + } + }, + /** + * Die Prüfungsspalten im aktiven Modus: + * 'antritt' - eine Spalte je Antrittsnummer, das Datum steht in der Zelle. Robust auch dann, + * wenn jeder Student sein eigenes Prüfungsdatum hat. + * 'datum' - eine Spalte je Prüfungsdatum. Kompakt, wenn ganze Jahrgänge am selben Tag + * antreten; der erste Antritt ist hier eine Datumsspalte wie jede andere. + */ + buildPruefungColumns() { + if(this.pruefungsspaltenModus === 'antritt') { + let count = 0 + this.studenten?.forEach(s => { + // eine Spalte mehr, solange bei dieser Zeile noch ein Antritt angelegt werden darf + const needed = (s.pruefungen?.length ?? 0) + (this.canAddPruefung(s) ? 1 : 0) + if(needed > count) count = needed + }) + + return Array.from({length: count}, (_, i) => + this.pruefungColumnDef('antritt_' + (i + 1), this.$capitalize(this.$p.t('benotungstool/c4antrittNr', [i + 1]))) + ) + } + + return (this.distinctPruefungsDates ?? []).map(date => + this.pruefungColumnDef(date, this.formatDatumDMY(date)) + ) + }, + /** + * Prüfungsspalten neu setzen: Basisspalten, dann die Termine im aktiven Modus, zuletzt der + * abschliessende Termin. Einzige Stelle, an der die Spalten gebaut werden. + * + * setColumns baut die gesamte Tabelle neu auf (und setzt dabei den Scrollstand zurück), + * deshalb nur aufrufen, wenn sich die Spalten wirklich geändert haben: beim Bearbeiten einer + * Prüfung oder beim Anlegen auf einem bereits vorhandenen Termin bleiben sie gleich. + */ + applyPruefungColumns(force = false) { + const table = this.$refs.notenTable?.tabulator + if(!table || !this.notenTableOptions) return + + const pruefungCols = this.buildPruefungColumns() + const key = pruefungCols.map(c => c.field).join('|') + + if(!force && key === this._pruefungColumnKey) return + this._pruefungColumnKey = key + + const cols = [...this.notenTableOptions.columns.slice(0, -1)] + const kommCol = this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF + ? this.notenTableOptions.columns[this.notenTableOptions.columns.length - 1] + : null + + pruefungCols.forEach(c => cols.push(c)) + if(kommCol) cols.push(kommCol) // abschliessender Termin bleibt die letzte Spalte + + table.setColumns(this.restoreColumnLayout(cols)) + }, + /** + * Führt eine Tabellenoperation aus, die neu rendert (setColumns / setData / redraw), und + * stellt den Scrollstand danach wieder her - sonst springt die Tabelle nach jedem Speichern + * nach ganz oben links und der Benutzer verliert seine Zeile aus dem Blick. + * + * Gelesen und gesetzt wird direkt am .tabulator-tableholder. Tabulators interne + * rowManager.scrollLeft/scrollTop werden nicht in allen Renderpfaden nachgeführt - vor allem + * horizontal nicht, daher sprang die Tabelle bisher trotz Wiederherstellung nach links. + * + * Wiederhergestellt wird zusätzlich in den nächsten beiden Frames: Tabulator rendert Spalten + * und Zeilen teilweise erst danach und setzt den Scrollstand dabei erneut zurück. Vue.nextTick + * läuft auf der Microtask-Queue und damit zu früh. + */ + preserveScroll(operation) { + const holder = this.$refs.notenTable?.tabulator?.element?.querySelector('.tabulator-tableholder') + + const left = holder?.scrollLeft ?? 0 + const top = holder?.scrollTop ?? 0 + + const result = operation() + + // nichts wiederherzustellen -> auch nicht gegen ein legitimes Scrollen nach oben arbeiten + if(!holder || (!left && !top)) return result + + const restore = () => { + holder.scrollLeft = left + holder.scrollTop = top + } + + restore() + requestAnimationFrame(() => { restore(); requestAnimationFrame(restore) }) + + // setData liefert in Tabulator ein Promise -> nach dem Laden noch einmal + if(result && typeof result.then === 'function') result.then(() => requestAnimationFrame(restore)) + + return result + }, + /** + * Gespeicherte Breite/Sichtbarkeit/Reihenfolge auf frisch gebaute Spalten anwenden, bevor + * Tabulator sie übernimmt - sonst geraten die internen Definitionen mit den Vue-Reactives + * aneinander. + */ + restoreColumnLayout(cols) { + const saved = this.loadState() + if(!saved?.columns) return cols + + const colMap = new Map(cols.map(c => [c.field, c])) + const restored = [] + + // Spalten in der GESPEICHERTEN Reihenfolge, mit gespeicherter Breite/Sichtbarkeit + saved.columns.forEach(savedCol => { + const originalDef = colMap.get(savedCol.field) + if(originalDef) { + restored.push({...originalDef, width: savedCol.width, visible: savedCol.visible}) + colMap.delete(savedCol.field) + } + }) + + colMap.forEach(def => restored.push(def)) // neue Spalten anhängen + + this.colLayoutRestored = true + return restored + }, + /** Spaltenmodus umschalten; die Wahl bleibt lokal gespeichert. */ + setPruefungsspalten(modus) { + if(modus !== 'antritt' && modus !== 'datum') return + + this.pruefungsspalten = modus + localStorage.setItem('notenToolPruefungsspalten', modus) + + this.studenten?.forEach(s => this.indexPruefungen(s)) + + // vertikale Position halten - die Spalten wechseln komplett, die Zeile bleibt dieselbe + this.preserveScroll(() => { + this.applyPruefungColumns(true) + const loaded = this.$refs.notenTable?.tabulator?.setData(this.studenten) + this.$refs.notenTable?.tabulator?.redraw(true) + return loaded + }) }, reformatStudentRow(student) { const table = this.$refs.notenTable.tabulator if(!table) return - + const row = table.rowManager.getRowFromDataObject(student) + if(!row) return // Zeile noch nicht gerendert (zB direkt nach einem Datenwechsel) const rowComponent = row.getComponent() rowComponent.reformat() }, - correctOldTerminTypenForStudent(student, saved) { - // check if student has a preceding pruefung from same type and remove it since in this case - // the new pruefung will have overwritten the old one. - // EXCEPTION: an "entschuldigt" Termin must remain as its own dated entry (it was not an - // attempt, so the new pruefung is an additional dated record, not an overwrite of it). - const oldP = student.pruefungen.find(p => - p.pruefungstyp_kurzbz == saved.pruefungstyp_kurzbz - && p.pruefung_id != saved.pruefung_id - && !(this.config?.NOTE_ENTSCHULDIGT != null && p.note == this.config.NOTE_ENTSCHULDIGT) - ) - if(oldP) { - delete student[oldP.datum] // delete the variable col value - - const pIndex = student.pruefungen.indexOf(oldP) - if (pIndex > -1) { - student.pruefungen.splice(pIndex, 1) - } - // student.pruefungen.splice(student.pruefungen.indexOf(oldP), 1) // delete from pruefungen array - - // check if on that date any other student has a pruefung, if not delete it from distinctPruefungsDates - const other = this.studenten.find(s => s[oldP.datum] !== undefined) - if(!other) { - const dateIndex = this.distinctPruefungsDates.indexOf(oldP.datum); - if (dateIndex > -1) { - this.distinctPruefungsDates.splice(dateIndex, 1); - } - // this.distinctPruefungsDates.splice(this.distinctPruefungsDates.findIndex(oldP.datum), 1) - } - } - }, importPruefungen() { // Prüfungsimport: every row must carry a date: "UID/Matrikelnr Datum Note". // Each row creates a dated exam attempt. @@ -902,13 +1006,8 @@ export const Benotungstool = { }, selectableCheck(row, e) { - const data = row.getData(); - - if(data['kommPruef']) return false - else if(data.hoechsterAntritt >= this.maxAntrittCount) return false // 2 or 3 pruefungen counted - - return true; // student can be selected to add pruefung - + // auswählbar ist, wer überhaupt noch einen Antritt bekommen darf + return this.canAddPruefung(row.getData()); }, getColumnsDefinition() { const columns = [] @@ -967,22 +1066,26 @@ export const Benotungstool = { handleClick: this.selectAllHandler }, width: 50, + minWidth: 50, cssClass: this.stickyClass('selectCol'), field: 'selectCol', title: '' }) - columns.push({title: 'UID', field: 'uid', tooltip: false, topCalc: this.sumCalcFunc, formatter: centeredTextFormatter, cssClass: this.stickyClass('uid')}) - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4mail'))), field: 'email', formatter: this.mailFormatter, tooltip: false, visible: false, variableHeight: true}) - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4antrittCountv2'))), field: 'hoechsterAntritt', formatter: centeredTextFormatter, tooltip: false}) - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4vorname'))), field: 'vorname', formatter: centeredTextFormatter, headerFilter: true, tooltip: false, cssClass: this.stickyClass('vorname')}) - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4nachname'))), field: 'nachname', formatter: centeredTextFormatter, headerFilter: true, cssClass: this.stickyClass('nachname')}) - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4anwesenheitsquote'))), field: 'anwquote', formatter: this.percentFormatter}) - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4mobility'))), field: 'mobility_zusatz', formatter: centeredTextFormatter, headerFilter: true, visible: false}) + // Mindestbreiten: Spalten mit Kopffilter oder Editor brauchen mehr Platz als ihr Titel, + // sonst starten sie so schmal, dass Filterfeld und Werte abgeschnitten sind. + columns.push({title: 'UID', field: 'uid', tooltip: false, topCalc: this.sumCalcFunc, formatter: centeredTextFormatter, minWidth: 110, cssClass: this.stickyClass('uid')}) + columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4mail'))), field: 'email', formatter: this.mailFormatter, tooltip: false, visible: false, minWidth: 170, variableHeight: true}) + columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4antrittCountv2'))), field: 'hoechsterAntritt', formatter: centeredTextFormatter, tooltip: false, minWidth: 100}) + columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4vorname'))), field: 'vorname', formatter: centeredTextFormatter, headerFilter: true, tooltip: false, minWidth: 140, cssClass: this.stickyClass('vorname')}) + columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4nachname'))), field: 'nachname', formatter: centeredTextFormatter, headerFilter: true, minWidth: 140, cssClass: this.stickyClass('nachname')}) + columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4anwesenheitsquote'))), field: 'anwquote', formatter: this.percentFormatter, minWidth: 120}) + columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4mobility'))), field: 'mobility_zusatz', formatter: centeredTextFormatter, headerFilter: true, visible: false, minWidth: 140}) if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_MOODLE_LE_NOTE) { - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4teilnoten'))), field: 'teilnote', formatter: this.teilnotenFormatter, variableHeight: true}) + columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4teilnoten'))), field: 'teilnote', formatter: this.teilnotenFormatter, minWidth: 160, variableHeight: true}) } if(this.config?.CIS_GESAMTNOTE_PUNKTE) { - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4punkte'))), field: 'punkte', + columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4punkte'))), field: 'punkte', + minWidth: 110, editor: this.liveNumberEditor, editable: (cell) => { const rowData = cell.getRow().getData(); @@ -994,6 +1097,7 @@ export const Benotungstool = { }) } columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4notenvorschlag'))), field: 'note_vorschlag', + minWidth: 160, editor: 'list', editorParams: (cell) => { // write original cell value into row to it can be retrieved if edit is cancelled without selection @@ -1032,10 +1136,11 @@ export const Benotungstool = { return '
' + val + '
' } }) - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4notenvorschlagUebernehmen'))), field: 'übernehmen', width: 150, hozAlign: 'center', formatter: this.arrowFormatter, - cellClick: this.saveNote, + columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4notenvorschlagUebernehmen'))), field: 'übernehmen', width: 150, minWidth: 100, hozAlign: 'center', formatter: this.arrowFormatter, + cellClick: this.saveNote, variableHeight: true}) columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4lvnote'))), field: 'lv_note', + minWidth: 160, formatter: this.notenFormatter, headerFilter: 'list', headerFilterParams: () => { @@ -1043,9 +1148,10 @@ export const Benotungstool = { }, headerFilterFunc: this.notenFilterFunc }) - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4freigabe'))), field: 'freigegeben', formatter: this.freigabeFormatter, variableHeight: true}) + columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4freigabe'))), field: 'freigegeben', formatter: this.freigabeFormatter, minWidth: 130, variableHeight: true}) columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4zeugnisnote'))), field: 'note', + minWidth: 160, formatter: this.notenFormatter, topCalc: this.negativeNotenCalc, topCalcFormatter: this.negativeNotenCalcFormatter, @@ -1061,7 +1167,7 @@ export const Benotungstool = { sorter: this.pruefungSorter, topCalc: this.terminCalcFunc, topCalcFormatter: this.terminCalcFormatter, - hozAlign:"center", minWidth: 150, visible: false, + hozAlign:"center", minWidth: 200, visible: false, tooltip: false }) @@ -1110,8 +1216,7 @@ export const Benotungstool = { const data = row.getData() - const notSelectable = data.pruefungen?.find(p => p.pruefungstyp_kurzbz == 'kommPruef') || data.hoechsterAntritt >= this.maxAntrittCount - if(notSelectable) { + if(!this.canAddPruefung(data)) { const el = row.getElement() el.children[0]?.children[0]?.remove() @@ -1282,182 +1387,127 @@ export const Benotungstool = { return '
'+val+'
' }, - pruefungAttemptRank(typ) { - // ranking of the attempt types so we can compare "höhere" pruefungen - switch(typ) { - case 'Termin1': return 1 - case 'Termin2': return 2 - case 'Termin3': return 3 - case 'kommPruef': return 4 - default: return 0 - } + /** Ob für diese Zeile überhaupt noch ein Antritt angelegt werden darf (Serverstand). */ + canAddPruefung(student) { + return NotenRules.canAddPruefung(student, this.config) }, - hasLaterOrHigherPruefung(student, pruefung) { - // a Termin2/3 grade may no longer be changed once the student already has a - // later-dated exam OR an exam of a higher attempt (e.g. a Termin3/kommPruef - // exists while looking at the Termin2). Mirrors the add-button's youngerPruefung guard. + hasLaterPruefung(student, pruefung) { + // Die Note eines Termins ist gesperrt, sobald im Verlauf ein späterer Termin steht (das + // Datum darf weiterhin korrigiert werden). Die Position kommt vom Server, es wird also + // nicht mehr über den Termintyp verglichen. if(!pruefung) return false - const currentRank = this.pruefungAttemptRank(pruefung.pruefungstyp_kurzbz) - const currentDate = (pruefung.datum ?? '').slice(0, 10) - return (student.pruefungen ?? []).some(p => { - if(p === pruefung) return false - if(p.pruefung_id != null && pruefung.pruefung_id != null && p.pruefung_id === pruefung.pruefung_id) return false - const laterDate = (p.datum ?? '').slice(0, 10) > currentDate - const higherRank = this.pruefungAttemptRank(p.pruefungstyp_kurzbz) > currentRank - return laterDate || higherRank - }) + const alle = [...(student.pruefungen ?? [])] + if(student.kommPruef) alle.push(student.kommPruef) + + return alle.some(p => p.pruefung_id != pruefung.pruefung_id && p.position > pruefung.position) }, + /** + * Zelle einer Prüfungsspalte. Termintyp-agnostisch: welcher Antritt in der Zelle steht, ergibt + * sich aus der Spaltenzuordnung (siehe indexPruefungen); Antrittsnummer und ob der Termin + * zählt, kommen aus dem Verlauf vom Server. + */ pruefungFormatter(cell) { const data = cell.getData() + // Angerechnet: die Zeile bleibt sichtbar (die Person gehört zur Lehrveranstaltung), es gibt + // aber keine Prüfungen dazu - weder anlegen noch ändern. + if(data.verlauf?.angerechnet) return '' + + // Eine bereits im Zeugnis stehende, nicht überschreibbare Note sperrt die Terminspalten. + // Ohne Zeugnisnote bleiben sie offen: dort wird der erste Antritt überhaupt erst erfasst. const noteDef = data.note ? this.notenOptions.find(n => n.note == data.note) : null - if(!data.note || !noteDef?.lkt_ueberschreibbar) { - return '' - } - - const colDef = cell.getColumn().getDefinition() - - // column is just a date, student can have any of his antritte on this date, so we need to get - // student.pruefungen and look for a pruefung with this cols title as date + if(noteDef && !noteDef.lkt_ueberschreibbar) return '' const field = cell.getColumn().getField() - let studentPruefung = null - if(field === 'kommPruef') { studentPruefung = data['kommPruef'] } - else if(field === "Termin1") { studentPruefung = data['Termin1'] } - else { studentPruefung = data.pruefungen.find(p => p.datum === field) } + const studentPruefung = data[field] + + // im Antrittsmodus trägt die Spalte kein Datum -> es steht in der Zelle + const antrittModus = this.pruefungsspaltenModus === 'antritt' + + // feste Aufteilung Note | Datum | Aktion (siehe .pruefung-cell), damit Datum und Button + // über alle Zeilen hinweg fluchten, egal wie lang die Notenbezeichnung ist + const rowDiv = document.createElement('div') + rowDiv.className = antrittModus ? 'pruefung-cell mit-datum' : 'pruefung-cell' + + const addSlot = (className, content) => { + const div = document.createElement('div') + div.className = className + + if(typeof content === 'string') div.textContent = content + else if(content instanceof HTMLElement) div.appendChild(content) + + rowDiv.appendChild(div) + return div + } + + const addButton = (label, title, onClick) => { + const button = document.createElement('button') + button.className = 'btn btn-outline-secondary' + button.textContent = label + if(title) button.title = title + button.addEventListener('click', onClick) + + addSlot('pruefung-action', button) + } - // is this column/cell allowed to have an add pruefung action - const canAdd = field !== 'kommPruef' && field !== "Termin1" && data.hoechsterAntritt < this.maxAntrittCount - - // TODO: check for some time limit maybe? old pruefungen can be changed/created - - // Create root row div - const rowDiv = document.createElement('div'); - rowDiv.className = 'row flex-nowrap'; - rowDiv.style.display = 'flex'; - rowDiv.style.justifyContent = 'center'; - rowDiv.style.alignItems = 'center'; - rowDiv.style.height = '100%'; - - let attemptLabel = '' - let attemptClass = '' if(studentPruefung) { - let color = '' - - - switch(studentPruefung.pruefungstyp_kurzbz) { - case 'Termin1': - color = 'green' - break - case 'Termin2': - color = 'yellow' - break - case 'Termin3': - color = 'orange' - break - case 'kommPruef': - color = 'red' - break - } + // Antrittsnummer als Badge; nicht zählende Termine (entschuldigt, nicht beurteilt) + // tragen keine Nummer und werden neutral dargestellt + const attemptLabel = studentPruefung.terminal + ? 'K' + : (studentPruefung.antritt_nr ? String(studentPruefung.antritt_nr) : '–') + const attemptClass = studentPruefung.terminal + ? 'attempt-k' + : (studentPruefung.zaehlt ? ('attempt-t' + Math.min(studentPruefung.antritt_nr, 3)) : 'attempt-none') - // rowDiv.style.borderLeft = `4px solid ${color}`; - rowDiv.style.marginLeft = "6px"; // small indent so text doesn't overlap - rowDiv.style.boxSizing = "border-box"; + rowDiv.classList.add('pruefung-badge', attemptClass) + rowDiv.setAttribute('data-attempt', attemptLabel) - switch(studentPruefung.pruefungstyp_kurzbz) { - case 'Termin1': attemptLabel = '1'; attemptClass = 'attempt-t1'; break - case 'Termin2': attemptLabel = '2'; attemptClass = 'attempt-t2'; break - case 'Termin3': attemptLabel = '3'; attemptClass = 'attempt-t3'; break - case 'kommPruef': attemptLabel = 'K'; attemptClass = 'attempt-k'; break - } + // der abschliessende Termin wird in einem anderen Tool gepflegt -> keine Aktion, und + // der dafür reservierte Platz gehört der Notenbezeichnung + if(studentPruefung.terminal) rowDiv.classList.add('ohne-aktion') + + const noteDefEntry = this.notenOptions.find(n => n.note == studentPruefung.note) + const bezeichnung = noteDefEntry?.bezeichnung || '' + addSlot('pruefung-note', bezeichnung).title = bezeichnung + + if(antrittModus) addSlot('pruefung-datum', this.formatDatumDMY(studentPruefung.datum)) + + if(studentPruefung.terminal) return rowDiv + + // sobald ein späterer Termin existiert ist die NOTE gesperrt, das Datum darf aber noch + // korrigiert werden -> Button bleibt aktiv, die Note wird im Modal gesperrt + const noteLocked = this.hasLaterPruefung(data, studentPruefung) + + addButton( + this.$capitalize(this.$p.t('benotungstool/changePruefungButtonText')), + noteLocked ? this.$capitalize(this.$p.t('benotungstool/pruefungNoteLockedHint')) : null, + () => this.openPruefungModal(data, studentPruefung, field) + ) + + return rowDiv } - - function createCol(content, classParam) { - const colDiv = document.createElement('div'); - colDiv.className = classParam ?? 'col-4'; - colDiv.style.justifyContent = 'center'; - colDiv.style.alignItems = 'center'; - colDiv.style.alignContent = 'center'; - colDiv.style.height = '100%'; - if (typeof content === 'string') { - colDiv.textContent = content; - } else if (content instanceof HTMLElement) { - colDiv.appendChild(content); - } + // leere Zelle: Anlegen nur, wenn überhaupt noch ein Antritt möglich ist und die Spalte + // chronologisch nach allen bereits eingetragenen Terminen liegt + if(field === 'kommPruef' || !this.canAddPruefung(data)) return '' - return colDiv; - } - - if(data[field]) { - - const noteDefEntry = data.note ? this.notenOptions.find(n => n.note == data[field].note) : null - - if(attemptClass) rowDiv.classList.add('pruefung-badge', attemptClass); - if(attemptLabel) rowDiv.setAttribute('data-attempt', attemptLabel); - - // Second column (note_bezeichnung) - rowDiv.appendChild(createCol(noteDefEntry.bezeichnung || '', 'col-auto d-flex justify-content-center align-items-center')); - - // no actions on kommPruef allowed - // no actions on termin1 aka pruefung 0 aka ursprüngliche note erlaubt - if(field === 'kommPruef' || field === "Termin1") { - // Prüfungsordnung §2: for the original Zeugnisnote (Termin1 = first Antritt) optionally - // show its Benotungsdatum inline next to the grade - if(field === "Termin1" - && this.config?.SHOW_BENOTUNGSDATUM_ON_NOTENVORSCHLAG_UEBERNAHME - && studentPruefung?.datum) { - const dateDiv = createCol(this.formatDatumDMY(studentPruefung.datum), 'col-auto d-flex justify-content-center align-items-center') - dateDiv.classList.add('text-muted') - dateDiv.style.fontSize = '0.75rem' - dateDiv.style.whiteSpace = 'nowrap' - dateDiv.style.marginLeft = '6px' - rowDiv.appendChild(dateDiv) - } - return rowDiv - } - - if(data[field]?.pruefungstyp_kurzbz !== 'Termin1') { - // once a later-dated or higher-attempt pruefung exists (e.g. a Termin3/kommPruef has - // already been entered) the GRADE is locked, but the exam DATE may still be corrected - // (within the neighbouring exam dates) -> keep the button enabled, lock the note in the modal - const noteLocked = this.hasLaterOrHigherPruefung(data, data[field]) - - // Third column (button) - const button = document.createElement('button'); - button.className = 'btn btn-outline-secondary'; - button.textContent = this.$capitalize(this.$p.t('benotungstool/changePruefungButtonText')); - if(noteLocked) { - button.title = this.$capitalize(this.$p.t('benotungstool/pruefungNoteLockedHint')) - } - button.addEventListener('click', () => { - this.openPruefungModal(data, data[field], field); - }); - - rowDiv.appendChild(createCol(button, 'col-4 d-flex justify-content-center align-items-center')); - } - - return rowDiv; - - } else if (canAdd) { // return new btn action - // dont render the add button in cells where a younger pruefung exists for the students - const youngerPruefung = data.pruefungen.find(p => p.datum > field) - if(youngerPruefung) return rowDiv - - const button = document.createElement('button'); - button.className = 'btn btn-outline-secondary'; - button.textContent = this.$capitalize(this.$p.t('benotungstool/addPruefungButtonText')); - button.addEventListener('click', () => { - this.openPruefungModal(data, null, field) - }); - - rowDiv.appendChild(createCol(button), 'col-4 d-flex justify-content-center align-content-center'); - - return rowDiv; + if(antrittModus) { + // nur die unmittelbar nächste freie Antrittsspalte darf einen Neuanlage-Button zeigen + if(field !== ('antritt_' + ((data.pruefungen?.length ?? 0) + 1))) return '' } else { - return '' + // keine Neuanlage vor einem bereits eingetragenen Termin + if((data.pruefungen ?? []).some(p => p.datum >= field)) return '' } + + addButton( + this.$capitalize(this.$p.t('benotungstool/addPruefungButtonText')), + null, + () => this.openPruefungModal(data, null, field) + ) + + return rowDiv }, parseISODate(iso) { const parts = (iso ?? '').slice(0, 10).split('-') @@ -1495,15 +1545,13 @@ export const Benotungstool = { openPruefungModal(student, pruefung = null, field) { this.pruefungStudent = student this.pruefung = pruefung - const dateStr = this.pruefung?.datum ?? field - const pruefungDateParts = dateStr.split('-') + // Im Antrittsmodus trägt die Spalte kein Datum -> beim Anlegen mit heute vorbelegen. + const dateStr = this.pruefung?.datum ?? (/^\d{4}-\d{2}-\d{2}$/.test(field) ? field : null) + this.selectedPruefungDate = dateStr ? this.parseISODate(dateStr) : new Date() - const newDate = new Date(Number(pruefungDateParts[0]), Number(pruefungDateParts[1]) - 1, Number(pruefungDateParts[2])) - this.selectedPruefungDate = newDate - - // grade is locked once a later/higher pruefung exists; only the date may be corrected - this.pruefungNoteLocked = !!(pruefung && this.hasLaterOrHigherPruefung(student, pruefung)) + // grade is locked once a later pruefung exists; only the date may be corrected + this.pruefungNoteLocked = !!(pruefung && this.hasLaterPruefung(student, pruefung)) // constrain the date to stay between the neighbouring exam dates const bounds = this.getPruefungDateBounds(student, pruefung, field) @@ -1565,17 +1613,6 @@ export const Benotungstool = { buildMailToLink(student){ return 'mailto:' + student.uid +'@'+ this.domain }, - insertSortedDate(arr, dateStr) { - // Binary search to find insertion index - let left = 0, right = arr.length; - while (left < right) { - const mid = (left + right) >> 1; - if (arr[mid] < dateStr) left = mid + 1; - else right = mid; - } - arr.splice(left, 0, dateStr); // insert at index - return arr; - }, tableResolve(resolve) { this.tableBuiltResolve = resolve }, @@ -1585,7 +1622,7 @@ export const Benotungstool = { async setupData(data){ // in case we reload when changing lva_id or stsem to always consider local storage layout this.colLayoutRestored = false; - + this.studenten = data[0] ?? [] this.studenten.forEach(s => { s.pruefungen = [] @@ -1594,58 +1631,36 @@ export const Benotungstool = { }) this.pruefungen = data[1] ?? [] this.domain = data[2] - - // contains notenvorschläge from moodle, lv_note + + // contains notenvorschläge from moodle, lv_note und den Prüfungsverlauf je Student this.teilnoten = data[3] ?? [] - - // let pruefungenRegularColCount = 0; + this.distinctPruefungsDates = [] - const cols = [...this.notenTableOptions.columns.slice(0, -1)]; - let kommCol = null - if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) kommCol = this.notenTableOptions.columns[this.notenTableOptions.columns.length - 1]; - + this.pruefungen?.forEach(p => { - const dateParts = p.datum.split('-') - p.dateObj = new Date(dateParts[0], +(dateParts[1]) - 1, dateParts[2]) - const student = this.studenten.find(s => s.uid === p.student_uid) if(!student) return - - if(p.pruefungstyp_kurzbz !== 'kommPruef' - && p.pruefungstyp_kurzbz !== 'Termin1' - && !this.distinctPruefungsDates.includes(p.datum)) { - this.distinctPruefungsDates.push(p.datum) - } - - // seperate kommPruefungen from previous pruefungen counts since the column count variability always ends with this - if(p.pruefungstyp_kurzbz == 'kommPruef') { + + p.dateObj = this.parseISODate(p.datum) + + // der abschliessende Termin steht in einer eigenen, immer letzten Spalte + if(p.terminal) { student['kommPruef'] = p } else { student.pruefungen.push(p) + if(!this.distinctPruefungsDates.includes(p.datum)) this.distinctPruefungsDates.push(p.datum) } - }) + this.distinctPruefungsDates.sort() + this.studenten?.forEach(s => { - // sort students regular pruefungen by datum - s.pruefungen.sort((p1, p2) => { - if(p1.datum > p2.datum) { - return 1 - } else if (p1.datum < p2.datum) { - return -1 - } else { - return 0 - } - }) - // set the sorted pruefungen to their respective column fields - s.pruefungen.forEach((p, i) => { - // Termin1 aka originale Zeugnisnote should note create variable columns - if(p.pruefungstyp_kurzbz == "Termin1") { - s["Termin1"] = p - } else { - s[p.datum] = p - } - }) + // der Server liefert die Termine bereits in Verlaufsreihenfolge; hier nur absichern + s.pruefungen.sort((p1, p2) => (p1.position ?? 0) - (p2.position ?? 0)) + + // Antrittszahl, Grenzen und die nächste mögliche Rolle kommen vom Server + s.verlauf = this.teilnoten[s.uid]?.verlauf ?? null + this.indexPruefungen(s) s.hoechsterAntritt = this.getAntrittCountStudent(s) // multiselect label, uid first: "uid – Nachname Vorname – Antritte: n" @@ -1656,9 +1671,9 @@ export const Benotungstool = { s.freigabedatum = this.parseDate(this.teilnoten[s.uid]['freigabedatum']) s.benotungsdatum = this.parseDate(this.teilnoten[s.uid]['benotungsdatum']) s.freigegeben = this.checkFreigabe(s.freigabedatum, s.benotungsdatum, s.uid); - + s.punkte = this.teilnoten[s.uid].punkte_lv - + const grades = this.teilnoten[s.uid].grades s.teilnote = '' s.mobility_zusatz = this.teilnoten[s.uid].mobility_zusatz @@ -1668,111 +1683,13 @@ export const Benotungstool = { if(notenOption.positiv) s.teilnote += (''+g.text +''+ '
') else s.teilnote += (''+g.text +''+ '
') }) - - const vueThis = this - Object.defineProperty(s, 'selectable', { - get() { - const kP = s.pruefungen?.find(p => p.pruefungstyp_kurzbz == 'kommPruef') - const maxAntrittReached = s.hoechsterAntritt >= vueThis.maxAntrittCount - return !(kP || maxAntrittReached) - - }, - set() { - // empty setter so tabulator doesnt scream - }, - enumerable: true, - configurable: true - }) - - }) - - // if we have any pruefungen at all someone must have original note/termin1 pruefung - if(this.distinctPruefungsDates.length) { - cols.push({ - title: this.$capitalize(this.$p.t('benotungstool/c4originalZnote')), - field: "Termin1", - formatter: this.pruefungFormatter, - titleFormatter: this.pruefungTitleFormatter, - sorter: this.pruefungSorter, - topCalc: this.terminCalcFunc, - topCalcFormatter: this.terminCalcFormatter, - hozAlign:"center", - widthGrow: 1, - minWidth: 200, - width: 250, - visible: true, - tooltip: false - }) - } - this.distinctPruefungsDates.sort((d1, d2) => { - if(d1 > d2) { - return 1 - } else if (d1 < d2) { - return -1 - } else { - return 0 - } - }) - this.distinctPruefungsDates.forEach((date)=>{ - const dateparts = date.split('-') - const titledate = `${dateparts[2]}.${dateparts[1]}.${dateparts[0]}` - - cols.push({ - title: titledate, - field: date, - formatter: this.pruefungFormatter, - titleFormatter: this.pruefungTitleFormatter, - sorter: this.pruefungSorter, - topCalc: this.terminCalcFunc, - topCalcFormatter: this.terminCalcFormatter, - hozAlign:"center", - widthGrow: 1, - minWidth: 200, - width: 250, - visible: true, - tooltip: false - }) + this.recalculateSelectable(s) }) - if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) cols.push(kommCol) // keep kommPruef Col as last + // frisch geladene LV -> Spalten in jedem Fall neu aufbauen, Scrollstand bewusst zurücksetzen + this.applyPruefungColumns(true) - - // preemptively do the persistence part of columns already here so we get the merged definition before - // tabulator internal go to war with vue reactives - let colsUsed = null - const saved = this.loadState(); - if (saved && saved.columns) { - // new columns map for lookup - const colMap = new Map(cols.map(c => [c.field, c])); - - const restoredCols = []; - - // add columns in the SAVED order, applying saved width/visibility - saved.columns.forEach(savedCol => { - const originalDef = colMap.get(savedCol.field); - if (originalDef) { - restoredCols.push({ - ...originalDef, // Keep formatters, calcs, etc. - width: savedCol.width, - visible: savedCol.visible - }); - colMap.delete(savedCol.field); // Remove so we don't double-add - } - }); - - // append any NEW columns aka pruefungs cols - colMap.forEach((def) => { - restoredCols.push(def); - }); - - colsUsed = restoredCols; - this.colLayoutRestored = true; - } - - const colsFinal = colsUsed ?? cols - - this.$refs.notenTable.tabulator.setColumns(colsFinal) this.$refs.notenTable.tabulator.setData(this.studenten); this.$refs.notenTable.tabulator.redraw(true); @@ -2002,9 +1919,6 @@ export const Benotungstool = { getOptionLabelLe(option) { return option.infoString }, - getPruefungstypForStudentByAntritt(student) { - return NotenRules.pruefungstypForAntritt(student, this.config) - }, savePruefungEingabe() { // keep the date within the neighbouring exam dates (a typed value can bypass the picker bounds) if((this.pruefungDateMin && this.selectedPruefungDate < this.pruefungDateMin) || @@ -2018,11 +1932,7 @@ export const Benotungstool = { const day = String(this.selectedPruefungDate.getDate()).padStart(2, '0'); const dateStr = `${year}-${month}-${day}`; - // first pruefung is always "Termin2" since normal note counts as Termin1 - // const pOffset = this.pruefung === null && this.pruefungStudent.pruefungen.length === 0 ? 2 : 1 - - const typ = this.pruefung ? this.pruefung.pruefungstyp_kurzbz : this.getPruefungstypForStudentByAntritt(this.pruefungStudent) - // when the grade is locked (later/higher pruefung exists) keep the existing note untouched + // when the grade is locked (later pruefung exists) keep the existing note untouched const note = this.pruefungNoteLocked && this.pruefung ? this.pruefung.note : (this.selectedPruefungNote?.note ?? 9) // noch nicht eingetragen @@ -2039,7 +1949,6 @@ export const Benotungstool = { this.lv_id, this.pruefungStudent.lehreinheit_id, this.sem_kurzbz, - typ, this.pruefung?.pruefung_id ?? null // null = adding a new pruefung, otherwise edit of this record )).then(res => { if(res.meta.status === 'success') { //'Prüfung für Student ' + this.pruefungStudent.uid + ' bearbeitet oder angelegt' @@ -2050,182 +1959,32 @@ export const Benotungstool = { true ) const s = this.studenten.find(s => s.uid === res.data[1]?.student_uid) - - s.freigabedatum = this.parseDate(res.data[1]?.['freigabedatum']) - s.benotungsdatum = this.parseDate(res.data[1]?.['benotungsdatum']) - - s.freigegeben = this.checkFreigabe(s.freigabedatum, s.benotungsdatum, s.uid); - - s.lv_note = res.data[1]?.note - const oldScrollLeft = this.$refs.notenTable.tabulator.rowManager.scrollLeft - const oldScrollTop = this.$refs.notenTable.tabulator.rowManager.scrollTop - - // add new pruefung to row - if(!this.pruefung) { - this.handleAddNewTermin(res.data, s) - } else { // update existing - const oldIndex = s.pruefungen.findIndex(p => p.pruefung_id == this.pruefung.pruefung_id) - if(oldIndex !== -1) { - s.pruefungen.splice(oldIndex, 1, res.data[0]) - s[res.data[0].datum] = res.data[0] - } - // antritte might have changed due to different benotung - s.hoechsterAntritt = this.getAntrittCountStudent(s) - this.recalculateSelectable(s) - this.reformatStudentRow(s) - } - - this.$refs.notenTable.tabulator.redraw(true) - Vue.nextTick(()=> { - const table = this.$refs.notenTable.tabulator.element.querySelector('.tabulator-tableholder') - if(table) { - table.scrollLeft = oldScrollLeft; - table.scrollTop = oldScrollTop; - } + this.applyLvGesamtnote(s, res.data[1]) + + // Zeile komplett aus dem Serververlauf neu aufbauen: das deckt Anlegen und + // Bearbeiten gleichermassen ab. Scrollstand halten, damit die bearbeitete Zeile + // im Blick bleibt. + this.colLayoutRestored = false + this.preserveScroll(() => { + this.applyVerlauf(s, res.data[2]) + this.applyPruefungColumns() + this.$refs.notenTable.tabulator.redraw(true) }) - } }).finally(()=> { this.pruefungStudent = null this.pruefung = null this.loading = false }) - + this.$refs.modalContainerPruefung.hide() }, - handleAddNewTermin(data, student){ - this.colLayoutRestored = false; // always keep persistence options in mind when modifying table cols dynamically - - const savedPruefung = data[0] - const extra = data[2] - - // check for extra pruefung (termin1) to add before - if(extra) { - student["Termin1"] = extra - student.pruefungen.push(extra) // push to pruefungen array for antritt evaluation - } - - this.correctOldTerminTypenForStudent(student, savedPruefung) - - if(!this.distinctPruefungsDates.includes(savedPruefung.datum)) { - this.insertSortedDate(this.distinctPruefungsDates, savedPruefung.datum) - } - - // add pruefung to pruefungen array - student.pruefungen.push(savedPruefung) - - // add pruefung to student via its datum as a field - student[savedPruefung.datum] = savedPruefung - - // usually should be in order naturally, just to be save - student.pruefungen.sort((p1, p2) => { - if(p1.datum > p2.datum) { - return 1 - } else if (p1.datum < p2.datum) { - return -1 - } else { - return 0 - } - }) - - // recalculate student antritte - - student.hoechsterAntritt = this.getAntrittCountStudent(student) - this.recalculateSelectable(student) - this.reformatStudentRow(student) - - // add col to table - const cols = [...this.notenTableOptions.columns.slice(0, -1)]; - let kommCol = null - if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) kommCol = this.notenTableOptions.columns[this.notenTableOptions.columns.length - 1]; - - if(this.distinctPruefungsDates.length) { - cols.push({ - title: this.$capitalize(this.$p.t('benotungstool/c4originalZnote')), - field: "Termin1", - formatter: this.pruefungFormatter, - titleFormatter: this.pruefungTitleFormatter, - sorter: this.pruefungSorter, - topCalc: this.terminCalcFunc, - topCalcFormatter: this.terminCalcFormatter, - hozAlign:"center", - widthGrow: 1, - minWidth: 200, - width: 250, - visible: true, - tooltip: false - }) - } - - this.distinctPruefungsDates.forEach((date)=>{ - const dateparts = date.split('-') - const titledate = `${dateparts[2]}.${dateparts[1]}.${dateparts[0]}` - - cols.push({ - title: titledate,//this.$p.t('benotungstool/pruefungNr', [index+1]), - field: date, - formatter: this.pruefungFormatter, - titleFormatter: this.pruefungTitleFormatter, - sorter: this.pruefungSorter, - topCalc: this.terminCalcFunc, - topCalcFormatter: this.terminCalcFormatter, - hozAlign:"center", - widthGrow: 1, - minWidth: 200, - width: 250, - visible: true, - tooltip: false - }) - }) - - if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) cols.push(kommCol) // keep kommPruef Col as last - - - // preemptively do the persistence part of columns already here so we get the merged definition before - // tabulator internal go to war with vue reactives - let colsUsed = null - const saved = this.loadState(); - if (saved && saved.columns) { - // new columns map for lookup - const colMap = new Map(cols.map(c => [c.field, c])); - - const restoredCols = []; - - // add columns in the SAVED order, applying saved width/visibility - saved.columns.forEach(savedCol => { - const originalDef = colMap.get(savedCol.field); - if (originalDef) { - restoredCols.push({ - ...originalDef, // Keep formatters, calcs, etc. - width: savedCol.width, - visible: savedCol.visible - }); - colMap.delete(savedCol.field); // Remove so we don't double-add - } - }); - - // append any NEW columns aka pruefungs cols - colMap.forEach((def) => { - restoredCols.push(def); - }); - - colsUsed = restoredCols; - this.colLayoutRestored = true; - } - - - const colsFinal = colsUsed ?? cols - this.$refs.notenTable.tabulator.setColumns(colsFinal) - // this.$refs.notenTable.tabulator.redraw(true) - // redraw table outside this function - }, recalculateSelectable(student) { const vueThis = this Object.defineProperty(student, 'selectable', { get() { - const kP = student.pruefungen?.find(p => p.pruefungstyp_kurzbz == 'kommPruef') - return !(kP || student.hoechsterAntritt >= vueThis.maxAntrittCount) + return vueThis.canAddPruefung(student) }, set() { // empty setter so tabulator doesnt scream @@ -2268,6 +2027,10 @@ export const Benotungstool = { this.$refs.modalContainerNotenSpeichern.show() }, openNewPruefungsdatumModal() { + // gleiche Eingaben wie im Dialog aus der Tabelle -> beide starten leer, ohne Note + this.selectedPruefungNote = null + this.selectedPruefungPunkte = null + this.selectedPruefungDate = new Date() this.$refs.modalContainerNeuesPruefungsdatum.show() }, openPruefungImportModal() { @@ -2280,34 +2043,18 @@ export const Benotungstool = { return option.bezeichnung }, addPruefung(){ - - this.$refs.modalContainerNeuesPruefungsdatum.hide() - - // filter students that already have a pruefung on datum - const year = this.selectedPruefungDate.getFullYear(); const month = String(this.selectedPruefungDate.getMonth() + 1).padStart(2, '0'); // Months are 0-based const day = String(this.selectedPruefungDate.getDate()).padStart(2, '0'); const dateStrDb = `${year}-${month}-${day}`; const dateStrFront = `${day}.${month}.${year}`; - - const uids = [] - // const uids = this.selectedUids.map(student => { - this.selectedUids.forEach(student => { - - // if a student doesnt have a lv_note entered definitely dont allow to create pruefung - if(!student.lv_note) { - this.$fhcAlert.alertWarning('Student ' + student.uid + ' hat noch keine LV-Note eingetragen! Es wird keine Prüfung angelegt.') - return - } - if(!student.note) { - this.$fhcAlert.alertWarning('Student ' + student.uid + ' hat noch keine Zeugnis Note eingetragen! Es wird keine Prüfung angelegt.') - return - } + const uids = [] + + this.selectedUids.forEach(student => { // check if student antrittCount is too high already - if(student.hoechsterAntritt >= this.maxAntrittCount) { + if(!this.canAddPruefung(student)) { this.$fhcAlert.alertWarning('Student ' + student.uid + ' hat bereits ' + student.hoechsterAntritt + ' Prüfungsantritte abgelegt. Es wird keine Prüfung angelegt.') return } @@ -2323,46 +2070,53 @@ export const Benotungstool = { uids.push({ uid: student.uid, - lehreinheit_id: student.lehreinheit_id, - typ: this.getPruefungstypForStudentByAntritt(student)//student.hoechsterAntritt + lehreinheit_id: student.lehreinheit_id }) }) - + + this.$refs.modalContainerNeuesPruefungsdatum.hide() + + if(!uids.length) return + + // eine fehlende LV-Note blockiert nicht: sie entsteht serverseitig mit der Note dieser + // Prüfung. Der Hinweis darauf steht im Dialog, siehe pruefungOhneLvNote. this.loading = true; this.$api.call(ApiNoten.createPruefungen( uids, - dateStrDb, + dateStrDb, this.lv_id, this.sem_kurzbz, + this.selectedPruefungNote?.note ?? null, + this.selectedPruefungPunkte ?? null )).then(res => { if(res.meta.status === "success") { - - // iterate over response data + + // iterate over response data // -> alert successful pruefungen // -> alert denied pruefungen + reason - + let uidListSuccess = '' let uidListError = '' const successData = [] Object.keys(res.data).forEach(student_uid => { const student = res.data[student_uid] // actual pruefung has been allocated - if(student.savedPruefung || student.extraPruefung) { + if(student.savedPruefung) { uidListSuccess += ' ' + student_uid - + // keep res.data format intact for handleResponse method successData[student_uid] = student } else { // there should be an error message why no pruefungen where allocated for this person, many reasons possible uidListError += student_uid + ' - ' + student +'\n'// student variable is the error message here } }) - + if(uidListError != '') { this.$fhcAlert.alertError( this.$capitalize(this.$p.t('benotungstool/c4pruefungAnlageError', [dateStrFront])) + ': ' + uidListError + ' ' ) } - + if(uidListSuccess != '') { this.$fhcAlert.alertDefault( 'success', @@ -2373,7 +2127,7 @@ export const Benotungstool = { this.handleAddNewPruefungenResponse({data: successData}, uids) } - + } }).finally(()=> this.loading = false) }, @@ -2443,7 +2197,7 @@ export const Benotungstool = { const limit = limitMap[note] if (limit == null) return - // all fixed antritte: Termin1/2/3 sit in pruefungen, kommPruef is separate + // alle Termine des Verlaufs; der abschliessende steht separat an der Zeile const allPruefungen = [...this.pruefungStudent.pruefungen] if (this.pruefungStudent.kommPruef) allPruefungen.push(this.pruefungStudent.kommPruef) @@ -2477,8 +2231,18 @@ export const Benotungstool = { + this.$p.t('global/gesamt') + ': ' + (this.studenten?.length || 0) + ''; }, - maxAntrittCount() { - return NotenRules.maxAntrittCount(this.config) + // ausgewählte Studenten, für die noch keine LV-Note existiert: sie entsteht mit dieser + // Prüfung. Nur ein Hinweis im Dialog - blockiert nichts und braucht keine Bestätigung. + pruefungOhneLvNote() { + return (this.selectedUids ?? []).filter(s => NotenRules.brauchtNeueLvNote(s)) + }, + // derselbe Hinweis im Dialog aus der Tabelle: beide Dialoge zeigen dasselbe an + pruefungStudentOhneLvNote() { + return !this.pruefung && !!this.pruefungStudent && NotenRules.brauchtNeueLvNote(this.pruefungStudent) + }, + // aktive Spaltenaufteilung: Wahl des Benutzers, sonst die Vorgabe aus der Konfiguration + pruefungsspaltenModus() { + return this.pruefungsspalten ?? this.config?.CIS_GESAMTNOTE_PRUEFUNGSSPALTEN ?? 'antritt' }, getFreigabeCounter() { return this.studenten ? this.studenten.reduce((acc, cur) => { @@ -2656,7 +2420,29 @@ export const Benotungstool = { - + +
+
{{$capitalize($p.t('benotungstool/c4punkte'))}}:
+
+ + +
+
+ +
+
{{$capitalize($p.t('lehre/note'))}}:
+
+ + +
+
+
{{$capitalize($p.t('benotungstool/prueflingSelectionv2'))}}:
@@ -2670,6 +2456,19 @@ export const Benotungstool = { class="w-100" />
+ +
+
+
+
{{ $p.t('benotungstool/c4lvNoteWirdAngelegtHinweis') }}
+
+ + , {{ s.vorname }} {{ s.nachname }} ({{ s.uid }}) + +
+
+
+