Merge branch 'cis40_2026-05_ma_rc_routerlink' into demo-cis40

This commit is contained in:
Harald Bamberger
2026-07-03 14:36:49 +02:00
19 changed files with 1647 additions and 455 deletions
+12 -1
View File
@@ -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
$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)
+176 -27
View File
@@ -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;
@@ -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
+63 -16
View File
@@ -36,6 +36,7 @@ require_once('../../../include/notenschluessel.class.php');
require_once('../../../include/Excel/excel.php');
require_once('../../../include/phrasen.class.php');
require_once('../../../include/pruefung.class.php');
require_once('../../../include/benutzerberechtigung.class.php');
$uid = get_uid();
@@ -44,7 +45,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 +91,21 @@ if(isset($_GET['lehreinheit_id']))
else
$lehreinheit_id = '';
// Permissions
$berechtigung = new benutzerberechtigung();
$berechtigung->getBerechtigungen($uid);
// LV load
$lvobj = new lehrveranstaltung($lvid);
// Check permissions
if (!$berechtigung->isBerechtigt('admin')
&& !$berechtigung->isBerechtigt('assistenz')
&& !$berechtigung->isBerechtigt('lehre', $lvobj->oe_kurzbz, 's')
&& !check_lektor_lehrveranstaltung($uid, $lvid, $stsem)
)
die('Sie haben keine Berechtigung fuer diese Seite');
/*
* Create Excel File
*/
@@ -143,7 +159,38 @@ else
// let's merge
$format_title->setAlign('merge');
$lvobj = new lehrveranstaltung($lvid);
// 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;
$worksheet->write(0,0,$p->t('anwesenheitsliste/notenliste')." ".($sprache=='English'?$lvobj->bezeichnung_english:$lvobj->bezeichnung),$format_bold);
@@ -338,13 +385,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 +399,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 +423,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);
}
}
+4
View File
@@ -373,4 +373,8 @@ define('TESTTOOL_EXTERNE_UEBERWACHUNG_ALLOWED', false);
//enable tags in StudVW
define('STV_TAGS_ENABLED', false);
//student accounts grace period
define('STUDENTS_KEEP_PERMISSIONS_AFTER_USER_INACTIVE_PERIOD', '0 days');
define('STUDENTS_KEEP_PERMISSIONS_AFTER_USER_INACTIVE_ROLES', serialize(array('NO_DEFINED_ROLE')));
?>
+36 -1
View File
@@ -340,6 +340,40 @@ class benutzerberechtigung extends basis_db
return true;
}
protected function hasPreStudentStatusInGracePeriod($uid)
{
$period = defined('STUDENTS_KEEP_PERMISSIONS_AFTER_USER_INACTIVE_PERIOD')
? STUDENTS_KEEP_PERMISSIONS_AFTER_USER_INACTIVE_PERIOD
: '0 days';
$mapfunc = function($val) {
return $this->db_add_param($val);
};
$roles = defined('STUDENTS_KEEP_PERMISSIONS_AFTER_USER_INACTIVE_ROLES')
? implode(', ', array_map($mapfunc, unserialize(STUDENTS_KEEP_PERMISSIONS_AFTER_USER_INACTIVE_ROLES)))
: 'NO_DEFINED_ROLE';
$sql = <<<EOSQL
SELECT
1
FROM
campus.vw_student vs
WHERE
vs.uid = {$this->db_add_param($uid)}
AND
vs.aktiv = false
AND
public.get_rolle_prestudent(vs.prestudent_id, null) IN ({$roles})
AND
(vs.updateaktivam + INTERVAL {$this->db_add_param($period)})::date >= CURRENT_DATE
EOSQL;
$result = $this->db_query($sql);
if($result && $this->db_num_rows($result) > 0)
{
return true;
}
return false;
}
/**
* Laedt die Berechtigungen eines Users
* @param $uid
@@ -355,7 +389,8 @@ class benutzerberechtigung extends basis_db
if($row = $this->db_fetch_object($result))
{
// Wenn die Person nicht aktiv ist dann hat diese auch keine Rechte
if($this->db_parse_bool($row->aktiv) == false)
if($this->db_parse_bool($row->aktiv) == false
&& $this->hasPreStudentStatusInGracePeriod($uid) === false)
return false;
}
else
+66 -23
View File
@@ -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-<field>` class on #notentable (set in JS from the user's selection).
The cumulative left offsets (--sl-<field>) 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-<field> 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;
}
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; }
+2 -2
View File
@@ -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){
+1 -275
View File
@@ -1,287 +1,13 @@
import FhcDashboard from '../../components/Dashboard/Dashboard.js';
import PluginsPhrasen from '../../plugins/Phrasen.js';
import Theme from '../../plugins/Theme.js';
import contrast from '../../directives/contrast.js';
import {setScrollbarWidth} from "../../helpers/CssVarCalcHelpers.js";
import LvPlan from "../../components/Cis/LvPlan/Lehrveranstaltung.js";
import MyLvPlan from "../../components/Cis/LvPlan/MyLvPlan.js";
import Mylv from "../../components/Cis/Mylv/MyLv.js";
import Profil from "../../components/Cis/Profil/Profil.js";
import Raumsuche from "../../components/Cis/Raumsuche/Raumsuche.js";
import CmsNews from "../../components/Cis/Cms/News.js";
import CmsContent from "../../components/Cis/Cms/Content.js";
import Info from "../../components/Cis/Mylv/Semester/Studiengang/Lv/Info.js";
import RoomInformation, {DEFAULT_MODE_RAUMINFO_DESKTOP, DEFAULT_MODE_RAUMINFO_MOBILE} from "../../components/Cis/Mylv/RoomInformation.js";
import AbgabetoolStudent from "../../components/Cis/Abgabetool/AbgabetoolStudent.js";
import AbgabetoolMitarbeiter from "../../components/Cis/Abgabetool/AbgabetoolMitarbeiter.js";
import AbgabetoolAssistenz from "../../components/Cis/Abgabetool/AbgabetoolAssistenz.js";
import DeadlineOverview from "../../components/Cis/Abgabetool/DeadlineOverview.js";
import Studium from "../../components/Cis/Studium/Studium.js";
import StgOrgLvPlan from "../../components/Cis/LvPlan/StgOrg.js";
import OtherLvPlan from "../../components/Cis/LvPlan/OtherLvPlan.js";
import PaabgabeUebersicht from "../../components/Cis/ProjektabgabeUebersicht/ProjektabgabeUebersicht.js";
import Benotungstool from "../../components/Cis/Benotungstool/Benotungstool.js";
import Zeitsperren from "../../components/Cis/Zeitsperren/Zeitsperren.js";
import Compat from "../../components/Cis/Compat.js";
import ApiRouteInfo from '../../api/factory/routeinfo.js';
import {capitalize} from "../../helpers/StringHelpers.js";
import ApiAuthinfo from "../../api/factory/authinfo.js";
const ciPath = FHC_JS_DATA_STORAGE_OBJECT.app_root.replace(/(https:|)(^|\/\/)(.*?\/)/g, '') + FHC_JS_DATA_STORAGE_OBJECT.ci_router;
const isMobile = window.matchMedia("(max-width: 767px)").matches;
const router = VueRouter.createRouter({
history: VueRouter.createWebHistory(`/${ciPath}`),
routes: [
{
path: `/Cis/Compat/:mode(ci|legacy)/:path(.*)`,
name: 'Compat',
component: Compat,
props: (route) => {
return {
mode: route.params.mode,
path: route.params.path,
query_string: VueRouter.stringifyQuery(route.query)
};
}
},
{
path: `/Cis/Studium`,
name: 'Studium',
component: Studium,
props: true
},
{
path: `/Cis/Profil/View/:uid`,
name: 'ProfilView',
component: Profil,
props: true
},
{
path: `/Cis/Profil`,
name: 'Profil',
component: Profil,
props: true
},
{
path: `/Cis/Abgabetool/Student/:student_uid_prop?`,
name: 'AbgabetoolStudent',
component: AbgabetoolStudent,
props: true
},
{
path: `/Cis/Abgabetool/Mitarbeiter`,
name: 'AbgabetoolMitarbeiter',
component: AbgabetoolMitarbeiter,
props: true
},
{
path: `/Cis/Abgabetool/Assistenz/:stg_kz_prop?`,
name: 'AbgabetoolAssistenz',
component: AbgabetoolAssistenz,
props: true
},
{
path: `/Cis/Abgabetool/Deadlines/:person_uid_prop?`,
name: 'DeadlineOverview',
component: DeadlineOverview,
props: true
},
{
path: `/Cis/Raumsuche`,
name: 'Raumsuche',
component: Raumsuche,
props: true
},
{
path: `/Cis/ProjektabgabeUebersicht`,
name: 'PaabgabeUebersicht',
component: PaabgabeUebersicht,
props: true
},
{
path: `/Cis/Benotungstool/:lv_id?/:sem_kurzbz?`,
name: 'Benotungstool',
component: Benotungstool,
props: true
},
// Redirect old links to new format
{
path: "/CisVue/Cms/getRoomInformation/:ort_kurzbz",
name: "RoomInformationOld",
component: RoomInformation,
redirect: (to) => {
return { // redirect to longer Rauminfo url and map params
name: "RoomInformation",
params: { // in this case always populate other params since they are not optional
ort_kurzbz: to.params.ort_kurzbz,
mode: isMobile ? DEFAULT_MODE_RAUMINFO_MOBILE : DEFAULT_MODE_RAUMINFO_DESKTOP,
focus_date: new Date().toISOString().split("T")[0]
},
};
},
},
{
path: `/CisVue/Cms/getRoomInformation/:mode/:focus_date/:ort_kurzbz`,
name: 'RoomInformation',
component: RoomInformation,
props: (route) => { // validate and set mode/focus date if for some reason missing
const validModes = ["Month", "Week", "Day"];
// check mode string
const mode = route.params.mode &&
validModes.includes(route.params.mode.charAt(0).toUpperCase() + route.params.mode.slice(1).toLowerCase())
? route.params.mode.charAt(0).toUpperCase() + route.params.mode.slice(1).toLowerCase()
: (isMobile ? DEFAULT_MODE_RAUMINFO_MOBILE : DEFAULT_MODE_RAUMINFO_DESKTOP);
// default to today date if not provided
const d = new Date(route.params.focus_date)
const focus_date = !isNaN(d) ? route.params.focus_date : new Date().toISOString().split("T")[0];
// for consistency reasons format the props into one object but actually use a new name to we dont collide with
// existing viewData declaration written from codeigniter 3 into routerview tag
return {
propsViewData: {
mode,
focus_date,
ort_kurzbz: route.params.ort_kurzbz
}
};
},
beforeEnter: (to, from, next) => {
// missing mode or focus_date -> set defaults
if (!to.params.mode || !to.params.focus_date) {
next({
name: "RoomInformation",
params: {
mode: to.params.mode || DEFAULT_MODE_RAUMINFO,
focus_date: to.params.focus_date || new Date().toISOString().split("T")[0],
ort_kurzbz: route.params.ort_kurzbz
}
});
} else {
next();
}
}
},
{
path: `/CisVue/Cms/Content/:content_id`,
name: 'Content',
component: CmsContent,
props: true
},
{
path: `/CisVue/Cms/News`,
name: 'News',
component: CmsNews,
props: true
},
{
path: `/Cis/MyLv/:studiensemester?`,
name: 'MyLv',
component: Mylv,
props: true,
},
{
path: `/Cis/MyLv/Info/:studien_semester/:lehrveranstaltung_id`,
name: 'LvInfo',
component: Info,
props: true
},
// Redirect old links to new format
{
// only trigger on first param being numeric to avoid paths like "LvPlan/Month" entering here
path: "/Cis/LvPlan/:lv_id(\\d+)",
name: "LvPlanOld",
component: LvPlan,
redirect(to) {
const route = Vue.unref(router.currentRoute);
const { mode, focus_date } = route.params; // keep mode and focus_date if available
return { // redirect to longer LvPlan url and map params
name: "LvPlan",
params: {
mode,
focus_date,
lv_id: to.params.lv_id
},
};
},
},
{
path: `/Cis/LvPlan/:mode?/:focus_date?/:lv_id?`,
name: 'LvPlan',
component: LvPlan,
props(route) {
return {
propsViewData: route.params
};
}
},
{
path: `/Cis/MyLvPlan/:mode?/:focus_date?`,
name: 'MyLvPlan',
component: MyLvPlan,
props(route) {
return {
propsViewData: route.params
};
}
},
{
path: `/Cis/StgOrgLvPlan/:mode?/:focus_date?/:stgkz?/:sem?/:verband?/:gruppe?`,
name: 'StgOrgLvPlan',
component: StgOrgLvPlan,
props(route) {
return {
propsViewData: route.params
};
}
},
{
path: `/Cis/OtherLvPlan/:otherUid/:mode?/:focus_date?`,
name: "OtherLvPlan",
component: OtherLvPlan,
props(route) {
return {
propsViewData: route.params
};
}
},
{
path: `/Cis4`,
name: 'Cis4',
component: FhcDashboard,
props: {dashboard: 'CIS'},
},
{
path: `/`,
name: 'FhcDashboard',
component: FhcDashboard,
props: {dashboard: 'CIS'},
},
{
path: '/:pathMatch(.*)*',
name: 'Fallback',
component: FhcDashboard,
props: {dashboard: 'CIS'},
redirect: () => {
return {
name: "Cis4",
params: {
dashboard: 'CIS'
},
};
},
},
{
path: `/Cis/Zeitsperren`,
name: 'Zeitsperren',
component: Zeitsperren,
props: true
},
]
})
import {router} from "../../routers/Cis/CisRouter.js";
const app = Vue.createApp({
name: 'CisApp',
+3
View File
@@ -5,6 +5,8 @@ import Theme from "../../plugins/Theme.js";
import ApiSearchbar from '../../api/factory/searchbar.js';
import ApiLvPlan from "../../api/factory/lvPlan.js";
import {router} from "../../routers/Cis/CisRouter.js";
const app = Vue.createApp({
name: 'CisMenuApp',
components: {
@@ -193,6 +195,7 @@ const app = Vue.createApp({
FhcApps.makeExtendable(app);
app.use(router);
app.use(primevue.config.default, {
zIndex: {
overlay: 9000,
@@ -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-<field>` 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 <TAB> Datum <TAB> 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 <TAB> 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 '<div style="">'+val+'</div>'
},
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 += ('<span>'+g.text +'</span>'+ '<br/>')
else s.teilnote += ('<span style="color: red;">'+g.text +'</span>'+ '<br/>')
})
@@ -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: `
<bs-modal ref="modalContainerPruefungImport" class="bootstrap-prompt" dialogClass="modal-lg" bodyClass="px-4 py-4">
<template v-slot:title>{{$capitalize($p.t('benotungstool/c4pruefungImportieren'))}}</template>
<template v-slot:default>
<div class="row justify-content-center">
<div class="col-12" v-html="getPruefungimportHinweistext"></div>
</div>
<div class="row mt-3 justify-content-center">
<div class="col-12">
<Textarea v-model="importString" rows="5" class="w-100" :placeholder="$p.t('benotungstool/c4importPlaceholder')"></Textarea>
</div>
</div>
<div class="row mt-3 justify-content-center">
<div class="col-12">
<NotenlisteLinks
:lehrveranstaltung="selectedLehrveranstaltung"
:sem_kurzbz="selectedSemester?.studiensemester_kurzbz"
:selected-lehreinheit="selectedLehreinheit" />
</div>
</div>
</template>
<template v-slot:footer>
<button type="button" class="btn btn-primary" @click="importPruefungen">{{ $capitalize($p.t('benotungstool/c4import')) }}</button>
</template>
</bs-modal>
<bs-modal ref="modalContainerNotenImport" class="bootstrap-prompt" dialogClass="modal-lg" bodyClass="px-4 py-4">
<template v-slot:title>{{$capitalize($p.t('benotungstool/c4notenImportieren'))}}</template>
<template v-slot:default>
@@ -2187,7 +2539,15 @@ export const Benotungstool = {
</div>
<div class="row mt-3 justify-content-center">
<div class="col-12">
<Textarea v-model="importString" rows="5" class="w-100"></Textarea>
<Textarea v-model="importStringNoten" rows="5" class="w-100" :placeholder="$p.t('benotungstool/c4importNotePlaceholder')"></Textarea>
</div>
</div>
<div class="row mt-3 justify-content-center">
<div class="col-12">
<NotenlisteLinks
:lehrveranstaltung="selectedLehrveranstaltung"
:sem_kurzbz="selectedSemester?.studiensemester_kurzbz"
:selected-lehreinheit="selectedLehreinheit" />
</div>
</div>
</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">
</datepicker>
</div>
</div>
<div v-if="pruefungNoteLocked" class="row mt-2 justify-content-center">
<div class="col-9 text-center text-muted small">{{$capitalize($p.t('benotungstool/pruefungNoteLockedHint'))}}</div>
</div>
<div v-if="config?.CIS_GESAMTNOTE_PUNKTE == true" class="row mt-3 align-items-center justify-content-center">
<div class="col-3 text-center">{{$capitalize($p.t('benotungstool/c4punkte'))}}:</div>
<div class="col-6">
<InputNumber
v-model="selectedPruefungPunkte"
@input="debouncedFetchPunkteForPruefung"
:disabled="pruefungNoteLocked"
inputId="selectedPruefungInput" :min="0" :max="100000"
class="w-100">
</InputNumber>
@@ -2282,7 +2650,7 @@ export const Benotungstool = {
<div class="col-3 text-center">{{$capitalize($p.t('lehre/note'))}}:</div>
<div class="col-6">
<Dropdown :placeholder="$capitalize($p.t('lehre/note'))"
:disabled="config?.CIS_GESAMTNOTE_PUNKTE == true"
:disabled="config?.CIS_GESAMTNOTE_PUNKTE == true || pruefungNoteLocked"
:style="{'width': '100%'}" :optionLabel="getOptionLabelNotePruefung"
v-model="selectedPruefungNote" :options="notenOptionsLehre" showClear>
<template #optionsgroup="slotProps">
@@ -2316,10 +2684,10 @@ export const Benotungstool = {
<FhcOverlay :active="loading"></FhcOverlay>
<div class="row align-items-center">
<div class="row">
<div class="col-auto me-auto">
<h2>{{$capitalize($p.t('benotungstool/benotungstoolTitle'))}}</h2>
<h4>{{ selectedLehrveranstaltung?.lv_bezeichnung }}</h4>
<h5>{{ selectedLehrveranstaltung?.lv_bezeichnung }}</h5>
</div>
<div class="col-auto d-none d-xxl-flex">
@@ -2339,7 +2707,7 @@ export const Benotungstool = {
<label class="col-form-label">{{$capitalize($p.t('lehre/lehreinheit'))}}:</label>
</div>
<div class="col-2">
<Dropdown @change="leChanged" :style="{'width': '100%'}" v-bind="LehreinheitenModule"
<Dropdown :style="{'width': '100%'}" v-bind="LehreinheitenModule"
v-model="selectedLehreinheit" showClear appendTo="self">
<template #option="slotProps">
<div>
@@ -2365,7 +2733,6 @@ export const Benotungstool = {
</Dropdown>
</div>
</div>
<hr>
<div id="notentable" class="row" :style="'overflow-x: auto;'">
<core-filter-cmpt
@@ -2382,14 +2749,27 @@ export const Benotungstool = {
:sideMenu="false"
>
<template #actions>
<Multiselect
v-model="stickyColumnSelection"
:options="freezableColumnOptions"
optionLabel="label" optionValue="field"
:placeholder="$capitalize($p.t('benotungstool/freezeColumnsToggle'))"
:maxSelectedLabels="0"
:selectedItemsLabel="$capitalize($p.t('benotungstool/freezeColumnsLabel'))"
showToggleAll
@change="onStickySelectionChange"
class="ml-2"
style="min-width: 12rem" />
<button @click="openNewPruefungsdatumModal" role="button" :class="getNewBtnClass">
{{$capitalize($p.t('benotungstool/c4addNewPruefung'))}} <i class="fa fa-plus"></i>
</button>
<Divider layout="vertical" style="transform: translateY(12px)"/>
<button @click="openNotenImportModal" role="button" :class="getNotenImportBtnClass">
<button v-if="config?.CIS_GESAMTNOTE_PRUEFUNGSIMPORT" @click="openPruefungImportModal" role="button" :class="getNotenImportBtnClass">
{{$capitalize($p.t('benotungstool/c4pruefungImportieren'))}} <i class="fa fa-file-import"></i>
</button>
<button v-if="config?.CIS_GESAMTNOTE_NOTENIMPORT" @click="openNotenImportModal" role="button" :class="getNotenImportBtnClass">
{{$capitalize($p.t('benotungstool/c4notenImportieren'))}} <i class="fa fa-file-import"></i>
</button>
<button @click="openSaveModal" role="button" :class="getSaveBtnClass">
@@ -0,0 +1,73 @@
// components/Lehre/Benotungstool/NotenlisteLinks.js
import LehreinheitenModule from '../../DropdownModes/LehreinheitenModule.js';
export const NotenlisteLinks = {
name: "NotenlisteLinks",
props: {
lehrveranstaltung: { type: Object, default: null },
sem_kurzbz: { type: String, default: null },
selectedLehreinheit: { type: Object, default: null }
},
computed: {
LehreinheitenModule() {
return LehreinheitenModule;
},
lehreinheiten() {
// reuse the already-loaded LE options from the shared module
const all = LehreinheitenModule.options ?? [];
// when a single Lehreinheit is selected, restrict the list to just that one
if (this.selectedLehreinheit?.lehreinheit_id != null) {
return all.filter(le => le.lehreinheit_id === this.selectedLehreinheit.lehreinheit_id);
}
return all;
},
baseUrl() {
return FHC_JS_DATA_STORAGE_OBJECT.app_root
+ 'cis/private/lehre/notenliste.xls.php';
},
ready() {
const lv = this.lehrveranstaltung;
return !!(lv && lv.studiengang_kz != null && lv.lv_semester != null
&& lv.lehrveranstaltung_id != null && this.sem_kurzbz);
},
gesamtUrl() {
if (!this.ready) return null;
return this.buildUrl();
}
},
methods: {
buildUrl(lehreinheit_id = null) {
const lv = this.lehrveranstaltung;
const params = new URLSearchParams({
stg: lv.studiengang_kz,
sem: lv.lv_semester,
lvid: lv.lehrveranstaltung_id,
stsem: this.sem_kurzbz
});
if (lehreinheit_id != null) params.set('lehreinheit_id', lehreinheit_id);
return this.baseUrl + '?' + params.toString();
}
},
template: `
<div v-if="ready">
<div class="fw-bold mb-2">{{ $capitalize($p.t('benotungstool/c4notenlisten')) }}</div>
<div class="mb-1">
<a class="Item" :href="gesamtUrl" target="_blank" rel="noopener">
{{ $capitalize($p.t('benotungstool/c4gesamtliste')) }} {{ lehrveranstaltung.lv_bezeichnung }}
</a>
</div>
<div v-for="le in lehreinheiten" :key="le.lehreinheit_id" class="mb-1" style="padding-left: 1.5rem;">
<a class="Item" :href="buildUrl(le.lehreinheit_id)" target="_blank" rel="noopener">
{{ le.infoString }}
</a>
</div>
</div>
<div v-else class="text-muted">
{{ $capitalize($p.t('benotungstool/c4keineStudentenGefunden')) }}
</div>
`
};
export default NotenlisteLinks;
+4 -15
View File
@@ -21,10 +21,11 @@ export default {
},
watch: {
propsWatchHelper: function() {
let currentiFrameURL = this.$refs.compatiframe ? this.$refs.compatiframe.src : '';
if(this.lastLoadediFrameURL === '') {
return;
}
console.log('currentiFrameURL: ' + currentiFrameURL);
console.log('lastLoadediFrameURL: ' + this.lastLoadediFrameURL);
let currentiFrameURL = this.$refs.compatiframe ? this.$refs.compatiframe.src : '';
let url = this.buildSrcUrl();
if(this.lastLoadediFrameURL !== url) {
@@ -34,8 +35,6 @@ export default {
},
methods: {
buildSrcUrl: function() {
console.log('srcUrl begin: ' + this.path);
let url = false;
switch(this.mode) {
case 'ci':
@@ -51,13 +50,9 @@ export default {
url += '?' + this.query_string;
}
console.log('srcUrl end: ' + url);
return url;
},
loadHandler: function() {
console.log('loadHandler');
console.log(JSON.stringify(this.$refs.compatiframe.contentWindow.location));
let iframe_href = this.$refs.compatiframe.contentWindow.location.href;
let ci_urlstart = FHC_JS_DATA_STORAGE_OBJECT.app_root + 'index.ci.php/';
let legacy_urlstart = FHC_JS_DATA_STORAGE_OBJECT.app_root;
@@ -65,10 +60,6 @@ export default {
this.lastLoadediFrameURL = iframe_href;
console.log('iframe_href: ' + iframe_href);
console.log('ci_urlstart: ' + ci_urlstart);
console.log('legacy_url_start: ' + legacy_urlstart);
if(iframe_href.startsWith(ci_urlstart)) {
routerpath = iframe_href.replace(
ci_urlstart, '/Cis/Compat/ci/');
@@ -79,8 +70,6 @@ export default {
return;
}
console.log(routerpath);
if(this.$route.fullPath !== routerpath) {
this.$router.push(routerpath);
}
+9 -4
View File
@@ -1,5 +1,10 @@
import CisMenuLink from "./Link.js";
export default {
name: 'CisMenuEntry',
components: {
CisMenuLink
},
props: {
entry: Object,
level: {
@@ -170,7 +175,7 @@ export default {
<template v-else>
<template v-if="hasChilds">
<div class="btn-group w-100">
<a :target="target"
<cis-menu-link :target="target"
:href="(entry.menu_open && hasFullLink) ? entry.url : null"
@click="toggleCollapse"
:class="{
@@ -179,7 +184,7 @@ export default {
'fw-bold':active
}">
{{ entry.titel }}
</a>
</cis-menu-link>
<button @click.prevent="toggleCollapse" :aria-expanded="entry.menu_open"
:class="{
'btn btn-default rounded-0 dropdown-toggle dropdown-toggle-split flex-grow-0': true,
@@ -193,7 +198,7 @@ export default {
<cis-menu-entry :highestMatchingUrlCount="highestMatchingUrlCount" :activeContent="activeContent" v-for="child in entry.childs" :key="child" :entry="child" :level="level + 1"/>
</ul>
</template>
<a v-else
<cis-menu-link v-else
:href="entry.url"
:target="target"
:class="{
@@ -203,6 +208,6 @@ export default {
}"
@mouseup="setActiveEntry(entry.content_id)">
{{ entry.titel }}
</a>
</cis-menu-link>
</template>`
};
+34
View File
@@ -0,0 +1,34 @@
import {isCompatLink, calcCompatRouterLink} from '../../../helpers/CompatLinkHelpers.js';
export default {
name: 'CisMenuLink',
props: {
href: {
type: [String, null],
default: null
}
},
methods: {
isCompatLink() {
if(this.href === null) {
return false;
}
return isCompatLink(this.href);
},
calcCompatRouterLink() {
return calcCompatRouterLink(this.href);
}
},
template: `
<router-link v-if="this.isCompatLink()"
:to="this.calcCompatRouterLink()"
>
<slot></slot> (routerlink)
</router-link>
<a v-else
:href="this.href"
>
<slot></slot> (ahref)
</a>
`
};
@@ -3,6 +3,8 @@ import LvPruefungen from "./Lv/Pruefungen.js";
import ApiLehre from '../../../../../api/factory/lehre.js';
import ApiAddons from '../../../../../api/factory/addons.js';
import {isCompatLink, calcCompatRouterLink} from '../../../../../helpers/CompatLinkHelpers.js';
// TODO(chris): L10n
export default {
@@ -106,6 +108,12 @@ export default {
pruefungenData: this.pruefungenData,
bezeichnung: this.bezeichnung
});
},
isCompatLink(link) {
return isCompatLink(link);
},
calcCompatRouterLink(link) {
return calcCompatRouterLink(link);
}
},
created() {
@@ -159,7 +167,13 @@ export default {
<ul v-else class="dropdown-menu dropdown-menu p-0">
<li v-for="([text, link], i) in menuItem.c4_linkList" :key="i">
<a class="dropdown-item border-bottom" :href="link" target="#">{{ text }}</a>
<template v-if="this.isCompatLink(link)">
<router-link
class="dropdown-item border-bottom"
:to="this.calcCompatRouterLink(link)"
>{{ text }}</router-link>
</template>
<a v-else="" class="dropdown-item border-bottom" :href="link" target="#">{{ text }}</a>
</li>
</ul>
</div>
+48
View File
@@ -0,0 +1,48 @@
/**
* check if an absolute url is a URL to Compat CI-Controller
*
* @param {string} link
* @returns {boolean}
*/
const isCompatLink = function(link) {
let ci_router_url = FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router;
let isCompatLink = link.startsWith(ci_router_url + '/Cis/Compat/');
return isCompatLink;
};
/**
* calc Param Object with path and query members for use with router-link component
*
* @param {string} link
* @returns {object}
*/
const calcCompatRouterLink = function (link) {
let ci_router_url = FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router;
let uri = link.replace(ci_router_url, '').split('?');
let path = uri[0];
let query = (uri.length === 2) ? VueRouter.parseQuery(uri[1]) : {};
return {
"path": path,
"query": query
};
};
const isRouterLink = function(router, url) {
if(url === null) {
return false;
}
const robj = router.resolve(url, router.currentRoute);
console.log('isRouterLink');
};
export {
isCompatLink,
calcCompatRouterLink,
isRouterLink
};
export default {
isCompatLink,
calcCompatRouterLink,
isRouterLink
};
+280
View File
@@ -0,0 +1,280 @@
import FhcDashboard from '../../components/Dashboard/Dashboard.js';
import LvPlan from "../../components/Cis/LvPlan/Lehrveranstaltung.js";
import MyLvPlan from "../../components/Cis/LvPlan/MyLvPlan.js";
import Mylv from "../../components/Cis/Mylv/MyLv.js";
import Profil from "../../components/Cis/Profil/Profil.js";
import Raumsuche from "../../components/Cis/Raumsuche/Raumsuche.js";
import CmsNews from "../../components/Cis/Cms/News.js";
import CmsContent from "../../components/Cis/Cms/Content.js";
import Info from "../../components/Cis/Mylv/Semester/Studiengang/Lv/Info.js";
import RoomInformation, {DEFAULT_MODE_RAUMINFO_DESKTOP, DEFAULT_MODE_RAUMINFO_MOBILE} from "../../components/Cis/Mylv/RoomInformation.js";
import AbgabetoolStudent from "../../components/Cis/Abgabetool/AbgabetoolStudent.js";
import AbgabetoolMitarbeiter from "../../components/Cis/Abgabetool/AbgabetoolMitarbeiter.js";
import AbgabetoolAssistenz from "../../components/Cis/Abgabetool/AbgabetoolAssistenz.js";
import DeadlineOverview from "../../components/Cis/Abgabetool/DeadlineOverview.js";
import Studium from "../../components/Cis/Studium/Studium.js";
import StgOrgLvPlan from "../../components/Cis/LvPlan/StgOrg.js";
import OtherLvPlan from "../../components/Cis/LvPlan/OtherLvPlan.js";
import PaabgabeUebersicht from "../../components/Cis/ProjektabgabeUebersicht/ProjektabgabeUebersicht.js";
import Benotungstool from "../../components/Cis/Benotungstool/Benotungstool.js";
import Zeitsperren from "../../components/Cis/Zeitsperren/Zeitsperren.js";
import Compat from "../../components/Cis/Compat.js";
const ciPath = FHC_JS_DATA_STORAGE_OBJECT.app_root.replace(/(https:|)(^|\/\/)(.*?\/)/g, '') + FHC_JS_DATA_STORAGE_OBJECT.ci_router;
const isMobile = window.matchMedia("(max-width: 767px)").matches;
const router = VueRouter.createRouter({
history: VueRouter.createWebHistory(`/${ciPath}`),
routes: [
{
path: `/Cis/Compat/:mode(ci|legacy)/:path(.*)`,
name: 'Compat',
component: Compat,
props: (route) => {
return {
mode: route.params.mode,
path: route.params.path,
query_string: VueRouter.stringifyQuery(route.query)
};
}
},
{
path: `/Cis/Studium`,
name: 'Studium',
component: Studium,
props: true
},
{
path: `/Cis/Profil/View/:uid`,
name: 'ProfilView',
component: Profil,
props: true
},
{
path: `/Cis/Profil`,
name: 'Profil',
component: Profil,
props: true
},
{
path: `/Cis/Abgabetool/Student/:student_uid_prop?`,
name: 'AbgabetoolStudent',
component: AbgabetoolStudent,
props: true
},
{
path: `/Cis/Abgabetool/Mitarbeiter`,
name: 'AbgabetoolMitarbeiter',
component: AbgabetoolMitarbeiter,
props: true
},
{
path: `/Cis/Abgabetool/Assistenz/:stg_kz_prop?`,
name: 'AbgabetoolAssistenz',
component: AbgabetoolAssistenz,
props: true
},
{
path: `/Cis/Abgabetool/Deadlines/:person_uid_prop?`,
name: 'DeadlineOverview',
component: DeadlineOverview,
props: true
},
{
path: `/Cis/Raumsuche`,
name: 'Raumsuche',
component: Raumsuche,
props: true
},
{
path: `/Cis/ProjektabgabeUebersicht`,
name: 'PaabgabeUebersicht',
component: PaabgabeUebersicht,
props: true
},
{
path: `/Cis/Benotungstool/:lv_id?/:sem_kurzbz?`,
name: 'Benotungstool',
component: Benotungstool,
props: true
},
// Redirect old links to new format
{
path: "/CisVue/Cms/getRoomInformation/:ort_kurzbz",
name: "RoomInformationOld",
component: RoomInformation,
redirect: (to) => {
return { // redirect to longer Rauminfo url and map params
name: "RoomInformation",
params: { // in this case always populate other params since they are not optional
ort_kurzbz: to.params.ort_kurzbz,
mode: isMobile ? DEFAULT_MODE_RAUMINFO_MOBILE : DEFAULT_MODE_RAUMINFO_DESKTOP,
focus_date: new Date().toISOString().split("T")[0]
},
};
},
},
{
path: `/CisVue/Cms/getRoomInformation/:mode/:focus_date/:ort_kurzbz`,
name: 'RoomInformation',
component: RoomInformation,
props: (route) => { // validate and set mode/focus date if for some reason missing
const validModes = ["Month", "Week", "Day"];
// check mode string
const mode = route.params.mode &&
validModes.includes(route.params.mode.charAt(0).toUpperCase() + route.params.mode.slice(1).toLowerCase())
? route.params.mode.charAt(0).toUpperCase() + route.params.mode.slice(1).toLowerCase()
: (isMobile ? DEFAULT_MODE_RAUMINFO_MOBILE : DEFAULT_MODE_RAUMINFO_DESKTOP);
// default to today date if not provided
const d = new Date(route.params.focus_date)
const focus_date = !isNaN(d) ? route.params.focus_date : new Date().toISOString().split("T")[0];
// for consistency reasons format the props into one object but actually use a new name to we dont collide with
// existing viewData declaration written from codeigniter 3 into routerview tag
return {
propsViewData: {
mode,
focus_date,
ort_kurzbz: route.params.ort_kurzbz
}
};
},
beforeEnter: (to, from, next) => {
// missing mode or focus_date -> set defaults
if (!to.params.mode || !to.params.focus_date) {
next({
name: "RoomInformation",
params: {
mode: to.params.mode || DEFAULT_MODE_RAUMINFO,
focus_date: to.params.focus_date || new Date().toISOString().split("T")[0],
ort_kurzbz: route.params.ort_kurzbz
}
});
} else {
next();
}
}
},
{
path: `/CisVue/Cms/Content/:content_id`,
name: 'Content',
component: CmsContent,
props: true
},
{
path: `/CisVue/Cms/News`,
name: 'News',
component: CmsNews,
props: true
},
{
path: `/Cis/MyLv/:studiensemester?`,
name: 'MyLv',
component: Mylv,
props: true,
},
{
path: `/Cis/MyLv/Info/:studien_semester/:lehrveranstaltung_id`,
name: 'LvInfo',
component: Info,
props: true
},
// Redirect old links to new format
{
// only trigger on first param being numeric to avoid paths like "LvPlan/Month" entering here
path: "/Cis/LvPlan/:lv_id(\\d+)",
name: "LvPlanOld",
component: LvPlan,
redirect(to) {
const route = Vue.unref(router.currentRoute);
const { mode, focus_date } = route.params; // keep mode and focus_date if available
return { // redirect to longer LvPlan url and map params
name: "LvPlan",
params: {
mode,
focus_date,
lv_id: to.params.lv_id
},
};
},
},
{
path: `/Cis/LvPlan/:mode?/:focus_date?/:lv_id?`,
name: 'LvPlan',
component: LvPlan,
props(route) {
return {
propsViewData: route.params
};
}
},
{
path: `/Cis/MyLvPlan/:mode?/:focus_date?`,
name: 'MyLvPlan',
component: MyLvPlan,
props(route) {
return {
propsViewData: route.params
};
}
},
{
path: `/Cis/StgOrgLvPlan/:mode?/:focus_date?/:stgkz?/:sem?/:verband?/:gruppe?`,
name: 'StgOrgLvPlan',
component: StgOrgLvPlan,
props(route) {
return {
propsViewData: route.params
};
}
},
{
path: `/Cis/OtherLvPlan/:otherUid/:mode?/:focus_date?`,
name: "OtherLvPlan",
component: OtherLvPlan,
props(route) {
return {
propsViewData: route.params
};
}
},
{
path: `/Cis4`,
name: 'Cis4',
component: FhcDashboard,
props: {dashboard: 'CIS'},
},
{
path: `/`,
name: 'FhcDashboard',
component: FhcDashboard,
props: {dashboard: 'CIS'},
},
{
path: '/:pathMatch(.*)*',
name: 'Fallback',
component: FhcDashboard,
props: {dashboard: 'CIS'},
redirect: () => {
return {
name: "Cis4",
params: {
dashboard: 'CIS'
},
};
},
},
{
path: `/Cis/Zeitsperren`,
name: 'Zeitsperren',
component: Zeitsperren,
props: true
},
]
});
export {
router
}
+355 -5
View File
@@ -59949,6 +59949,26 @@ I have been informed that I am under no obligation to consent to the transmissio
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4pruefungImportieren',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Prüfungsimport',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Import Exams',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
@@ -60729,6 +60749,226 @@ I have been informed that I am under no obligation to consent to the transmissio
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'pruefungNoteLocked',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Die Note für Studierenden {0} kann nicht mehr geändert werden, da bereits eine spätere Prüfung oder eine Prüfung mit höherem Antritt existiert. Das Prüfungsdatum kann weiterhin angepasst werden.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The grade for student {0} can no longer be changed because a later exam or an exam of a higher attempt already exists. The exam date can still be adjusted.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'pruefungNoteLockedHint',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Note gesperrt (spätere Prüfung bzw. höherer Antritt vorhanden) nur das Prüfungsdatum kann angepasst werden.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Grade locked (a later exam or higher attempt exists) only the exam date can be adjusted.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'pruefungDatumOutOfRange',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Das Prüfungsdatum für Studierenden {0} muss zwischen dem vorherigen und dem nachfolgenden Prüfungstermin liegen.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The exam date for student {0} must be between the previous and the following exam date.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'pruefungDatumOutOfRangeHint',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Das Prüfungsdatum muss zwischen dem vorherigen und dem nachfolgenden Prüfungstermin liegen.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The exam date must be between the previous and the following exam date.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'freezeColumnsToggle',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Spalten fixieren',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Freeze columns',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'freezeColumnsLabel',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => '{0} Spalten fixiert',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => '{0} columns frozen',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4selection',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Auswahl',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Selection',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4importRowNotDateFormat',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Zeile {0} wurde übersprungen: erwartet wird Personenkennzeichen, Datum (TT.MM.JJJJ) und Note (Tab-getrennt).',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Row {0} was skipped: expected Person ID, date (DD.MM.YYYY) and grade (tab separated).',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4importPlaceholder',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Personenkennzeichen ⇥ TT.MM.JJJJ ⇥ Note',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Person ID ⇥ DD.MM.YYYY ⇥ Grade',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4importNotePlaceholder',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Personenkennzeichen ⇥ Note',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Person ID ⇥ Grade',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4importRowNotNoteFormat',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Zeile {0} wurde übersprungen: erwartet wird Personenkennzeichen und Note (Tab-getrennt).',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Row {0} was skipped: expected Person ID and grade (tab separated).',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
@@ -60784,19 +61024,19 @@ I have been informed that I am under no obligation to consent to the transmissio
array(
'sprache' => 'German',
'text' => ' Laden Sie sich die Notenliste im Excel-Format unter CIS Lehrveranstaltungen Anwesenheits- und Notenlisten Notenliste herunter.<br>
Tragen Sie die Noten in das Dokument und speichern Sie dieses.<br>
Markieren Sie im Excel-Dokument die Inhalte der Spalten Personenkennzeichen und Note für jene Studierende, deren Noten Sie importieren möchten (ohne Überschrift !)<br>
Tragen Sie die Noten in das Dokument ein.<br>
Markieren Sie die Inhalte der Spalten Personenkennzeichen und Note für jene Studierende, deren Noten Sie importieren möchten (ohne Überschrift !)<br>
Kopieren Sie die markierten Inhalte mittels strg + c oder Bearbeiten Kopieren in die Zwischenablage<br>
Einfügen der Inhalte mittels strg + v oder Bearbeiten Einfügen<br>
Mit einem Klick auf Import werden die Noten übernommen.<br>',
Mit einem Klick auf Importieren werden die Noten übernommen.<br>',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => ' Download the grade list in Excel format from CIS Courses Attendance and Grade Lists Grade List.<br>
Enter the grades into the document and save it.<br>
In the Excel document, select the contents of the Person ID and Grade columns for the students whose grades you want to import (without headings!).<br>
Enter the grades into the document.<br>
Select the contents of the Person ID and Grade columns for the students whose grades you want to import (without headings!).<br>
Copy the selected content to the clipboard using Ctrl + c or Edit Copy.<br>
Paste the content using Ctrl + v or Edit Paste.<br>
Click Import to import the grades.<br>',
@@ -60805,6 +61045,36 @@ I have been informed that I am under no obligation to consent to the transmissio
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'notenimportHinweistextv6',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => ' Laden Sie sich die Notenliste im Excel-Format unter CIS Lehrveranstaltungen Anwesenheits- und Notenlisten Notenliste herunter.<br>
Tragen Sie pro Studierendem das Prüfungsdatum und die Note ein.<br>
Jeder Import legt eine datierte Prüfung an (Prüfungsimport) das Datum ist daher verpflichtend.<br>
Format pro Zeile (Tab-getrennt, ohne Überschrift): Personenkennzeichen &nbsp;&nbsp; Datum (TT.MM.JJJJ) &nbsp;&nbsp; Note<br>
Markieren Sie die entsprechenden Spalten, kopieren Sie sie (Strg + c) und fügen Sie sie oben ein (Strg + v).<br>
Mit einem Klick auf Importieren werden die Prüfungen übernommen.<br>',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => ' Download the grade list in Excel format from CIS Courses Attendance and Grade Lists Grade List.<br>
For each student, enter the exam date and the grade.<br>
Every import creates a dated exam (exam import) the date is therefore mandatory.<br>
Format per line (tab separated, without headings): Person ID &nbsp;&nbsp; date (DD.MM.YYYY) &nbsp;&nbsp; grade<br>
Select the relevant columns, copy them (Ctrl + c) and paste them above (Ctrl + v).<br>
Click Import to import the exams.<br>',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
@@ -60965,6 +61235,86 @@ I have been informed that I am under no obligation to consent to the transmissio
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4moodleTeilnotenError',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Moodle Addon Teilnotenimport fehlgeschlagen: {0}',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Moodle Addon partial grades import failed: {0}',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4notenlisten',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Notenlisten',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Grading Sheets',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4gesamtliste',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Gesamtliste',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Full Course Grading Sheet',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'benotungstool',
'phrase' => 'c4keineStudentenGefunden',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Keine Studenten gefunden.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'No students found.',
'description' => '',
'insertvon' => 'system'
)
)
),
// CIS4 GESAMTNOTENEINGABE ENDE ------------------------------------------------------------------------------------
//**************************** Pruefungsprotokolle start
array(