From 0410c954474f9e8a586654f7d484f7779da0af89 Mon Sep 17 00:00:00 2001 From: Paolo Date: Mon, 1 Jun 2026 12:13:44 +0200 Subject: [PATCH 1/5] cis/private/lehre/notenliste.xls.php now checks if the lector belongs to the teaching unit --- cis/private/lehre/notenliste.xls.php | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/cis/private/lehre/notenliste.xls.php b/cis/private/lehre/notenliste.xls.php index 25f353c12..170d35bba 100644 --- a/cis/private/lehre/notenliste.xls.php +++ b/cis/private/lehre/notenliste.xls.php @@ -44,7 +44,7 @@ $uid = get_uid(); $sprache = getSprache(); $p = new phrasen($sprache); -if(!check_lektor($uid)) +if (!check_lektor($uid)) die('Sie haben keine Berechtigung fuer diese Seite'); if (!$db = new basis_db()) @@ -90,6 +90,20 @@ if(isset($_GET['lehreinheit_id'])) else $lehreinheit_id = ''; +// Checks if the logged lector belongs to this teaching unit +$qry = "SELECT DISTINCT 1 + FROM campus.vw_lehreinheit vwl + WHERE lehrveranstaltung_id = ".$db->db_add_param($lvid, FHC_INTEGER)." + AND studiensemester_kurzbz = ".$db->db_add_param($stsem)." + AND vwl.mitarbeiter_uid = ".$db->db_add_param($uid); +if ($lehreinheit_id != '') + $qry .= " AND lehreinheit_id=".$db->db_add_param($lehreinheit_id, FHC_INTEGER); + +if (!$result = $db->db_query($qry)) + die($p->t('tools/fehlerBeimAuslesenDerNoten')); +if (!$db->db_fetch_object($result)) + die('Sie haben keine Berechtigung fuer diese Seite'); + /* * Create Excel File */ From 8bf18b0d377d02c9d7c47678aaa4961906503e6e Mon Sep 17 00:00:00 2001 From: Johann Hoffmann Date: Fri, 26 Jun 2026 12:52:18 +0200 Subject: [PATCH 2/5] =?UTF-8?q?pr=C3=BCfung/notenimport=20enabled=20per=20?= =?UTF-8?q?config=20+=20adapt=20notenliste.xls.php=20highlighting=20as=20p?= =?UTF-8?q?er=20config;=20check=20if=20higher=20antritt=20pr=C3=BCfungen?= =?UTF-8?q?=20exist=20when=20editing=20existing=20ones=20(changing=20grade?= =?UTF-8?q?s=20is=20not=20allowed,=20dates=20inbetween=20the=20other=202?= =?UTF-8?q?=20antritt=20termine=20allowed);=20pr=C3=BCfungstermine=20with?= =?UTF-8?q?=20entschuldigt=20note=20are=20being=20preserved=20since=20the?= =?UTF-8?q?=20whole=20series=20can=20have=201max;=20frozen=20columns=20sel?= =?UTF-8?q?ection=20dropdown=20+=204=20columns=20freezable=20(selector,=20?= =?UTF-8?q?uid,=20vorname,=20nachname)=20->=20setting=20saved=20in=20local?= =?UTF-8?q?=20storage;=20better=20mirroring=20of=20new=20pruefung=20studen?= =?UTF-8?q?ten=20dropdown=20with=20actual=20table=20sort=20+=20update=20th?= =?UTF-8?q?e=20label=20there=20to=20be=20uid=20+=20name=20aswell;=20fixed?= =?UTF-8?q?=20the=20note=5Fvorschlag=20select=20input=20cell;=20table=20so?= =?UTF-8?q?rt=20doesnt=20clear=20selection;=20show=20the=20NotenlisteLinks?= =?UTF-8?q?.js=20in=20import=20modal=20and=20filter=20it=20based=20on=20LE?= =?UTF-8?q?=20selection;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- application/config/noten.php | 13 +- .../controllers/api/frontend/v1/Noten.php | 203 ++++++- .../education/Lehrveranstaltung_model.php | 1 + cis/private/lehre/notenliste.xls.php | 61 +- public/css/Cis4/Benotungstool.css | 89 ++- public/js/api/factory/noten.js | 4 +- .../Cis/Benotungstool/Benotungstool.js | 550 +++++++++++++++--- .../Cis/Benotungstool/NotenlisteLinks.js | 73 +++ system/phrasesupdate.php | 360 +++++++++++- 9 files changed, 1197 insertions(+), 157 deletions(-) create mode 100644 public/js/components/Cis/Benotungstool/NotenlisteLinks.js diff --git a/application/config/noten.php b/application/config/noten.php index 0eedde2ae..6fdee8b86 100644 --- a/application/config/noten.php +++ b/application/config/noten.php @@ -3,4 +3,15 @@ if (!defined('BASEPATH')) exit('No direct script access allowed'); // 'entschuldigt' & 'noch nicht eingetragen' -> wirken sich nicht auf Antritte aus -$config['NOTEN_OHNE_ANTRITT'] = [9, 17]; // tbl_note pk \ No newline at end of file +$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 + +// 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; + +// 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) \ No newline at end of file diff --git a/application/controllers/api/frontend/v1/Noten.php b/application/controllers/api/frontend/v1/Noten.php index 0d385a5d2..0cabb639f 100644 --- a/application/controllers/api/frontend/v1/Noten.php +++ b/application/controllers/api/frontend/v1/Noten.php @@ -85,6 +85,8 @@ class Noten extends FHCAPI_Controller public function getCisConfig() { $NOTEN_OHNE_ANTRITT = $this->config->item('NOTEN_OHNE_ANTRITT'); + $NOTEN_OCCURANCE_LIMIT_MAP = $this->config->item('NOTEN_OCCURANCE_LIMIT_MAP'); + $NOTE_ENTSCHULDIGT = $this->config->item('NOTE_ENTSCHULDIGT'); $this->terminateWithSuccess( array( @@ -117,11 +119,21 @@ class Noten extends FHCAPI_Controller // toggles availability of the teilnoten column... existas but do we really need this? 'CIS_GESAMTNOTE_PRUEFUNG_MOODLE_LE_NOTE' => CIS_GESAMTNOTE_PRUEFUNG_MOODLE_LE_NOTE, + + // availability of the two import flows (application/config/noten.php); when both are + // true they are shown separately + 'CIS_GESAMTNOTE_PRUEFUNGSIMPORT' => $this->config->item('CIS_GESAMTNOTE_PRUEFUNGSIMPORT'), + 'CIS_GESAMTNOTE_NOTENIMPORT' => $this->config->item('CIS_GESAMTNOTE_NOTENIMPORT'), // send a mail when approving grades 'CIS_GESAMTNOTE_FREIGABEMAIL_NOTE' => CIS_GESAMTNOTE_FREIGABEMAIL_NOTE, - 'NOTEN_OHNE_ANTRITT' => $NOTEN_OHNE_ANTRITT + 'NOTEN_OHNE_ANTRITT' => $NOTEN_OHNE_ANTRITT, + + 'NOTEN_OCCURANCE_LIMIT_MAP' => $NOTEN_OCCURANCE_LIMIT_MAP, + + // pk of the 'entschuldigt' note; used to preserve excused Termine on new pruefung creation + 'NOTE_ENTSCHULDIGT' => $NOTE_ENTSCHULDIGT ) ); } @@ -180,7 +192,7 @@ class Noten extends FHCAPI_Controller $grades[$uid]['grades'] = []; $result = $this->LvgesamtnoteModel->getLvGesamtNoten($lv_id, $uid, $sem_kurzbz); - $this->addMeta($uid.'getLvGesamtNoten', $result); +// $this->addMeta($uid.'getLvGesamtNoten', $result); if(!isError($result) && hasData($result)) { $lvgesamtnote = getData($result)[0]; $grades[$uid]['note_lv'] = $lvgesamtnote->note; @@ -209,6 +221,7 @@ class Noten extends FHCAPI_Controller ] ); } catch (Throwable $t) { +// $this->addMeta('throwable', $t->getTrace()); $this->addMeta('getExternalGradesError', $t->getMessage()); } @@ -369,7 +382,7 @@ class Noten extends FHCAPI_Controller foreach($result->noten as $note) { $resultLVGes = $this->LvgesamtnoteModel->getLvGesamtNoteVorschlag($lv_id, $note->uid, $sem_kurzbz); - $this->addMeta($note->uid.'$resultLVGes', $resultLVGes); +// $this->addMeta($note->uid.'$resultLVGes', $resultLVGes); if (!isError($resultLVGes) && hasData($resultLVGes)) { $lvgesamtnote = getData($resultLVGes)[0]; @@ -521,10 +534,12 @@ class Noten extends FHCAPI_Controller $datum = $result->datum; $lva_id = $result->lva_id; $lehreinheit_id = $result->lehreinheit_id; - + // pruefung_id identifies the record being edited; null when a new pruefung is added + $pruefung_id = property_exists($result, 'pruefung_id') ? $result->pruefung_id : null; + $stsem = $result->sem_kurzbz; $typ = $result->typ; - + $jetzt = date("Y-m-d H:i:s"); if(CIS_GESAMTNOTE_PUNKTE && isset($punkte) && $punkte >= 0) { @@ -549,6 +564,14 @@ class Noten extends FHCAPI_Controller $note = getData($result)[0]->note; } + // 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). + // 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'); + } + $this->load->model('education/Lehrveranstaltung_model', 'LehrveranstaltungModel'); $this->load->model('organisation/Studiengang_model', 'StudiengangModel'); @@ -566,6 +589,10 @@ class Noten extends FHCAPI_Controller $result = $this->LvgesamtnoteModel->getLvGesamtNoten($lva_id, $student_uid, $stsem); + + $origLvNote = null; + $origLvPunkte = null; + $origBenotungsdatum = null; if(!isError($result) && !hasData($result)) { $id = $this->LvgesamtnoteModel->insert( @@ -601,6 +628,11 @@ class Noten extends FHCAPI_Controller { $lvgesamtnote = getData($result)[0]; + $orig = getData($result)[0]; + $origLvNote = $lvgesamtnote->note; + $origLvPunkte = $lvgesamtnote->punkte; + $origBenotungsdatum = $lvgesamtnote->benotungsdatum; + $id = $this->LvgesamtnoteModel->update( [$lvgesamtnote->student_uid, $lvgesamtnote->studiensemester_kurzbz, $lvgesamtnote->lehrveranstaltung_id], array( @@ -624,7 +656,7 @@ class Noten extends FHCAPI_Controller } // 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); + $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; @@ -638,17 +670,17 @@ class Noten extends FHCAPI_Controller /** * private helper method to update/insert pruefungstermine */ - private function savePruefungstermin($typ, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte = '', $datum) + private function savePruefungstermin($typ, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte = '', $datum, $origLvNote = null, $origLvPunkte = null, $origBenotungsdatum = null) { // 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); +// $this->addMeta('lvgesamtnoteData', $lvgesamtnoteData); // allocating pruefungen before lv note is forbidden if($lvgesamtnoteData == null) return $this->p->t('benotungstool', 'c4keineLvNoteEingetragen'); - + $status = []; // send $grades reference to moodle addon @@ -691,7 +723,12 @@ class Noten extends FHCAPI_Controller $resultLV = $this->LvgesamtnoteModel->getLvGesamtNoteVorschlag($lva_id, $student_uid, $stsem); // update Termin1 note - if (hasData($resultLV)) + if ($origLvNote !== null) { + $pr_note = $origLvNote; + $pr_punkte = $origLvPunkte; + $benotungsdatum = $origBenotungsdatum ?? $jetzt; + } + else if (hasData($resultLV)) { $lvgesamtnote = getData($resultLV)[0]; $pr_note = $lvgesamtnote->note; @@ -736,12 +773,20 @@ class Noten extends FHCAPI_Controller - // Die Pruefung wird als Termin2 eingetragen + // 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); - // if there is a termin 2 entry already update it + + $termin2 = null; if(!isError($result2) && hasData($result2)) { - // update - $termin2 = getData($result2)[0]; + foreach(getData($result2) as $t2) { + if(!$this->isEntschuldigtNote($t2->note)) { $termin2 = $t2; break; } + } + } + + if($termin2 !== null) { + // update existing (non-excused) Termin2 $id = $this->LePruefungModel->update( $termin2->pruefung_id, array( @@ -761,8 +806,8 @@ class Noten extends FHCAPI_Controller $this->logLib->logInfoDB(array('termin2 updated',$res, getAuthUID(), getAuthPersonId())); - } else if(!isError($result2) && !hasData($result2)) { - // new entry termin 2 + } else if(!isError($result2)) { + // no editable Termin2 (none yet, or only an entschuldigt one) -> insert new, excused stays $id = $this->LePruefungModel->insert( array( @@ -790,15 +835,20 @@ class Noten extends FHCAPI_Controller } - } else if($typ == "Termin3" && defined('CIS_GESAMTNOTE_PRUEFUNG_TERMIN3') && CIS_GESAMTNOTE_PRUEFUNG_TERMIN3) + } 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)) { - // update - $termin3 = getData($result3)[0]; + 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( @@ -817,8 +867,8 @@ class Noten extends FHCAPI_Controller $this->logLib->logInfoDB(array('termin3 updated',$res, getAuthUID(), getAuthPersonId())); - } else if(!isError($result3) && !hasData($result3)) { - // insert new termin3 + } else if(!isError($result3)) { + // no editable Termin3 (none yet, or only an entschuldigt one) -> insert new, excused stays $id = $this->LePruefungModel->insert( array( @@ -852,6 +902,105 @@ class Noten extends FHCAPI_Controller return $pruefungenChanged; } + /** + * ranking of the pruefung attempt types, used to detect whether a "höhere" + * (later attempt) pruefung exists for a student + */ + private function pruefungAttemptRank($typ) + { + switch($typ) { + case 'Termin1': return 1; + case 'Termin2': return 2; + case 'Termin3': return 3; + case 'kommPruef': return 4; + default: return 0; + } + } + + /** + * 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. + */ + private function isEntschuldigtNote($note) + { + $entschuldigt = $this->config->item('NOTE_ENTSCHULDIGT'); + return $entschuldigt !== null && $note == $entschuldigt; + } + + /** + * 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. + * + * 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). + * + * 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). + * + * @param string $student_uid + * @param int $lva_id + * @param string $stsem + * @param int $newNote the (already resolved) note being saved + * @param string $newDatum the datum being saved (Y-m-d) + * @param int $pruefung_id pk of the record being edited, or null for an add + * @return string|null + */ + private function validatePruefungEdit($student_uid, $lva_id, $stsem, $newNote, $newDatum, $pruefung_id) + { + 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; + + $pruefungen = getData($result); + + // the record being edited + $current = null; + foreach($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; + + $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) { + 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 > $currentDate || $this->pruefungAttemptRank($p->pruefungstyp_kurzbz) > $currentRank) { + $hasLaterOrHigher = true; + } + } + + // grade is locked once a later/higher pruefung exists + if($hasLaterOrHigher && $newNote != $current->note) { + return $this->p->t('benotungstool', 'pruefungNoteLocked', [$student_uid]); + } + + // datum must stay strictly between the neighbouring exam dates + if(($lower !== null && $new <= $lower) || ($upper !== null && $new >= $upper)) { + return $this->p->t('benotungstool', 'pruefungDatumOutOfRange', [$student_uid]); + } + + return null; + } + /** * POST METHOD * expects 'sem_kurzbz', 'lv_id', 'student_uid', 'note' @@ -874,7 +1023,7 @@ class Noten extends FHCAPI_Controller $result = $this->LvgesamtnoteModel->getLvGesamtNoteVorschlag($lv_id, $student_uid, $sem_kurzbz); - $this->addMeta('LvgesamtnoteModelresult', $result); +// $this->addMeta('LvgesamtnoteModelresult', $result); if(!isError($result) && hasData($result)) { $lvgesamtnote = getData($result)[0]; @@ -950,12 +1099,12 @@ class Noten extends FHCAPI_Controller { $result = $this->LvgesamtnoteModel->getLvGesamtNoteVorschlag($lv_id, $note->uid, $sem_kurzbz); - $this->addMeta($note->uid.'$result', $result); +// $this->addMeta($note->uid.'$result', $result); if(CIS_GESAMTNOTE_PUNKTE) { $resultNote = $this->NotenschluesselaufteilungModel->getNote($note->punkte, $lv_id, $sem_kurzbz); $note->note = $this->getDataOrTerminateWithError($resultNote); - $this->addMeta($note->uid.'note', $note); +// $this->addMeta($note->uid.'note', $note); } if(!isError($result) && hasData($result)) { @@ -1074,9 +1223,9 @@ class Noten extends FHCAPI_Controller if(CIS_GESAMTNOTE_PUNKTE) { $result = $this->NotenschluesselaufteilungModel->getNote($pruefung->punkte, $lv_id, $sem_kurzbz); - $this->addMeta($pruefung->uid."result", $result); +// $this->addMeta($pruefung->uid."result", $result); $pruefung->note = $this->getDataOrTerminateWithError($result); - $this->addMeta($pruefung->uid."note", $pruefung->note); +// $this->addMeta($pruefung->uid."note", $pruefung->note); } $student_uid = $pruefung->uid; diff --git a/application/models/education/Lehrveranstaltung_model.php b/application/models/education/Lehrveranstaltung_model.php index 2498b9246..c616e260c 100644 --- a/application/models/education/Lehrveranstaltung_model.php +++ b/application/models/education/Lehrveranstaltung_model.php @@ -1350,6 +1350,7 @@ class Lehrveranstaltung_model extends DB_Model public function getLvForLektorInSemester($sem_kurzbz, $uid) { $qry = "SELECT DISTINCT (tbl_lehrveranstaltung.lehrveranstaltung_id), UPPER(tbl_studiengang.typ::varchar(1) || tbl_studiengang.kurzbz) as stg_kurzbz, + tbl_studiengang.studiengang_kz, tbl_lehrveranstaltung.semester as lv_semester, tbl_lehrveranstaltung.bezeichnung as lv_bezeichnung, (SELECT kurzbz FROM public.tbl_mitarbeiter diff --git a/cis/private/lehre/notenliste.xls.php b/cis/private/lehre/notenliste.xls.php index 25f353c12..4e91ae7cb 100644 --- a/cis/private/lehre/notenliste.xls.php +++ b/cis/private/lehre/notenliste.xls.php @@ -143,6 +143,39 @@ else // let's merge $format_title->setAlign('merge'); + // Importkonfiguration aus dem CI-Config (application/config/noten.php) lesen, damit nur die + // Spalten des jeweils aktiven Importtyps hervorgehoben (grau) werden. + $CIS_GESAMTNOTE_NOTENIMPORT = false; + $CIS_GESAMTNOTE_PRUEFUNGSIMPORT = true; + $notenConfigFile = __DIR__.'/../../../application/config/noten.php'; + if(is_file($notenConfigFile)) + { + if(!defined('BASEPATH')) define('BASEPATH', true); + $config = array(); + include($notenConfigFile); + if(isset($config['CIS_GESAMTNOTE_NOTENIMPORT'])) $CIS_GESAMTNOTE_NOTENIMPORT = $config['CIS_GESAMTNOTE_NOTENIMPORT']; + if(isset($config['CIS_GESAMTNOTE_PRUEFUNGSIMPORT'])) $CIS_GESAMTNOTE_PRUEFUNGSIMPORT = $config['CIS_GESAMTNOTE_PRUEFUNGSIMPORT']; + unset($config); + } + + // plain (non-highlighted) variants used when an import type is disabled + $format_plain =& $workbook->addFormat(); + $format_plain->setAlign('left'); + $format_plain->setNumFormat(49); + + $format_plainright =& $workbook->addFormat(); + $format_plainright->setAlign('right'); + $format_plainright->setNumFormat(49); + + // highlight only the cells belonging to an enabled import flow: + // - Notenimport -> Personenkennzeichen + Note (main block) + // - Prüfungsimport -> Personenkennzeichen + Datum + Note (Termin2/Termin3 blocks) + $fmtNoteImportPkz = $CIS_GESAMTNOTE_NOTENIMPORT ? $format_highlight : $format_plain; + $fmtNoteImportNote = $CIS_GESAMTNOTE_NOTENIMPORT ? $format_highlightright : $format_plainright; + $fmtPruefImportPkz = $CIS_GESAMTNOTE_PRUEFUNGSIMPORT ? $format_highlight : $format_plain; + $fmtPruefImportDate = $CIS_GESAMTNOTE_PRUEFUNGSIMPORT ? $format_highlightright_date : $format_plainright; + $fmtPruefImportNote = $CIS_GESAMTNOTE_PRUEFUNGSIMPORT ? $format_highlightright : $format_plainright; + $lvobj = new lehrveranstaltung($lvid); $worksheet->write(0,0,$p->t('anwesenheitsliste/notenliste')." ".($sprache=='English'?$lvobj->bezeichnung_english:$lvobj->bezeichnung),$format_bold); @@ -338,13 +371,13 @@ else $worksheet->write($lines,3,$elem->vorname); } $worksheet->write($lines,4,$elem->semester.$elem->verband.$elem->gruppe); - $worksheet->write($lines,5,trim($elem->matrikelnr),$format_highlight); - $worksheet->write($lines,6, $note, $format_highlightright); + $worksheet->write($lines,5,trim($elem->matrikelnr),$fmtNoteImportPkz); + $worksheet->write($lines,6, $note, $fmtNoteImportNote); // Nachprüfung if (defined('CIS_GESAMTNOTE_PRUEFUNG_TERMIN2') && CIS_GESAMTNOTE_PRUEFUNG_TERMIN2) { - $worksheet->write($lines,8, trim($elem->matrikelnr), $format_highlight); + $worksheet->write($lines,8, trim($elem->matrikelnr), $fmtPruefImportPkz); $pr = new Pruefung(); $pr->getPruefungen($elem->uid, "Termin2", $lvid, $stsem); $output2 = $pr->result; @@ -352,23 +385,23 @@ else if ($output2) { $resultPr = $output2[0]; - $worksheet->write($lines,9, date('d.m.Y', strtotime($resultPr->datum)), $format_highlightright_date); + $worksheet->write($lines,9, date('d.m.Y', strtotime($resultPr->datum)), $fmtPruefImportDate); if(defined('CIS_GESAMTNOTE_PUNKTE') && CIS_GESAMTNOTE_PUNKTE==true) - $worksheet->write($lines,10, $resultPr->punkte, $format_highlightright); + $worksheet->write($lines,10, $resultPr->punkte, $fmtPruefImportNote); else - $worksheet->write($lines,10, $resultPr->note, $format_highlightright); + $worksheet->write($lines,10, $resultPr->note, $fmtPruefImportNote); } else { - $worksheet->write($lines,9, '', $format_highlightright_date); - $worksheet->write($lines,10, '', $format_highlightright); + $worksheet->write($lines,9, '', $fmtPruefImportDate); + $worksheet->write($lines,10, '', $fmtPruefImportNote); } } // Nachprüfung if (defined('CIS_GESAMTNOTE_PRUEFUNG_TERMIN3') && CIS_GESAMTNOTE_PRUEFUNG_TERMIN3) { - $worksheet->write($lines,12, trim($elem->matrikelnr), $format_highlight); + $worksheet->write($lines,12, trim($elem->matrikelnr), $fmtPruefImportPkz); $pr = new Pruefung(); $pr->getPruefungen($elem->uid, "Termin3", $lvid, $stsem); $output3 = $pr->result; @@ -376,16 +409,16 @@ else if ($output3) { $resultPr = $output3[0]; - $worksheet->write($lines,13, date('d.m.Y', strtotime($resultPr->datum)), $format_highlightright_date); + $worksheet->write($lines,13, date('d.m.Y', strtotime($resultPr->datum)), $fmtPruefImportDate); if(defined('CIS_GESAMTNOTE_PUNKTE') && CIS_GESAMTNOTE_PUNKTE==true) - $worksheet->write($lines,14, $resultPr->punkte, $format_highlightright); + $worksheet->write($lines,14, $resultPr->punkte, $fmtPruefImportNote); else - $worksheet->write($lines,14, $resultPr->note, $format_highlightright); + $worksheet->write($lines,14, $resultPr->note, $fmtPruefImportNote); } else { - $worksheet->write($lines,13, '', $format_highlightright_date); - $worksheet->write($lines,14, '', $format_highlightright); + $worksheet->write($lines,13, '', $fmtPruefImportDate); + $worksheet->write($lines,14, '', $fmtPruefImportNote); } } diff --git a/public/css/Cis4/Benotungstool.css b/public/css/Cis4/Benotungstool.css index dc11d81a7..83a026d0e 100644 --- a/public/css/Cis4/Benotungstool.css +++ b/public/css/Cis4/Benotungstool.css @@ -1,27 +1,34 @@ -/* 1. Stick the Header */ -#notentable .tabulator-header .tabulator-col.sticky-col { - position: sticky; - left: 0; - z-index: 10; /* Must be higher than other headers */ - background-color: #fff; /* Opaque background is required */ - border-right: 2px solid #ddd; /* Optional: Separator line */ -} - -/* 2. Stick the Data Cells */ +/* Sticky identity columns. The four freezable columns (selection/uid/Vorname/Nachname) always carry + the .sticky-col class for the opaque background; whether a column is actually pinned is toggled + per column via a `sticky-on-` class on #notentable (set in JS from the user's selection). + The cumulative left offsets (--sl-) are computed in JS (recomputeStickyOffsets) so that + multiple sticky columns stack next to each other instead of overlapping at left:0. */ +#notentable .tabulator-header .tabulator-col.sticky-col, #notentable .tabulator-tableholder .tabulator-row .tabulator-cell.sticky-col { - position: sticky; - left: 0; - z-index: 10; /* Ensure it floats above other cells */ - background-color: #fff; /* Match your row background color */ - border-right: 2px solid #ddd; /* Optional: Separator line */ + background-color: #fff; /* opaque so scrolled cells don't show through */ } -/* 3. Fix for Hover Effects (Optional) */ -/* If you use hover rows, you need to ensure the sticky cell matches the hover color */ +/* keep sticky cells opaque on row hover */ #notentable .tabulator-row:hover .tabulator-cell.sticky-col { - background-color: #ccc; /* Match your existing hover color */ + background-color: #ccc; } +/* enable pinning per column only when the container opts the field in. + left offset comes from the matching --sl- CSS variable (set on #notentable from JS). */ +#notentable.sticky-on-selectCol .sticky-col[tabulator-field="selectCol"], +#notentable.sticky-on-uid .sticky-col[tabulator-field="uid"], +#notentable.sticky-on-vorname .sticky-col[tabulator-field="vorname"], +#notentable.sticky-on-nachname .sticky-col[tabulator-field="nachname"] { + position: sticky; + z-index: 10; /* float above the scrolling pruefungs columns */ + border-right: 1px solid #ddd; /* separator on the sticky block */ +} + +#notentable.sticky-on-selectCol .sticky-col[tabulator-field="selectCol"] { left: var(--sl-selectCol, 0); } +#notentable.sticky-on-uid .sticky-col[tabulator-field="uid"] { left: var(--sl-uid, 0); } +#notentable.sticky-on-vorname .sticky-col[tabulator-field="vorname"] { left: var(--sl-vorname, 0); } +#notentable.sticky-on-nachname .sticky-col[tabulator-field="nachname"] { left: var(--sl-nachname, 0); } + /* styling for points input column for notenvorschläge in benotungstool*/ #notentable .tabulator-tableholder .tabulator-editable[tabulator-field="punkte"] { @@ -39,9 +46,45 @@ #notentable .tabulator-tableholder .tabulator-editable[tabulator-field="note_vorschlag"]::after { content: "▾"; position: absolute; - right: 6px; - color: rgba(176, 176, 106, 0.73);; - font-size: x-large; - bottom: 6px; + right: 8px; + top: 50%; + transform: translateY(-50%) rotate(0deg); + transform-origin: center; + color: rgba(120, 120, 60, 0.85); + font-size: 0.9rem; + line-height: 1; pointer-events: none; -} \ No newline at end of file + transition: transform 0.15s ease; +} + +/* rotate the indicator up while the dropdown editor is open */ +#notentable .tabulator-tableholder .tabulator-cell.tabulator-editing[tabulator-field="note_vorschlag"]::after { + transform: translateY(-50%) rotate(180deg); +} + +.pruefung-badge { + position: relative; + padding-left: 32px; /* badge width (20) + its left offset (6) + gap (6) */ +} + +.pruefung-badge::before { + content: attr(data-attempt); + position: absolute; + left: 6px; + top: 50%; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + border-radius: 50%; + color: #fff; + font-size: 0.75rem; + font-weight: bold; +} + +.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 diff --git a/public/js/api/factory/noten.js b/public/js/api/factory/noten.js index 701aed199..d0b8940ff 100644 --- a/public/js/api/factory/noten.js +++ b/public/js/api/factory/noten.js @@ -49,11 +49,11 @@ export default { params: { lv_id, sem_kurzbz, student_uid, note, punkte } }; }, - saveStudentPruefung(student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, typ){ + saveStudentPruefung(student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, typ, 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 } + params: { student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, typ, pruefung_id } }; }, createPruefungen(uids, datum, lva_id, sem_kurzbz){ diff --git a/public/js/components/Cis/Benotungstool/Benotungstool.js b/public/js/components/Cis/Benotungstool/Benotungstool.js index 0440ffba0..cb2af33e7 100644 --- a/public/js/components/Cis/Benotungstool/Benotungstool.js +++ b/public/js/components/Cis/Benotungstool/Benotungstool.js @@ -8,6 +8,7 @@ import BsOffcanvas from '../../Bootstrap/Offcanvas.js'; import VueDatePicker from '../../vueDatepicker.js.php'; import LehreinheitenModule from '../../DropdownModes/LehreinheitenModule.js'; import MobilityLegende from '../../Mobility/Legende.js'; +import NotenlisteLinks from "./NotenlisteLinks.js"; import FhcOverlay from "../../Overlay/FhcOverlay.js"; import {debounce} from "../../../helpers/debounce.js"; import {centeredTextFormatter} from "../../../tabulator/formatter/centered.js"; @@ -19,6 +20,7 @@ export const Benotungstool = { BsOffcanvas, CoreFilterCmpt, MobilityLegende, + NotenlisteLinks, Dropdown: primevue.dropdown, Divider: primevue.divider, InputNumber: primevue.inputnumber, @@ -49,6 +51,11 @@ export const Benotungstool = { sortRestored: false, stateRestored: false, persistenceID: 'notenToolTable2026-02-16', + freezePersistenceID: 'notenToolStickyCols', + // the identity columns the user may pin to the left while scrolling the pruefungs columns + freezableColumnFields: ['selectCol', 'uid', 'vorname', 'nachname'], + // which of those are currently sticky (per-column selection, persisted) + stickyColumnSelection: JSON.parse(localStorage.getItem('notenToolStickyCols') ?? '["selectCol","uid"]'), debouncedFetchPunkteForPruefung: null, config: null, // cis config neuesPruefungsdatumModalVisible: false, @@ -59,14 +66,19 @@ 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) + pruefungDateMin: null, + pruefungDateMax: null, distinctPruefungsDates: null, pruefungStudent: null, pruefung: null, password: '', changedNotenCounter: 0, + tableVersion: 0, // incremented on table sort/filter/data changes so getStudentenOptions mirrors the table tabulatorUuid: Vue.ref(0), domain: '', - importString: '', + importString: '', // Prüfungsimport textarea (uid + date + note) + importStringNoten: '', // legacy Notenimport textarea (uid + note) teilnoten: null, lv: null, studenten: null, @@ -101,7 +113,7 @@ export const Benotungstool = { event: "cellEdited", handler: async (cell) => { const field = cell.getField() - + if(field === 'note_vorschlag') { const rowData = cell.getRow().getData(); const newValue = cell.getValue(); @@ -112,14 +124,52 @@ export const Benotungstool = { // revert value cell.setValue(original, true); } - + delete rowData._originalNoteVorschlag; // Clean up - + const row = cell.getRow() row.reformat() // trigger reformat of arrow + + this.detachNoteVorschlagToggle() } } }, + { + event: "cellEditing", + handler: (cell) => { + if(cell.getField() !== 'note_vorschlag') return + const el = cell.getElement() + if(!el) return + + this.detachNoteVorschlagToggle() // drop any stale listener + + // clicking the already-open cell again should close the editor. Ignore clicks on the + // dropdown options themselves so selecting a value still works. + const listener = (ev) => { + if(ev.target?.closest && ev.target.closest('.tabulator-edit-list')) return + ev.stopPropagation() + try { cell.cancelEdit() } catch(e) {} + // the options popup is rendered outside the cell and lingers after cancelEdit -> remove it + document.querySelectorAll('.tabulator-edit-list').forEach(list => list.remove()) + } + this._nvCloseEl = el + this._nvCloseListener = listener + // defer so the very click that opened the editor doesn't immediately close it again. + // capture phase so it still fires if the editor's input stops propagation. + setTimeout(() => { + if(this._nvCloseEl === el && this._nvCloseListener === listener) { + el.addEventListener('mousedown', listener, true) + } + }, 0) + } + }, + { + event: "cellEditCancelled", + handler: (cell) => { + if(cell.getField() !== 'note_vorschlag') return + this.detachNoteVorschlagToggle() + } + }, { event: "cellClick", handler: async (e, cell) => { @@ -139,7 +189,8 @@ export const Benotungstool = { handler: (filters, rows) => { this.filteredRows = rows; this.filteredcount = rows.length; - + this.tableVersion++; // keep the "neue Prüfung" dropdown in sync with the filtered table + if (!this.selectedUids.length) return; const visibleData = new Set(rows.map(r => r.getData())); @@ -196,6 +247,48 @@ export const Benotungstool = { localStorage.setItem(this.persistenceID, JSON.stringify(state)); }, + stickyClass(field) { + // the freezable identity columns always carry the sticky-col class; whether they are + // actually sticky is toggled per column via container classes (see applyStickyColumnState), + // which avoids re-running updateDefinition on columns that have reactive (Vue.computed) titles + return this.freezableColumnFields.includes(field) ? 'sticky-col' : undefined + }, + recomputeStickyOffsets() { + // position each sticky column at the cumulative width of the preceding sticky columns + // so multiple sticky columns stack next to each other instead of overlapping at left:0 + const table = this.$refs.notenTable?.tabulator + const el = document.getElementById('notentable') + if(!table || !el) return + + let offset = 0 + // iterate in actual display order so column reordering is respected + table.getColumns().forEach(col => { + const field = col.getField() + if(!this.freezableColumnFields.includes(field)) return + + if(this.stickyColumnSelection.includes(field)) { + el.style.setProperty('--sl-' + field, offset + 'px') + offset += col.getWidth() + } else { + el.style.setProperty('--sl-' + field, '0px') + } + }) + }, + applyStickyColumnState() { + // reflect the current per-column sticky selection onto the table container + // (a `sticky-on-` class enables position:sticky for that column in CSS) + offsets + const el = document.getElementById('notentable') + if(!el) return + this.freezableColumnFields.forEach(field => { + el.classList.toggle('sticky-on-' + field, this.stickyColumnSelection.includes(field)) + }) + this.recomputeStickyOffsets() + }, + onStickySelectionChange() { + // MultiSelect change handler: persist + apply (instant, no table rebuild required) + try { localStorage.setItem(this.freezePersistenceID, JSON.stringify(this.stickyColumnSelection)) } catch(e) {} + this.applyStickyColumnState() + }, handleTableBuilt() { const table = this.$refs.notenTable.tabulator; @@ -214,8 +307,19 @@ export const Benotungstool = { table.on(eventName, () => this.saveState(table)); }); + // keep the sticky-column offsets in sync when columns are resized / moved / shown-hidden + ["columnResized", "columnMoved", "columnVisibilityChanged"].forEach(eventName => { + table.on(eventName, () => this.recomputeStickyOffsets()); + }); + + // keep the "neue Prüfung" dropdown order in sync with the table's current sort + table.on("dataSorted", () => this.tableVersion++); + // renderComplete restore state logic table.on("renderComplete", () => { + // widths are settled here, so (re)apply the sticky container classes + cumulative offsets + this.applyStickyColumnState(); + if (this.stateRestored) return; // layout restore should be happening in setupData() @@ -261,6 +365,14 @@ export const Benotungstool = { row.deselect(); } }, + detachNoteVorschlagToggle() { + // remove the "click again to close" listener attached while a note_vorschlag editor is open + if(this._nvCloseEl && this._nvCloseListener) { + this._nvCloseEl.removeEventListener('mousedown', this._nvCloseListener, true) + } + this._nvCloseEl = null + this._nvCloseListener = null + }, // using this to expose input event of editor element properly, tabulator makes it hard to access on default editor // implemented after tabulator/src/js/modules/edit/defaults/editors/number.js liveNumberEditor(cell, onRendered, success, cancel) { @@ -357,11 +469,10 @@ export const Benotungstool = { return } - if(!student.note) { - this.$fhcAlert.alertWarning('Student ' + student.uid + ' hat noch keine Zeugnis 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) { this.$fhcAlert.alertWarning('Student ' + student.uid + ' hat bereits ' + student.hoechsterAntritt + ' Prüfungsantritte abgelegt. Die Zeile wurde übersprungen.') @@ -409,7 +520,7 @@ export const Benotungstool = { // find notenoption and check if its allowed to use in lehre const notenOption = this.notenOptions.find(n => n.note == note) - if(!notenOption.lehre) { + if(!notenOption?.lehre) { this.$fhcAlert.alertWarning(this.$p.t('benotungstool/c4importNoGradeFoundForIdInRow', [rowParts[0], rowNum])) return } @@ -451,13 +562,13 @@ export const Benotungstool = { let punkte = null let note = null if(this.config?.CIS_GESAMTNOTE_PUNKTE) { - punkte = Number.parseFloat(rowParts[1]) + punkte = Number.parseFloat(rowParts[2]) } else { - note = rowParts[1] + note = rowParts[2] // find notenoption and check if its allowed to use in lehre const notenOption = this.notenOptions.find(n => n.note == note) - if(!notenOption.lehre) { + if(!notenOption?.lehre) { this.$fhcAlert.alertWarning(this.$p.t('benotungstool/c4importNoGradeFoundForIdInRow', [rowParts[0], rowNum])) return } @@ -662,9 +773,15 @@ export const Benotungstool = { 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 - const oldP = student.pruefungen.find(p => p.pruefungstyp_kurzbz == saved.pruefungstyp_kurzbz) + // 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 @@ -685,35 +802,50 @@ export const Benotungstool = { } } }, - importNoten() { + importPruefungen() { + // Prüfungsimport: every row must carry a date: "UID/Matrikelnr Datum Note". + // Each row creates a dated exam attempt. const rows = this.importString.split('\n') const bulk = [] - let mode = '' - // read the lines - rows.forEach((r,i) => { + + rows.forEach((r, i) => { + if(r.trim() === '') return // ignore empty/trailing lines const rowParts = r.split('\t') + const rowNum = i + 1 if(rowParts.length === 3) { - this.parsePruefung(rowParts, bulk, i) - mode = 'pruefung' // if line parts are not uniform we are in trouble - } else if(rowParts.length === 2) { - this.parseNote(rowParts, bulk, i) - mode = 'note' + this.parsePruefung(rowParts, bulk, rowNum) + } else { + this.$fhcAlert.alertWarning(this.$p.t('benotungstool/c4importRowNotDateFormat', [rowNum])) } }) - - // parsers check for notenOption.lehre === true and if student uid/matrikelnr matches - - // pruefungen check for younger pruefungen, so there are no further antritte with - // previous dates from automatic imports - if(mode === 'note') { - this.validateNotenBulk(bulk) - this.saveNotenBulk(bulk) - } - else if (mode === 'pruefung') { - this.validatePruefungBulk(bulk) - this.savePruefungBulk(bulk) - } - + + // parsePruefung validates date + grade and resolves uid/matrikelnr; + // validatePruefungBulk additionally checks antritte and that no earlier-dated antritt is created + this.validatePruefungBulk(bulk) + this.savePruefungBulk(bulk) + + this.$refs.modalContainerPruefungImport.hide() + }, + importNoten() { + // classic Notenimport (legacy, config gated): "UID/Matrikelnr Note" per row, + // writes the LV grade directly without creating a pruefung. + const rows = this.importStringNoten.split('\n') + const bulk = [] + + rows.forEach((r, i) => { + if(r.trim() === '') return // ignore empty/trailing lines + const rowParts = r.split('\t') + const rowNum = i + 1 + if(rowParts.length === 2) { + this.parseNote(rowParts, bulk, rowNum) + } else { + this.$fhcAlert.alertWarning(this.$p.t('benotungstool/c4importRowNotNoteFormat', [rowNum])) + } + }) + + this.validateNotenBulk(bulk) + this.saveNotenBulk(bulk) + this.$refs.modalContainerNotenImport.hide() }, selectionArraysAreEqual(arr1, arr2) { @@ -740,7 +872,7 @@ export const Benotungstool = { return { height: 700, virtualDom: true, - virtualDomBuffer: 5000, + renderVerticalBuffer: 1000, index: 'uid', layout: 'fitData', placeholder: this.$capitalize(this.$p.t('global/noDataAvailable')), @@ -774,6 +906,9 @@ export const Benotungstool = { let checkbox = document.createElement("input"); checkbox.type = "checkbox"; + // reflect the row's actual selection state so it survives re-renders (e.g. sorting/filtering) + checkbox.checked = cell.getRow().isSelected(); + // Handle select manually checkbox.addEventListener("click", (e) => { e.stopPropagation(); @@ -791,6 +926,12 @@ export const Benotungstool = { let checkbox = document.createElement("input"); checkbox.type = "checkbox"; + // reflect "all selectable rows selected" so the header box survives re-renders too + onRendered(() => { + const allowed = (cell.getTable().getRows("active") || []).filter(r => r.getData().selectable); + checkbox.checked = allowed.length > 0 && allowed.every(r => r.isSelected()); + }); + // Handle "select all" manually checkbox.addEventListener("click", (e) => { e.stopPropagation(); @@ -812,15 +953,15 @@ export const Benotungstool = { handleClick: this.selectAllHandler }, width: 50, - cssClass: 'sticky-col', + cssClass: this.stickyClass('selectCol'), field: 'selectCol', title: '' }) - columns.push({title: 'UID', field: 'uid', tooltip: false, topCalc: this.sumCalcFunc, formatter: centeredTextFormatter, cssClass: 'sticky-col'}) + 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}) - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4nachname'))), field: 'nachname', formatter: centeredTextFormatter, headerFilter: true}) + 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}) if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_MOODLE_LE_NOTE) { @@ -1115,11 +1256,37 @@ export const Benotungstool = { }, teilnotenFormatter(cell) { const val = cell.getValue() - + let style = 'white-space: pre-line;' - + 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 + } + }, + 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. + 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 + }) + }, pruefungFormatter(cell) { const data = cell.getData() @@ -1152,8 +1319,12 @@ export const Benotungstool = { rowDiv.style.alignItems = 'center'; rowDiv.style.height = '100%'; + let attemptLabel = '' + let attemptClass = '' if(studentPruefung) { let color = '' + + switch(studentPruefung.pruefungstyp_kurzbz) { case 'Termin1': color = 'green' @@ -1169,9 +1340,16 @@ export const Benotungstool = { break } - rowDiv.style.borderLeft = `4px solid ${color}`; + // rowDiv.style.borderLeft = `4px solid ${color}`; rowDiv.style.marginLeft = "6px"; // small indent so text doesn't overlap rowDiv.style.boxSizing = "border-box"; + + 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 + } } function createCol(content, classParam) { @@ -1195,6 +1373,9 @@ export const Benotungstool = { 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')); @@ -1205,10 +1386,18 @@ export const Benotungstool = { } 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); }); @@ -1237,25 +1426,60 @@ export const Benotungstool = { return '' } }, + parseISODate(iso) { + const parts = (iso ?? '').slice(0, 10).split('-') + if(parts.length !== 3) return null + return new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2])) + }, + addDays(date, days) { + const d = new Date(date) + d.setDate(d.getDate() + days) + return d + }, + getPruefungDateBounds(student, pruefung, fallbackDate) { + // the exam date must stay strictly between the dates of the chronologically adjacent + // pruefungen so the attempt order is preserved. Returns inclusive datepicker bounds. + const refDate = (pruefung?.datum ?? fallbackDate ?? '').slice(0, 10) + let lower = null, upper = null; + (student.pruefungen ?? []).forEach(p => { + if(pruefung && p.pruefung_id != null && pruefung.pruefung_id != null && p.pruefung_id === pruefung.pruefung_id) return + const d = (p.datum ?? '').slice(0, 10) + if(!d) return + if(d < refDate) { if(lower === null || d > lower) lower = d } + else if(d > refDate) { if(upper === null || d < upper) upper = d } + }) + return { + min: lower ? this.addDays(this.parseISODate(lower), 1) : null, + max: upper ? this.addDays(this.parseISODate(upper), -1) : null + } + }, openPruefungModal(student, pruefung = null, field) { this.pruefungStudent = student this.pruefung = pruefung const dateStr = this.pruefung?.datum ?? field - + const pruefungDateParts = dateStr.split('-') - + 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)) + + // constrain the date to stay between the neighbouring exam dates + const bounds = this.getPruefungDateBounds(student, pruefung, field) + this.pruefungDateMin = bounds.min + this.pruefungDateMax = bounds.max + + if(this.pruefung?.note) { this.selectedPruefungNote = this.notenOptions.find(n => n.note == this.pruefung.note) } else { this.selectedPruefungNote = null } - + this.selectedPruefungPunkte = this.pruefung?.punkte ?? null - + this.$refs.modalContainerPruefung.show() }, pruefungTitleFormatter(cell) { @@ -1326,7 +1550,8 @@ export const Benotungstool = { this.studenten = data[0] ?? [] this.studenten.forEach(s => { s.pruefungen = [] - s.infoString = `${s.vorname} ${s.nachname}`// (${s.semester}${s.verband}${s.gruppe}) Mat.: ${s.matrikelnr}`// used for multiselect + // fallback label; the full label incl. Antritte is set once hoechsterAntritt is known (below) + s.infoString = `${s.uid} – ${s.nachname} ${s.vorname}`// used for multiselect (uid first); full label incl. Antritte set below }) this.pruefungen = data[1] ?? [] this.domain = data[2] @@ -1384,6 +1609,9 @@ export const Benotungstool = { }) s.hoechsterAntritt = this.getAntrittCountStudent(s) + // multiselect label, uid first: "uid – Nachname Vorname – Antritte: n" + // (order in the dropdown mirrors the table, see getStudentenOptions) + s.infoString = `${s.uid} – ${s.nachname} ${s.vorname} – ${this.$capitalize(this.$p.t('benotungstool/c4antrittCountv2'))}: ${s.hoechsterAntritt}` s.email = this.buildMailToLink(s) s.lv_note = this.teilnoten[s.uid].note_lv s.freigabedatum = this.parseDate(this.teilnoten[s.uid]['freigabedatum']) @@ -1396,7 +1624,8 @@ export const Benotungstool = { s.teilnote = '' s.mobility_zusatz = this.teilnoten[s.uid].mobility_zusatz grades.forEach(g => { - const notenOption = this.notenOptions.find(n=>n.note == g.grade) + // some moodle noten are numeric, some are strings like "Sehr Gut", "Bestanden" etc... + const notenOption = this.notenOptions.find(n=>n.note == g.grade || n.bezeichnung == g.grade) if(notenOption.positiv) s.teilnote += (''+g.text +''+ '
') else s.teilnote += (''+g.text +''+ '
') }) @@ -1503,19 +1732,33 @@ export const Benotungstool = { } const colsFinal = colsUsed ?? cols - this.loading = false - + this.$refs.notenTable.tabulator.setColumns(colsFinal) this.$refs.notenTable.tabulator.setData(this.studenten); this.$refs.notenTable.tabulator.redraw(true); + + // reflect the sticky-column selection on the freshly (re)built columns + this.applyStickyColumnState() + + // refresh the "neue Prüfung" dropdown options for the freshly loaded data + this.tableVersion++ + + // keep the loading overlay up until the browser has actually painted the rebuilt table + // (the caller, loadNoten, awaits setupData before clearing `loading`) + await new Promise(requestAnimationFrame) }, loadNoten(lv_id, sem_kurzbz) { if (!lv_id || !sem_kurzbz) return this.loading = true this.$api.call(ApiNoten.getStudentenNoten(lv_id, sem_kurzbz)) - .then(res => { - if(res?.data) this.setupData(res.data) - if(res?.meta?.getExternalGradesError) this.$fhcAlert.alertError(res.meta.getExternalGradesError) + .then(async res => { + if(res?.data) await this.setupData(res.data) + else { + this.$fhcAlert.alertError('no data found') + this.$refs.notenTable.tabulator.setData([]); + this.$refs.notenTable.tabulator.redraw(true); + } + if(res?.meta?.getExternalGradesError) this.$fhcAlert.alertError(this.$p.t('benotungstool/c4moodleTeilnotenError', [res.meta.getExternalGradesError])) }).finally(()=> { this.loading = false }) @@ -1582,7 +1825,7 @@ export const Benotungstool = { LehreinheitenModule.bindParams(Vue.ref(Vue.computed(() => this.LeDropdownParams))); // fetch noten dropdown - this.$api.call(ApiNoten.getNoten()).then(async res => { + await this.$api.call(ApiNoten.getNoten()).then(async res => { this.notenOptions = res.data this.notenOptionsLehre = res.data.filter(n => n.lehre === true) @@ -1634,7 +1877,7 @@ export const Benotungstool = { if (lvId) { this.loadNoten(lvId, sem) } else if (this.$refs.notenTable?.tabulator) { - this.$refs.notenTable.tabulator.setData([]) // no matching LV -> clear + this.$refs.notenTable.tabulator.setData([]) } }).finally(() => this.loading = false) }, @@ -1664,18 +1907,30 @@ export const Benotungstool = { } }, savePruefungEingabe() { + // keep the date within the neighbouring exam dates (a typed value can bypass the picker bounds) + if((this.pruefungDateMin && this.selectedPruefungDate < this.pruefungDateMin) || + (this.pruefungDateMax && this.selectedPruefungDate > this.pruefungDateMax)) { + this.$fhcAlert.alertWarning(this.$capitalize(this.$p.t('benotungstool/pruefungDatumOutOfRangeHint'))) + return + } + 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 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) - const note = this.selectedPruefungNote?.note ?? 9 // noch nicht eingetragen - const punkte = this.selectedPruefungPunkte ?? 0 - + // when the grade is locked (later/higher pruefung exists) keep the existing note untouched + const note = this.pruefungNoteLocked && this.pruefung + ? this.pruefung.note + : (this.selectedPruefungNote?.note ?? 9) // noch nicht eingetragen + const punkte = this.pruefungNoteLocked && this.pruefung + ? (this.pruefung.punkte ?? 0) + : (this.selectedPruefungPunkte ?? 0) + this.loading = true this.$api.call(ApiNoten.saveStudentPruefung( this.pruefungStudent.uid, @@ -1685,7 +1940,8 @@ export const Benotungstool = { this.lv_id, this.pruefungStudent.lehreinheit_id, this.sem_kurzbz, - typ + 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' this.$fhcAlert.alertDefault( @@ -1873,11 +2129,13 @@ export const Benotungstool = { return !(kP || student.hoechsterAntritt >= vueThis.maxAntrittCount) }, set() { - // empty setter so tabulator doesnt scream + // empty setter so tabulator doesnt scream }, enumerable: true, configurable: true }) + // a student's selectability may have changed -> refresh the "neue Prüfung" dropdown options + this.tableVersion++ }, saveNoteneingabe() { this.loading = true @@ -1913,6 +2171,9 @@ export const Benotungstool = { openNewPruefungsdatumModal() { this.$refs.modalContainerNeuesPruefungsdatum.show() }, + openPruefungImportModal() { + this.$refs.modalContainerPruefungImport.show() + }, openNotenImportModal() { this.$refs.modalContainerNotenImport.show() }, @@ -2033,6 +2294,7 @@ export const Benotungstool = { for(let i = 0; i < pLen; i++) { const p = student.pruefungen[i] + // TODO: filter for limit here but in a perfect world they should never be able to exceed that anyway const isDefinedAsAntrittsloseNote = this.config.NOTEN_OHNE_ANTRITT.find(n_pk => n_pk == p.note) if(!isDefinedAsAntrittsloseNote) pruefungsAntrittCount++ } @@ -2074,11 +2336,18 @@ export const Benotungstool = { }); }, selectedLehreinheit(newVal) { - if(!this.$refs.notenTable) return - this.$refs.notenTable.tabulator.getFilters() - .filter(f => f.field === 'lehreinheit_id') - .forEach(() => table.removeFilter("lehreinheit_id", "=", undefined)) - if(newVal) this.$refs.notenTable.tabulator.setFilter("lehreinheit_id", "=", newVal.lehreinheit_id); + const table = this.$refs.notenTable?.tabulator + if (!table) return + + const others = table.getFilters().filter(f => f.field !== 'lehreinheit_id') + + table.clearFilter() + + const next = newVal + ? [...others, { field: 'lehreinheit_id', type: '=', value: newVal.lehreinheit_id }] + : others + + if (next.length) table.setFilter(next) }, selectedLehrveranstaltung(newVal, oldVal) { if (this.selectedLehreinheit) { @@ -2094,6 +2363,39 @@ export const Benotungstool = { const kommPruefCol = this.$refs.notenTable?.tabulator.getColumn("kommPruef") kommPruefCol.hide() } + }, + selectedPruefungNote(newVal, oldVal) { + if (!newVal || !this.pruefungStudent) return + + const limitMap = this.config?.NOTEN_OCCURANCE_LIMIT_MAP + if (!limitMap) return + console.log(limitMap) + + const note = newVal.note + const limit = limitMap[note] + if (limit == null) return + + // all fixed antritte: Termin1/2/3 sit in pruefungen, kommPruef is separate + const allPruefungen = [...this.pruefungStudent.pruefungen] + if (this.pruefungStudent.kommPruef) allPruefungen.push(this.pruefungStudent.kommPruef) + + // count existing occurrences of this note, excluding the pruefung being edited + // (its note is about to be replaced by this very selection) + const existingCount = allPruefungen.reduce((acc, p) => { + if (this.pruefung && p.pruefung_id === this.pruefung.pruefung_id) return acc + if (p.note == note) acc++ + return acc + }, 0) + + // this selection adds one more occurrence -> would it cross the limit? + if (existingCount + 1 > limit) { + // TODO: phrase + this.$fhcAlert.alertWarning( + 'Note "' + newVal.bezeichnung + '" darf bei ' + this.pruefungStudent.uid + + ' maximal ' + limit + ' mal vergeben werden. Auswahl wurde zurückgesetzt.' + ) + this.selectedPruefungNote = oldVal // revert to last valid choice + } } }, computed: { @@ -2133,7 +2435,20 @@ export const Benotungstool = { } }, getStudentenOptions() { - return this.studenten ? this.studenten.filter(s => s.selectable) : [] + // the "neue Prüfung" multiselect mirrors the table: same row order (current sort + filter) + // and the same selectable set. tableVersion is bumped on sort/filter/data changes so this + // recomputes whenever the table order changes. + const _ = this.tableVersion // reactive dependency + const table = this.$refs.notenTable?.tabulator + + if(!table) { + return this.studenten ? this.studenten.filter(s => s.selectable) : [] + } + + // getRows("active") returns the rows in their current display order (after sort + filter) + return table.getRows("active") + .map(r => r.getData()) + .filter(s => s.selectable) }, getKommPruefCount(){ let counter = 0 @@ -2168,8 +2483,20 @@ export const Benotungstool = { getNotenfreigabeHinweistext() { return this.$capitalize(this.$p.t('benotungstool/notenfreigabeHinweistextv4')) }, + getPruefungimportHinweistext() { + return this.$capitalize(this.$p.t('benotungstool/notenimportHinweistextv6')) + }, getNotenimportHinweistext() { return this.$capitalize(this.$p.t('benotungstool/notenimportHinweistextv5')) + }, + freezableColumnOptions() { + // the identity columns the user may pin to the left (matches freezableColumnFields) + return [ + { field: 'selectCol', label: this.$capitalize(this.$p.t('benotungstool/c4selection')) }, + { field: 'uid', label: 'UID' }, + { field: 'vorname', label: this.$capitalize(this.$p.t('benotungstool/c4vorname')) }, + { field: 'nachname', label: this.$capitalize(this.$p.t('benotungstool/c4nachname')) } + ] } }, created() { @@ -2179,6 +2506,31 @@ export const Benotungstool = { this.setupMounted() }, template: ` + + + + + + @@ -2208,6 +2568,7 @@ export const Benotungstool = { v-model="selectedPruefungDate" :clearable="false" format="dd.MM.yyyy" + placeholder="TT.MM.JJJJ" :enableTimePicker="false" :text-input="true" :auto-apply="true"> @@ -2262,17 +2623,24 @@ export const Benotungstool = { :clearable="false" :enableTimePicker="false" format="dd.MM.yyyy" + placeholder="TT.MM.JJJJ" + :min-date="pruefungDateMin" + :max-date="pruefungDateMax" :text-input="true" :auto-apply="true"> +
+
{{$capitalize($p.t('benotungstool/pruefungNoteLockedHint'))}}
+
{{$capitalize($p.t('benotungstool/c4punkte'))}}:
@@ -2282,7 +2650,7 @@ export const Benotungstool = {
{{$capitalize($p.t('lehre/note'))}}: