From fe8cf9bc832a98383858ba74d3ca4bf275954feb Mon Sep 17 00:00:00 2001 From: ma0068 Date: Thu, 11 Jun 2026 15:34:45 +0200 Subject: [PATCH 01/60] new widget CIS4 MA Widget - db-update for new widget studstatus - different view for stgL and stgA - update phrases --- .../api/frontend/v1/StudstatusAntraege.php | 145 ++++++++++++++++++ .../education/Studierendenantrag_model.php | 71 +++++++++ .../models/person/Benutzerfunktion_model.php | 34 ++++ public/js/api/factory/widget/studstatus.js | 25 +++ .../components/DashboardWidget/StudStatus.js | 75 +++++++++ .../components/Studierendenantrag/Antrag.js | 2 +- system/dbupdate_3.4.php | 1 + ...048_cis4_mitarbeiter_studstatus_widget.php | 44 ++++++ system/phrasesupdate.php | 102 ++++++++++++ 9 files changed, 498 insertions(+), 1 deletion(-) create mode 100644 application/controllers/api/frontend/v1/StudstatusAntraege.php create mode 100644 public/js/api/factory/widget/studstatus.js create mode 100644 public/js/components/DashboardWidget/StudStatus.js create mode 100644 system/dbupdate_3.4/77048_cis4_mitarbeiter_studstatus_widget.php diff --git a/application/controllers/api/frontend/v1/StudstatusAntraege.php b/application/controllers/api/frontend/v1/StudstatusAntraege.php new file mode 100644 index 000000000..44dc4134d --- /dev/null +++ b/application/controllers/api/frontend/v1/StudstatusAntraege.php @@ -0,0 +1,145 @@ +. + */ + +if (! defined('BASEPATH')) exit('No direct script access allowed'); + +class StudstatusAntraege extends FHCAPI_Controller +{ + + /** + * Object initialization + */ + public function __construct() + { + parent::__construct([ + 'getTodos' => self::PERM_LOGGED + ]); + + //load models + $this->load->model('education/Studierendenantrag_model', 'StudierendenantragModel'); + $this->load->model('person/Benutzerfunktion_model', 'BenutzerfunktionModel'); + + } + + //------------------------------------------------------------------------------------------------------------------ + // Public methods + + /** + * gets all todos of antraegen of the user with stg leitungsfunction + * or list of open antraege for user with assistance function + * @access public + * @return array || [] + */ + public function getTodos() + { + $uid = getAuthUID(); + + //at first get studiengang with leitungsfunktion + $result = $this->BenutzerfunktionModel->getSTGLByUID($uid); + + if(hasData($result)) + { + $funktionen = getData($result); + + $studiengaenge = []; + + foreach ($funktionen as $funktion) { + $studiengaenge[] = $funktion->studiengang_kz; + } + + $dataAntrage = []; + + $result = $this->StudierendenantragModel->getOpenAntraegeForStgl($studiengaenge); + + if (isError($result)) { + $this->terminateWithError(getError($result), self::ERROR_TYPE_GENERAL); + } + $dataAntrage[] = getData($result) ?: []; + $statusRole = 1; + + $this->terminateWithSuccess([count($dataAntrage) > 0 ? $dataAntrage[0] : $dataAntrage, $statusRole]); + + } + + //TODO delete after check if needed or not + //get studiengaenge of assistance (regarding benutzerfunktion) + //$result = $this->BenutzerfunktionModel->getSTGAssByUID($uid); + /* if(hasData($result)) { + $statusRole = 2; + $funktionen = getData($result); + + $studiengaenge = []; + + foreach ($funktionen as $funktion) { + $studiengaenge[] = $funktion->studiengang_kz; + } + + $ci->addMeta('stgAss', $studiengaenge); + $dataAntrage = []; + + foreach ($studiengaenge as $studiengang) + { + $result = $this->StudierendenantragModel->getOpenAntraegeForAss($studiengang); + + if (isError($result)) { + $this->terminateWithError(getError($result), self::ERROR_TYPE_GENERAL); + } + if(hasData($result)) { + $dataAntrage[] = getData($result); + $statusRole = 2; + } + } + } + else + { + $statusRole = 0; + $dataAntrage = []; + //$this->terminateWithSuccess([[], $statusRole]); + }*/ + + //get studiengaenge of assistance (regarding rights) + $stgAss = $this->permissionlib->getSTG_isEntitledFor('student/studierendenantrag'); + if(!is_array($stgAss)) + { + $statusRole = 0; + $this->terminateWithSuccess([[], $statusRole]); + } + else { + $statusRole = 2; + $studiengaenge = []; + + foreach ($stgAss as $stg) { + $studiengaenge[] = (int) $stg; + } + + $dataAntrage = []; + $result = $this->StudierendenantragModel->getOpenAntraegeForAss($studiengaenge); + + if (isError($result)) { + $this->terminateWithError(getError($result), self::ERROR_TYPE_GENERAL); + } + if(hasData($result)) { + $dataAntrage[] = getData($result); + $statusRole = 2; + } + + $this->terminateWithSuccess([count($dataAntrage) > 0 ? $dataAntrage[0] : $dataAntrage, $statusRole]); + + } + } +} \ No newline at end of file diff --git a/application/models/education/Studierendenantrag_model.php b/application/models/education/Studierendenantrag_model.php index 677d01f04..f7519b6a3 100644 --- a/application/models/education/Studierendenantrag_model.php +++ b/application/models/education/Studierendenantrag_model.php @@ -490,4 +490,75 @@ class Studierendenantrag_model extends DB_Model return hasData($this->load()); } + + /** + * Loads all todos for a certain studiengangsleiter + * + * @param integer $stg_kz + * + * @return stdClass + */ + public function getOpenAntraegeForStgl($stg_kz) + { + $qry = " + SELECT + studierendenantrag_id, + prestudent_id, + typ, + person.nachname, + person.vorname, + ps.studiengang_kz + FROM campus.tbl_studierendenantrag a + JOIN public.tbl_prestudent ps USING (prestudent_id) + JOIN public.tbl_person person USING (person_id) + WHERE studiengang_kz in ? + AND ( + (typ IN ('Abmeldung', 'AbmeldungStgl', 'Unterbrechung') + AND campus.get_status_studierendenantrag(a.studierendenantrag_id) = 'Erstellt') + OR + (typ = 'Wiederholung' + AND campus.get_status_studierendenantrag(a.studierendenantrag_id) = 'Lvszugewiesen') + ) + ORDER BY typ; + "; + + $params = array($stg_kz); + return $this->execQuery($qry, $params); + } + + /** + * Lists all open antraege for a certain stg + * + * @param Array $stg_kz + * + * @return stdClass + */ + public function getOpenAntraegeForAss($stg_kz) + { + $qry = " + SELECT + UPPER(stg.typ) || UPPER(stg.kurzbz) || ' ' || stg.bezeichnung AS studiengang, + COUNT(*) AS anzahl + FROM campus.tbl_studierendenantrag + JOIN public.tbl_prestudent p USING (prestudent_id) + JOIN public.tbl_studiengang stg ON p.studiengang_kz=stg.studiengang_kz + JOIN campus.tbl_studierendenantrag_status as s ON campus.get_status_id_studierendenantrag(campus.tbl_studierendenantrag.studierendenantrag_id) = studierendenantrag_status_id + JOIN campus.tbl_studierendenantrag_statustyp as st USING (studierendenantrag_statustyp_kurzbz) + WHERE ( + s.studierendenantrag_statustyp_kurzbz NOT IN('Zurueckgezogen', 'Genehmigt', 'Abgelehnt', 'EinspruchAbgelehnt', 'Abgemeldet', 'Pause', 'EmailVersandt') + OR ( + s.studierendenantrag_statustyp_kurzbz = 'Genehmigt' + AND tbl_studierendenantrag.typ = 'AbmeldungStgl' + ) + ) + AND p.studiengang_kz IN ? + GROUP BY studiengang + ORDER BY studiengang + " + ; + + $params = array($stg_kz); + return $this->execQuery($qry, $params); + } + } diff --git a/application/models/person/Benutzerfunktion_model.php b/application/models/person/Benutzerfunktion_model.php index dff422b7d..8c5986462 100644 --- a/application/models/person/Benutzerfunktion_model.php +++ b/application/models/person/Benutzerfunktion_model.php @@ -321,6 +321,40 @@ class Benutzerfunktion_model extends DB_Model return $record; } + /** + * Get active Assistenz(en) of the user by UID. + * @param $uid + */ + public function getSTGAssByUID($uid) + { + $query = ' + SELECT + uid, + oe_kurzbz, + studiengang_kz, + typ, + tbl_studiengang.bezeichnung + FROM + public.tbl_benutzerfunktion + JOIN public.tbl_studiengang USING (oe_kurzbz) + WHERE + funktion_kurzbz = \'ass\' + AND (datum_von IS NULL OR datum_von <= now()) + AND (datum_bis IS NULL OR datum_bis >= now()) + AND uid = ? + ORDER BY + oe_kurzbz + '; + + $parameters_array = array(); + if (is_string($uid)) + { + $parameters_array[] = $uid; + } + + return $this->execQuery($query, $parameters_array); + } + function updateBenutzerfunktion($funktionJson) { $funktionJson['updatevon'] = getAuthUID(); diff --git a/public/js/api/factory/widget/studstatus.js b/public/js/api/factory/widget/studstatus.js new file mode 100644 index 000000000..96f55652a --- /dev/null +++ b/public/js/api/factory/widget/studstatus.js @@ -0,0 +1,25 @@ +/** + * Copyright (C) 2026 fhcomplete.org + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +export default { + getTodos() { + return { + method: 'get', + url: '/api/frontend/v1/StudstatusAntraege/getTodos' + }; + }, +}; diff --git a/public/js/components/DashboardWidget/StudStatus.js b/public/js/components/DashboardWidget/StudStatus.js new file mode 100644 index 000000000..8b5b52fa0 --- /dev/null +++ b/public/js/components/DashboardWidget/StudStatus.js @@ -0,0 +1,75 @@ +import AbstractWidget from './Abstract.js'; + +import ApiStudstatus from '../../api/factory/widget/studstatus.js'; + +export default { + name: "WidgetStudstatus", + mixins: [AbstractWidget], + data(){ + return { + statusData: [], + role: 0 + }; + }, + mounted(){ + this.getTodos(); + }, + computed: { + countTodosLeitung(){ + return this.statusData.length; + }, + countTodosAss(){ + return this.statusData.reduce( + (sum, item) => sum + item.anzahl, + 0 + ); + }, + linkStudstatus(){ + return ( + FHC_JS_DATA_STORAGE_OBJECT.app_root + + FHC_JS_DATA_STORAGE_OBJECT.ci_router + + "/lehre/Studierendenantrag/leitung/" + ); + }, + }, + methods: { + getTodos(){ + this.$api + .call(ApiStudstatus.getTodos()) + .then(result => { + this.statusData = result.data[0]; + this.role = result.data[1]; + }) + .catch(this.$fhcAlert.handleSystemError); + } + }, + template:/*html*/` +
+
+ {{$p.t('dashboard','noPermissionStudstatus')}} +
+
+

{{countTodosLeitung}} {{$p.t('dashboard','antraegeToEdit')}}:

+ +
+ {{status.typ}} {{status.vorname}} {{status.nachname}} ({{status.prestudent_id}}) +
+
+
+ {{countTodosAss}} {{$p.t('dashboard','antraegeOpen')}}: +

+ +
+ {{status.studiengang}} ({{status.anzahl}}) +
+
+
+ {{$p.t('dashboard','antraegeNoOpen')}} +
+ + +
+ ` +}; \ No newline at end of file diff --git a/public/js/components/Studierendenantrag/Antrag.js b/public/js/components/Studierendenantrag/Antrag.js index b04801075..d0543fae2 100644 --- a/public/js/components/Studierendenantrag/Antrag.js +++ b/public/js/components/Studierendenantrag/Antrag.js @@ -21,7 +21,7 @@ export default { studierendenantragId: Number, infoArray: Array, statusMsg: String, - statusSeverity: String + statusSeverity: String, }, data() { return { diff --git a/system/dbupdate_3.4.php b/system/dbupdate_3.4.php index d43f1f3d3..88beecc41 100644 --- a/system/dbupdate_3.4.php +++ b/system/dbupdate_3.4.php @@ -95,6 +95,7 @@ require_once('dbupdate_3.4/71645_studvw_messagetab_ladezeit.php'); require_once('dbupdate_3.4/71566_studienordnungsdokument_neuer_organisationseinheitstyp_programm.php'); require_once('dbupdate_3.4/70376_lohnguide.php'); require_once('dbupdate_3.4/75888_reihungstest_mehrfachdurchfuehrung.php'); +require_once('dbupdate_3.4/77048_cis4_mitarbeiter_studstatus_widget.php'); // *** Pruefung und hinzufuegen der neuen Attribute und Tabellen echo '

Pruefe Tabellen und Attribute!

'; diff --git a/system/dbupdate_3.4/77048_cis4_mitarbeiter_studstatus_widget.php b/system/dbupdate_3.4/77048_cis4_mitarbeiter_studstatus_widget.php new file mode 100644 index 000000000..f132a98ca --- /dev/null +++ b/system/dbupdate_3.4/77048_cis4_mitarbeiter_studstatus_widget.php @@ -0,0 +1,44 @@ +db_query("SELECT 1 FROM dashboard.tbl_widget WHERE widget_kurzbz= 'studstatus';")) +{ + if($db->db_num_rows($result) == 0) + { + $qry = " + INSERT INTO dashboard.tbl_widget ( + widget_id, + widget_kurzbz, + beschreibung, + arguments, + setup + ) + VALUES ( + 9, + 'studstatus', + 'Widget Todos Studierendenstatus', + '{ + \"css\": \"d-flex justify-content-center align-items-center h-100\", + \"title\": \"Tasks Studstatus\" + }'::jsonb, + '{ + \"file\": \"public/js/components/DashboardWidget/StudStatus.js\", + \"icon\": \"/skin/images/fh_technikum_wien_illustration_klein.png\", + \"name\": \"Studierendenstatus\", + \"width\": { + \"max\": 2, + \"min\": 1 + }, + \"height\": { + \"max\": 2, + \"min\": 1 + }, + \"hideFooter\": true + }'::jsonb + );"; + + if(!$db->db_query($qry)) + echo 'dashboard.tbl_widget: '.$db->db_last_error().'
'; + else + echo '
dashboard.tbl_widget: Widget studstatus hinzugefuegt!
'; + } +} diff --git a/system/phrasesupdate.php b/system/phrasesupdate.php index 5f6269365..c5e2bca35 100644 --- a/system/phrasesupdate.php +++ b/system/phrasesupdate.php @@ -58177,6 +58177,108 @@ I have been informed that I am under no obligation to consent to the transmissio ) ), // ### Phrases Dashboard Admin END + // ### Phrases Dashboard Studstatus Widget START + array( + 'app' => 'core', + 'category' => 'dashboard', + 'phrase' => 'antraegeToEdit', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'zu bearbeitende Studierendenanträge', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Student applications to be processed', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'dashboard', + 'phrase' => 'antraegeOpen', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'offene Studierendenanträge', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'open student applications', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'dashboard', + 'phrase' => 'antraegeNoOpen', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Keine offenen Studierendenanträge', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'No open student applications', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'dashboard', + 'phrase' => 'linkVerwaltungStudstatus', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Zur Verwaltung des Studierendenstatus', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Link to the administration of the student status', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'dashboard', + 'phrase' => 'noPermissionStudstatus', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Keine Berechtigung für Bearbeitung von Studierendenanträgen.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'No permission to process student applications.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + // ### Phrases Dashboard Studstatus Widget END ); From 765884b0dc225dfb09bc7ad9a6a7d86823802010 Mon Sep 17 00:00:00 2001 From: ma0068 Date: Thu, 11 Jun 2026 16:07:49 +0200 Subject: [PATCH 02/60] add also insert statement for dashboard.tbl_dashboard_widget --- ...048_cis4_mitarbeiter_studstatus_widget.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/system/dbupdate_3.4/77048_cis4_mitarbeiter_studstatus_widget.php b/system/dbupdate_3.4/77048_cis4_mitarbeiter_studstatus_widget.php index f132a98ca..4dd78cc28 100644 --- a/system/dbupdate_3.4/77048_cis4_mitarbeiter_studstatus_widget.php +++ b/system/dbupdate_3.4/77048_cis4_mitarbeiter_studstatus_widget.php @@ -42,3 +42,22 @@ if($result = @$db->db_query("SELECT 1 FROM dashboard.tbl_widget WHERE widget_kur echo '
dashboard.tbl_widget: Widget studstatus hinzugefuegt!
'; } } + +// neuer eintrag in dashboard.tbl_dashboard_widget +if($result = @$db->db_query("SELECT 1 FROM dashboard.tbl_dashboard_widget WHERE dashboard_id= 1 AND widget_id = 9;")) +{ + if($db->db_num_rows($result) == 0) + { + $qry = " + INSERT INTO + dashboard.tbl_dashboard_widget (dashboard_id, widget_id) + VALUES + (1, 9); + "; + } + + if(!$db->db_query($qry)) + echo 'dashboard.tbl_dashboard_widget: '.$db->db_last_error().'
'; + else + echo '
dashboard.tbl_dashboard_widget: Widget studstatus hinzugefuegt!
'; +} \ No newline at end of file From c5c29df6ccd6415607e813fc1d1df7152f793420 Mon Sep 17 00:00:00 2001 From: Alexei Karpenko Date: Tue, 14 Jul 2026 20:51:41 +0200 Subject: [PATCH 03/60] Gehaltsbestandteil, Gehaltsbestandteil_model: added gehaltstyp_bezeichnung --- .../vertragsbestandteil/Gehaltsbestandteil.php | 11 ++++++++++- .../vertragsbestandteil/Gehaltsbestandteil_model.php | 9 ++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/application/libraries/vertragsbestandteil/Gehaltsbestandteil.php b/application/libraries/vertragsbestandteil/Gehaltsbestandteil.php index 95bbdd908..70c362ed7 100644 --- a/application/libraries/vertragsbestandteil/Gehaltsbestandteil.php +++ b/application/libraries/vertragsbestandteil/Gehaltsbestandteil.php @@ -21,6 +21,7 @@ class Gehaltsbestandteil extends AbstractBestandteil implements \JsonSerializabl protected $valorisierungssperre; protected $valorisierung; protected $auszahlungen; + protected $gehaltstyp_bezeichnung; protected $insertamum; protected $insertvon; @@ -47,7 +48,8 @@ class Gehaltsbestandteil extends AbstractBestandteil implements \JsonSerializabl isset($data->valorisierungssperre) && $this->setValorisierungssperre($data->valorisierungssperre); isset($data->valorisierung) && $this->setValorisierung($data->valorisierung); isset($data->auszahlungen) && $this->setAuszahlungen($data->auszahlungen); - + isset($data->gehaltstyp_bezeichnung) && $this->setGehaltstyp_bezeichnung($data->gehaltstyp_bezeichnung); + isset($data->insertamum) && $this->setInsertamum($data->insertamum); isset($data->insertvon) && $this->setInsertvon($data->insertvon); isset($data->updateamum) && $this->setUpdateamum($data->updateamum); @@ -242,6 +244,13 @@ class Gehaltsbestandteil extends AbstractBestandteil implements \JsonSerializabl return $this; } + public function setGehaltstyp_bezeichnung($gehaltstyp_bezeichnung) + { + $this->markDirty('gehaltstyp_bezeichnung', $this->gehaltstyp_bezeichnung, $gehaltstyp_bezeichnung); + $this->gehaltstyp_bezeichnung = $gehaltstyp_bezeichnung; + return $this; + } + public function setInsertamum($insertamum) { $this->markDirty('insertamum', $this->insertamum, $insertamum); diff --git a/application/models/vertragsbestandteil/Gehaltsbestandteil_model.php b/application/models/vertragsbestandteil/Gehaltsbestandteil_model.php index 2669105a1..57031ac08 100644 --- a/application/models/vertragsbestandteil/Gehaltsbestandteil_model.php +++ b/application/models/vertragsbestandteil/Gehaltsbestandteil_model.php @@ -177,7 +177,8 @@ LEFT JOIN $stichtagclause .= ')'; } - $this->addSelect('*'); + $this->addSelect('tbl_gehaltsbestandteil.*, tbl_gehaltstyp.bezeichnung AS gehaltstyp_bezeichnung'); + $this->addJoin('hr.tbl_gehaltstyp', 'gehaltstyp_kurzbz'); $where = <<escape($dienstverhaeltnis_id)} {$stichtagclause} @@ -213,9 +214,11 @@ SELECT g.updateamum, g.updatevon, g.valorisierung, - g.auszahlungen + g.auszahlungen, + gt.bezeichnung AS gehaltstyp_bezeichnung FROM - hr.tbl_gehaltsbestandteil g + hr.tbl_gehaltsbestandteil g + JOIN hr.tbl_gehaltstyp gt USING (gehaltstyp_kurzbz) LEFT JOIN hr.tbl_valorisierung_historie vh ON vh.gehaltsbestandteil_id = g.gehaltsbestandteil_id AND vh.valorisierungsdatum = ( SELECT From 32de4aac6b74679aae532c49e1be35db6e577815 Mon Sep 17 00:00:00 2001 From: Johann Hoffmann Date: Thu, 23 Jul 2026 16:59:40 +0200 Subject: [PATCH 04/60] =?UTF-8?q?assistenz=20recht=20&=20features=20gesamt?= =?UTF-8?q?noteneingabe;=20load=20stg=20array=20for=20assistenzen=20to=20c?= =?UTF-8?q?hoose=20any=20lva=20from;=20added=20configurable=20datelimit=20?= =?UTF-8?q?for=20each=20semester=20with=20defaults=20as=20per=20upcoming?= =?UTF-8?q?=20pr=C3=BCfungsordnung;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- application/config/noten.php | 12 +- application/controllers/Cis/Benotungstool.php | 2 +- .../controllers/api/frontend/v1/Noten.php | 509 ++++++++++++++++-- .../education/Lehrveranstaltung_model.php | 46 ++ public/js/api/factory/noten.js | 14 + .../Cis/Benotungstool/Benotungstool.js | 335 ++++++++---- system/checksystem.php | 1 + system/phrasesupdate.php | 100 ++++ 8 files changed, 869 insertions(+), 150 deletions(-) diff --git a/application/config/noten.php b/application/config/noten.php index 6fdee8b86..7bdfce1ad 100644 --- a/application/config/noten.php +++ b/application/config/noten.php @@ -14,4 +14,14 @@ $config['NOTE_ENTSCHULDIGT'] = 17; // availability of the two Benotungstool import flows. When both are true they are shown as // separate buttons/dialogs. $config['CIS_GESAMTNOTE_PRUEFUNGSIMPORT'] = true; // dated import that creates a pruefung per row -$config['CIS_GESAMTNOTE_NOTENIMPORT'] = false; // classic note-only import (uid + note, no date) \ No newline at end of file +$config['CIS_GESAMTNOTE_NOTENIMPORT'] = false; // classic note-only import (uid + note, no date) + +$config['SHOW_BENOTUNGSDATUM_ON_NOTENVORSCHLAG_UEBERNAHME'] = true; + +// Noteneintragungsfrist (Prüfungsordnung §1): grade/pruefung entry is only permitted up to this +// deadline. The month/day below is applied to the studiensemester's year: +// Sommersemester (SSyyyy) -> deadline in the SAME calendar year (default 15th November) +// Wintersemester (WSyyyy) -> deadline in the FOLLOWING calendar year (default 15th May) +$config['CIS_GESAMTNOTE_NOTENEINTRAGUNGSFRIST'] = false; // switch to use the window enforcement +$config['NOTENEINTRAGUNGSFRIST_SS'] = ['month' => 11, 'day' => 15]; // Sommersemester deadline (same year) +$config['NOTENEINTRAGUNGSFRIST_WS'] = ['month' => 5, 'day' => 15]; // Wintersemester deadline (following year) \ No newline at end of file diff --git a/application/controllers/Cis/Benotungstool.php b/application/controllers/Cis/Benotungstool.php index 94faeff98..fe01f3595 100644 --- a/application/controllers/Cis/Benotungstool.php +++ b/application/controllers/Cis/Benotungstool.php @@ -13,7 +13,7 @@ class Benotungstool extends Auth_Controller public function __construct() { parent::__construct([ - 'index' => self::PERM_LOGGED + 'index' => array('lehre/benotungstool:rw', 'lehre/benotungstool_assistenz:rw') ]); $this->_ci =& get_instance(); diff --git a/application/controllers/api/frontend/v1/Noten.php b/application/controllers/api/frontend/v1/Noten.php index 0cabb639f..98ba825a8 100644 --- a/application/controllers/api/frontend/v1/Noten.php +++ b/application/controllers/api/frontend/v1/Noten.php @@ -28,18 +28,21 @@ class Noten extends FHCAPI_Controller */ public function __construct() { + $permissions = array('lehre/benotungstool:rw', 'lehre/benotungstool_assistenz:rw'); parent::__construct([ - 'getStudentenNoten' => array('lehre/benotungstool:rw'), - 'getNoten' => array('lehre/benotungstool:rw'), - 'saveStudentenNoten' => array('lehre/benotungstool:rw'), - 'getNotenvorschlagStudent' => array('lehre/benotungstool:rw'), - 'saveNotenvorschlag' => array('lehre/benotungstool:rw'), - 'saveStudentPruefung' => array('lehre/benotungstool:rw'), - 'createPruefungen' => array('lehre/benotungstool:rw'), - 'saveNotenvorschlagBulk' => array('lehre/benotungstool:rw'), - 'savePruefungenBulk' => array('lehre/benotungstool:rw'), - 'getCisConfig' => array('lehre/benotungstool:rw'), - 'getNoteByPunkte' => array('lehre/benotungstool:rw') + 'getStudentenNoten' => $permissions, + 'getNoten' => $permissions, + 'saveStudentenNoten' => $permissions, + 'getNotenvorschlagStudent' => $permissions, + 'saveNotenvorschlag' => $permissions, + 'saveStudentPruefung' => $permissions, + 'createPruefungen' => $permissions, + 'saveNotenvorschlagBulk' => $permissions, + 'savePruefungenBulk' => $permissions, + 'getCisConfig' => $permissions, + 'getNoteByPunkte' => $permissions, + 'getBenotungstoolContext' => $permissions, + 'getLvForStudiengang' => $permissions ]); $this->load->library('AuthLib', null, 'AuthLib'); @@ -72,6 +75,7 @@ class Noten extends FHCAPI_Controller $this->load->model('education/Lvgesamtnote_model', 'LvgesamtnoteModel'); $this->load->model('education/Lehrveranstaltung_model', 'LehrveranstaltungModel'); $this->load->model('education/Notenschluesselaufteilung_model', 'NotenschluesselaufteilungModel'); + $this->load->model('education/Note_model', 'NoteModel'); $this->load->model('person/Person_model', 'PersonModel'); $this->load->model('organisation/Studienplan_model', 'StudienplanModel'); $this->load->model('crm/Student_model', 'StudentModel'); @@ -84,9 +88,11 @@ 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'); + // resolved from tbl_note (Bezeichnung) with config fallback -> single source of truth + $special = $this->resolveSpecialNotes(); + $NOTEN_OHNE_ANTRITT = $special['ohneAntritt']; + $NOTEN_OCCURANCE_LIMIT_MAP = $special['limitMap']; + $NOTE_ENTSCHULDIGT = $special['entschuldigt']; $this->terminateWithSuccess( array( @@ -99,12 +105,12 @@ class Noten extends FHCAPI_Controller // only relevant in punkte calculation in backend // 'CIS_GESAMTNOTE_GEWICHTUNG' => CIS_GESAMTNOTE_GEWICHTUNG, - // this one should always be set true since fh prüfungsordnung requires at least 3 antritte (t1+t2+kP) - // send it anyway to use in maxAntritte calculation + // enables the first in-tool retake (Termin2). + // Used in the maxAntritte calculation. 'CIS_GESAMTNOTE_PRUEFUNG_TERMIN2' => CIS_GESAMTNOTE_PRUEFUNG_TERMIN2, - - // should in 99% of cases be kept true to enable 4 antritte in total, but if a certain - // fh still works with 3 antritte per note this can limit the max number of antritte accordingly + + // legacy: enables a SECOND in-tool retake (Termin3) for other installations whose ruleset + // uses three explicit Nachprüfungs-Termine. Each enabled retake type raises the maxAntritte cap. 'CIS_GESAMTNOTE_PRUEFUNG_TERMIN3' => CIS_GESAMTNOTE_PRUEFUNG_TERMIN3, // used to toggle availability of kommPruef type pruefungen @@ -133,11 +139,144 @@ class Noten extends FHCAPI_Controller '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 + 'NOTE_ENTSCHULDIGT' => $NOTE_ENTSCHULDIGT, + + // show the Benotungsdatum of the first Antritt in the "Ursprüngliche Zeugnisnote" column + 'SHOW_BENOTUNGSDATUM_ON_NOTENVORSCHLAG_UEBERNAHME' => $this->config->item('SHOW_BENOTUNGSDATUM_ON_NOTENVORSCHLAG_UEBERNAHME'), + + // Noteneintragungsfrist window (enforced server-side; also surfaced so the UI can hint at it) + 'CIS_GESAMTNOTE_NOTENEINTRAGUNGSFRIST' => $this->config->item('CIS_GESAMTNOTE_NOTENEINTRAGUNGSFRIST') ) ); } + /** + * GET METHOD + * expects (optional) 'sem_kurzbz' + * Single role-determining entry point for the tool: the server decides which flow the current user + * gets and returns exactly the initial dropdown data for it. + * A teacher (holds lehre/benotungstool) gets their assigned + * Lehrveranstaltungen directly. Assistenz (only lehre/benotungstool_assistenz) gets the + * Studiengänge they are entitled for and picks one before an LV is loaded. + */ + public function getBenotungstoolContext() { + $sem_kurzbz = $this->input->get("sem_kurzbz", TRUE); + $lv_id = $this->input->get("lv_id", TRUE); // optional: deep-link target, used to preselect + + $this->load->library('PermissionLib'); + + // teachers keep the classic assigned-LV flow; the Studiengang flow is only for Assistenz. + // Role determination mirrors assertLvAccess, which scopes each role's actual data + // access (teachers to their own LVs, Assistenzen to their entitled Studiengänge). + $isLektor = $this->permissionlib->isBerechtigt('lehre/benotungstool'); + $entitledStgs = $this->permissionlib->getSTG_isEntitledFor('lehre/benotungstool_assistenz'); + $isAssistenz = !$isLektor && is_array($entitledStgs) && count($entitledStgs) > 0; + + $studiengaenge = array(); + $lehrveranstaltungen = array(); + $preselectStudiengang_kz = null; + + if (isset($sem_kurzbz) && !isEmptyString($sem_kurzbz)) { + if ($isAssistenz) { + $this->load->model('organisation/Studiengang_model', 'StudiengangModel'); + $result = $this->StudiengangModel->getByStgs($entitledStgs, $sem_kurzbz); + if (!isError($result)) $studiengaenge = getData($result) ?? array(); + + // deep-link: resolve the Studiengang of the requested LV so the frontend can preselect + // the Studiengang dropdown (and then its LV) - only if the Assistenz is entitled for it + if (isset($lv_id) && !isEmptyString($lv_id)) { + $res = $this->LehrveranstaltungModel->load($lv_id); + if (!isError($res) && hasData($res)) { + $stg = getData($res)[0]->studiengang_kz; + if (in_array($stg, $entitledStgs)) $preselectStudiengang_kz = $stg; + } + } + } else { + $result = $this->LehrveranstaltungModel->getLvForLektorInSemester($sem_kurzbz, getAuthUID()); + if (!isError($result)) $lehrveranstaltungen = getData($result) ?? array(); + } + } + + $this->terminateWithSuccess(array( + 'isAssistenz' => $isAssistenz, + 'studiengaenge' => $studiengaenge, + 'lehrveranstaltungen' => $lehrveranstaltungen, + 'preselectStudiengang_kz' => $preselectStudiengang_kz + )); + } + + /** + * GET METHOD + * expects 'studiengang_kz', 'sem_kurzbz' + * Returns the Lehrveranstaltungen of a Studiengang in a Studiensemester for the Assistenz flow. + * The caller may only load Studiengänge they are entitled for via lehre/benotungstool_assistenz. + */ + public function getLvForStudiengang() { + $studiengang_kz = $this->input->get("studiengang_kz", TRUE); + $sem_kurzbz = $this->input->get("sem_kurzbz", TRUE); + + if (!isset($studiengang_kz) || isEmptyString($studiengang_kz) + || !isset($sem_kurzbz) || isEmptyString($sem_kurzbz)) { + $this->terminateWithError($this->p->t('global', 'wrongParameters'), 'general'); + } + + $this->load->library('PermissionLib'); + $entitledStgs = $this->permissionlib->getSTG_isEntitledFor('lehre/benotungstool_assistenz'); + $isAdmin = $this->permissionlib->isBerechtigt('admin'); + + if (!$isAdmin && (!is_array($entitledStgs) || !in_array($studiengang_kz, $entitledStgs))) { + $this->terminateWithError($this->p->t('ui', 'keineBerechtigung'), 'general'); + } + + $result = $this->LehrveranstaltungModel->getLvForStudiengangInSemester($sem_kurzbz, $studiengang_kz); + $data = $this->getDataOrTerminateWithError($result); + $this->terminateWithSuccess($data); + } + + /** + * Scopes data access to the Lehrveranstaltungen a user may act on, so a shared/guessed URL cannot be + * used to read or edit foreign grades. A teacher (lehre/benotungstool) may + * only touch LVs they are assigned to teach in the given Studiensemester. Assistenz may + * touch LVs of a Studiengang they are entitled for. + */ + private function assertLvAccess($lv_id, $sem_kurzbz = null) + { + $this->load->library('PermissionLib'); + + // admins keep full access + if ($this->permissionlib->isBerechtigt('admin')) return; + + // teachers: only their own LVs (assigned as lehreinheitmitarbeiter in this semester) + if ($this->permissionlib->isBerechtigt('lehre/benotungstool')) { + $res = $this->LehrveranstaltungModel->getLektorIsTeachingLva($lv_id, getAuthUID(), $sem_kurzbz); + $rows = getData($res); + if (!isError($res) && !empty($rows) && $rows[0]->teaches > 0) return; + // not a teacher of this LV -> fall through (a both-role user may still be entitled as Assistenz) + } + + // (pure or additional) Assistenz: only LVs of an entitled Studiengang + $entitledStgs = $this->permissionlib->getSTG_isEntitledFor('lehre/benotungstool_assistenz'); + $lv = null; + if (is_array($entitledStgs) && count($entitledStgs) > 0) { + $res = $this->LehrveranstaltungModel->load($lv_id); + if (!isError($res) && hasData($res)) { + $lv = getData($res)[0]; + if (in_array($lv->studiengang_kz, $entitledStgs)) return; + } + } + + if ($lv === null) { + $res = $this->LehrveranstaltungModel->load($lv_id); + if (!isError($res) && hasData($res)) $lv = getData($res)[0]; + } + $bezeichnung = $lv !== null ? $lv->bezeichnung : $lv_id; + + $this->terminateWithError( + $this->p->t('benotungstool', 'keineBerechtigungNoten', [$bezeichnung, $sem_kurzbz]), + 'general' + ); + } + /** * GET METHOD * expects 'lv_id', 'sem_kurzbz' @@ -153,13 +292,15 @@ class Noten extends FHCAPI_Controller if (!isset($lv_id) || isEmptyString($lv_id) || !isset($sem_kurzbz) || isEmptyString($sem_kurzbz)) $this->terminateWithError($this->p->t('global', 'wrongParameters'), 'general'); - + + $this->assertLvAccess($lv_id, $sem_kurzbz); + // get studenten for lva & sem with zeugnisnote if available $studenten = $this->LehrveranstaltungModel->getStudentsByLv($sem_kurzbz, $lv_id); $studentenData = $this->getDataOrTerminateWithError($studenten); if(count($studentenData) == 0) { - $this->terminateWithError('No students found for lva and semester'); + $this->terminateWithError($this->p->t('benotungstool', 'c4keineStudentenGefunden')); } $func = function ($value) { @@ -325,9 +466,11 @@ class Noten extends FHCAPI_Controller $lv_id = $result->lv_id; $sem_kurzbz = $result->sem_kurzbz; - + + $this->assertLvAccess($lv_id, $sem_kurzbz); + $ret = []; - + $res = $this->LehrveranstaltungModel->load($lv_id); if(isError($res) || !hasData($res)) { $this->terminateWithError($this->p->t('benotungstool', 'noValidLvFoundForId', [$lv_id])); @@ -370,7 +513,7 @@ class Noten extends FHCAPI_Controller " . $this->p->t('lehre','studiengang') . "\n " . $this->p->t('benotungstool','c4nachname') . "\n " . $this->p->t('benotungstool','c4vorname') . "\n"; - if(defined(CIS_GESAMTNOTE_PUNKTE) && CIS_GESAMTNOTE_PUNKTE) { + if(defined('CIS_GESAMTNOTE_PUNKTE') && CIS_GESAMTNOTE_PUNKTE) { $studlist .= "" . $this->p->t('benotungstool','c4punkte') . "\n"; } $studlist .= "" . $this->p->t('benotungstool','c4grade') . "\n"; @@ -382,7 +525,6 @@ 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); if (!isError($resultLVGes) && hasData($resultLVGes)) { $lvgesamtnote = getData($resultLVGes)[0]; @@ -416,7 +558,7 @@ class Noten extends FHCAPI_Controller $studlist .= "" . trim($note->nachname) . ""; $studlist .= "" . trim($note->vorname) . ""; - if(defined(CIS_GESAMTNOTE_PUNKTE) && CIS_GESAMTNOTE_PUNKTE) { + if(defined('CIS_GESAMTNOTE_PUNKTE') && CIS_GESAMTNOTE_PUNKTE) { $studlist .= "" . trim($lvgesamtnote->punkte) . ""; } $studlist .= "" .$note->noteBezeichnung. ""; @@ -493,10 +635,9 @@ class Noten extends FHCAPI_Controller || $lv_id === NULL || trim((string)$lv_id) === '') { $this->terminateWithError($this->p->t('global', 'missingParameters'), 'general'); } + + $this->assertLvAccess($lv_id, $sem_kurzbz); - // TODO: we need a zuordnungscheck here? any lektor can get any grades? - // what about assistenz with different rights doing lectors job once again? - // students checking their own grades? $result = $this->LvgesamtnoteModel->getLvGesamtNoteVorschlag($lv_id, $uid, $sem_kurzbz); $data = $this->getDataOrTerminateWithError($result); @@ -540,6 +681,11 @@ class Noten extends FHCAPI_Controller $stsem = $result->sem_kurzbz; $typ = $result->typ; + $this->assertLvAccess($lva_id, $stsem); + + // Prüfungsordnung §1: no entry/change past the Noteneintragungsfrist + $this->enforceNoteneintragungsfrist($stsem); + $jetzt = date("Y-m-d H:i:s"); if(CIS_GESAMTNOTE_PUNKTE && isset($punkte) && $punkte >= 0) { @@ -572,19 +718,28 @@ class Noten extends FHCAPI_Controller $this->terminateWithError($editError, 'general'); } + // for a NEW attempt (no pruefung_id) enforce the add rules server-side + // maxAntritte calc, chronological order, occurrence limit for entschuldigt + if($pruefung_id === null || $pruefung_id === '') { + $addError = $this->validatePruefungAdd($student_uid, $lva_id, $stsem, $note, $datum); + if($addError !== null) { + $this->terminateWithError($addError, 'general'); + } + } + $this->load->model('education/Lehrveranstaltung_model', 'LehrveranstaltungModel'); $this->load->model('organisation/Studiengang_model', 'StudiengangModel'); $res = $this->LehrveranstaltungModel->load($lva_id); if(isError($res) || !hasData($res)) { - $this->terminateWithError('Keine gültige Lehrveranstaltung gefunden für ID: '.$lva_id); + $this->terminateWithError($this->p->t('benotungstool', 'noValidLvFoundForId', [$lva_id])); } - + $studiengang_kz = getData($res)[0]->studiengang_kz; $res = $this->StudiengangModel->load($studiengang_kz); if(isError($res) || !hasData($res)) { - $this->terminateWithError('Kein gültiger Studiengang gefunden für ID: '.$studiengang_kz); + $this->terminateWithError($this->p->t('benotungstool', 'noValidStudiengangFoundForId', [$studiengang_kz])); } @@ -670,7 +825,7 @@ 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, $origLvNote = null, $origLvPunkte = null, $origBenotungsdatum = null) + 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 @@ -697,9 +852,17 @@ class Noten extends FHCAPI_Controller ); if(count($status) > 0 && $status[0] == true) { - $this->load->model('education/Note_model', 'NoteModel'); - $result = $this->NoteModel->getEntschuldigtNote(); - $note = getData($result)[0]->note; + $entschuldigtNote = $this->resolveSpecialNotes()['entschuldigt']; + + // the occurrence limit was validated on the PRE-override note, so re-check it here: only + // apply the auto-excuse if it would not exceed the allowed number of excused Termine + if($entschuldigtNote !== null) { + $allRes = $this->LePruefungModel->getPruefungenByUidTypLvStudiensemester($student_uid, null, $lva_id, $stsem); + $existing = (!isError($allRes) && hasData($allRes)) ? getData($allRes) : []; + if(!$this->exceedsNoteOccuranceLimit($existing, $entschuldigtNote, null)) { + $note = $entschuldigtNote; + } + } } $jetzt = date("Y-m-d H:i:s"); @@ -923,7 +1086,7 @@ class Noten extends FHCAPI_Controller */ private function isEntschuldigtNote($note) { - $entschuldigt = $this->config->item('NOTE_ENTSCHULDIGT'); + $entschuldigt = $this->resolveSpecialNotes()['entschuldigt']; return $entschuldigt !== null && $note == $entschuldigt; } @@ -998,9 +1161,231 @@ class Noten extends FHCAPI_Controller return $this->p->t('benotungstool', 'pruefungDatumOutOfRange', [$student_uid]); } + // an edit may also change the note (e.g. to 'entschuldigt') -> keep the occurrence limit + if($this->exceedsNoteOccuranceLimit($pruefungen, $newNote, $current->pruefung_id)) { + return $this->p->t('benotungstool', 'noteOccuranceLimitReached', [$student_uid]); + } + return null; } + /** + * Validates ADDING a new pruefung (attempt). Returns an error string if the add is not + * allowed, or null if it is.. + * + * Rules (Prüfungsordnung §1): + * - Rule A: the number of counting Prüfungsantritte may not exceed the configured maximum + * - Rule B: attempts are taken in chronological order + * - Rule C: a note carrying an occurrence limit (entschuldigt) may not exceed it. + * + * @param string $student_uid + * @param int $lva_id + * @param string $stsem + * @param int $note the (already resolved) note being saved + * @param string $datum the datum being saved (Y-m-d) + * @return string|null + */ + private function validatePruefungAdd($student_uid, $lva_id, $stsem, $note, $datum) + { + $result = $this->LePruefungModel->getPruefungenByUidTypLvStudiensemester($student_uid, null, $lva_id, $stsem); + $pruefungen = (!isError($result) && hasData($result)) ? getData($result) : []; + + // current LV note (the implicit first Antritt), read before it gets mutated by the caller + $lvNote = null; + $resLv = $this->LvgesamtnoteModel->getLvGesamtNoten($lva_id, $student_uid, $stsem); + if(!isError($resLv) && hasData($resLv)) $lvNote = getData($resLv)[0]->note; + + // Rule A: max Antritte + if($this->computeAntrittCount($pruefungen, $lvNote) >= $this->computeMaxAntritte()) { + return $this->p->t('benotungstool', 'maxAntritteReached', [$student_uid, $this->computeMaxAntritte()]); + } + + // Rule B: no new attempt on/before an existing dated attempt + $newDate = substr((string)$datum, 0, 10); + foreach($pruefungen as $p) { + $d = substr((string)$p->datum, 0, 10); + if($d !== '' && $d >= $newDate) { + return $this->p->t('benotungstool', 'pruefungDatumBeforeExisting', [$student_uid]); + } + } + + // Rule C: occurrence limit (e.g. only one 'entschuldigt' across the fixed Antritte) + if($this->exceedsNoteOccuranceLimit($pruefungen, $note, null)) { + return $this->p->t('benotungstool', 'noteOccuranceLimitReached', [$student_uid]); + } + + return null; + } + + /** + * Number of counting Prüfungsantritte for a student, mirroring the frontend getAntrittCountStudent: + * a kommPruef caps the count at 4, notes in NOTEN_OHNE_ANTRITT (entschuldigt / noch nicht + * eingetragen) do not count, and when no pruefung counts yet the original LV note counts as the + * first Antritt (unless it is a non-lehre note such as 'angerechnet'). + */ + private function computeAntrittCount($pruefungen, $lvNote) + { + $notenOhneAntritt = $this->resolveSpecialNotes()['ohneAntritt']; + + foreach($pruefungen as $p) { + if($p->pruefungstyp_kurzbz == 'kommPruef') return 4; + } + + $count = 0; + foreach($pruefungen as $p) { + if(!in_array($p->note, $notenOhneAntritt)) $count++; + } + + if($count === 0 && $lvNote !== null && $this->isLehreNote($lvNote)) return 1; + + return $count; + } + + /** + * Maximum number of counting Prüfungsantritte enterable IN THIS TOOL, mirroring the frontend + * maxAntrittCount: the original note (always 1) plus each enabled in-tool retake type (Termin2 and + * the legacy Termin3). kommPruef is the terminal attempt, entered elsewhere, and is intentionally + * not part of this cap. For our installation (Termin2 on, Termin3 off) this is 2; excused Termine do + * not count (see computeAntrittCount), which is what lets Termin2 occur twice within the cap. + */ + private function computeMaxAntritte() + { + $max = 1; + if(defined('CIS_GESAMTNOTE_PRUEFUNG_TERMIN2') && CIS_GESAMTNOTE_PRUEFUNG_TERMIN2) $max++; + if(defined('CIS_GESAMTNOTE_PRUEFUNG_TERMIN3') && CIS_GESAMTNOTE_PRUEFUNG_TERMIN3) $max++; + return $max; + } + + /** + * Whether the given note is flagged as a 'lehre' note in tbl_note. + */ + private function isLehreNote($note) + { + $res = $this->NoteModel->load($note); + if(isError($res) || !hasData($res)) return false; + return (bool) getData($res)[0]->lehre; + } + + /** + * Single source of truth for the "special" note PKs (entschuldigt & noch nicht eingetragen). + * tbl_note is resolved by Bezeichnung (install-independent) and takes precedence; the hardcoded + * config PKs (NOTE_ENTSCHULDIGT / NOTEN_OHNE_ANTRITT) are only a fallback. This removes the second + * source of truth so the entschuldigt-preservation and Antritt-counting logic can't disagree with + * the Bezeichnung lookups on installs whose PKs differ. Cached per request. + * + * @return array{entschuldigt: mixed, ohneAntritt: array, limitMap: array} + */ + private function resolveSpecialNotes() + { + static $cache = null; + if($cache !== null) return $cache; + + $cfgEntschuldigt = $this->config->item('NOTE_ENTSCHULDIGT'); + $cfgOhneAntritt = $this->config->item('NOTEN_OHNE_ANTRITT'); + $cfgLimitMap = $this->config->item('NOTEN_OCCURANCE_LIMIT_MAP'); + if(!is_array($cfgOhneAntritt)) $cfgOhneAntritt = []; + if(!is_array($cfgLimitMap)) $cfgLimitMap = []; + + // resolve by Bezeichnung, fall back to the config PKs + $resEnt = $this->NoteModel->getEntschuldigtNote(); + $entschuldigt = (!isError($resEnt) && hasData($resEnt)) ? getData($resEnt)[0]->note : $cfgEntschuldigt; + + $resNn = $this->NoteModel->getNochNichtEingetragenNote(); + $nochNicht = (!isError($resNn) && hasData($resNn)) ? getData($resNn)[0]->note : ($cfgOhneAntritt[0] ?? null); + + $ohneAntritt = array_values(array_filter([$nochNicht, $entschuldigt], function($v){ return $v !== null; })); + + // re-key the configured entschuldigt limit onto the resolved PK (keep any other entries as-is) + $limitMap = []; + foreach($cfgLimitMap as $k => $v) { + $limitMap[($k == $cfgEntschuldigt) ? $entschuldigt : $k] = $v; + } + + $cache = [ + 'entschuldigt' => $entschuldigt, + 'ohneAntritt' => $ohneAntritt, + 'limitMap' => $limitMap + ]; + return $cache; + } + + /** + * Check for configured occurrence limit (NOTEN_OCCURANCE_LIMIT_MAP) among the given pruefungen. + * $excludePruefungId skips the record currently being edited + * Noten without a configured limit never exceed. + */ + private function exceedsNoteOccuranceLimit($pruefungen, $note, $excludePruefungId = null) + { + $limitMap = $this->resolveSpecialNotes()['limitMap']; + if(!is_array($limitMap)) return false; + + $limit = null; + foreach($limitMap as $limitNote => $limitVal) { + if($limitNote == $note) { $limit = $limitVal; break; } + } + if($limit === null) return false; + + $count = 0; + foreach($pruefungen as $p) { + if($excludePruefungId !== null && $p->pruefung_id == $excludePruefungId) continue; + if($p->note == $note) $count++; + } + + return ($count + 1) > $limit; + } + + /** + * Enforces the Noteneintragungsfrist (Prüfungsordnung §1) for grade/pruefung entry. Terminates the + * request with an error when the deadline for the given studiensemester has passed. Does nothing + * when the window enforcement is disabled or the deadline cannot be determined. + */ + private function enforceNoteneintragungsfrist($sem_kurzbz) + { + if(!$this->config->item('CIS_GESAMTNOTE_NOTENEINTRAGUNGSFRIST')) return; + + $deadline = $this->computeNoteneintragungsfrist($sem_kurzbz); + if($deadline === null) return; + + if(new DateTime() > $deadline) { + $this->terminateWithError( + $this->p->t('benotungstool', 'noteneintragungsfristVorbei', [$deadline->format('d.m.Y')]), + 'general' + ); + } + } + + /** + * Computes the Noteneintragungsfrist deadline (end of day) for a studiensemester_kurzbz of the + * form '{SS|WS}yyyy'. SS -> deadline in the same calendar year, WS -> in the following year. The + * month/day come from the noten config. Returns a DateTime, or null when it can't be determined. + */ + private function computeNoteneintragungsfrist($sem_kurzbz) + { + if(!is_string($sem_kurzbz) || strlen($sem_kurzbz) < 6) return null; + + $type = strtoupper(substr($sem_kurzbz, 0, 2)); + $year = (int) substr($sem_kurzbz, 2, 4); + if($year <= 0) return null; + + if($type === 'SS') { + $cfg = $this->config->item('NOTENEINTRAGUNGSFRIST_SS'); + $deadlineYear = $year; + } elseif($type === 'WS') { + $cfg = $this->config->item('NOTENEINTRAGUNGSFRIST_WS'); + $deadlineYear = $year + 1; + } else { + return null; + } + + $month = (is_array($cfg) && isset($cfg['month'])) ? (int)$cfg['month'] : ($type === 'SS' ? 11 : 5); + $day = (is_array($cfg) && isset($cfg['day'])) ? (int)$cfg['day'] : 15; + + $deadline = new DateTime(); + $deadline->setDate($deadlineYear, $month, $day); + $deadline->setTime(23, 59, 59); + return $deadline; + } + /** * POST METHOD * expects 'sem_kurzbz', 'lv_id', 'student_uid', 'note' @@ -1020,7 +1405,12 @@ class Noten extends FHCAPI_Controller $sem_kurzbz = $result->sem_kurzbz; $note = $result->note; $punkte = $result->punkte; - + + $this->assertLvAccess($lv_id, $sem_kurzbz); + + // Prüfungsordnung §1: no entry/change past the Noteneintragungsfrist + $this->enforceNoteneintragungsfrist($sem_kurzbz); + $result = $this->LvgesamtnoteModel->getLvGesamtNoteVorschlag($lv_id, $student_uid, $sem_kurzbz); // $this->addMeta('LvgesamtnoteModelresult', $result); @@ -1092,7 +1482,12 @@ class Noten extends FHCAPI_Controller $lv_id = $result->lv_id; $sem_kurzbz = $result->sem_kurzbz; $noten = $result->noten; - + + $this->assertLvAccess($lv_id, $sem_kurzbz); + + // Prüfungsordnung §1: no entry/change past the Noteneintragungsfrist + $this->enforceNoteneintragungsfrist($sem_kurzbz); + $retLvNoten = []; foreach($noten as $note) @@ -1177,21 +1572,34 @@ class Noten extends FHCAPI_Controller $uids = $result->uids; $datum = $result->datum; $lva_id = $result->lva_id; - + $stsem = $result->sem_kurzbz; - + + $this->assertLvAccess($lva_id, $stsem); + + // Prüfungsordnung §1: no entry past the Noteneintragungsfrist + $this->enforceNoteneintragungsfrist($stsem); + $ret = []; $this->load->model('education/Note_model', 'NoteModel'); $result = $this->NoteModel->getNochNichtEingetragenNote(); $note = getData($result)[0]->note; - + foreach ($uids as $student) { $student_uid = $student->uid; $typ = $student->typ; $punkte = null; // new pruefungen never have punkte, $lehreinheit_id = $student->lehreinheit_id; + + // server-side add guards (max Antritte, chronological order, occurrence limit) + $addError = $this->validatePruefungAdd($student_uid, $lva_id, $stsem, $note, $datum); + if($addError !== null) { + $ret[$student->uid] = $addError; // string result is surfaced per-student in the UI + continue; + } + $ret[$student->uid] = $this->savePruefungstermin($typ, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum); } @@ -1216,7 +1624,12 @@ class Noten extends FHCAPI_Controller $lv_id = $result->lv_id; $sem_kurzbz = $result->sem_kurzbz; $pruefungen = $result->pruefungen; - + + $this->assertLvAccess($lv_id, $sem_kurzbz); + + // Prüfungsordnung §1: no entry past the Noteneintragungsfrist + $this->enforceNoteneintragungsfrist($sem_kurzbz); + $ret = []; foreach ($pruefungen as $pruefung) { @@ -1235,6 +1648,14 @@ class Noten extends FHCAPI_Controller $punkte = $pruefung->punkte; $lehreinheit_id = $pruefung->lehreinheit_id; + + // server-side add guards (max Antritte, chronological order, occurrence limit) + $addError = $this->validatePruefungAdd($student_uid, $lv_id, $sem_kurzbz, $note, $datum); + if($addError !== null) { + $ret[$student_uid] = $addError; // string result is surfaced per-student in the UI + continue; + } + $ret[$student_uid] = $this->savePruefungstermin($typ, $student_uid, $lv_id, $sem_kurzbz, $lehreinheit_id, $note, $punkte, $datum); } diff --git a/application/models/education/Lehrveranstaltung_model.php b/application/models/education/Lehrveranstaltung_model.php index c616e260c..1925730cf 100644 --- a/application/models/education/Lehrveranstaltung_model.php +++ b/application/models/education/Lehrveranstaltung_model.php @@ -1369,4 +1369,50 @@ class Lehrveranstaltung_model extends DB_Model return $this->execReadOnlyQuery($qry, array($sem_kurzbz, $uid)); } + + /** + * All Lehrveranstaltungen of a Studiengang offered in a Studiensemester. + */ + public function getLvForStudiengangInSemester($sem_kurzbz, $studiengang_kz) { + $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, + tbl_lehrveranstaltung.orgform_kurzbz as orgform + FROM + lehre.tbl_lehrveranstaltung JOIN public.tbl_studiengang USING(studiengang_kz) + WHERE + tbl_lehrveranstaltung.studiengang_kz = ? + AND tbl_lehrveranstaltung.aktiv = TRUE + AND EXISTS ( + SELECT 1 FROM lehre.tbl_lehreinheit le + WHERE le.lehrveranstaltung_id = tbl_lehrveranstaltung.lehrveranstaltung_id + AND le.studiensemester_kurzbz = ? + ) + ORDER BY stg_kurzbz,lv_semester,lv_bezeichnung"; + + return $this->execReadOnlyQuery($qry, array($studiengang_kz, $sem_kurzbz)); + } + + /** + * Number of Lehreinheit assignments the given mitarbeiter has for a Lehrveranstaltung (i.e. whether + * they teach it), optionally restricted to a Studiensemester. Returns a single row with a `teaches` + * count - check `teaches > 0`. Used to scope teacher access in the Benotungstool to their own LVs. + */ + public function getLektorIsTeachingLva($lva_id, $ma_uid, $studiensemester_kurzbz = null) + { + $params = array($lva_id, $ma_uid); + $query = "SELECT COUNT(*) AS teaches + FROM lehre.tbl_lehreinheitmitarbeiter + JOIN lehre.tbl_lehreinheit USING (lehreinheit_id) + JOIN lehre.tbl_lehrveranstaltung USING (lehrveranstaltung_id) + WHERE lehrveranstaltung_id = ? AND mitarbeiter_uid = ?"; + if ($studiensemester_kurzbz !== null && $studiensemester_kurzbz !== '') { + $query .= " AND tbl_lehreinheit.studiensemester_kurzbz = ?"; + $params[] = $studiensemester_kurzbz; + } + + return $this->execReadOnlyQuery($query, $params); + } } diff --git a/public/js/api/factory/noten.js b/public/js/api/factory/noten.js index d0b8940ff..964cc5e73 100644 --- a/public/js/api/factory/noten.js +++ b/public/js/api/factory/noten.js @@ -83,5 +83,19 @@ export default { url: '/api/frontend/v1/Noten/getNoteByPunkte', params: { punkte, lv_id, sem_kurzbz } }; + }, + getBenotungstoolContext(sem_kurzbz, lv_id = null) { + return { + method: 'get', + url: '/api/frontend/v1/Noten/getBenotungstoolContext', + params: { sem_kurzbz, lv_id } + }; + }, + getLvForStudiengang(studiengang_kz, sem_kurzbz) { + return { + method: 'get', + url: '/api/frontend/v1/Noten/getLvForStudiengang', + params: { studiengang_kz, sem_kurzbz } + }; } } \ No newline at end of file diff --git a/public/js/components/Cis/Benotungstool/Benotungstool.js b/public/js/components/Cis/Benotungstool/Benotungstool.js index cb2af33e7..b0b5969b8 100644 --- a/public/js/components/Cis/Benotungstool/Benotungstool.js +++ b/public/js/components/Cis/Benotungstool/Benotungstool.js @@ -1,7 +1,5 @@ import {CoreFilterCmpt} from "../../filter/Filter.js"; -import ApiLehre from "../../../api/factory/lehre.js"; import ApiNoten from "../../../api/factory/noten.js"; -import ApiAuthinfo from "../../../api/factory/authinfo.js"; import ApiStudiensemester from "../../../api/factory/studiensemester.js"; import BsModal from '../../Bootstrap/Modal.js'; import BsOffcanvas from '../../Bootstrap/Offcanvas.js'; @@ -42,7 +40,6 @@ export const Benotungstool = { }, data() { return { - uid: null, headerFiltersRestored: false, filtersRestored: false, filteredRows: null, @@ -85,6 +82,9 @@ export const Benotungstool = { pruefungen: null, studiensemester: null, selectedSemester: null, + isAssistenz: false, + assistenzStudiengaenge: null, + selectedStudiengang: null, lehrveranstaltungen: null, selectedLehrveranstaltung: null, tableBuiltResolve: null, @@ -206,10 +206,6 @@ export const Benotungstool = { ]}; }, methods: { - async fetchAuthUID() { - const authIdResponse = await this.$api.call(ApiAuthinfo.getAuthUID()); - this.uid = authIdResponse.data.uid; - }, loadState() { return JSON.parse(localStorage.getItem(this.persistenceID) || "null"); }, @@ -621,6 +617,18 @@ export const Benotungstool = { this.$api.call(ApiNoten.saveStudentPruefungBulk(this.lv_id, this.sem_kurzbz, pruefungenbulk)) .then((res)=> { if(res.meta.status === 'success') { + // separate per-row backend rejections (localized string messages) from actual saves + let errorList = '' + Object.keys(res.data ?? {}).forEach(uid => { + const entry = res.data[uid] + if(!entry?.savedPruefung && !entry?.extraPruefung) { + errorList += entry + '\n' + } + }) + if(errorList !== '') { + this.$fhcAlert.alertError(errorList) + } + this.$fhcAlert.alertDefault( 'success', 'Info', @@ -637,12 +645,17 @@ export const Benotungstool = { const pruefungen = res.data uids.forEach(entry => { - const saved = pruefungen[entry.uid].savedPruefung?.[0] - const extra = pruefungen[entry.uid].extraPruefung?.[0] + const rowResult = pruefungen[entry.uid] + const saved = rowResult?.savedPruefung?.[0] + const extra = rowResult?.extraPruefung?.[0] const student = this.studenten.find(s => s.uid == entry.uid) if(!student) return + // a per-row backend rejection returns a localized error string (or produced nothing) -> + // skip it here; callers surface those messages to the user separately + if(!saved) return + // check for extra pruefung (termin1) to add before if(extra) { student["Termin1"] = extra @@ -1381,9 +1394,21 @@ export const Benotungstool = { // no actions on kommPruef allowed // no actions on termin1 aka pruefung 0 aka ursprüngliche note erlaubt - if(field === 'kommPruef' || field === "Termin1") { + if(field === 'kommPruef' || field === "Termin1") { + // Prüfungsordnung §2: for the original Zeugnisnote (Termin1 = first Antritt) optionally + // show its Benotungsdatum inline next to the grade + if(field === "Termin1" + && this.config?.SHOW_BENOTUNGSDATUM_ON_NOTENVORSCHLAG_UEBERNAHME + && studentPruefung?.datum) { + const dateDiv = createCol(this.formatDatumDMY(studentPruefung.datum), 'col-auto d-flex justify-content-center align-items-center') + dateDiv.classList.add('text-muted') + dateDiv.style.fontSize = '0.75rem' + dateDiv.style.whiteSpace = 'nowrap' + dateDiv.style.marginLeft = '6px' + rowDiv.appendChild(dateDiv) + } return rowDiv - } + } if(data[field]?.pruefungstyp_kurzbz !== 'Termin1') { // once a later-dated or higher-attempt pruefung exists (e.g. a Termin3/kommPruef has @@ -1431,6 +1456,12 @@ export const Benotungstool = { if(parts.length !== 3) return null return new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2])) }, + formatDatumDMY(datum) { + // 'YYYY-MM-DD[ ...]' -> 'DD.MM.YYYY' + const parts = (datum ?? '').slice(0, 10).split('-') + if(parts.length !== 3) return '' + return `${parts[2]}.${parts[1]}.${parts[0]}` + }, addDays(date, days) { const d = new Date(date) d.setDate(d.getDate() + days) @@ -1775,22 +1806,73 @@ export const Benotungstool = { this.notenTableOptions.height = window.visualViewport.height - rect.top - 50 this.$refs.notenTable.tabulator.setHeight(this.notenTableOptions.height) }, - loadLehrveranstaltungen(sem_kurzbz, preselectLvId = null) { - return this.$api.call(ApiLehre.getZugewieseneLv(this.uid, sem_kurzbz)).then(res => { - this.lehrveranstaltungen = res.data - this.lehrveranstaltungen.forEach(lva => { - lva.fullString = `${lva.stg_kurzbz} - ${lva.lv_semester} - ${lva.orgform}: ${lva.lv_bezeichnung}` - }) - this.selectedLehrveranstaltung = preselectLvId - ? this.lehrveranstaltungen.find(lva => lva.lehrveranstaltung_id == preselectLvId) ?? null - : null + setLehrveranstaltungen(lvaData, preselectLvId = null) { + this.lehrveranstaltungen = lvaData + this.lehrveranstaltungen.forEach(lva => { + lva.fullString = `${lva.stg_kurzbz} - ${lva.lv_semester} - ${lva.orgform}: ${lva.lv_bezeichnung}` }) + this.selectedLehrveranstaltung = preselectLvId + ? this.lehrveranstaltungen.find(lva => lva.lehrveranstaltung_id == preselectLvId) ?? null + : null + }, + // Decides how the LV dropdown is fed for the current user: Assistenzen pick a Studiengang first + // (LVs scoped by entitlement), teachers get their assigned LVs directly. Also loads the + // entitled Studiengänge for the Studiengang dropdown when in Assistenz mode. + setupLvSource(sem_kurzbz, preselectLvId = null) { + return this.$api.call(ApiNoten.getBenotungstoolContext(sem_kurzbz, preselectLvId)).then(res => { + this.isAssistenz = !!res.data?.isAssistenz + + if (this.isAssistenz) { + this.setAssistenzStudiengaenge(res.data?.studiengaenge ?? []) + + // deep-link: preselect the requested LV's Studiengang, load its LVs and preselect the LV. + // Without a (resolvable) deep-link, wait for a manual Studiengang selection. + const preStgKz = res.data?.preselectStudiengang_kz + this.selectedStudiengang = preStgKz + ? this.assistenzStudiengaenge.find(s => s.studiengang_kz == preStgKz) ?? null + : null + + if (this.selectedStudiengang) { + return this.loadLehrveranstaltungenForStudiengang(this.selectedStudiengang.studiengang_kz, sem_kurzbz, preselectLvId) + } + + this.lehrveranstaltungen = null + this.selectedLehrveranstaltung = null + } else { + // teacher: assigned LVs delivered by the same context call (deep-linked lv_id preselected) + this.setLehrveranstaltungen(res.data?.lehrveranstaltungen ?? [], preselectLvId) + } + }) + }, + setAssistenzStudiengaenge(studiengaenge) { + this.assistenzStudiengaenge = studiengaenge + this.assistenzStudiengaenge.forEach(stg => { + stg.fullString = `${stg.kuerzel} - ${stg.bezeichnung}` + }) + }, + loadLehrveranstaltungenForStudiengang(studiengang_kz, sem_kurzbz, preselectLvId = null) { + return this.$api.call(ApiNoten.getLvForStudiengang(studiengang_kz, sem_kurzbz)).then(res => { + this.setLehrveranstaltungen(res.data, preselectLvId) + }) + }, + stgChanged(e) { + const sem = this.selectedSemester?.studiensemester_kurzbz ?? this.sem_kurzbz + const stg = e.value?.studiengang_kz ?? null + + this.selectedLehrveranstaltung = null + if (stg && sem) { + this.loading = true + this.loadLehrveranstaltungenForStudiengang(stg, sem).finally(() => this.loading = false) + } else { + this.lehrveranstaltungen = null + } + }, + getOptionLabelStg(option) { + return option.fullString }, async setupCreated() { this.loading = true - await this.fetchAuthUID() - this.debouncedFetchPunkteForPruefung = debounce(this.fetchNoteForPunktePruefung, 500) // fetch cis config regarding gesamtnoteneingabe, needs to be fetched before setup can finish @@ -1818,7 +1900,7 @@ export const Benotungstool = { this.selectedSemester = defaultSem ?? this.studiensemester[0] ?? null const sem = this.selectedSemester?.studiensemester_kurzbz - if (sem) this.loadLehrveranstaltungen(sem, this.lv_id) + if (sem) this.setupLvSource(sem, this.lv_id) }) LehreinheitenModule.setupContext(this.$.appContext.config.globalProperties) @@ -1864,23 +1946,45 @@ export const Benotungstool = { ssChanged(e) { const sem = e.value.studiensemester_kurzbz const keepLvId = this.selectedLehrveranstaltung?.lehrveranstaltung_id ?? this.lv_id + const keepStg = this.selectedStudiengang?.studiengang_kz this.loading = true - this.loadLehrveranstaltungen(sem, keepLvId).then(() => { - const lvId = this.selectedLehrveranstaltung?.lehrveranstaltung_id ?? null + this.$api.call(ApiNoten.getBenotungstoolContext(sem)).then(res => { + this.isAssistenz = !!res.data?.isAssistenz - this.$router.push({ - name: "Benotungstool", - params: { sem_kurzbz: sem, lv_id: lvId ?? undefined } - }) + if (this.isAssistenz) { + // keep the selected Studiengang if it still exists this semester, then reload its LVs + this.setAssistenzStudiengaenge(res.data?.studiengaenge ?? []) + this.selectedStudiengang = keepStg + ? this.assistenzStudiengaenge.find(s => s.studiengang_kz == keepStg) ?? null + : null - if (lvId) { - this.loadNoten(lvId, sem) - } else if (this.$refs.notenTable?.tabulator) { - this.$refs.notenTable.tabulator.setData([]) + const loadLvs = this.selectedStudiengang + ? this.loadLehrveranstaltungenForStudiengang(this.selectedStudiengang.studiengang_kz, sem, keepLvId) + : Promise.resolve(this.lehrveranstaltungen = null) + + return loadLvs.then(() => this.afterSemesterChange(sem)) } + + // teacher: assigned LVs for the new semester come from the same context call + this.setLehrveranstaltungen(res.data?.lehrveranstaltungen ?? [], keepLvId) + return this.afterSemesterChange(sem) }).finally(() => this.loading = false) }, + afterSemesterChange(sem) { + const lvId = this.selectedLehrveranstaltung?.lehrveranstaltung_id ?? null + + this.$router.push({ + name: "Benotungstool", + params: { sem_kurzbz: sem, lv_id: lvId ?? undefined } + }) + + if (lvId) { + this.loadNoten(lvId, sem) + } else if (this.$refs.notenTable?.tabulator) { + this.$refs.notenTable.tabulator.setData([]) + } + }, getOptionLabel(option) { return option.studiensemester_kurzbz }, @@ -1890,21 +1994,24 @@ export const Benotungstool = { getOptionLabelLe(option) { return option.infoString }, - getPruefungstypForStudentByAntritt(student) { - // when adding new pruefungen, determine the next pruefungstyp by using the antritt counter - switch (student.hoechsterAntritt) { - case 0: - return "Termin2" - break - case 1: - return "Termin2" - break - case 2: - return "Termin3" - break - default: - return "" - } + getPruefungstypForStudentByAntritt(student) { + // Determine the next in-tool pruefungstyp when adding a new attempt. The order of retake + // types is driven by config so each installation keeps its own ruleset: + // - Termin1 (the original Zeugnisnote) is attempt 1 and is not offered here. + // - each enabled retake type is the next offered attempt: Termin2, then legacy Termin3. + // - kommPruef is the terminal attempt and is entered elsewhere, so it is never offered here. + // Our installation enables only Termin2 (Termin3 is legacy for other unis), so every offered + // retake resolves to Termin2 - which is also why Termin2 can occur twice: the excused Termin + // does not increment hoechsterAntritt (see getAntrittCountStudent), so the next offered attempt + // stays Termin2 until a counting Termin2 exists. + const retakeTypes = [] + if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_TERMIN2) retakeTypes.push("Termin2") + if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_TERMIN3) retakeTypes.push("Termin3") + + // hoechsterAntritt is the number of counting attempts already taken (>=1 once an original + // note exists). The next retake sits at index hoechsterAntritt-1 (attempt 1 -> first retake). + const idx = Math.max(0, student.hoechsterAntritt - 1) + return retakeTypes[idx] ?? "" }, savePruefungEingabe() { // keep the date within the neighbouring exam dates (a typed value can bypass the picker bounds) @@ -2279,14 +2386,17 @@ export const Benotungstool = { }).finally(()=> this.loading = false) }, getAntrittCountStudent(student) { - // checks for existence of a prüfung with a note that resolves to a - // "angetretene Prüfung" -> anything except "entschuldigt" & "noch nicht eingetragen" - // and returns the next allowed pruefungstyp from the number of taken pruefungen - - // 1 -> reguläre note - // 2 -> erste Nachprüfung / Termin2 - // 3 -> 2te Nachprüfung / Termin3 - // 4 -> kommPruef + // Number of counting Prüfungsantritte the student has taken. Counting is by NOTE, not by + // pruefungstyp: any note except the "antrittslose" ones (entschuldigt & noch nicht eingetragen, + // see NOTEN_OHNE_ANTRITT) counts as an attempt. That is exactly what makes an excused Termin2 + // not consume an attempt, so Termin2 can be entered a second time. + // + // Antritt numbering in our installation: 1 = original note, 2 = Termin2 (may be entered twice, + // one excused), terminal = kommPruef. (Termin3 is a legacy retake for other installations and + // is simply counted like any other pruefung when enabled.) + // + // A present kommPruef is terminal: return a sentinel (4) that is >= any maxAntrittCount, so no + // further attempts can be added here regardless of installation. if(student['kommPruef']) return 4 let pruefungsAntrittCount = 0 @@ -2410,11 +2520,17 @@ export const Benotungstool = { + ': ' + (this.studenten?.length || 0) + ''; }, maxAntrittCount() { + // Maximum number of counting Prüfungsantritte enterable IN THIS TOOL: the original note + // (always 1) plus each enabled in-tool retake type. kommPruef is the terminal attempt and is + // entered elsewhere, so it is intentionally NOT part of this cap. For our installation + // (Termin2 on, Termin3 off) this is 2 (original + Termin2); Termin3 is legacy for other unis. + // Excused Termine do not count (see getAntrittCountStudent), which is what lets Termin2 occur + // twice within this cap. let maxAntritte = 1; - + if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_TERMIN2) maxAntritte++ if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_TERMIN3) maxAntritte++ - + return maxAntritte; }, getFreigabeCounter() { @@ -2684,53 +2800,64 @@ export const Benotungstool = { -
-
-

{{$capitalize($p.t('benotungstool/benotungstoolTitle'))}}

-
{{ selectedLehrveranstaltung?.lv_bezeichnung }}
+
+
+

{{$capitalize($p.t('benotungstool/benotungstoolTitle'))}}

+
{{ selectedLehrveranstaltung?.lv_bezeichnung }}
- -
- -
-
- - - -
- -
- -
-
- - - -
- -
- -
-
- - - + +
+
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
diff --git a/system/checksystem.php b/system/checksystem.php index 1f7968282..8d95d3ce5 100644 --- a/system/checksystem.php +++ b/system/checksystem.php @@ -151,6 +151,7 @@ $berechtigungen = array( array('lehre/abgabetool','Projektabgabetool, Studentenansicht'), array('lehre/abgabetool:download','Download von Projektarbeitsabgaben'), array('lehre/benotungstool','Cis4 Gesamtnoteneingabe'), + array('lehre/benotungstool_assistenz','Cis4 Gesamtnoteneingabe für Assistenz'), array('lehre/freifach','Freifachverwaltung'), array('lehre/lehrfach','Lehrfachverwaltung'), array('lehre/lehrfach:begrenzt','Lehrfachverwaltung - nur aktiv aenderbar, nur aktive LF werden angezeigt'), diff --git a/system/phrasesupdate.php b/system/phrasesupdate.php index 8c8579342..b9269528d 100644 --- a/system/phrasesupdate.php +++ b/system/phrasesupdate.php @@ -59258,6 +59258,106 @@ I have been informed that I am under no obligation to consent to the transmissio ) ) ), + array( + 'app' => 'core', + 'category' => 'benotungstool', + 'phrase' => 'maxAntritteReached', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Für Studierenden {0} sind bereits die maximal zulässigen Prüfungsantritte ({1}) vergeben. Es wurde keine Prüfung angelegt.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Student {0} has already used the maximum number of exam attempts ({1}). No exam was created.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'benotungstool', + 'phrase' => 'pruefungDatumBeforeExisting', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Das Prüfungsdatum für Studierenden {0} darf nicht vor oder am selben Tag wie ein bereits bestehender Prüfungstermin liegen.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'The exam date for student {0} must not be on or before an existing exam date.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'benotungstool', + 'phrase' => 'noteOccuranceLimitReached', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Die gewählte Note darf für Studierenden {0} nicht so oft vergeben werden. Es wurde keine Prüfung angelegt.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'The selected grade may not be assigned that many times for student {0}. No exam was created.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'benotungstool', + 'phrase' => 'noteneintragungsfristVorbei', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Die Noteneintragungsfrist ({0}) für dieses Studiensemester ist abgelaufen. Es können keine Noten oder Prüfungen mehr eingetragen werden.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'The grade entry deadline ({0}) for this study semester has passed. Grades and exams can no longer be entered.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'benotungstool', + 'phrase' => 'keineBerechtigungNoten', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Keine Berechtigung, um in der Lehrveranstaltung "{0}" im Semester {1} Noten einzutragen.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'No permission to enter grades for the course "{0}" in semester {1}.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), array( 'app' => 'core', 'category' => 'benotungstool', From e876b9e4abe2a5b39af8cb33a8c8605c86569d1d Mon Sep 17 00:00:00 2001 From: ma0068 Date: Fri, 24 Jul 2026 13:11:40 +0200 Subject: [PATCH 05/60] Zeitsperren Mitarbeiter - add component to Vertragsverwaltung --- .../api/frontend/v1/MaZeitsperren.php | 115 +++++ .../models/ressource/Zeitsperre_model.php | 191 ++++++++ public/js/api/factory/zeitsperren.js | 68 +++ public/js/apps/Cis/Cis.js | 7 + .../Details/aktuelleZeitsperren.js | 441 ++++++++++++++++++ .../ZeitsperrenMitarbeiter/ZeitsperrenMa.js | 116 +++++ .../Vertraege/Vertragsverwaltung.js | 7 +- public/vendor | 1 + 8 files changed, 945 insertions(+), 1 deletion(-) create mode 100644 application/controllers/api/frontend/v1/MaZeitsperren.php create mode 100644 public/js/api/factory/zeitsperren.js create mode 100644 public/js/components/Cis/ZeitsperrenMitarbeiter/Details/aktuelleZeitsperren.js create mode 100644 public/js/components/Cis/ZeitsperrenMitarbeiter/ZeitsperrenMa.js create mode 120000 public/vendor diff --git a/application/controllers/api/frontend/v1/MaZeitsperren.php b/application/controllers/api/frontend/v1/MaZeitsperren.php new file mode 100644 index 000000000..93c0f619f --- /dev/null +++ b/application/controllers/api/frontend/v1/MaZeitsperren.php @@ -0,0 +1,115 @@ + self::PERM_LOGGED, + 'getAllZeitsperrenFixeMa' => self::PERM_LOGGED, + 'getAllZeitsperrenLector' => self::PERM_LOGGED, + 'getAllZeitsperrenOes' => self::PERM_LOGGED, + 'getZeitsperrenAss' => self::PERM_LOGGED, + 'getStgLectors' => self::PERM_LOGGED, + 'loadZeitsperrenLectorStg' => self::PERM_LOGGED, + ]); + + // Load Libraries + $this->load->library('VariableLib', ['uid' => getAuthUID()]); + $this->load->library('form_validation'); + + // Load language phrases + $this->loadPhrases([ + 'ui', + 'person', + 'zeitsperren' + ]); + + // Load models + $this->load->model('ressource/Zeitsperre_model', 'ZeitsperreModel'); + } + + //only user with zeitsperren + public function getAllActiveZeitsperren($days) + { + + $result = $this->ZeitsperreModel->getZeitsperrenForNextDays($days); + $data = $this->getDataOrTerminateWithError($result); + + $this->terminateWithSuccess($data); + } + + //TODO: check query: more results after join with hr-table + //all fixe Ma + public function getAllZeitsperrenFixeMa($days) + { + $result = $this->ZeitsperreModel->getMitarbeiterWithZeitsperren($days, true, false, null, false); + $data = $this->getDataOrTerminateWithError($result); + + $this->terminateWithSuccess($data); + } + + public function getAllZeitsperrenLector($days) + { + $result = $this->ZeitsperreModel->getMitarbeiterWithZeitsperren($days, false, true, null, false); + + $data = $this->getDataOrTerminateWithError($result); + + $this->terminateWithSuccess($data); + } + + public function getZeitsperrenAss($days) + { + $result = $this->ZeitsperreModel->getMitarbeiterWithZeitsperren($days, false, false, null, true); + + $data = $this->getDataOrTerminateWithError($result); + + $this->terminateWithSuccess($data); + } + + public function getAllZeitsperrenOes($days, $oe) + { + $result = $this->ZeitsperreModel->getMitarbeiterWithZeitsperren($days, false, false, $oe, false); + + $data = $this->getDataOrTerminateWithError($result); + + $this->terminateWithSuccess($data); + } + + public function loadZeitsperrenLectorStg($days, $stg) + { + $result = $this->ZeitsperreModel->getMitarbeiterWithZeitsperren($days, false, true, false, false, $stg); + + $data = $this->getDataOrTerminateWithError($result); + + $this->terminateWithSuccess($data); + } + + public function getStgLectors() + { + $sql = " + select + studiengang_kz, + kurzbz, + kurzbzlang, + typ, + bezeichnung, + kurzbzlang || ' (' || bezeichnung || ')' AS label + from + public.tbl_studiengang + where + typ in ('b','m') + order by typ asc, kurzbz Asc + "; + + $this->load->model('organisation/Studiengang_model', 'StudiengangModel'); + $result = $this->StudiengangModel->execReadOnlyQuery($sql); + $data = $this->getDataOrTerminateWithError($result); + + $this->terminateWithSuccess($data); + } + +} + diff --git a/application/models/ressource/Zeitsperre_model.php b/application/models/ressource/Zeitsperre_model.php index 078d29d8b..4a6bc330e 100644 --- a/application/models/ressource/Zeitsperre_model.php +++ b/application/models/ressource/Zeitsperre_model.php @@ -61,4 +61,195 @@ class Zeitsperre_model extends DB_Model return $this->execQuery($qry); } + + /** + * getZeitsperren of Mitarbeiter for the next days + * @ days: count of the intervall of the next days + * @return array + */ + public function getZeitsperrenForNextDays($days, $type=null) + { + //TODO(Manu) von und bis im controller berechnen und hier übergeben + $von = date('Y-m-d'); + $bis = date('Y-m-d', strtotime("+{$days} days")); + + //version with campus.vw_mitarbeiter + $paramsArray = [$von, $bis, $bis, $von]; + $qry = "select + nachname, + vorname, + uid, + vondatum, + bisdatum, + vertretung_uid, + erreichbarkeit_kurzbz + --lektor, + -- fixangestellt + from + campus.vw_mitarbeiter + join campus.tbl_zeitsperre on + (uid = mitarbeiter_uid) + where + ((? <= bisdatum + and ? >= bisdatum) + or (?>= vondatum + and ? <= vondatum)) + "; + + //fix, lektor etc just with all ma + if($type=="fix") { + $qry.= "AND fixangestellt = true"; + } + if($type=="lector") { + $qry.= "AND lektor = true"; + } + + //wenn lektor= WAHR und fixangestellt = Falsch: --> externer Lektor + + $qry .= " order by nachname"; + + + + //version with hr.dienstverhaeltnis +/* $paramsArray = [$bis, $von]; + $qry = " + SELECT + ps.nachname, + ps.vorname, + zs.mitarbeiter_uid, + zs.vondatum, + zs.bisdatum, + zs.vertretung_uid, + zs.erreichbarkeit_kurzbz + FROM campus.tbl_zeitsperre zs + JOIN public.tbl_benutzer bn + ON bn.uid = zs.mitarbeiter_uid + JOIN public.tbl_person ps + ON ps.person_id = bn.person_id "; + + if ($type != null) { + $qry.= " JOIN hr.tbl_dienstverhaeltnis dv on bn.uid = dv.mitarbeiter_uid "; + } + + $qry.=" + WHERE + zs.vondatum <= ? + AND zs.bisdatum >= ?"; + + if($type=="fix") { + $qry.= " and dv.vertragsart_kurzbz = 'echterdv'"; + } + if($type=="lector") { + $qry.= " and dv.vertragsart_kurzbz in ('externerlehrender', 'gastlektor')"; + } + + $qry .= " + ORDER BY + ps.nachname, + ps.vorname, + zs.vondatum;";*/ + + $result = $this->execQuery($qry, $paramsArray); + + return $result; + } + + public function getMitarbeiterWithZeitsperren($days, $fix=false, $lector=false, $oe=null, $ass=false, $stg=false) + { + //TODO(Manu) von und bis im controller berechnen und hier übergeben + $von = date('Y-m-d'); + $bis = date('Y-m-d', strtotime("+{$days} days")); + + //version with campus.vw_mitarbeiter + $paramsArray = [$von, $bis, $bis, $von]; + + if($oe) + { + $paramsArray[] = $oe; + } + + if($stg) + { + $paramsArray[] = $stg; + } + + $qry = " + SELECT + m.nachname, + m.vorname, + m.uid, + z.vondatum, + z.bisdatum, + z.vertretung_uid, + z.erreichbarkeit_kurzbz, + m.lektor, + m.fixangestellt, + mv.kurzbz + FROM campus.vw_mitarbeiter m + join public.tbl_benutzer bn on bn.uid = m.uid"; + + if($oe || $ass || $stg) + { + $qry .= " JOIN public.tbl_benutzerfunktion bf on bf.uid = bn.uid"; + } + + if($stg) + { + $qry .= " JOIN public.tbl_studiengang stg using (oe_kurzbz)"; + } + + $qry.= " + LEFT JOIN campus.tbl_zeitsperre z + ON m.uid = z.mitarbeiter_uid + AND ( + (? <= z.bisdatum AND ? >= z.bisdatum) + OR + (? >= z.vondatum AND ? <= z.vondatum) + ) + left join public.tbl_mitarbeiter mv on z.vertretung_uid = mv.mitarbeiter_uid + where bn.aktiv = true + "; + + + if($fix) { + $qry.= " AND m.fixangestellt = true"; + } + if($lector) { + $qry.= " AND m.lektor = true"; + } + + if ($oe) + { + $qry.= " + AND bf.funktion_kurzbz ='oezuordnung' + AND bf.oe_kurzbz= ? + AND (bf.datum_von is null or bf.datum_von <=now()) + AND (bf.datum_bis is null or bf.datum_bis >= now()) + "; + } + + if ($ass) + { + $qry.= " + AND bf.funktion_kurzbz ='ass' + AND (bf.datum_von is null or bf.datum_von <=now()) + AND (bf.datum_bis is null or bf.datum_bis >= now()) + "; + } + + if ($stg) + { + $qry.= " + AND stg.studiengang_kz = ? + "; + } + + $qry .= " order by nachname, vorname, uid"; + + + $result = $this->execQuery($qry, $paramsArray); + + return $result; + + } } diff --git a/public/js/api/factory/zeitsperren.js b/public/js/api/factory/zeitsperren.js new file mode 100644 index 000000000..f6b9a9886 --- /dev/null +++ b/public/js/api/factory/zeitsperren.js @@ -0,0 +1,68 @@ +/** + * Copyright (C) 2026 fhcomplete.org + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +export default { + loadAllActiveZeitsperren(days) { + return { + method: 'get', + url:'api/frontend/v1/MaZeitsperren/getAllActiveZeitsperren/' + days + }; + }, + loadAllZeitsperrenFixeMa(days) { + return { + method: 'get', + url:'api/frontend/v1/MaZeitsperren/getAllZeitsperrenFixeMa/' + days + }; + }, + loadAllZeitsperrenLector(days) { + return { + method: 'get', + url:'api/frontend/v1/MaZeitsperren/getAllZeitsperrenLector/' + days + }; + }, + getAllOes(){ + return { + method: 'get', + url:'api/frontend/v1/funktionen/Funktionen/getAllOrgUnits' + }; + }, + loadAllZeitsperrenOE(days, oe) { + return { + method: 'get', + url:'api/frontend/v1/MaZeitsperren/getAllZeitsperrenOes/' + days + '/' + oe, + }; + }, + loadZeitsperrenAss(days) { + console.log("url: loadZeitsperrenAss"); + return { + method: 'get', + url:'api/frontend/v1/MaZeitsperren/getZeitsperrenAss/' + days + }; + }, + getAllStg(){ + return { + method: 'get', + url:'api/frontend/v1/MaZeitsperren/getStgLectors' + }; + }, + loadZeitsperrenLectorStg(days,stg) { + console.log("url: loadZeitsperrenLectorStg"); + return { + method: 'get', + url:'api/frontend/v1/MaZeitsperren/loadZeitsperrenLectorStg/' + days + '/' + stg, + }; + }, +} \ No newline at end of file diff --git a/public/js/apps/Cis/Cis.js b/public/js/apps/Cis/Cis.js index d4f846f25..e801f172e 100644 --- a/public/js/apps/Cis/Cis.js +++ b/public/js/apps/Cis/Cis.js @@ -19,6 +19,7 @@ import DeadlineOverview from "../../components/Cis/Abgabetool/DeadlineOverview.j 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 ZeitsperrenMa from "../../components/Cis/ZeitsperrenMitarbeiter/ZeitsperrenMa.js"; import ApiRouteInfo from '../../api/factory/routeinfo.js'; import {capitalize} from "../../helpers/StringHelpers.js"; @@ -245,6 +246,12 @@ const router = VueRouter.createRouter({ }; }, }, + { + path: `/Cis/ZeitsperrenMa`, + name: 'ZeitsperrenMa', + component: ZeitsperrenMa, + props: true + }, ] }) diff --git a/public/js/components/Cis/ZeitsperrenMitarbeiter/Details/aktuelleZeitsperren.js b/public/js/components/Cis/ZeitsperrenMitarbeiter/Details/aktuelleZeitsperren.js new file mode 100644 index 000000000..65663184f --- /dev/null +++ b/public/js/components/Cis/ZeitsperrenMitarbeiter/Details/aktuelleZeitsperren.js @@ -0,0 +1,441 @@ +import {CoreFilterCmpt} from "../../../filter/Filter.js"; +import ApiMaTimelocks from "../../../../api/factory/zeitsperren.js"; +import FormForm from "../../../Form/Form.js"; +import FormInput from "../../../Form/Input.js"; +import PvAutoComplete from "../../../../../../index.ci.php/public/js/components/primevue/autocomplete/autocomplete.esm.min.js"; + +export default { + name: "aktuelleZeitsperren", + components: { + FormForm, + FormInput, + PvAutoComplete, + CoreFilterCmpt + }, + props: { + type: { + type: String, + default: 'all', + validator(value) { + return [ + 'fix', + 'lector', + 'oe', + 'ass', + 'lecStg' + ].includes(value) + } + } + }, + data(){ + return { + interval: 14, + days: {}, + arrayMaTimelocks: [], + listOes: [], + filteredOes: [], + selectedOe: null, + oe_kurzbz: null, + listStg: [], + filteredStg: [], + selectedStg: null, + studiengang_kz: null + } + }, + methods: { + loadAllActiveZeitsperren(days){ + this.$api + .call(ApiMaTimelocks.loadAllActiveZeitsperren(days)) + .then(result => { + this.arrayMaTimelocks = result.data; + }) + }, + loadZeitsperrenFixeMa(days){ + this.$api + .call(ApiMaTimelocks.loadAllZeitsperrenFixeMa(days)) + .then(result => { + this.arrayMaTimelocks = result.data; + }) + }, + loadZeitsperrenLector(days){ + this.$api + .call(ApiMaTimelocks.loadAllZeitsperrenLector(days)) + .then(result => { + this.arrayMaTimelocks = result.data; + }) + }, + loadZeitsperrenOE(days, oe){ + console.log("in function oe " + oe); + this.$api + .call(ApiMaTimelocks.loadAllZeitsperrenOE(days, oe)) + .then(result => { + this.arrayMaTimelocks = result.data; + }) + }, + loadZeitsperrenAss(days){ + console.log("in ASS "); + this.$api + .call(ApiMaTimelocks.loadZeitsperrenAss(days)) + .then(result => { + this.arrayMaTimelocks = result.data; + }) + }, + loadZeitsperrenStg(days, stg){ + console.log("in function stg " + stg); + this.$api + .call(ApiMaTimelocks.loadZeitsperrenLectorStg(days, stg)) + .then(result => { + this.arrayMaTimelocks = result.data; + }) + }, + createDays() { + const today = new Date(); + + //get Monday before today + const mondayBeforeToday = new Date(today); + const day = today.getDay(); + const diff = day === 0 ? 6 : day - 1; + + mondayBeforeToday.setDate(today.getDate() - diff); + + this.days = Array.from({ length: this.interval+1 }, (_, i) => { + const date = new Date(mondayBeforeToday); + date.setDate(mondayBeforeToday.getDate() + i); + + return { + date: date.toISOString().substring(0, 10), // 2026-07-13 + weekday: date.toLocaleDateString('de-AT', { weekday: 'short' }), + day: date.toLocaleDateString('de-AT', { + day: '2-digit', + month: '2-digit' + }) + }; + }); + }, + isBlocked(mitarbeiter, tag) { + if (!mitarbeiter?.sperren) { + return false; + } + + return mitarbeiter.sperren.some(s => + tag >= s.vondatum && + tag <= s.bisdatum + ); + }, + getSperre(mitarbeiter, tag){ + return mitarbeiter.sperren.find(s => + tag >= s.vondatum && + tag <= s.bisdatum + ); + }, + limitDays(event) { + let value = event.target.value.replace(/\D/g, '').slice(0, 2); + event.target.value = value; + this.interval = value ? Number(value) : null; + }, + filterOes(event) { + const query = event.query.toLowerCase(); + + this.filteredOes = this.listOes.filter(item => + item.label.toLowerCase().includes(query) + ); + }, + filterStg(event) { + const query = event.query.toLowerCase(); + + this.filteredStg = this.listStg.filter(item => + item.label.toLowerCase().includes(query) + ); + }, + //TODO(Manu) check link profile + link(uid) { + //https://www.fhcomplete.local/cis.php/Cis/Profil/getView/bell + return ( + FHC_JS_DATA_STORAGE_OBJECT.app_root + + FHC_JS_DATA_STORAGE_OBJECT.ci_router + + "/Cis/Profil/getView/" + + uid + ); + } + }, + computed: { + today(){ + const today = new Date(); + const day = today.toLocaleDateString('de-AT', { + day: '2-digit', + month: '2-digit', + year: 'numeric' + }); + return day; + }, + mondayBeforeToday(){ + const today = new Date(); + //get Monday before today + const mondayBeforeToday = new Date(today); + const day = today.getDay(); + const diff = day === 0 ? 6 : day - 1; + + mondayBeforeToday.setDate(today.getDate() - diff); + + return mondayBeforeToday; + }, + endInterval(){ + const today = new Date(); + const end = new Date(this.mondayBeforeToday); + end.setDate(end.getDate() + this.interval); + + return end.toLocaleDateString('de-AT', { + day: '2-digit', + month: '2-digit', + year: 'numeric' + }); + }, + mitarbeiter() { + const map = {}; + + //vor version with uid in query + this.arrayMaTimelocks.forEach(z => { + if (!map[z.uid]) { + map[z.uid] = []; + } + + map[z.uid].push(z); + }); + + return Object.entries(map).map(([mitarbeiter_uid, sperren]) => ({ + mitarbeiter_uid, + sperren + })); + }, + }, + watch: { + interval: { + handler(newVal) { + if (newVal) { + if(this.type == "fix") { + console.log("fix"); + this.loadZeitsperrenFixeMa(newVal); + } + else if(this.type == "lector") { + console.log("lector"); + this.loadZeitsperrenLector(newVal); + } + else if(this.type == "oe") { + console.log("oe: in handler"); + if(this.oe_kurzbz && this.oe_kurzbz !="") + this.loadZeitsperrenOE(newVal, this.oe_kurzbz); + else + console.log("Bitte OE auswählen"); + } + else if(this.type == "all") + { + console.log("all active"); + this.loadAllActiveZeitsperren(newVal); + } + else if(this.type == "ass") { + console.log("ass"); + this.loadZeitsperrenAss(newVal); + } + else + { + console.log("not defined"); + } + this.createDays(); + } + }, + deep: true, + }, + selectedOe(newVal) { + this.oe_kurzbz = newVal?.oe_kurzbz || ''; + }, + oe_kurzbz(newVal){ + { + if(newVal != "") + this.loadZeitsperrenOE(this.interval, newVal); + } + }, + selectedStg(newVal) { + this.studiengang_kz = newVal?.studiengang_kz || ''; + }, + studiengang_kz(newVal){ + { + console.log("STG triggered"); + if(newVal != "") + this.loadZeitsperrenStg(this.interval, newVal); + } + }, + }, + created(){ + if(this.type == "fix") { + console.log("fix"); + this.loadZeitsperrenFixeMa(this.interval); + } + else if(this.type == "lector") { + console.log("lector"); + this.loadZeitsperrenLector(this.interval); + } + else if(this.type == "oe") { + console.log("oeg"); + this.$api + .call(ApiMaTimelocks.getAllOes()) + .then(result => { + this.listOes = result.data; +/* this.listStg = this.listOes.filter(item => + item.label.includes("[Studiengang]") && item.aktiv || item.label.includes("[Lehrgang]") && item.aktiv + );*/ + }) + .catch(this.$fhcAlert.handleSystemError); + } + else if(this.type == "lecStg") { + console.log("lecStg"); + this.$api + .call(ApiMaTimelocks.getAllStg()) + .then(result => { + this.listStg = result.data; + }) + .catch(this.$fhcAlert.handleSystemError); + } + else if(this.type == "all") { + console.log("all active"); + this.loadAllActiveZeitsperren(this.interval); + } + else if(this.type == "ass") { + console.log("ass"); + this.loadZeitsperrenAss(this.interval); + } + else + { + console.log("not defined"); + } + this.createDays(); + +/* console.log('adams, 2026-07-16'); + console.log(this.isBlocked('adams', '2026-07-16'));*/ + }, + template: ` +
+

Mitarbeiter*innen mit aktuellen Zeitsperren + Fixangestellte + aller fixer Lektoren + nach Organisationseinheit {{ mondayBeforeToday.toLocaleDateString('de-AT') }} - {{endInterval}} +

+
+ + + + +
+ + + + + +
+ + +
+ + + + + +
+
+ +
count results: {{mitarbeiter.length}}, days: {{interval}} +
type: {{type}}
{{oe_kurzbz}} | {{selectedOe}} +
{{selectedStg}} | {{studiengang_kz}} + {{arrayMaTimelocks[0]}} +
+ + + + + + + + + + + + + + + + + +
UID +
{{ day.weekday }}
+
{{ day.day }}
+
{{m.sperren[0].nachname}} {{m.sperren[0].vorname}} + +
+ + + +
+ `, +} \ No newline at end of file diff --git a/public/js/components/Cis/ZeitsperrenMitarbeiter/ZeitsperrenMa.js b/public/js/components/Cis/ZeitsperrenMitarbeiter/ZeitsperrenMa.js new file mode 100644 index 000000000..3780cbfbe --- /dev/null +++ b/public/js/components/Cis/ZeitsperrenMitarbeiter/ZeitsperrenMa.js @@ -0,0 +1,116 @@ +import aktuelleZeitsperren from './Details/aktuelleZeitsperren.js'; + +export default { + name: 'ZeitsperrenMa', + components: { + aktuelleZeitsperren, + }, + data(){ + return { + arrayAllMa : [], + showAllCurrent: false, + showAllFix: false, + showAllLectors: false, + showSearchOes: false, + showAllAss: false, + showLectorsOe: false, + } + }, + methods: { + showTableAllCurrent(){ + this.showAllCurrent = !this.showAllCurrent; + this.showAllFix = false; + this.showAllLectors = false; + this.showSearchOes = false; + this.showAllAss = false; + this.showLectorsOe = false; + }, + showTableAllFix(){ + this.showAllFix = !this.showAllFix; + this.showAllCurrent = false; + this.showAllLectors = false; + this.showSearchOes = false; + this.showAllAss = false; + this.showLectorsOe = false; + }, + showTableAllLectors(){ + this.showAllLectors = !this.showAllLectors; + this.showAllFix = false; + this.showAllCurrent = false; + this.showSearchOes = false; + this.showAllAss = false; + this.showLectorsOe = false; + }, + showTableSearchOes(){ + this.showSearchOes = !this.showSearchOes; + this.showAllFix = false; + this.showAllCurrent = false; + this.showAllLectors = false; + this.showAllAss = false; + this.showLectorsOe = false; + }, + showTableAllAss(){ + this.showAllAss = !this.showAllAss; + this.showSearchOes = false; + this.showAllFix = false; + this.showAllCurrent = false; + this.showAllLectors = false; + this.showLectorsOe = false; + }, + showTableLectorsFromStg(){ + this.showLectorsOe = !this.showLectorsOe; + this.showSearchOes = false; + this.showAllFix = false; + this.showAllCurrent = false; + this.showAllAss = false; + this.showAllLectors = false; + }, + + }, + template: ` +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+ + `, +} \ No newline at end of file diff --git a/public/js/components/Vertraege/Vertragsverwaltung.js b/public/js/components/Vertraege/Vertragsverwaltung.js index afde796b2..15277e28c 100644 --- a/public/js/components/Vertraege/Vertragsverwaltung.js +++ b/public/js/components/Vertraege/Vertragsverwaltung.js @@ -4,6 +4,7 @@ import FhcHeader from "../DetailHeader/DetailHeader.js"; import VertraegeMitarbeiter from "./Vertraege.js"; import VerticalSplit from "../verticalsplit/verticalsplit.js"; import ApiCoreVertraege from '../../api/factory/vertraege/vertraege.js'; +import ZeitsperrenProv from '../Cis/ZeitsperrenMitarbeiter/ZeitsperrenMa.js'; export default { @@ -13,7 +14,8 @@ export default { VerticalSplit, MitarbeiterHeader, FhcHeader, - VertraegeMitarbeiter + VertraegeMitarbeiter, + ZeitsperrenProv }, props: { config: Object, @@ -50,6 +52,9 @@ export default { }, template: ` + + +
diff --git a/public/vendor b/public/vendor new file mode 120000 index 000000000..9c39cc9f8 --- /dev/null +++ b/public/vendor @@ -0,0 +1 @@ +../vendor \ No newline at end of file From 273f00a9c431b68525fb7205a1c3b0dee38a3b09 Mon Sep 17 00:00:00 2001 From: Johann Hoffmann Date: Fri, 24 Jul 2026 13:52:05 +0200 Subject: [PATCH 06/60] =?UTF-8?q?added=20accept/cancel=20prompt=20to=20Not?= =?UTF-8?q?envorschlag=20=C3=BCbernehmen=20spelling=20out=20the=20current?= =?UTF-8?q?=20benotungsdatum=20to=20the=20user;=20also=20added=20a=20list?= =?UTF-8?q?=20over=20all=20changedNoten=20which=20are=20about=20to=20be=20?= =?UTF-8?q?freigegeben=20in=20the=20Notenfreigabe=20Modal=20before=20enter?= =?UTF-8?q?ing=20a=20password;=20fixed/added=20some=20phrasen;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/api/frontend/v1/Noten.php | 5 +- .../Cis/Benotungstool/Benotungstool.js | 70 ++++++++- system/phrasesupdate.php | 140 ++++++++++++++++++ 3 files changed, 208 insertions(+), 7 deletions(-) diff --git a/application/controllers/api/frontend/v1/Noten.php b/application/controllers/api/frontend/v1/Noten.php index 98ba825a8..15ea11b11 100644 --- a/application/controllers/api/frontend/v1/Noten.php +++ b/application/controllers/api/frontend/v1/Noten.php @@ -68,7 +68,8 @@ class Noten extends FHCAPI_Controller 'person', 'benotungstool', 'lehre', - 'ui' + 'ui', + 'password' ]); $this->load->model('education/LePruefung_model', 'LePruefungModel'); @@ -461,7 +462,7 @@ class Noten extends FHCAPI_Controller } if(!$this->AuthLib->checkUserAuthByUsernamePassword(getAuthUID(), $result->password)->retval) { - $this->terminateWithError($this->p->t('global', 'wrongPassword'), 'general'); + $this->terminateWithError($this->p->t('password', 'wrongPassword'), 'general'); } $lv_id = $result->lv_id; diff --git a/public/js/components/Cis/Benotungstool/Benotungstool.js b/public/js/components/Cis/Benotungstool/Benotungstool.js index b0b5969b8..ecc1aaca6 100644 --- a/public/js/components/Cis/Benotungstool/Benotungstool.js +++ b/public/js/components/Cis/Benotungstool/Benotungstool.js @@ -1235,18 +1235,31 @@ export const Benotungstool = { return value }, - saveNote(e, cell) { // Notenvorschlag freigeben + async saveNote(e, cell) { // Notenvorschlag freigeben const row = cell.getRow() const data = row.getData() - + if(!data.note_vorschlag) return - + // if vorschlag is the same as lv_note do nothing if(data.note_vorschlag == data.lv_note) return - + // if the student already has pruefungen disable this part if(data.pruefungen.length) return - + + // confirm before writing the LV-Gesamtnote + const noteOption = this.notenOptions.find(opt => opt.note == data.note_vorschlag) + const noteBezeichnung = noteOption ? noteOption.bezeichnung : data.note_vorschlag + const dateLocale = this.$p.user_language?.value === 'English' ? 'en-GB' : 'de-AT' + const benotungsdatum = new Date().toLocaleString(dateLocale) + if(await this.$fhcAlert.confirm({ + message: this.$p.t('benotungstool/c4notenvorschlagUebernehmenConfirm', [noteBezeichnung, data.vorname, data.nachname, data.uid, benotungsdatum]), + acceptClass: 'p-button-primary', + rejectClass: 'p-button-secondary' + }) === false) { + return + } + this.loading = true this.$api.call(ApiNoten.saveNotenvorschlag(this.lv_id, this.sem_kurzbz, data.uid, data.note_vorschlag, data.punkte)) .then((res) => { @@ -2596,6 +2609,24 @@ export const Benotungstool = { }, []) : [] return cs }, + // Row-by-row preview for the Noten-freigeben modal, over the exact set that + // saveStudentenNoten will release (changedNoten). Freigabe releases the LV-Note + // (lv_note); it does not itself write the Zeugnisnote (that is a separate übernahme). + // So we compare what is currently on record in the Zeugnis (note) against the + // LV-Note that is about to be freigegeben (lv_note), and flag any mismatch. + freigabeSummary() { + return this.changedNoten.map(s => { + const zeugnisOpt = this.notenOptions?.find(opt => opt.note == s.note) + const releaseOpt = this.notenOptions?.find(opt => opt.note == s.lv_note) + return { + uid: s.uid, + name: `${s.vorname} ${s.nachname}`, + currentNote: zeugnisOpt ? zeugnisOpt.bezeichnung : (s.note || '—'), + releasedNote: releaseOpt ? releaseOpt.bezeichnung : (s.lv_note || '—'), + changed: (s.note ?? '') != (s.lv_note ?? '') + } + }) + }, getNotenfreigabeHinweistext() { return this.$capitalize(this.$p.t('benotungstool/notenfreigabeHinweistextv4')) }, @@ -2717,6 +2748,35 @@ export const Benotungstool = {
+
+
+
{{ $p.t('benotungstool/c4freigabeSummaryHeading', [freigabeSummary.length]) }}
+
+ + + + + + + + + + + + + + + + + +
{{ $capitalize($p.t('benotungstool/c4student')) }}{{ $capitalize($p.t('benotungstool/c4freigabeSummaryCurrent')) }}{{ $capitalize($p.t('benotungstool/c4freigabeSummaryReleased')) }}
{{ row.name }} ({{ row.uid }}){{ row.currentNote }}{{ row.releasedNote }}
+
+
{{ $p.t('benotungstool/c4freigabeSummaryLegend') }}
+
+
+
+
{{ $p.t('benotungstool/c4freigabeSummaryEmpty') }}
+
diff --git a/system/phrasesupdate.php b/system/phrasesupdate.php index b9269528d..c21b682ac 100644 --- a/system/phrasesupdate.php +++ b/system/phrasesupdate.php @@ -59704,6 +59704,146 @@ I have been informed that I am under no obligation to consent to the transmissio ) ) ), + array( + 'app' => 'core', + 'category' => 'benotungstool', + 'phrase' => 'c4notenvorschlagUebernehmenConfirm', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Möchten Sie wirklich die Note "{0}" für den Studenten {1} {2} ({3}) mit Benotungsdatum {4} eintragen?', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Do you really want to enter the grade "{0}" for the student {1} {2} ({3}) with grading date {4}?', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'benotungstool', + 'phrase' => 'c4freigabeSummaryHeading', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Folgende Noten werden freigegeben ({0}):', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'The following grades will be approved ({0}):', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'benotungstool', + 'phrase' => 'c4student', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Student', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Student', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'benotungstool', + 'phrase' => 'c4freigabeSummaryCurrent', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Bisher im Zeugnis', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Currently on transcript', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'benotungstool', + 'phrase' => 'c4freigabeSummaryReleased', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Wird freigegeben', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Will be approved', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'benotungstool', + 'phrase' => 'c4freigabeSummaryLegend', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Grün markierte Noten weichen von der bisher im Zeugnis eingetragenen Note ab.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Grades highlighted in green differ from the grade currently on the transcript.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'benotungstool', + 'phrase' => 'c4freigabeSummaryEmpty', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Es sind keine geänderten Noten zur Freigabe vorhanden.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'There are no changed grades to approve.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), array( 'app' => 'core', 'category' => 'benotungstool', From 9c7a85826b86aea5ecf698d4cab9f0a914fde5df Mon Sep 17 00:00:00 2001 From: ma0068 Date: Wed, 29 Jul 2026 17:52:23 +0200 Subject: [PATCH 07/60] refactor ZeitsperrenMa, rename aktuelleZeitsperren, add Phrases to ZeitsperrenMa --- ...elleZeitsperren.js => listsZeitsperren.js} | 3 +- .../ZeitsperrenMitarbeiter/ZeitsperrenMa.js | 153 +++++++----------- system/phrasesupdate.php | 123 ++++++++++++++ 3 files changed, 182 insertions(+), 97 deletions(-) rename public/js/components/Cis/ZeitsperrenMitarbeiter/Details/{aktuelleZeitsperren.js => listsZeitsperren.js} (99%) diff --git a/public/js/components/Cis/ZeitsperrenMitarbeiter/Details/aktuelleZeitsperren.js b/public/js/components/Cis/ZeitsperrenMitarbeiter/Details/listsZeitsperren.js similarity index 99% rename from public/js/components/Cis/ZeitsperrenMitarbeiter/Details/aktuelleZeitsperren.js rename to public/js/components/Cis/ZeitsperrenMitarbeiter/Details/listsZeitsperren.js index 65663184f..ab9dbdfcf 100644 --- a/public/js/components/Cis/ZeitsperrenMitarbeiter/Details/aktuelleZeitsperren.js +++ b/public/js/components/Cis/ZeitsperrenMitarbeiter/Details/listsZeitsperren.js @@ -5,7 +5,7 @@ import FormInput from "../../../Form/Input.js"; import PvAutoComplete from "../../../../../../index.ci.php/public/js/components/primevue/autocomplete/autocomplete.esm.min.js"; export default { - name: "aktuelleZeitsperren", + name: "listsTimelocks", components: { FormForm, FormInput, @@ -314,6 +314,7 @@ export default { }, template: `
+ type: {{type}}

Mitarbeiter*innen mit aktuellen Zeitsperren Fixangestellte aller fixer Lektoren diff --git a/public/js/components/Cis/ZeitsperrenMitarbeiter/ZeitsperrenMa.js b/public/js/components/Cis/ZeitsperrenMitarbeiter/ZeitsperrenMa.js index 3780cbfbe..b2e1b1234 100644 --- a/public/js/components/Cis/ZeitsperrenMitarbeiter/ZeitsperrenMa.js +++ b/public/js/components/Cis/ZeitsperrenMitarbeiter/ZeitsperrenMa.js @@ -1,115 +1,76 @@ -import aktuelleZeitsperren from './Details/aktuelleZeitsperren.js'; +import listsZeitsperren from './Details/listsZeitsperren.js' export default { name: 'ZeitsperrenMa', components: { - aktuelleZeitsperren, + listsZeitsperren, }, data(){ return { - arrayAllMa : [], - showAllCurrent: false, - showAllFix: false, - showAllLectors: false, - showSearchOes: false, - showAllAss: false, - showLectorsOe: false, + activeView: null, + buttons: [ + { + key: 'all', + title: 'zeitsperren/btn_all_current', + class: 'btn-primary' + }, + { + key: 'fix', + title: 'zeitsperren/btn_all_fix', + class: 'btn-secondary' + }, + { + key: 'lector', + title: 'zeitsperren/btn_fix_lect', + class: 'btn-secondary' + }, + { + key: 'oe', + title: 'zeitsperren/btn_oes', + class: 'btn-secondary' + }, + { + key: 'ass', + title: 'zeitsperren/btn_all_ass', + class: 'btn-secondary' + }, + { + key: 'lecStg', + title: 'zeitsperren/btn_lect_stg', + class: 'btn-secondary' + } + ], } }, methods: { - showTableAllCurrent(){ - this.showAllCurrent = !this.showAllCurrent; - this.showAllFix = false; - this.showAllLectors = false; - this.showSearchOes = false; - this.showAllAss = false; - this.showLectorsOe = false; + show(view) { + console.log(view); + this.activeView = this.activeView === view ? null : view; }, - showTableAllFix(){ - this.showAllFix = !this.showAllFix; - this.showAllCurrent = false; - this.showAllLectors = false; - this.showSearchOes = false; - this.showAllAss = false; - this.showLectorsOe = false; - }, - showTableAllLectors(){ - this.showAllLectors = !this.showAllLectors; - this.showAllFix = false; - this.showAllCurrent = false; - this.showSearchOes = false; - this.showAllAss = false; - this.showLectorsOe = false; - }, - showTableSearchOes(){ - this.showSearchOes = !this.showSearchOes; - this.showAllFix = false; - this.showAllCurrent = false; - this.showAllLectors = false; - this.showAllAss = false; - this.showLectorsOe = false; - }, - showTableAllAss(){ - this.showAllAss = !this.showAllAss; - this.showSearchOes = false; - this.showAllFix = false; - this.showAllCurrent = false; - this.showAllLectors = false; - this.showLectorsOe = false; - }, - showTableLectorsFromStg(){ - this.showLectorsOe = !this.showLectorsOe; - this.showSearchOes = false; - this.showAllFix = false; - this.showAllCurrent = false; - this.showAllAss = false; - this.showAllLectors = false; - }, - }, template: `
- +
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
+
+ +
- -
- -
-
- -
-
- -
-
- -
-
- -
-
- -
- + +
`, diff --git a/system/phrasesupdate.php b/system/phrasesupdate.php index 14be42163..e91ca3d71 100644 --- a/system/phrasesupdate.php +++ b/system/phrasesupdate.php @@ -60360,6 +60360,129 @@ I have been informed that I am under no obligation to consent to the transmissio ) ), // generic tabulator phrases END + // PHRASES ZEITSPERREN START + array( + 'app' => 'core', + 'category' => 'zeitsperren', + 'phrase' => 'btn_all_current', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Aktuelle Zeitsperren', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Current Timelocks', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'zeitsperren', + 'phrase' => 'btn_all_fix', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Alle Fixangestellten', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'All permanent employees', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'zeitsperren', + 'phrase' => 'btn_fix_lect', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Alle fixen Lektor*innen', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'All permanent lecturers', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'zeitsperren', + 'phrase' => 'btn_oes', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Nach Organisationseinheit', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'By organizational unit', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'zeitsperren', + 'phrase' => 'btn_all_ass', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Alle Assistent*innen', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'All Assistants', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'zeitsperren', + 'phrase' => 'btn_lect_stg', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Lektor*innen nach Stg', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Lecturers by study program', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + // PHRASES ZEITSPERREN END + ); From 55dc2b26d250da54fa61569a521f5e7e3c7c3333 Mon Sep 17 00:00:00 2001 From: ma0068 Date: Thu, 30 Jul 2026 11:01:23 +0200 Subject: [PATCH 08/60] refactor listsZeitsperren add phrases remove logs add watcher for type show table in view lectorStg and byOe only if selectedValue exists --- public/js/api/factory/zeitsperren.js | 2 - .../Details/listsZeitsperren.js | 284 +++++++++--------- .../ZeitsperrenMitarbeiter/ZeitsperrenMa.js | 4 +- system/phrasesupdate.php | 141 +++++++++ 4 files changed, 280 insertions(+), 151 deletions(-) diff --git a/public/js/api/factory/zeitsperren.js b/public/js/api/factory/zeitsperren.js index f6b9a9886..5fc8b9c20 100644 --- a/public/js/api/factory/zeitsperren.js +++ b/public/js/api/factory/zeitsperren.js @@ -46,7 +46,6 @@ export default { }; }, loadZeitsperrenAss(days) { - console.log("url: loadZeitsperrenAss"); return { method: 'get', url:'api/frontend/v1/MaZeitsperren/getZeitsperrenAss/' + days @@ -59,7 +58,6 @@ export default { }; }, loadZeitsperrenLectorStg(days,stg) { - console.log("url: loadZeitsperrenLectorStg"); return { method: 'get', url:'api/frontend/v1/MaZeitsperren/loadZeitsperrenLectorStg/' + days + '/' + stg, diff --git a/public/js/components/Cis/ZeitsperrenMitarbeiter/Details/listsZeitsperren.js b/public/js/components/Cis/ZeitsperrenMitarbeiter/Details/listsZeitsperren.js index ab9dbdfcf..f8f3b459d 100644 --- a/public/js/components/Cis/ZeitsperrenMitarbeiter/Details/listsZeitsperren.js +++ b/public/js/components/Cis/ZeitsperrenMitarbeiter/Details/listsZeitsperren.js @@ -39,7 +39,8 @@ export default { listStg: [], filteredStg: [], selectedStg: null, - studiengang_kz: null + studiengang_kz: null, + showTable: true } }, methods: { @@ -65,7 +66,6 @@ export default { }) }, loadZeitsperrenOE(days, oe){ - console.log("in function oe " + oe); this.$api .call(ApiMaTimelocks.loadAllZeitsperrenOE(days, oe)) .then(result => { @@ -73,7 +73,6 @@ export default { }) }, loadZeitsperrenAss(days){ - console.log("in ASS "); this.$api .call(ApiMaTimelocks.loadZeitsperrenAss(days)) .then(result => { @@ -81,7 +80,6 @@ export default { }) }, loadZeitsperrenStg(days, stg){ - console.log("in function stg " + stg); this.$api .call(ApiMaTimelocks.loadZeitsperrenLectorStg(days, stg)) .then(result => { @@ -112,6 +110,46 @@ export default { }; }); }, + createArrayMa(type){ + this.showTable = true; + if(type == "fix") { + this.loadZeitsperrenFixeMa(this.interval); + } + else if(type == "lector") { + this.loadZeitsperrenLector(this.interval); + } + else if(type == "oe") { + this.showTable = false; + this.selectedOe = null; + this.$api + .call(ApiMaTimelocks.getAllOes()) + .then(result => { + this.listOes = result.data; + + }) + .catch(this.$fhcAlert.handleSystemError); + } + else if(type == "lecStg") { + this.showTable = false; + this.selectedStg = null; + this.$api + .call(ApiMaTimelocks.getAllStg()) + .then(result => { + this.listStg = result.data; + }) + .catch(this.$fhcAlert.handleSystemError); + } + else if(type == "all") { + this.loadAllActiveZeitsperren(this.interval); + } + else if(type == "ass") { + this.loadZeitsperrenAss(this.interval); + } + else + { + this.$fhcAlert.alertError(this.$p.t('ui', 'error_fieldNotFound', {field: "Typ Zeitsperre"})); + } + }, isBlocked(mitarbeiter, tag) { if (!mitarbeiter?.sperren) { return false; @@ -193,7 +231,6 @@ export default { mitarbeiter() { const map = {}; - //vor version with uid in query this.arrayMaTimelocks.forEach(z => { if (!map[z.uid]) { map[z.uid] = []; @@ -213,32 +250,25 @@ export default { handler(newVal) { if (newVal) { if(this.type == "fix") { - console.log("fix"); this.loadZeitsperrenFixeMa(newVal); } else if(this.type == "lector") { - console.log("lector"); this.loadZeitsperrenLector(newVal); } else if(this.type == "oe") { - console.log("oe: in handler"); if(this.oe_kurzbz && this.oe_kurzbz !="") this.loadZeitsperrenOE(newVal, this.oe_kurzbz); - else - console.log("Bitte OE auswählen"); } else if(this.type == "all") { - console.log("all active"); this.loadAllActiveZeitsperren(newVal); } else if(this.type == "ass") { - console.log("ass"); this.loadZeitsperrenAss(newVal); } else { - console.log("not defined"); + this.$fhcAlert.alertError(this.$p.t('ui', 'error_fieldNotFound', {field: "Typ Zeitsperre"})); } this.createDays(); } @@ -250,8 +280,10 @@ export default { }, oe_kurzbz(newVal){ { - if(newVal != "") + if(newVal != "") { this.loadZeitsperrenOE(this.interval, newVal); + this.showTable = true; + } } }, selectedStg(newVal) { @@ -259,146 +291,103 @@ export default { }, studiengang_kz(newVal){ { - console.log("STG triggered"); - if(newVal != "") + if(newVal != "") { this.loadZeitsperrenStg(this.interval, newVal); + this.showTable = true; + } + } + }, + type(newVal){ + { + if(newVal != "") + this.createArrayMa(newVal); } }, }, created(){ - if(this.type == "fix") { - console.log("fix"); - this.loadZeitsperrenFixeMa(this.interval); - } - else if(this.type == "lector") { - console.log("lector"); - this.loadZeitsperrenLector(this.interval); - } - else if(this.type == "oe") { - console.log("oeg"); - this.$api - .call(ApiMaTimelocks.getAllOes()) - .then(result => { - this.listOes = result.data; -/* this.listStg = this.listOes.filter(item => - item.label.includes("[Studiengang]") && item.aktiv || item.label.includes("[Lehrgang]") && item.aktiv - );*/ - }) - .catch(this.$fhcAlert.handleSystemError); - } - else if(this.type == "lecStg") { - console.log("lecStg"); - this.$api - .call(ApiMaTimelocks.getAllStg()) - .then(result => { - this.listStg = result.data; - }) - .catch(this.$fhcAlert.handleSystemError); - } - else if(this.type == "all") { - console.log("all active"); - this.loadAllActiveZeitsperren(this.interval); - } - else if(this.type == "ass") { - console.log("ass"); - this.loadZeitsperrenAss(this.interval); - } - else - { - console.log("not defined"); - } + this.createArrayMa(this.type); this.createDays(); - -/* console.log('adams, 2026-07-16'); - console.log(this.isBlocked('adams', '2026-07-16'));*/ }, template: `
- type: {{type}} -

Mitarbeiter*innen mit aktuellen Zeitsperren - Fixangestellte - aller fixer Lektoren - nach Organisationseinheit {{ mondayBeforeToday.toLocaleDateString('de-AT') }} - {{endInterval}} -

-
- - - - -
- - - - - -
- - -
- - - - - -
-
- -
count results: {{mitarbeiter.length}}, days: {{interval}} -
type: {{type}}
{{oe_kurzbz}} | {{selectedOe}} -
{{selectedStg}} | {{studiengang_kz}} - {{arrayMaTimelocks[0]}} -
- - - - +

{{$p.t('zeitsperren/title_allMa')}} + {{$p.t('zeitsperren/title_fix')}} + {{$p.t('zeitsperren/title_fixLecturers')}} + {{$p.t('zeitsperren/title_byOe')}} + {{$p.t('zeitsperren/title_lectStg')}} + {{$p.t('zeitsperren/title_ass')}} + {{ mondayBeforeToday.toLocaleDateString('de-AT') }} - {{endInterval}} +

+
+ + + +
+ + + + + +
+ + +
+ + + + + +
+
+ +
@@ -434,9 +423,10 @@ export default {
UID
- - +
+

Keine Einträge vorhanden

+
`, } \ No newline at end of file diff --git a/public/js/components/Cis/ZeitsperrenMitarbeiter/ZeitsperrenMa.js b/public/js/components/Cis/ZeitsperrenMitarbeiter/ZeitsperrenMa.js index b2e1b1234..29561cf9e 100644 --- a/public/js/components/Cis/ZeitsperrenMitarbeiter/ZeitsperrenMa.js +++ b/public/js/components/Cis/ZeitsperrenMitarbeiter/ZeitsperrenMa.js @@ -44,9 +44,8 @@ export default { }, methods: { show(view) { - console.log(view); this.activeView = this.activeView === view ? null : view; - }, + } }, template: `
@@ -69,6 +68,7 @@ export default {
diff --git a/system/phrasesupdate.php b/system/phrasesupdate.php index e91ca3d71..6ca838a6b 100644 --- a/system/phrasesupdate.php +++ b/system/phrasesupdate.php @@ -60481,6 +60481,147 @@ I have been informed that I am under no obligation to consent to the transmissio ) ) ), + array( + 'app' => 'core', + 'category' => 'zeitsperren', + 'phrase' => 'title_allMa', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Mitarbeiter*innen mit aktuellen Zeitsperren', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Employees with current time blocks', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'zeitsperren', + 'phrase' => 'title_fix', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Zeitsperren Fixangestellte', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Time blocks permanent employees', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'zeitsperren', + 'phrase' => 'title_fixLecturers', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Zeitsperren aller fixer Lektoren', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Time blocks for all regular lecturers', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'zeitsperren', + 'phrase' => 'title_byOe', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Zeitsperren nach Organisationseinheit', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Time blocks by organizational unit', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'zeitsperren', + 'phrase' => 'title_lectStg', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Zeitsperren Lektor*innen nach Studiengang', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Time blocks lecturers by study program', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'zeitsperren', + 'phrase' => 'title_ass', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Zeitsperren aller Assistent*innen', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Time blocks for all assistants', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'zeitsperren', + 'phrase' => 'lektor_innen', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Lektor*innen', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Lecturers', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + // PHRASES ZEITSPERREN END ); From 85fcecdcf6bbff65d16ed861c32acd92b7f00bf2 Mon Sep 17 00:00:00 2001 From: ma0068 Date: Thu, 30 Jul 2026 11:24:25 +0200 Subject: [PATCH 09/60] add type all to validator --- .../Cis/ZeitsperrenMitarbeiter/Details/listsZeitsperren.js | 1 + 1 file changed, 1 insertion(+) diff --git a/public/js/components/Cis/ZeitsperrenMitarbeiter/Details/listsZeitsperren.js b/public/js/components/Cis/ZeitsperrenMitarbeiter/Details/listsZeitsperren.js index f8f3b459d..cde46e0d8 100644 --- a/public/js/components/Cis/ZeitsperrenMitarbeiter/Details/listsZeitsperren.js +++ b/public/js/components/Cis/ZeitsperrenMitarbeiter/Details/listsZeitsperren.js @@ -18,6 +18,7 @@ export default { default: 'all', validator(value) { return [ + 'all', 'fix', 'lector', 'oe', From 96b4210d218bddb2359d99e6bcb2b54703522c29 Mon Sep 17 00:00:00 2001 From: ma0068 Date: Thu, 30 Jul 2026 15:29:15 +0200 Subject: [PATCH 10/60] refactor backend css file for showing inactive items in dropdown fix missing view for vertretung zeitsperren sticky top for header --- .../api/frontend/v1/MaZeitsperren.php | 24 ++- .../models/ressource/Zeitsperre_model.php | 71 +------- application/views/Vertragsverwaltung.php | 5 +- public/css/components/Zeitsperren.css | 3 + .../Details/listsZeitsperren.js | 154 +++++++++--------- system/phrasesupdate.php | 41 ++++- 6 files changed, 146 insertions(+), 152 deletions(-) create mode 100644 public/css/components/Zeitsperren.css diff --git a/application/controllers/api/frontend/v1/MaZeitsperren.php b/application/controllers/api/frontend/v1/MaZeitsperren.php index 93c0f619f..796adc40c 100644 --- a/application/controllers/api/frontend/v1/MaZeitsperren.php +++ b/application/controllers/api/frontend/v1/MaZeitsperren.php @@ -34,7 +34,6 @@ class MaZeitsperren extends FHCAPI_Controller //only user with zeitsperren public function getAllActiveZeitsperren($days) { - $result = $this->ZeitsperreModel->getZeitsperrenForNextDays($days); $data = $this->getDataOrTerminateWithError($result); @@ -45,7 +44,9 @@ class MaZeitsperren extends FHCAPI_Controller //all fixe Ma public function getAllZeitsperrenFixeMa($days) { - $result = $this->ZeitsperreModel->getMitarbeiterWithZeitsperren($days, true, false, null, false); + $von = date('Y-m-d'); + $bis = date('Y-m-d', strtotime("+{$days} days")); + $result = $this->ZeitsperreModel->getMitarbeiterWithZeitsperren($von, $bis, true, false, null, false); $data = $this->getDataOrTerminateWithError($result); $this->terminateWithSuccess($data); @@ -53,7 +54,9 @@ class MaZeitsperren extends FHCAPI_Controller public function getAllZeitsperrenLector($days) { - $result = $this->ZeitsperreModel->getMitarbeiterWithZeitsperren($days, false, true, null, false); + $von = date('Y-m-d'); + $bis = date('Y-m-d', strtotime("+{$days} days")); + $result = $this->ZeitsperreModel->getMitarbeiterWithZeitsperren($von, $bis, false, true, null, false); $data = $this->getDataOrTerminateWithError($result); @@ -62,7 +65,9 @@ class MaZeitsperren extends FHCAPI_Controller public function getZeitsperrenAss($days) { - $result = $this->ZeitsperreModel->getMitarbeiterWithZeitsperren($days, false, false, null, true); + $von = date('Y-m-d'); + $bis = date('Y-m-d', strtotime("+{$days} days")); + $result = $this->ZeitsperreModel->getMitarbeiterWithZeitsperren($von, $bis, false, false, null, true); $data = $this->getDataOrTerminateWithError($result); @@ -71,7 +76,9 @@ class MaZeitsperren extends FHCAPI_Controller public function getAllZeitsperrenOes($days, $oe) { - $result = $this->ZeitsperreModel->getMitarbeiterWithZeitsperren($days, false, false, $oe, false); + $von = date('Y-m-d'); + $bis = date('Y-m-d', strtotime("+{$days} days")); + $result = $this->ZeitsperreModel->getMitarbeiterWithZeitsperren($von, $bis, false, false, $oe, false); $data = $this->getDataOrTerminateWithError($result); @@ -80,7 +87,9 @@ class MaZeitsperren extends FHCAPI_Controller public function loadZeitsperrenLectorStg($days, $stg) { - $result = $this->ZeitsperreModel->getMitarbeiterWithZeitsperren($days, false, true, false, false, $stg); + $von = date('Y-m-d'); + $bis = date('Y-m-d', strtotime("+{$days} days")); + $result = $this->ZeitsperreModel->getMitarbeiterWithZeitsperren($von, $bis, false, true, false, false, $stg); $data = $this->getDataOrTerminateWithError($result); @@ -96,7 +105,8 @@ class MaZeitsperren extends FHCAPI_Controller kurzbzlang, typ, bezeichnung, - kurzbzlang || ' (' || bezeichnung || ')' AS label + kurzbzlang || ' (' || bezeichnung || ')' AS label, + aktiv from public.tbl_studiengang where diff --git a/application/models/ressource/Zeitsperre_model.php b/application/models/ressource/Zeitsperre_model.php index 4a6bc330e..4ecd1bda8 100644 --- a/application/models/ressource/Zeitsperre_model.php +++ b/application/models/ressource/Zeitsperre_model.php @@ -67,13 +67,11 @@ class Zeitsperre_model extends DB_Model * @ days: count of the intervall of the next days * @return array */ - public function getZeitsperrenForNextDays($days, $type=null) + public function getZeitsperrenForNextDays($days) { - //TODO(Manu) von und bis im controller berechnen und hier übergeben $von = date('Y-m-d'); $bis = date('Y-m-d', strtotime("+{$days} days")); - //version with campus.vw_mitarbeiter $paramsArray = [$von, $bis, $bis, $von]; $qry = "select nachname, @@ -82,85 +80,28 @@ class Zeitsperre_model extends DB_Model vondatum, bisdatum, vertretung_uid, - erreichbarkeit_kurzbz - --lektor, - -- fixangestellt + erreichbarkeit_kurzbz, + mv.kurzbz from campus.vw_mitarbeiter join campus.tbl_zeitsperre on (uid = mitarbeiter_uid) + left join public.tbl_mitarbeiter mv on campus.tbl_zeitsperre.vertretung_uid = mv.mitarbeiter_uid where ((? <= bisdatum and ? >= bisdatum) or (?>= vondatum and ? <= vondatum)) + order by nachname "; - //fix, lektor etc just with all ma - if($type=="fix") { - $qry.= "AND fixangestellt = true"; - } - if($type=="lector") { - $qry.= "AND lektor = true"; - } - - //wenn lektor= WAHR und fixangestellt = Falsch: --> externer Lektor - - $qry .= " order by nachname"; - - - - //version with hr.dienstverhaeltnis -/* $paramsArray = [$bis, $von]; - $qry = " - SELECT - ps.nachname, - ps.vorname, - zs.mitarbeiter_uid, - zs.vondatum, - zs.bisdatum, - zs.vertretung_uid, - zs.erreichbarkeit_kurzbz - FROM campus.tbl_zeitsperre zs - JOIN public.tbl_benutzer bn - ON bn.uid = zs.mitarbeiter_uid - JOIN public.tbl_person ps - ON ps.person_id = bn.person_id "; - - if ($type != null) { - $qry.= " JOIN hr.tbl_dienstverhaeltnis dv on bn.uid = dv.mitarbeiter_uid "; - } - - $qry.=" - WHERE - zs.vondatum <= ? - AND zs.bisdatum >= ?"; - - if($type=="fix") { - $qry.= " and dv.vertragsart_kurzbz = 'echterdv'"; - } - if($type=="lector") { - $qry.= " and dv.vertragsart_kurzbz in ('externerlehrender', 'gastlektor')"; - } - - $qry .= " - ORDER BY - ps.nachname, - ps.vorname, - zs.vondatum;";*/ - $result = $this->execQuery($qry, $paramsArray); return $result; } - public function getMitarbeiterWithZeitsperren($days, $fix=false, $lector=false, $oe=null, $ass=false, $stg=false) + public function getMitarbeiterWithZeitsperren($von, $bis, $fix=false, $lector=false, $oe=null, $ass=false, $stg=false) { - //TODO(Manu) von und bis im controller berechnen und hier übergeben - $von = date('Y-m-d'); - $bis = date('Y-m-d', strtotime("+{$days} days")); - - //version with campus.vw_mitarbeiter $paramsArray = [$von, $bis, $bis, $von]; if($oe) diff --git a/application/views/Vertragsverwaltung.php b/application/views/Vertragsverwaltung.php index 8fa6dff27..4a73be945 100644 --- a/application/views/Vertragsverwaltung.php +++ b/application/views/Vertragsverwaltung.php @@ -12,13 +12,14 @@ $includesArray = array( 'tinymce5' => true, 'phrases' => array( 'global', - 'ui', + 'ui' ), 'customCSSs' => [ 'public/css/components/vue-datepicker.css', 'public/css/components/primevue.css', 'public/css/Vertragsverwaltung.css', - 'public/css/components/Detailheader.css' + 'public/css/components/Detailheader.css', + 'public/css/components/Zeitsperren.css' ], 'customJSs' => [ #'vendor/npm-asset/primevue/tree/tree.min.js', diff --git a/public/css/components/Zeitsperren.css b/public/css/components/Zeitsperren.css new file mode 100644 index 000000000..3e5e4a632 --- /dev/null +++ b/public/css/components/Zeitsperren.css @@ -0,0 +1,3 @@ +.item-inactive { + text-decoration: line-through; +} \ No newline at end of file diff --git a/public/js/components/Cis/ZeitsperrenMitarbeiter/Details/listsZeitsperren.js b/public/js/components/Cis/ZeitsperrenMitarbeiter/Details/listsZeitsperren.js index cde46e0d8..fc3687853 100644 --- a/public/js/components/Cis/ZeitsperrenMitarbeiter/Details/listsZeitsperren.js +++ b/public/js/components/Cis/ZeitsperrenMitarbeiter/Details/listsZeitsperren.js @@ -311,80 +311,81 @@ export default { }, template: `
-

{{$p.t('zeitsperren/title_allMa')}} - {{$p.t('zeitsperren/title_fix')}} - {{$p.t('zeitsperren/title_fixLecturers')}} - {{$p.t('zeitsperren/title_byOe')}} - {{$p.t('zeitsperren/title_lectStg')}} - {{$p.t('zeitsperren/title_ass')}} - {{ mondayBeforeToday.toLocaleDateString('de-AT') }} - {{endInterval}} -

-
- - - -
- - - - - -
- - -
- - - - - +
+

{{$p.t('zeitsperren/title_allMa')}} + {{$p.t('zeitsperren/title_fix')}} + {{$p.t('zeitsperren/title_fixLecturers')}} + {{$p.t('zeitsperren/title_byOe')}} + {{$p.t('zeitsperren/title_lectStg')}} + {{$p.t('zeitsperren/title_ass')}} + {{ mondayBeforeToday.toLocaleDateString('de-AT') }} - {{endInterval}} +

+
+ + + +
+ + + + + +
+ +
+ + + + + +
@@ -404,14 +405,13 @@ export default { :key="m.uid" > {{m.sperren[0].nachname}} {{m.sperren[0].vorname}} - + + +
`, } \ No newline at end of file diff --git a/system/phrasesupdate.php b/system/phrasesupdate.php index 192d1df47..c03b43636 100644 --- a/system/phrasesupdate.php +++ b/system/phrasesupdate.php @@ -60661,6 +60661,26 @@ I have been informed that I am under no obligation to consent to the transmissio ) ) ), + array( + 'app' => 'core', + 'category' => 'zeitsperren', + 'phrase' => 'zeitsperrenVon', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Zeitsperren von', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Timelocks from', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), // PHRASES ZEITSPERREN END ); From 13515b25227ab2a4c6c8857ee7a6b490881f8bca Mon Sep 17 00:00:00 2001 From: chfhtw Date: Tue, 4 Aug 2026 09:40:47 +0200 Subject: [PATCH 15/60] Bugfix: after a drag and drop operation, when resizing the page, the moved item stays in the drop position even if it has had a position in the resized layout --- public/js/components/Drop/Grid.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/public/js/components/Drop/Grid.js b/public/js/components/Drop/Grid.js index 6d9777c28..d4d8c90d9 100644 --- a/public/js/components/Drop/Grid.js +++ b/public/js/components/Drop/Grid.js @@ -233,6 +233,14 @@ export default { if (value == oldValue) return; + /** NOTE(chris): tempPositionUpdates depends on the cols not to be + * updated. So if they do we reset to get a clean value. In case the + * drag and drop operation is still motion on the next tick this + * will be correct again and if there is no such operation we clean + * up. + */ + this.tempPositionUpdates = null; + this.reinitGrid(); }, rows: { @@ -246,6 +254,11 @@ export default { }, indexedItems: { handler(value) { + /** NOTE(chris): there was an update of the items, so in case + * it came from a drag and drop operation we need to clean up + */ + this.tempPositionUpdates = null; + this.reinitGrid(); }, immediate: true, From d824334538bd7f12df511413c601a18c6906196c Mon Sep 17 00:00:00 2001 From: chfhtw Date: Tue, 4 Aug 2026 09:42:18 +0200 Subject: [PATCH 16/60] Hiding widgets should look like deleting and recovering is moved to the "add widget" popup --- public/js/components/Dashboard/Dashboard.js | 43 +++++++++++---- public/js/components/Dashboard/Item.js | 28 +--------- public/js/components/Dashboard/Section.js | 19 +------ .../js/components/Dashboard/Widget/Picker.js | 55 +++++++++++++++---- 4 files changed, 81 insertions(+), 64 deletions(-) diff --git a/public/js/components/Dashboard/Dashboard.js b/public/js/components/Dashboard/Dashboard.js index bf45ad227..b88765856 100644 --- a/public/js/components/Dashboard/Dashboard.js +++ b/public/js/components/Dashboard/Dashboard.js @@ -62,6 +62,9 @@ export default { return [type, result]; })); + }, + hiddenWidgets() { + return this.widgets.filter(widget => widget.hidden); } }, methods: { @@ -69,6 +72,9 @@ export default { this.$refs.widgetpicker .getWidget() .then(widget_id => { + if (widget_id.hidden) + this.widgetUnhide(widget_id, widget.place); + widget.widget = widget_id; // NOTE(chris): min size widget.place = Object.fromEntries(Object.entries(widget.place).map(([key, value]) => { @@ -131,17 +137,6 @@ export default { this.widgets.forEach((widget, i) => { if (failed.includes(widget.id)) { this.widgets[i] = structuredClone(ObjectUtils.deepToRaw(this.originalWidgets[widget.id])); - /** NOTE(chris): if you wanna hide or unhide a - * preset and it fails: switch around the hidden - * value to revert it properly (checkboxes can't - * really handle it otherwise) - */ - if (payload[widget.id].hidden !== undefined) { - this.widgets[i].hidden = payload[widget.id].hidden; - this.$nextTick(() => { - this.widgets[i] = structuredClone(ObjectUtils.deepToRaw(this.originalWidgets[widget.id])); - }); - } } else if (payload[widget.id]) { payload[widget.id].id = widget.id; payload[widget.id].index = widget.index; @@ -160,6 +155,31 @@ export default { }) .catch(this.$fhcAlert.handleSystemError); }, + widgetUnhide(orig, place) { + place = Object.fromEntries(Object.entries(place).map(([key, value]) => { + value.w = this.sizeLimits[orig.widget].width.min; + value.h = this.sizeLimits[orig.widget].height.min; + // NOTE(chris): unpin pinned widgets because they got a new placement + value.pinned = false; + return [key, value]; + })); + // NOTE(chris): clear placement in other screensizes + Object.keys(orig.place).forEach(key => { + if (!place[key]) { + place[key] = { + x: undefined, + y: undefined, + pinned: false + }; + } + }); + let payload = {}; + payload[orig.id] = { + hidden: false, + place + }; + return this.widgetUpdate(payload); + }, async fetchViewData() { let viewDataResult = await this.$api.call(ApiDashboard.getViewData()); const viewData = viewDataResult.data; @@ -223,6 +243,7 @@ export default {
` } diff --git a/public/js/components/Dashboard/Item.js b/public/js/components/Dashboard/Item.js index 20c195178..160b34e91 100644 --- a/public/js/components/Dashboard/Item.js +++ b/public/js/components/Dashboard/Item.js @@ -36,7 +36,6 @@ export default { "width", "height", "custom", - "hidden", "editMode", "loading", // widget got added and is waiting for backend to save in db "item_data", @@ -115,14 +114,6 @@ export default { }, ready() { return this.component && this.arguments !== null; - }, - visible: { - get() { - return !this.hidden; - }, - set(value) { - this.$emit('remove', this.hidden); - } } }, methods: { @@ -202,10 +193,8 @@ export default { }, template: /*html*/ `
@@ -299,9 +288,9 @@ export default { > - + - - -
- -
-
diff --git a/public/js/components/Dashboard/Section.js b/public/js/components/Dashboard/Section.js index e8c56fd4e..b55eefb7a 100644 --- a/public/js/components/Dashboard/Section.js +++ b/public/js/components/Dashboard/Section.js @@ -124,8 +124,6 @@ export default { return { ...item, ...placement, weight }; }); - if (this.editModeIsActive) - return placedItems; return placedItems.filter(item => !item.hidden); } }, @@ -141,22 +139,12 @@ export default { handleConfigClosed() { this.configOpened = false }, - removeWidget(item, revert) { + removeWidget(item) { if (item.custom) { BsConfirm.popup(this.$p.t('dashboard', 'alert_deleteWidget')).then(() => this.$emit('widgetRemove', item.id, this.name)); } else { let update = {}; - update[item.id] = { hidden: !revert }; - - if (!revert) { - // NOTE(chris): move to last line - update[item.id].place = []; - let y = this.gridHeight; - if (this.additionalRow) - y--; - update[item.id].place[this.gridWidth] = { x: 0, y }; - } - + update[item.id] = { hidden: true }; this.updatePreset(update); } }, @@ -251,13 +239,12 @@ export default { :loading="item.loading" :config="item.config" :custom="item.custom" - :hidden="item.hidden" :editMode="editModeIsActive" :place="item.place[gridWidth]" :widget-template="indexedWidgetsTemplates[item.widget]" :source="adminMode ? null : item.source || 'custom'" @change="saveConfig($event, item)" - @remove="removeWidget(item, $event)" + @remove="removeWidget(item)" @config-opened="handleConfigOpened" @config-closed="handleConfigClosed" @pin-item="updatePositions" diff --git a/public/js/components/Dashboard/Widget/Picker.js b/public/js/components/Dashboard/Widget/Picker.js index 260b95874..284eb551c 100644 --- a/public/js/components/Dashboard/Widget/Picker.js +++ b/public/js/components/Dashboard/Widget/Picker.js @@ -7,11 +7,21 @@ export default { WidgetIcon, }, props: [ - "widgets" + "widgets", + "hiddenWidgets" ], data: () => ({ callbacks: {} }), + computed: { + hasAnyWidgets() { + if (!this.widgets) + return false; + if (!this.widgets.length && !this.hiddenWidgets?.length) + return false; + return true; + } + }, methods: { getWidget() { return new Promise((resolve,reject) => { @@ -37,23 +47,46 @@ export default {
From 2f86a1b0171738b58baef4a114d82115e1967cb3 Mon Sep 17 00:00:00 2001 From: chfhtw Date: Tue, 4 Aug 2026 10:46:08 +0200 Subject: [PATCH 18/60] Changing the edit button into a toggle --- public/js/components/Dashboard/Dashboard.js | 26 ++++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/public/js/components/Dashboard/Dashboard.js b/public/js/components/Dashboard/Dashboard.js index b88765856..e390f16d4 100644 --- a/public/js/components/Dashboard/Dashboard.js +++ b/public/js/components/Dashboard/Dashboard.js @@ -224,15 +224,23 @@ export default { }, template: /* html */`
-

- {{ userFirstName ? $p.t('global/personalGreeting', [ userFirstName ]) : '' }} - -

+
+

+ {{ userFirstName ? $p.t('global/personalGreeting', [ userFirstName ]) : '' }} +

+
+ + +
+
Date: Tue, 4 Aug 2026 14:05:17 +0200 Subject: [PATCH 19/60] finalizing current state of benotungstool api testsuite before touching implementation again; access suite and pruefungstermin suite have a few known failing tests, rest should run fine on a freshly resetted database! reset with 'php system/setup_testinstance.php --setup' for wrapped db setup routine; --- .../controllers/api/frontend/v1/Noten.php | 11 +- package.json | 4 +- .../Cis/Benotungstool/Benotungstool.js | 79 +--------- .../e2e/specs/api/noten/noten.access.cy.js | 147 ++++++++---------- .../e2e/specs/api/noten/noten.bulk.cy.js | 15 -- .../e2e/specs/api/noten/noten.freigabe.cy.js | 15 -- .../e2e/specs/api/noten/noten.frist.cy.js | 12 +- .../api/noten/noten.notenschluessel.cy.js | 15 -- .../api/noten/noten.notenvorschlag.cy.js | 32 ++-- .../api/noten/noten.pruefungsantritte.cy.js | 48 ++++++ .../api/noten/noten.pruefungstermin.cy.js | 113 +++++++++++++- .../api/noten/noten.ueberschreiben.cy.js | 46 ++++++ tests/cypress/support/api/notenApi.js | 7 +- tests/cypress/support/helpers/notenErrors.js | 8 + .../cypress/support/helpers/notenScenario.js | 10 +- .../cypress/support/helpers/notenTestData.js | 11 +- tests/cypress/tasks/db.js | 6 - 17 files changed, 320 insertions(+), 259 deletions(-) diff --git a/application/controllers/api/frontend/v1/Noten.php b/application/controllers/api/frontend/v1/Noten.php index 051257e44..48aed07f2 100644 --- a/application/controllers/api/frontend/v1/Noten.php +++ b/application/controllers/api/frontend/v1/Noten.php @@ -750,6 +750,10 @@ class Noten extends FHCAPI_Controller $origLvNote = null; $origLvPunkte = null; $origBenotungsdatum = null; + +// // TODO: check if savePruefungstermin wont throw an error before updating/inserting lv note +// if(!$this->ableToSavePruefungstermin($typ)) + if(!isError($result) && !hasData($result)) { $id = $this->LvgesamtnoteModel->insert( @@ -840,6 +844,7 @@ class Noten extends FHCAPI_Controller $status = []; + // TODO: config here? // send $grades reference to moodle addon Events::trigger( 'getEntschuldigungsStatusForStudentOnDate', @@ -1000,7 +1005,8 @@ 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); @@ -1060,7 +1066,8 @@ class Noten extends FHCAPI_Controller $this->logLib->logInfoDB(array('termin3 inserted',$res, getAuthUID(), getAuthPersonId())); } - } else { + } + else { $this->terminateWithError($this->p->t('benotungstool', 'wrongPruefungType', [$student_uid, $typ]), 'general'); } diff --git a/package.json b/package.json index 0ec784752..5337577d4 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,8 @@ "scripts": { "cy:open": "cypress open", "cy:run": "cypress run", - "cy:noten": "cypress run --spec \"tests/cypress/e2e/specs/api/noten/**/*.cy.js\"", - "cy:noten:p0": "cypress run --spec \"tests/cypress/e2e/specs/api/noten/noten.pruefungsantritte.cy.js,tests/cypress/e2e/specs/api/noten/noten.frist.cy.js,tests/cypress/e2e/specs/api/noten/noten.notenschluessel.cy.js\"", + "cy:noten": "cypress run --spec \"tests/cypress/e2e/specs/**/*.cy.js\"", + "cy:noten:p0": "cypress run --spec \"tests/cypress/e2e/specs/unit/notenRules.cy.js,tests/cypress/e2e/specs/api/noten/noten.pruefungsantritte.cy.js,tests/cypress/e2e/specs/api/noten/noten.pruefungstermin.cy.js,tests/cypress/e2e/specs/api/noten/noten.ueberschreiben.cy.js\"", "noten:check": "node tests/cypress/tools/dbCheck.js noten" }, "dependencies": { diff --git a/public/js/components/Cis/Benotungstool/Benotungstool.js b/public/js/components/Cis/Benotungstool/Benotungstool.js index ecc1aaca6..2399d8090 100644 --- a/public/js/components/Cis/Benotungstool/Benotungstool.js +++ b/public/js/components/Cis/Benotungstool/Benotungstool.js @@ -10,6 +10,7 @@ import NotenlisteLinks from "./NotenlisteLinks.js"; import FhcOverlay from "../../Overlay/FhcOverlay.js"; import {debounce} from "../../../helpers/debounce.js"; import {centeredTextFormatter} from "../../../tabulator/formatter/centered.js"; +import * as NotenRules from "./notenRules.js"; export const Benotungstool = { name: "Benotungstool", @@ -1185,15 +1186,8 @@ export const Benotungstool = { const [hour, minute, second] = timePart.split(":").map(Number); return new Date(year, month - 1, day, hour, minute, second); }, - checkFreigabe(freigabedatum, benotungsdatum, uid) { - if(!freigabedatum) { - // check for change -> set freigabe to 'changed' on change - return 'offen' - } else if(benotungsdatum > freigabedatum) { - return 'changed' - } else { - return 'ok' - } + checkFreigabe(freigabedatum, benotungsdatum) { + return NotenRules.checkFreigabe(freigabedatum, benotungsdatum) }, unselectableFormatter(row) { @@ -2008,23 +2002,7 @@ export const Benotungstool = { return option.infoString }, getPruefungstypForStudentByAntritt(student) { - // Determine the next in-tool pruefungstyp when adding a new attempt. The order of retake - // types is driven by config so each installation keeps its own ruleset: - // - Termin1 (the original Zeugnisnote) is attempt 1 and is not offered here. - // - each enabled retake type is the next offered attempt: Termin2, then legacy Termin3. - // - kommPruef is the terminal attempt and is entered elsewhere, so it is never offered here. - // Our installation enables only Termin2 (Termin3 is legacy for other unis), so every offered - // retake resolves to Termin2 - which is also why Termin2 can occur twice: the excused Termin - // does not increment hoechsterAntritt (see getAntrittCountStudent), so the next offered attempt - // stays Termin2 until a counting Termin2 exists. - const retakeTypes = [] - if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_TERMIN2) retakeTypes.push("Termin2") - if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_TERMIN3) retakeTypes.push("Termin3") - - // hoechsterAntritt is the number of counting attempts already taken (>=1 once an original - // note exists). The next retake sits at index hoechsterAntritt-1 (attempt 1 -> first retake). - const idx = Math.max(0, student.hoechsterAntritt - 1) - return retakeTypes[idx] ?? "" + return NotenRules.pruefungstypForAntritt(student, this.config) }, savePruefungEingabe() { // keep the date within the neighbouring exam dates (a typed value can bypass the picker bounds) @@ -2399,41 +2377,7 @@ export const Benotungstool = { }).finally(()=> this.loading = false) }, getAntrittCountStudent(student) { - // Number of counting Prüfungsantritte the student has taken. Counting is by NOTE, not by - // pruefungstyp: any note except the "antrittslose" ones (entschuldigt & noch nicht eingetragen, - // see NOTEN_OHNE_ANTRITT) counts as an attempt. That is exactly what makes an excused Termin2 - // not consume an attempt, so Termin2 can be entered a second time. - // - // Antritt numbering in our installation: 1 = original note, 2 = Termin2 (may be entered twice, - // one excused), terminal = kommPruef. (Termin3 is a legacy retake for other installations and - // is simply counted like any other pruefung when enabled.) - // - // A present kommPruef is terminal: return a sentinel (4) that is >= any maxAntrittCount, so no - // further attempts can be added here regardless of installation. - if(student['kommPruef']) return 4 - - let pruefungsAntrittCount = 0 - const pLen = student.pruefungen.length - 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++ - } - - // when student never had to take an exam beyond the original benotung - // aka pruefungsantritt (even though it does not have to have pruefungscharacter) - // it still counts as an antritt, except it is coming from a notenOption like "angerechnet" - // which indicates no participation at all - if(pruefungsAntrittCount === 0 && student.note){ - const noteOption = this.notenOptions.find(note => note.note == student.note) - - if(noteOption.lehre) return 1 - else return 0 - } - - return pruefungsAntrittCount + return NotenRules.antrittCountStudent(student, this.config, this.notenOptions) } }, watch: { @@ -2533,18 +2477,7 @@ export const Benotungstool = { + ': ' + (this.studenten?.length || 0) + ''; }, maxAntrittCount() { - // Maximum number of counting Prüfungsantritte enterable IN THIS TOOL: the original note - // (always 1) plus each enabled in-tool retake type. kommPruef is the terminal attempt and is - // entered elsewhere, so it is intentionally NOT part of this cap. For our installation - // (Termin2 on, Termin3 off) this is 2 (original + Termin2); Termin3 is legacy for other unis. - // Excused Termine do not count (see getAntrittCountStudent), which is what lets Termin2 occur - // twice within this cap. - let maxAntritte = 1; - - if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_TERMIN2) maxAntritte++ - if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_TERMIN3) maxAntritte++ - - return maxAntritte; + return NotenRules.maxAntrittCount(this.config) }, getFreigabeCounter() { return this.studenten ? this.studenten.reduce((acc, cur) => { diff --git a/tests/cypress/e2e/specs/api/noten/noten.access.cy.js b/tests/cypress/e2e/specs/api/noten/noten.access.cy.js index 4f1e368fc..5ea5ca4ea 100644 --- a/tests/cypress/e2e/specs/api/noten/noten.access.cy.js +++ b/tests/cypress/e2e/specs/api/noten/noten.access.cy.js @@ -37,30 +37,19 @@ describe("Noten API - access control", () => { }); }); - describe("authentication", () => { - it("rejects an unauthenticated request", () => { - // Without this the auth is still active from last login - cy.clearAllCookies(); + it("rejects requests that are not authenticated", () => { + cy.clearAllCookies(); // otherwise the previous login is still active - cy.request({ - method: "GET", - url: `${NOTEN_API}/getCisConfig`, - failOnStatusCode: false, - }).then((response) => { - expectAuthError(response); - }); - }); + cy.request({ method: "GET", url: `${NOTEN_API}/getCisConfig`, failOnStatusCode: false }).then( + (response) => expectAuthError(response), + ); - it("rejects invalid credentials", () => { - cy.request({ - method: "GET", - url: `${NOTEN_API}/getCisConfig`, - auth: { username: "no-such-user", password: "no-such-password" }, - failOnStatusCode: false, - }).then((response) => { - expectAuthError(response); - }); - }); + cy.request({ + method: "GET", + url: `${NOTEN_API}/getCisConfig`, + auth: { username: "no-such-user", password: "no-such-password" }, + failOnStatusCode: false, + }).then((response) => expectAuthError(response)); }); describe("the configured API user", () => { @@ -82,6 +71,9 @@ describe("Noten API - access control", () => { ); this.skip(); } + // cy.request reuses the session cookie of the previous (admin) call and the server prefers + // it over the Basic header - without this every test here silently runs as the admin user. + cy.clearAllCookies(); }); const teacherAuth = () => ({ @@ -89,40 +81,61 @@ describe("Noten API - access control", () => { password: Cypress.env("NOTEN_TEACHER_PASSWORD"), }); - it("lets a teacher read an LV they teach", () => { - notenApi.getBenotungstoolContext(ctx.semKurzbz).then(() => { - cy.request({ - method: "GET", - url: `${NOTEN_API}/getBenotungstoolContext`, - qs: { sem_kurzbz: ctx.semKurzbz }, - auth: teacherAuth(), - failOnStatusCode: false, - }).then((response) => { - const context = expectNotenSuccess(response, "teacher getBenotungstoolContext"); - const lvs = context.lehrveranstaltungen || []; - - expect( - lvs.length, - `${Cypress.env("NOTEN_TEACHER_USER")} must teach at least one LV in ` + - `${ctx.semKurzbz} for this test to mean anything`, - ).to.be.greaterThan(0); - - getStudentenNotenAs(teacherAuth(), lvs[0].lehrveranstaltung_id, ctx.semKurzbz).then( - (studentsResponse) => { - expectNotenSuccess(studentsResponse, "teacher reading their own LV"); - }, - ); - }); + /** + * Without this the denials below pass on a 401 from the LOGIN, never reaching assertLvAccess - + * a scoping suite that is green because the second account cannot log in at all. + */ + it("authenticates as the configured teacher", () => { + cy.request({ + method: "GET", + url: "/index.ci.php/api/frontend/v1/AuthInfo/getAuthUID", + auth: teacherAuth(), + failOnStatusCode: false, + }).then((response) => { + expect( + response.status, + `NOTEN_TEACHER_USER "${Cypress.env("NOTEN_TEACHER_USER")}" cannot log in, so every ` + + "authorization test below would only be re-testing the login. Fix the credentials.", + ).to.eq(200); + expect(String(response.body?.data?.uid).toLowerCase()).to.eq( + String(Cypress.env("NOTEN_TEACHER_USER")).toLowerCase(), + ); }); }); + it("lets a teacher read an LV they teach", () => { + cy.request({ + method: "GET", + url: `${NOTEN_API}/getBenotungstoolContext`, + qs: { sem_kurzbz: ctx.semKurzbz }, + auth: teacherAuth(), + failOnStatusCode: false, + }).then((response) => { + const context = expectNotenSuccess(response, "teacher getBenotungstoolContext"); + const lvs = context.lehrveranstaltungen || []; + + expect( + lvs.length, + `${Cypress.env("NOTEN_TEACHER_USER")} must teach at least one LV in ` + + `${ctx.semKurzbz} for this test to mean anything`, + ).to.be.greaterThan(0); + + getStudentenNotenAs(teacherAuth(), lvs[0].lehrveranstaltung_id, ctx.semKurzbz).then( + (studentsResponse) => { + expectNotenSuccess(studentsResponse, "teacher reading their own LV"); + }, + ); + }); + }); + + // assertLvAccess denies through terminateWithError -> 500 + phrase, not a 401. it("denies a teacher access to an LV they do not teach", () => { getStudentenNotenAs( teacherAuth(), Cypress.env("NOTEN_FOREIGN_LV_ID"), ctx.semKurzbz, ).then((response) => { - expectAuthError(response); + expectNotenError(response, "keineBerechtigungNoten"); }); }); @@ -140,46 +153,14 @@ describe("Noten API - access control", () => { auth: teacherAuth(), failOnStatusCode: false, }).then((response) => { - expectAuthError(response); + expectNotenError(response, "keineBerechtigungNoten"); }); }); }); - describe("getLvForStudiengang (Assistenz flow)", () => { - it("refuses a Studiengang the caller is not entitled for", () => { - // 0 is not a real studiengang_kz, so no caller can be entitled for it. Admins bypass the - // check entirely, in which case the request succeeds with an empty list instead. - notenApi.getLvForStudiengang(0, ctx.semKurzbz).then((response) => { - expect(response.status, "either denied (500) or allowed-as-admin (200)").to.be.oneOf([ - 200, 500, - ]); - - if (response.status === 500) { - expect(response.body).to.have.nested.property("meta.status", "error"); - expect(response.body.errors, "a denial carries a message").to.be.an("array").and.not - .be.empty; - } else { - expect(response.body.data, "an admin gets an empty LV list").to.be.an("array"); - } - }); - }); - - it("rejects a call without the required parameters", () => { - cy.request({ - method: "GET", - url: `${NOTEN_API}/getLvForStudiengang`, - qs: { sem_kurzbz: ctx.semKurzbz }, - auth: { - username: Cypress.env("adminusername"), - password: Cypress.env("adminpassword"), - }, - failOnStatusCode: false, - }).then((response) => { - expect(response.status, "missing studiengang_kz").to.eq(500); - expect(response.body).to.have.nested.property("meta.status", "error"); - }); - }); - }); + // getLvForStudiengang's refusal is NOT covered: the configured user is an admin, so + // isBerechtigt('admin') short-circuits the entitlement check and every call succeeds. It needs + // the same non-admin login as the teacher tests above. describe("getBenotungstoolContext shape", () => { it("returns the role-determining payload", () => { diff --git a/tests/cypress/e2e/specs/api/noten/noten.bulk.cy.js b/tests/cypress/e2e/specs/api/noten/noten.bulk.cy.js index 11bab1016..b51317022 100644 --- a/tests/cypress/e2e/specs/api/noten/noten.bulk.cy.js +++ b/tests/cypress/e2e/specs/api/noten/noten.bulk.cy.js @@ -115,21 +115,6 @@ describe("Noten API - bulk paths", () => { }); }); - it("rejects a call without the required parameters", () => { - cy.request({ - method: "POST", - url: "/index.ci.php/api/frontend/v1/Noten/createPruefungen", - body: { lva_id: ctx.lvId, sem_kurzbz: ctx.semKurzbz }, - auth: { - username: Cypress.env("adminusername"), - password: Cypress.env("adminpassword"), - }, - failOnStatusCode: false, - }).then((response) => { - expect(response.status, "missing uids/datum").to.eq(500); - expect(response.body).to.have.nested.property("meta.status", "error"); - }); - }); }); describe("savePruefungenBulk", () => { diff --git a/tests/cypress/e2e/specs/api/noten/noten.freigabe.cy.js b/tests/cypress/e2e/specs/api/noten/noten.freigabe.cy.js index 1c1580da5..dc92b538a 100644 --- a/tests/cypress/e2e/specs/api/noten/noten.freigabe.cy.js +++ b/tests/cypress/e2e/specs/api/noten/noten.freigabe.cy.js @@ -71,21 +71,6 @@ describe("Noten API - Notenfreigabe", () => { }); }); - it("rejects a Freigabe without the required parameters", () => { - cy.request({ - method: "POST", - url: "/index.ci.php/api/frontend/v1/Noten/saveStudentenNoten", - body: { lv_id: ctx.lvId, sem_kurzbz: ctx.semKurzbz }, - auth: { - username: Cypress.env("adminusername"), - password: Cypress.env("adminpassword"), - }, - failOnStatusCode: false, - }).then((response) => { - expect(response.status, "missing password/noten").to.eq(500); - expect(response.body).to.have.nested.property("meta.status", "error"); - }); - }); }); describe("happy path (sends mail - opt in via NOTEN_FREIGABE_ENABLED)", () => { diff --git a/tests/cypress/e2e/specs/api/noten/noten.frist.cy.js b/tests/cypress/e2e/specs/api/noten/noten.frist.cy.js index 29b7f7312..701ee13ca 100644 --- a/tests/cypress/e2e/specs/api/noten/noten.frist.cy.js +++ b/tests/cypress/e2e/specs/api/noten/noten.frist.cy.js @@ -116,13 +116,7 @@ describe("Noten API - Noteneintragungsfrist (Prüfungsordnung §1)", () => { }); }); - describe("deadline computation", () => { - it("derives the documented SS and WS deadlines", () => { - const cfg = fristConfig(); - - // pins the mapping, independent of the clock - expect(expectedFristString("SS2025", cfg.ss, cfg.ws)).to.eq("15.11.2025"); - expect(expectedFristString("WS2025", cfg.ss, cfg.ws)).to.eq("15.05.2026"); - }); - }); + // The SS/WS derivation is asserted against the SERVER in the two "deadline has passed" tests, + // which compare the deadline it names in the error message. A local check of expectedFristString() + // against constants only tested the helper, so it was removed. }); diff --git a/tests/cypress/e2e/specs/api/noten/noten.notenschluessel.cy.js b/tests/cypress/e2e/specs/api/noten/noten.notenschluessel.cy.js index a0e4ad871..1859a34ee 100644 --- a/tests/cypress/e2e/specs/api/noten/noten.notenschluessel.cy.js +++ b/tests/cypress/e2e/specs/api/noten/noten.notenschluessel.cy.js @@ -117,19 +117,4 @@ describe("Noten API - Notenschlüssel (getNoteByPunkte)", () => { }); }); - it("rejects a request without the required parameters", () => { - cy.request({ - method: "POST", - url: "/index.ci.php/api/frontend/v1/Noten/getNoteByPunkte", - body: { punkte: 50 }, - auth: { - username: Cypress.env("adminusername"), - password: Cypress.env("adminpassword"), - }, - failOnStatusCode: false, - }).then((response) => { - expect(response.status, "missing lv_id/sem_kurzbz").to.eq(500); - expect(response.body).to.have.nested.property("meta.status", "error"); - }); - }); }); diff --git a/tests/cypress/e2e/specs/api/noten/noten.notenvorschlag.cy.js b/tests/cypress/e2e/specs/api/noten/noten.notenvorschlag.cy.js index 2625b720d..8e75911f6 100644 --- a/tests/cypress/e2e/specs/api/noten/noten.notenvorschlag.cy.js +++ b/tests/cypress/e2e/specs/api/noten/noten.notenvorschlag.cy.js @@ -73,6 +73,21 @@ describe("Noten API - Notenvorschlag", () => { }); }); + // The unfiltered getter behind getNotenvorschlagStudent sees the same row fine - which is the + // getter finding 1 says getStudentenNoten should be using. + it("reports the offen note through getNotenvorschlagStudent", () => { + const student = ctx.students[0]; + + resetNotenState(ctx); + notenApi.saveNotenvorschlag(ctx.lvId, ctx.semKurzbz, student.uid, ctx.gradeNotes[0]); + + notenApi.getNotenvorschlagStudent(ctx.lvId, ctx.semKurzbz, student.uid).then((response) => { + const data = expectNotenSuccess(response, "getNotenvorschlagStudent"); + expect(String(data[0].note)).to.eq(String(ctx.gradeNotes[0])); + expect(data[0].freigabedatum, "still offen").to.be.null; + }); + }); + // EXPECTED TO FAIL while getLvGesamtNoten filters `freigabedatum < NOW()`: the row exists // (test above proves it) but the read model drops it until the note is freigegeben. it("reports the offen note back through getStudentenNoten", () => { @@ -150,21 +165,4 @@ describe("Noten API - Notenvorschlag", () => { }); }); - describe("parameter validation", () => { - it("rejects a call without student_uid", () => { - cy.request({ - method: "POST", - url: "/index.ci.php/api/frontend/v1/Noten/saveNotenvorschlag", - body: { lv_id: ctx.lvId, sem_kurzbz: ctx.semKurzbz, note: ctx.gradeNotes[0] }, - auth: { - username: Cypress.env("adminusername"), - password: Cypress.env("adminpassword"), - }, - failOnStatusCode: false, - }).then((response) => { - expect(response.status, "missing student_uid").to.eq(500); - expect(response.body).to.have.nested.property("meta.status", "error"); - }); - }); - }); }); diff --git a/tests/cypress/e2e/specs/api/noten/noten.pruefungsantritte.cy.js b/tests/cypress/e2e/specs/api/noten/noten.pruefungsantritte.cy.js index 06a49c9d8..6df0e371c 100644 --- a/tests/cypress/e2e/specs/api/noten/noten.pruefungsantritte.cy.js +++ b/tests/cypress/e2e/specs/api/noten/noten.pruefungsantritte.cy.js @@ -9,6 +9,7 @@ * instead of inserting. "entschuldigt" and "Noch nicht eingetragen" never count towards the cap. */ +import { notenApi } from "../../../../support/api/notenApi"; import { expectNotenError, expectNotenSuccess } from "../../../../support/helpers/notenErrors"; import { attemptDate, @@ -51,6 +52,25 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => { // one student per test, so a leaked row cannot reach the next scenario const studentFor = (index) => ctx.students[index % ctx.students.length]; + // Every rule below is keyed on these PKs. resolveSpecialNotes() resolves them from tbl_note by + // Bezeichnung and only falls back to the config values - on demo data entschuldigt is 14, not the + // 17 in application/config/noten.php. If the fallback ever wins, the rules silently stop matching. + it("resolves the special notes from tbl_note, not from config", () => { + notenApi.getCisConfig().then((response) => { + const config = expectNotenSuccess(response, "getCisConfig"); + + expect(String(config.NOTE_ENTSCHULDIGT)).to.eq(String(ctx.notes.entschuldigt)); + expect(config.NOTEN_OHNE_ANTRITT.map(String)).to.include.members([ + String(ctx.notes.entschuldigt), + String(ctx.notes.nochNichtEingetragen), + ]); + expect( + Object.keys(config.NOTEN_OCCURANCE_LIMIT_MAP).map(String), + "the occurrence limit must be keyed on the resolved PK", + ).to.include(String(ctx.notes.entschuldigt)); + }); + }); + describe("Rule A - maximum number of Prüfungsantritte", () => { it("rejects an attempt once the configured maximum is reached", () => { const student = studentFor(0); @@ -364,6 +384,34 @@ describe("Noten API - Prüfungsantritte (Prüfungsordnung §1)", () => { }); }); + // The add-time limit is covered by Rule C; this is the edit-time re-check + // (validatePruefungEdit:1167). Its converse - re-saving the excused row itself, which the + // exclusion must let through - is the date-only correction above. + it("rejects an edit that would exceed the entschuldigt limit", () => { + const student = studentFor(0); + + // the real Termin2 is the latest attempt, so no note lock masks the limit + givenThreeAttempts(student) + .then(() => readState(ctx)) + .then((data) => + attemptsOfTyp(data, student.uid, "Termin2").find( + (p) => String(p.note) !== String(ctx.notes.entschuldigt), + ), + ) + .then((real) => { + expect(real, "the non-excused Termin2").to.exist; + return editPruefung(ctx, student, { + pruefungId: real.pruefung_id, + note: ctx.notes.entschuldigt, + datum: attemptDate(ctx, 2), + typ: "Termin2", + }); + }) + .then((response) => { + expectNotenError(response, "noteOccuranceLimitReached"); + }); + }); + it("rejects a date on or before the previous attempt", () => { const student = studentFor(2); diff --git a/tests/cypress/e2e/specs/api/noten/noten.pruefungstermin.cy.js b/tests/cypress/e2e/specs/api/noten/noten.pruefungstermin.cy.js index 994c1492c..f6aa2d554 100644 --- a/tests/cypress/e2e/specs/api/noten/noten.pruefungstermin.cy.js +++ b/tests/cypress/e2e/specs/api/noten/noten.pruefungstermin.cy.js @@ -3,22 +3,29 @@ * savePruefungenBulk. The existing specs cover the validators; this one covers the writer. */ -import { expectNotenSuccess } from "../../../../support/helpers/notenErrors"; +import { + expectBulkRowError, + expectNotenError, + expectNotenSuccess, +} from "../../../../support/helpers/notenErrors"; import { attemptDate, baselineDate, loadNotenContext, + readLvGesamtnote, requireDbReset, + resetNotenState, seedPruefung, shiftDate, } from "../../../../support/helpers/notenTestData"; import { addPruefung, + attemptsOfTyp, editPruefung, givenBaseline, readState, } from "../../../../support/helpers/notenScenario"; -import { pruefungenOfTyp } from "../../../../support/api/notenApi"; +import { notenApi, pruefungenOfTyp } from "../../../../support/api/notenApi"; const dayOf = (value) => String(value).slice(0, 10); @@ -81,7 +88,7 @@ describe("Noten API - savePruefungstermin (write path)", () => { }); it("edits the pruefung identified by pruefung_id", () => { - // An excused and a real Termin2 side by side - no endpoint builds this, hence the direct seed. + // An excused and a real Termin2 side by side - no endpoint builds this, hence the direct seed const student = studentFor(2); const excusedDate = attemptDate(ctx, 1); const gradedDate = attemptDate(ctx, 2); @@ -92,6 +99,7 @@ describe("Noten API - savePruefungstermin (write path)", () => { let excusedId; seedPruefung(ctx, student, { note: ctx.notes.entschuldigt, datum: excusedDate, typ: "Termin2" }) .then((seeded) => { + console.log('ctx', ctx) excusedId = seeded.pruefungId; return seedPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: gradedDate, typ: "Termin2" }); }) @@ -133,4 +141,103 @@ describe("Noten API - savePruefungstermin (write path)", () => { expectNotenSuccess(response, "Termin2 on an un-freigegebene LV note"); }); }); + + it("updates the existing Termin2 instead of appending a second one", () => { + // The cap has room (Termin3 is on here), so the second add is allowed and must land on the + // same row - the update branch is otherwise only reached through an edit. + const student = studentFor(0); + + givenBaseline(ctx, student); + + let firstId; + addPruefung(ctx, student, { note: ctx.gradeNotes[0], datum: attemptDate(ctx, 1) }) + .then((response) => { + firstId = expectNotenSuccess(response, "first Termin2")[0].pruefung_id; + return addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: attemptDate(ctx, 2) }); + }) + .then((response) => expectNotenSuccess(response, "second Termin2")) + .then(() => readState(ctx)) + .then((data) => { + const rows = attemptsOfTyp(data, student.uid, "Termin2"); + + expect(rows, "the second add must not append a row").to.have.length(1); + expect(rows[0].pruefung_id, "same row").to.eq(firstId); + expect(String(rows[0].note)).to.eq(String(ctx.gradeNotes[1])); + expect(dayOf(rows[0].datum)).to.eq(attemptDate(ctx, 2)); + }); + }); + + it("stores an empty note as 'Noch nicht eingetragen'", () => { + const student = studentFor(1); + + givenBaseline(ctx, student); + + addPruefung(ctx, student, { note: "", datum: attemptDate(ctx, 1) }).then((response) => { + const [saved] = expectNotenSuccess(response, "Termin2 without a grade"); + expect(String(saved.note), "empty note is normalised").to.eq( + String(ctx.notes.nochNichtEingetragen), + ); + }); + }); + + describe("invalid pruefungstyp", () => { + // The only server-side typ check: which typ an attempt becomes is otherwise decided purely by + // the frontend (getPruefungstypForStudentByAntritt). + it("refuses a typ that is not an enabled retake", () => { + const student = studentFor(2); + + givenBaseline(ctx, student); + + // Termin1 is the snapshot, kommPruef is entered elsewhere, the third is nonsense + ["Termin1", "kommPruef", "NichtsDergleichen"].forEach((typ) => { + addPruefung(ctx, student, { + note: ctx.gradeNotes[1], + datum: attemptDate(ctx, 1), + typ, + }).then((response) => { + expectNotenError(response, "wrongPruefungType"); + }); + }); + }); + + + it("leaves the LV note untouched when the typ is rejected", () => { + const student = studentFor(3); + + givenBaseline(ctx, student); + + addPruefung(ctx, student, { + note: ctx.gradeNotes[1], + datum: attemptDate(ctx, 1), + typ: "NichtsDergleichen", + }) + .then((response) => expectNotenError(response, "wrongPruefungType")) + .then(() => readLvGesamtnote(ctx, student.uid)) + .then((row) => { + expect(String(row.note), "a rejected request must not re-grade the student").to.eq( + String(ctx.gradeNotes[0]), + ); + }); + }); + }); + + it("refuses a Prüfung for a student who has no LV note yet", () => { + // Only reachable through the bulk path: saveStudentPruefung inserts the missing LV note itself, + // so its own guard (Noten.php:839) can never fire. + const student = studentFor(0); + + resetNotenState(ctx); + + notenApi + .createPruefungen( + [{ uid: student.uid, typ: "Termin2", lehreinheit_id: student.lehreinheit_id }], + attemptDate(ctx, 1), + ctx.lvId, + ctx.semKurzbz, + ) + .then((response) => { + const data = expectNotenSuccess(response, "createPruefungen without an LV note"); + expectBulkRowError(data, student.uid, "c4keineLvNoteEingetragen"); + }); + }); }); diff --git a/tests/cypress/e2e/specs/api/noten/noten.ueberschreiben.cy.js b/tests/cypress/e2e/specs/api/noten/noten.ueberschreiben.cy.js index 6961380e8..6119053b9 100644 --- a/tests/cypress/e2e/specs/api/noten/noten.ueberschreiben.cy.js +++ b/tests/cypress/e2e/specs/api/noten/noten.ueberschreiben.cy.js @@ -75,4 +75,50 @@ describe("Noten API - Notenvorschlag overwrite rules", () => { ); }); }); + + // Same hole through the CSV import, which is the path an Assistenz actually drives. + // saveNotenvorschlagBulk validates nothing at all, so these are RED for the same reason. + describe("saveNotenvorschlagBulk", () => { + it("refuses a note the editor list never offers", function () { + if (ctx.notes.nichtLehre === null) this.skip(); + + const student = studentFor(2); + + givenBaseline(ctx, student); + + notenApi + .saveNotenvorschlagBulk(ctx.lvId, ctx.semKurzbz, [ + { uid: student.uid, note: ctx.notes.nichtLehre, punkte: null }, + ]) + .then(() => readLvGesamtnote(ctx, student.uid)) + .then((row) => { + expect(String(row.note), "a non-lehre note must not become the LV note").to.eq( + String(ctx.gradeNotes[0]), + ); + }); + }); + + it("refuses to change the Notenvorschlag once a Prüfung exists", () => { + const student = studentFor(3); + + givenBaseline(ctx, student); + + addPruefung(ctx, student, { note: ctx.gradeNotes[1], datum: attemptDate(ctx, 1) }).then( + (response) => { + expectNotenSuccess(response, "seed a Prüfung"); + }, + ); + + notenApi + .saveNotenvorschlagBulk(ctx.lvId, ctx.semKurzbz, [ + { uid: student.uid, note: ctx.gradeNotes[0], punkte: null }, + ]) + .then(() => readLvGesamtnote(ctx, student.uid)) + .then((row) => { + expect(String(row.note), "the attempt's grade must survive the import").to.eq( + String(ctx.gradeNotes[1]), + ); + }); + }); + }); }); diff --git a/tests/cypress/support/api/notenApi.js b/tests/cypress/support/api/notenApi.js index 53eb17d7a..2f247cc3e 100644 --- a/tests/cypress/support/api/notenApi.js +++ b/tests/cypress/support/api/notenApi.js @@ -1,8 +1,5 @@ /** * Wrappers over the Benotungstool API. Mirrors public/js/api/factory/noten.js. - * - * GET sends factory params as query string, POST as JSON body (the controller reads - * getPostJSON()). Envelope: 200 {data, meta.status:'success'} / 500 {errors[], meta.status:'error'}. */ const NOTEN_API = "/index.ci.php/api/frontend/v1/Noten"; @@ -36,6 +33,10 @@ export const notenApi = { */ getStudentenNoten: (lv_id, sem_kurzbz) => apiGet("getStudentenNoten", { lv_id, sem_kurzbz }), + /** Reads through the UNFILTERED getter, so it sees notes getStudentenNoten hides. uid -> caller. */ + getNotenvorschlagStudent: (lv_id, sem_kurzbz, uid = null) => + apiGet("getNotenvorschlagStudent", { lv_id, sem_kurzbz, uid }), + /** data -> [lvgesamtnote] */ saveNotenvorschlag: (lv_id, sem_kurzbz, student_uid, note, punkte = null) => apiPost("saveNotenvorschlag", { lv_id, sem_kurzbz, student_uid, note, punkte }), diff --git a/tests/cypress/support/helpers/notenErrors.js b/tests/cypress/support/helpers/notenErrors.js index e06e36bb0..4edd80fca 100644 --- a/tests/cypress/support/helpers/notenErrors.js +++ b/tests/cypress/support/helpers/notenErrors.js @@ -31,6 +31,14 @@ const PHRASES = { de: "Die Noteneintragungsfrist ({0}) für dieses Studiensemester ist abgelaufen. Es können keine Noten oder Prüfungen mehr eingetragen werden.", en: "The grade entry deadline ({0}) for this study semester has passed. Grades and exams can no longer be entered.", }, + wrongPruefungType: { + de: "Prüfung für Studierenden {0} konnte nicht bearbeitet werden, {1} ist keine gültiger Prüfungstyp.", + en: "Exam for Student {0} was not saved, {1} is not a valid exam type.", + }, + c4keineLvNoteEingetragen: { + de: "Keine LV Note eingetragen", + en: "No Subject Grade entered", + }, keineBerechtigungNoten: { de: 'Keine Berechtigung, um in der Lehrveranstaltung "{0}" im Semester {1} Noten einzutragen.', en: 'No permission to enter grades for the course "{0}" in semester {1}.', diff --git a/tests/cypress/support/helpers/notenScenario.js b/tests/cypress/support/helpers/notenScenario.js index b60441b04..eb2d93dea 100644 --- a/tests/cypress/support/helpers/notenScenario.js +++ b/tests/cypress/support/helpers/notenScenario.js @@ -3,14 +3,10 @@ import { notenApi, pruefungenOfTyp, gradesOf } from "../api/notenApi"; import { expectNotenSuccess } from "./notenErrors"; import { resetNotenState, seedBaseline } from "./notenTestData"; +import { retakeTypes } from "../../../../public/js/components/Cis/Benotungstool/notenRules.js"; -/** Enabled in-tool retake types, in attempt order. maxAntritte == 1 + this list's length. */ -export const enabledRetakeTypes = (context) => { - const types = []; - if (context.cisConfig.CIS_GESAMTNOTE_PRUEFUNG_TERMIN2) types.push("Termin2"); - if (context.cisConfig.CIS_GESAMTNOTE_PRUEFUNG_TERMIN3) types.push("Termin3"); - return types; -}; +/** The real client rule, so a divergence from the server shows up as a failing API spec. */ +export const enabledRetakeTypes = (context) => retakeTypes(context.cisConfig); /** * Adds an attempt (no pruefung_id -> server runs validatePruefungAdd). diff --git a/tests/cypress/support/helpers/notenTestData.js b/tests/cypress/support/helpers/notenTestData.js index e5725d6bd..a296bef3c 100644 --- a/tests/cypress/support/helpers/notenTestData.js +++ b/tests/cypress/support/helpers/notenTestData.js @@ -9,6 +9,8 @@ import { notenApi } from "../api/notenApi"; import { expectNotenSuccess } from "./notenErrors"; +// the client rule; the server's computeMaxAntritte must agree with it +import { maxAntrittCount as computeMaxAntritte } from "../../../../public/js/components/Cis/Benotungstool/notenRules.js"; import { describeFailure, performRead, performReset, performSeed, performSeedPruefung, resolveResetStrategy, } from "./notenReset"; @@ -102,11 +104,6 @@ const resolveLehrveranstaltung = (semKurzbz) => { }); }; -/** maxAntritte as the server computes it: 1 + each enabled retake type. */ -const computeMaxAntritte = (cisConfig) => - 1 + - (cisConfig.CIS_GESAMTNOTE_PRUEFUNG_TERMIN2 ? 1 : 0) + - (cisConfig.CIS_GESAMTNOTE_PRUEFUNG_TERMIN3 ? 1 : 0); /** -> { semKurzbz, lvId, cisConfig, maxAntritte, notes, gradeNotes, students, studentUids } */ export const loadNotenContext = () => { @@ -219,10 +216,6 @@ export const baselineDate = (context) => baselineBenotungsdatum(context).slice(0 /** attemptDate(ctx,1) < attemptDate(ctx,2) < ... , all after the baseline. */ export const attemptDate = (context, index) => shiftDate(baselineDate(context), 30 * index); -/** - * Hard failure rather than a skip: these are the regulatory rules, and a green run that never - * exercised them is worse than a red one. - */ export const requireDbReset = () => resolveResetStrategy().then((state) => { expect( diff --git a/tests/cypress/tasks/db.js b/tests/cypress/tasks/db.js index b14337c94..8bcd3f09f 100644 --- a/tests/cypress/tasks/db.js +++ b/tests/cypress/tasks/db.js @@ -51,7 +51,6 @@ const assertWritable = () => { } }; -/** Runs fn with a pooled client and always releases it. */ const withClient = async (fn) => { const client = await getPool().connect(); try { @@ -61,7 +60,6 @@ const withClient = async (fn) => { } }; -/** Same, in a transaction that rolls back on any throw. */ const inTransaction = (fn) => withClient(async (client) => { await client.query("BEGIN"); @@ -75,10 +73,6 @@ const inTransaction = (fn) => } }); -/** - * The pg_hba case is the one worth spelling out: it is server-side, so no local setting fixes it - * and the tunnel is the only workstation answer. - */ const explainFailure = (error) => { const msg = String(error && error.message ? error.message : error); From 56fcb23275c992bb2678b54282d4ce0251317a5b Mon Sep 17 00:00:00 2001 From: Johann Hoffmann Date: Tue, 4 Aug 2026 17:40:07 +0200 Subject: [PATCH 20/60] WIP --- .../controllers/api/frontend/v1/Noten.php | 1 + .../Cis/Benotungstool/Benotungstool.js | 15 +++--- .../Cis/Benotungstool/notenRules.js | 49 +++++++++++++++++++ 3 files changed, 58 insertions(+), 7 deletions(-) create mode 100644 public/js/components/Cis/Benotungstool/notenRules.js diff --git a/application/controllers/api/frontend/v1/Noten.php b/application/controllers/api/frontend/v1/Noten.php index 48aed07f2..d2dc0c8ef 100644 --- a/application/controllers/api/frontend/v1/Noten.php +++ b/application/controllers/api/frontend/v1/Noten.php @@ -752,6 +752,7 @@ class Noten extends FHCAPI_Controller $origBenotungsdatum = null; // // TODO: check if savePruefungstermin wont throw an error before updating/inserting lv note + // todo: also check if typ is wrong but another would be available? // if(!$this->ableToSavePruefungstermin($typ)) if(!isError($result) && !hasData($result)) { diff --git a/public/js/components/Cis/Benotungstool/Benotungstool.js b/public/js/components/Cis/Benotungstool/Benotungstool.js index 2399d8090..0b727ff88 100644 --- a/public/js/components/Cis/Benotungstool/Benotungstool.js +++ b/public/js/components/Cis/Benotungstool/Benotungstool.js @@ -1246,13 +1246,14 @@ export const Benotungstool = { const noteBezeichnung = noteOption ? noteOption.bezeichnung : data.note_vorschlag const dateLocale = this.$p.user_language?.value === 'English' ? 'en-GB' : 'de-AT' const benotungsdatum = new Date().toLocaleString(dateLocale) - if(await this.$fhcAlert.confirm({ - message: this.$p.t('benotungstool/c4notenvorschlagUebernehmenConfirm', [noteBezeichnung, data.vorname, data.nachname, data.uid, benotungsdatum]), - acceptClass: 'p-button-primary', - rejectClass: 'p-button-secondary' - }) === false) { - return - } + + // if(await this.$fhcAlert.confirm({ + // message: this.$p.t('benotungstool/c4notenvorschlagUebernehmenConfirm', [noteBezeichnung, data.vorname, data.nachname, data.uid, benotungsdatum]), + // acceptClass: 'p-button-primary', + // rejectClass: 'p-button-secondary' + // }) === false) { + // return + // } this.loading = true this.$api.call(ApiNoten.saveNotenvorschlag(this.lv_id, this.sem_kurzbz, data.uid, data.note_vorschlag, data.punkte)) diff --git a/public/js/components/Cis/Benotungstool/notenRules.js b/public/js/components/Cis/Benotungstool/notenRules.js new file mode 100644 index 000000000..b59dd4375 --- /dev/null +++ b/public/js/components/Cis/Benotungstool/notenRules.js @@ -0,0 +1,49 @@ +/** + * Prüfungsordnung §1 rules as the client applies them. Extracted from the Vue component so they are + * testable, and so the server-side mirrors (Noten.php: computeAntrittCount / computeMaxAntritte / + * getPruefungstypForStudentByAntritt) have something to be compared against. + */ + +/** Enabled in-tool retake types, in attempt order. Termin1 is the original note, kommPruef terminal. */ +export const retakeTypes = (config) => { + const types = []; + if (config?.CIS_GESAMTNOTE_PRUEFUNG_TERMIN2) types.push("Termin2"); + if (config?.CIS_GESAMTNOTE_PRUEFUNG_TERMIN3) types.push("Termin3"); + return types; +}; + +/** Original note (always 1) plus each enabled retake. kommPruef is entered elsewhere and not capped here. */ +export const maxAntrittCount = (config) => 1 + retakeTypes(config).length; + +/** + * Counting Prüfungsantritte. Counted by NOTE, not by typ: notes in NOTEN_OHNE_ANTRITT (entschuldigt, + * noch nicht eingetragen) never count, which is what lets Termin2 be entered twice. A kommPruef is + * terminal and returns a sentinel >= any maxAntrittCount. With no counting pruefung the original LV + * note is the first Antritt, unless it is a non-lehre note ("angerechnet") meaning no participation. + */ +export const antrittCountStudent = (student, config, notenOptions) => { + if (student["kommPruef"]) return 4; + + const ohneAntritt = config.NOTEN_OHNE_ANTRITT; + let count = 0; + for (const p of student.pruefungen) { + if (!ohneAntritt.find((pk) => pk == p.note)) count++; + } + + if (count === 0 && student.note) { + const noteOption = notenOptions.find((n) => n.note == student.note); + return noteOption.lehre ? 1 : 0; + } + + return count; +}; + +/** Next retake type to offer. Attempt 1 is the original note, so retake n sits at index n-1. */ +export const pruefungstypForAntritt = (student, config) => + retakeTypes(config)[Math.max(0, student.hoechsterAntritt - 1)] ?? ""; + +/** Grade state from the two timestamps. */ +export const checkFreigabe = (freigabedatum, benotungsdatum) => { + if (!freigabedatum) return "offen"; + return benotungsdatum > freigabedatum ? "changed" : "ok"; +}; From c9c92f82d33ca837ad666a5dcee910cc09cdfd19 Mon Sep 17 00:00:00 2001 From: Johann Hoffmann Date: Thu, 6 Aug 2026 10:33:46 +0200 Subject: [PATCH 21/60] WIP refactor --- application/config/noten.php | 37 +- .../controllers/api/frontend/v1/Noten.php | 958 ++++++------- .../api/frontend/v1/stv/Grades.php | 87 +- .../models/education/Lvgesamtnote_model.php | 2 +- .../benotungstool/lvgesamtnoteverwalten.php | 2 +- public/css/Cis4/Benotungstool.css | 60 +- public/js/api/factory/noten.js | 11 +- .../Cis/Benotungstool/Benotungstool.js | 1260 +++++++---------- .../Cis/Benotungstool/notenRules.js | 57 +- system/phrasesupdate.php | 160 +++ .../e2e/specs/api/noten/noten.bulk.cy.js | 41 +- .../e2e/specs/api/noten/noten.frist.cy.js | 4 +- .../api/noten/noten.pruefungsantritte.cy.js | 340 +++-- .../api/noten/noten.pruefungstermin.cy.js | 259 ++-- tests/cypress/support/api/notenApi.js | 29 +- .../cypress/support/helpers/notenScenario.js | 31 +- .../cypress/support/helpers/notenTestData.js | 44 +- 17 files changed, 1715 insertions(+), 1667 deletions(-) diff --git a/application/config/noten.php b/application/config/noten.php index 7bdfce1ad..63ad9d911 100644 --- a/application/config/noten.php +++ b/application/config/noten.php @@ -2,22 +2,51 @@ if (!defined('BASEPATH')) exit('No direct script access allowed'); -// 'entschuldigt' & 'noch nicht eingetragen' -> wirken sich nicht auf Antritte aus +// Noten die keinen Prüfungsantritt verbrauchen. Fallback-PKs; massgeblich sind die per Bezeichnung +// aufgelösten Noten (NOTEN_OHNE_ANTRITT_BEZEICHNUNGEN), da die PKs je Installation abweichen. $config['NOTEN_OHNE_ANTRITT'] = [9, 17]; // tbl_note pk -$config['NOTEN_OCCURANCE_LIMIT_MAP'] = [17 => 1]; // across the 4 fixed antritte only one can be entschuldigt +// 'Nicht beurteilt' ist eine Altlast: formal eine Note, inhaltlich keine Leistungsfeststellung. +// Wird weiterhin häufig vergeben und darf daher keinen Antritt kosten. +$config['NOTEN_OHNE_ANTRITT_BEZEICHNUNGEN'] = ['Noch nicht eingetragen', 'entschuldigt', 'Nicht beurteilt']; + +// Anrechnungsnoten: die Leistung wurde vorab anerkannt, der/die Studierende ist zwar Teil der +// Lehrveranstaltung, tritt aber zu keiner Prüfung an. Solange die ZEUGNISNOTE eine davon ist, sind +// für diese Lehrveranstaltung keine Prüfungen möglich. Auflösung wie oben über die Bezeichnung, +// die PKs sind nur Fallback. +$config['NOTEN_ANRECHNUNG_BEZEICHNUNGEN'] = ['angerechnet', 'intern angerechnet']; +$config['NOTEN_ANRECHNUNG'] = [6, 16]; // tbl_note pk + +$config['NOTEN_OCCURANCE_LIMIT_MAP'] = [17 => 1]; // über alle Antritte hinweg nur ein entschuldigt // tbl_note pk of the 'entschuldigt' note. An entschuldigt Termin is preserved as its own dated // entry when a new pruefung of the same type is created (instead of being overwritten). $config['NOTE_ENTSCHULDIGT'] = 17; +// Maximale Anzahl zählender Prüfungsantritte, die IN DIESEM TOOL eingetragen werden können +// (inkl. erstem Antritt). null = aus den Alt-Flags CIS_GESAMTNOTE_PRUEFUNG_TERMIN2/TERMIN3 +// ableiten (1 + je aktiviertem Termin), damit bestehende Installationen unverändert laufen. +// Der abschliessende Antritt (kommPruef) wird woanders eingetragen und zählt hier nicht mit. +$config['CIS_GESAMTNOTE_MAX_ANTRITTE'] = null; + +// Prüfungstypen die den Verlauf abschliessen: danach ist kein weiterer Antritt möglich. Das ist +// die einzige Stelle, an der der Typ eines Termins noch eine Bedeutung trägt. +$config['PRUEFUNG_TERMINAL_TYPEN'] = ['kommPruef', 'zusKommPruef']; + +// Spaltenaufteilung der Prüfungen in der Notentabelle: +// 'antritt' - eine Spalte je Antrittsnummer, Datum steht in der Zelle (robust bei Einzelterminen) +// 'datum' - eine Spalte je Prüfungsdatum (kompakt, wenn ganze Jahrgänge am selben Tag antreten) +// Nur die Vorgabe; im Tool umschaltbar und pro Benutzer gespeichert. +$config['CIS_GESAMTNOTE_PRUEFUNGSSPALTEN'] = 'antritt'; + +// Bei der Notenübernahme durch die Assistenz den ersten Antritt als eigene Prüfung anlegen. +$config['CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME'] = true; + // availability of the two Benotungstool import flows. When both are true they are shown as // separate buttons/dialogs. $config['CIS_GESAMTNOTE_PRUEFUNGSIMPORT'] = true; // dated import that creates a pruefung per row $config['CIS_GESAMTNOTE_NOTENIMPORT'] = false; // classic note-only import (uid + note, no date) -$config['SHOW_BENOTUNGSDATUM_ON_NOTENVORSCHLAG_UEBERNAHME'] = true; - // Noteneintragungsfrist (Prüfungsordnung §1): grade/pruefung entry is only permitted up to this // deadline. The month/day below is applied to the studiensemester's year: // Sommersemester (SSyyyy) -> deadline in the SAME calendar year (default 15th November) diff --git a/application/controllers/api/frontend/v1/Noten.php b/application/controllers/api/frontend/v1/Noten.php index d2dc0c8ef..c5a60a58f 100644 --- a/application/controllers/api/frontend/v1/Noten.php +++ b/application/controllers/api/frontend/v1/Noten.php @@ -47,6 +47,7 @@ class Noten extends FHCAPI_Controller $this->load->library('AuthLib', null, 'AuthLib'); $this->load->library('PhrasesLib'); + $this->load->library('PruefungsverlaufLib', null, 'VerlaufLib'); // Loads LogLib with different debug trace levels to get data of the job that extends this class // It also specify parameters to set database fields @@ -90,7 +91,7 @@ class Noten extends FHCAPI_Controller public function getCisConfig() { // resolved from tbl_note (Bezeichnung) with config fallback -> single source of truth - $special = $this->resolveSpecialNotes(); + $special = $this->VerlaufLib->getSpecialNotes(); $NOTEN_OHNE_ANTRITT = $special['ohneAntritt']; $NOTEN_OCCURANCE_LIMIT_MAP = $special['limitMap']; $NOTE_ENTSCHULDIGT = $special['entschuldigt']; @@ -106,14 +107,13 @@ class Noten extends FHCAPI_Controller // only relevant in punkte calculation in backend // 'CIS_GESAMTNOTE_GEWICHTUNG' => CIS_GESAMTNOTE_GEWICHTUNG, - // enables the first in-tool retake (Termin2). - // Used in the maxAntritte calculation. - 'CIS_GESAMTNOTE_PRUEFUNG_TERMIN2' => CIS_GESAMTNOTE_PRUEFUNG_TERMIN2, + // Maximale Anzahl zählender Antritte in diesem Tool. Serverseitig abgeleitet (explizite + // Konfiguration oder Alt-Flags TERMIN2/TERMIN3); der Client rechnet das nicht mehr nach. + 'CIS_GESAMTNOTE_MAX_ANTRITTE' => $this->VerlaufLib->getMaxAntritte(), + + // Vorgabe der Spaltenaufteilung ('antritt' | 'datum'), im Tool umschaltbar + 'CIS_GESAMTNOTE_PRUEFUNGSSPALTEN' => $this->config->item('CIS_GESAMTNOTE_PRUEFUNGSSPALTEN'), - // legacy: enables a SECOND in-tool retake (Termin3) for other installations whose ruleset - // uses three explicit Nachprüfungs-Termine. Each enabled retake type raises the maxAntritte cap. - 'CIS_GESAMTNOTE_PRUEFUNG_TERMIN3' => CIS_GESAMTNOTE_PRUEFUNG_TERMIN3, - // used to toggle availability of kommPruef type pruefungen 'CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF' => CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF, @@ -142,9 +142,6 @@ class Noten extends FHCAPI_Controller // pk of the 'entschuldigt' note; used to preserve excused Termine on new pruefung creation 'NOTE_ENTSCHULDIGT' => $NOTE_ENTSCHULDIGT, - // show the Benotungsdatum of the first Antritt in the "Ursprüngliche Zeugnisnote" column - 'SHOW_BENOTUNGSDATUM_ON_NOTENVORSCHLAG_UEBERNAHME' => $this->config->item('SHOW_BENOTUNGSDATUM_ON_NOTENVORSCHLAG_UEBERNAHME'), - // Noteneintragungsfrist window (enforced server-side; also surfaced so the UI can hint at it) 'CIS_GESAMTNOTE_NOTENEINTRAGUNGSFRIST' => $this->config->item('CIS_GESAMTNOTE_NOTENEINTRAGUNGSFRIST') ) @@ -429,7 +426,34 @@ class Noten extends FHCAPI_Controller $pruefungen = $this->LePruefungModel->getPruefungenByLvStudiensemester($lv_id, $sem_kurzbz); $pruefungenData = getData($pruefungen); - $this->terminateWithSuccess(array($studentenData, $pruefungenData, DOMAIN, $grades, $anwresult)); + // Prüfungsverlauf je Student serverseitig ableiten: Position, Antrittsnummer, ob ein Termin + // einen Antritt verbraucht und ob noch einer möglich ist. Der Client wertet das nur noch aus, + // damit die Regeln nicht in zwei Implementierungen auseinanderlaufen. + $proStudent = []; + foreach($pruefungenData ?: [] as $p) { + $proStudent[$p->student_uid][] = $p; + } + + // Zeugnisnoten kommen bereits mit der Studentenliste (tbl_zeugnisnote.note) - keine + // Extra-Abfrage je Zeile nötig + $zeugnisnoten = []; + foreach($studentenData as $s) $zeugnisnoten[$s->uid] = $s->note; + + $pruefungenAbgeleitet = []; + foreach(array_unique(array_merge($student_uids, array_keys($proStudent))) as $uid) { + $lvNote = isset($grades[$uid]) ? ($grades[$uid]['note_lv'] ?? null) : null; + $verlauf = $this->VerlaufLib->buildVerlauf($proStudent[$uid] ?? [], $lvNote, $zeugnisnoten[$uid] ?? null); + + foreach($verlauf->pruefungen as $p) $pruefungenAbgeleitet[] = $p; + + if(isset($grades[$uid])) { + $grades[$uid]['verlauf'] = $this->verlaufSummary( + $verlauf, false, $this->getLvGesamtnoteRow($lv_id, $uid, $sem_kurzbz) !== null + ); + } + } + + $this->terminateWithSuccess(array($studentenData, $pruefungenAbgeleitet, DOMAIN, $grades, $anwresult)); } /** @@ -550,6 +574,14 @@ class Noten extends FHCAPI_Controller if(hasData($res)) { $lvgesamtnote = getData($res)[0]; $ret[] = array('uid' => $note->uid, 'freigabedatum' => $lvgesamtnote->freigabedatum, 'benotungsdatum' => $lvgesamtnote->benotungsdatum); + + // Mit der Freigabe wird die Note verbindlich - genau hier entsteht der + // erste Prüfungstermin. Das Anlegen einer Prüfung legt nie zusätzlich + // einen zweiten Termin an. + $this->upsertErstantritt( + $lv_id, $lvgesamtnote->student_uid, $sem_kurzbz, + $lvgesamtnote->note, $lvgesamtnote->punkte, $lvgesamtnote->freigabedatum + ); } } @@ -659,9 +691,9 @@ class Noten extends FHCAPI_Controller * expects 'datum', 'lva_id', 'student_uid', 'note' * Inserts or updates a pruefung for lva & student_uid at given datum (YYYY-MM-DD). When creating a new * Pruefung, sets the provided (Prüfungs-) Note. - * Updates the LvGesamtnote of student. - * Can return 1 or 2 Prüfungen, since the original grade before the first prüfung is being saved as "Termin1" when - * a "Termin2" is being created. + * Updates the LvGesamtnote of student (niemals die Zeugnisnote - die Übernahme passiert in der STV). + * Can return 1 or 2 Prüfungen: fehlt der erste Antritt als eigener Datensatz (Altdaten), wird er + * beim Anlegen einer Wiederholung aus der ursprünglichen LV-Note nachgetragen. */ public function saveStudentPruefung() { // einzelne pruefung speichern $result = $this->getPostJSON(); @@ -681,7 +713,6 @@ class Noten extends FHCAPI_Controller $pruefung_id = property_exists($result, 'pruefung_id') ? $result->pruefung_id : null; $stsem = $result->sem_kurzbz; - $typ = $result->typ; $this->assertLvAccess($lva_id, $stsem); @@ -712,91 +743,71 @@ class Noten extends FHCAPI_Controller $note = getData($result)[0]->note; } + $result = $this->savePruefungFuerStudent($pruefung_id, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum); + + // Validierungs- und Schreibfehler kommen als lokalisierte Meldung zurück + if(is_string($result)) $this->terminateWithError($result, 'general'); + + $savedPruefung = $result['savedPruefung'] ?? []; + $savedPruefungData = count($savedPruefung) > 0 ? $savedPruefung[0] : null; + $lvgesamtnote = $result['lvgesamtnote'] ?? null; + + // eine Aktion = ein Termin, daher kein zusätzlicher Datensatz mehr in der Antwort + $this->terminateWithSuccess(array($savedPruefungData, $lvgesamtnote, $result['verlauf'] ?? null)); + } + + /** + * Kern einer Prüfungseingabe für EINEN Studenten: prüfen, LV-Note schreiben, dann den Termin. + * + * Einzige Stelle, an der das passiert - der Dialog aus der Tabelle (saveStudentPruefung) und die + * Bulk-Endpunkte verwenden sie gleichermassen. Beide Wege tun damit exakt dasselbe, der Bulk-Weg + * nur für mehrere Studenten auf einmal. Die Zeugnisnote bleibt unberührt, deren Übernahme + * passiert in der Studierendenverwaltung. + * + * @return array|string ['savedPruefung' => [...], 'lvgesamtnote' => stdClass, 'verlauf' => [...]] + * oder eine lokalisierte Fehlermeldung + */ + private function savePruefungFuerStudent($pruefung_id, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum) + { // validate the edit before any write: the date must stay between the neighbouring exams and, - // once a later/higher pruefung exists, the grade may no longer be changed (only the date). + // once a later pruefung exists, the grade may no longer be changed (only the date). // only applies when editing an existing record ($pruefung_id set) $editError = $this->validatePruefungEdit($student_uid, $lva_id, $stsem, $note, $datum, $pruefung_id); - if($editError !== null) { - $this->terminateWithError($editError, 'general'); - } + if($editError !== null) return $editError; // for a NEW attempt (no pruefung_id) enforce the add rules server-side // maxAntritte calc, chronological order, occurrence limit for entschuldigt if($pruefung_id === null || $pruefung_id === '') { $addError = $this->validatePruefungAdd($student_uid, $lva_id, $stsem, $note, $datum); - if($addError !== null) { - $this->terminateWithError($addError, 'general'); - } + if($addError !== null) return $addError; } - - $this->load->model('education/Lehrveranstaltung_model', 'LehrveranstaltungModel'); - $this->load->model('organisation/Studiengang_model', 'StudiengangModel'); - - $res = $this->LehrveranstaltungModel->load($lva_id); - if(isError($res) || !hasData($res)) { - $this->terminateWithError($this->p->t('benotungstool', 'noValidLvFoundForId', [$lva_id])); + // fehlt die lehreinheit_id (der Client liefert sie nicht immer), serverseitig auflösen - + // sonst scheitert das Insert stillschweigend am NOT NULL + if(!$lehreinheit_id) { + $resLe = $this->LehrveranstaltungModel->getLeByStudent($student_uid, $stsem, $lva_id); + if(!isError($resLe) && hasData($resLe)) $lehreinheit_id = current(getData($resLe))->lehreinheit_id; } - $studiengang_kz = getData($res)[0]->studiengang_kz; - $res = $this->StudiengangModel->load($studiengang_kz); - if(isError($res) || !hasData($res)) { - $this->terminateWithError($this->p->t('benotungstool', 'noValidStudiengangFoundForId', [$studiengang_kz])); - } - + $jetzt = date("Y-m-d H:i:s"); - $result = $this->LvgesamtnoteModel->getLvGesamtNoten($lva_id, $student_uid, $stsem); + // ungefiltert: eine noch nicht freigegebene LV-Note existiert bereits und muss aktualisiert + // statt neu angelegt werden (sonst Verstoss gegen den Primärschlüssel) + $bestehendeLvNote = $this->getLvGesamtnoteRow($lva_id, $student_uid, $stsem); - $origLvNote = null; - $origLvPunkte = null; - $origBenotungsdatum = null; - -// // TODO: check if savePruefungstermin wont throw an error before updating/inserting lv note - // todo: also check if typ is wrong but another would be available? -// if(!$this->ableToSavePruefungstermin($typ)) - - if(!isError($result) && !hasData($result)) { - - $id = $this->LvgesamtnoteModel->insert( - array( - 'student_uid' => $student_uid, - 'lehrveranstaltung_id' => $lva_id, - 'studiensemester_kurzbz' => $stsem, - 'note' => $note, - 'punkte' => $punkte, - 'mitarbeiter_uid' => getAuthUID(), - 'benotungsdatum' => $jetzt, - 'freigabedatum' => null, - 'freigabevon_uid' => null, - 'bemerkung' => null, - 'updateamum' => null, - 'updatevon' => null, - 'insertamum' => $jetzt, - 'insertvon' => getAuthUID() - ) - ); - if($id) { - $res = $this->LvgesamtnoteModel->load($id->retval); - if(hasData($res)) $lvgesamtnote = getData($res)[0]; - } + // ursprüngliche Note festhalten, bevor sie überschrieben wird: sie entscheidet, ob die + // LV-Note als impliziter erster Antritt zählt + $origLvNote = $bestehendeLvNote ? $bestehendeLvNote->note : null; + $lvgesamtnote = $bestehendeLvNote; - $this->logLib->logInfoDB(array('saveStudentPruefung insert lvnote', $res, array( - 'insertvon' => getAuthUID(), - 'insertamum' => date('Y-m-d H:i:s') - ), getAuthUID(), getAuthPersonId(), $student_uid, $lva_id, $stsem, $note,$punkte, $jetzt)); + if($bestehendeLvNote === null) { + $lvgesamtnote = $this->createLvGesamtnote($lva_id, $student_uid, $stsem, $note, $punkte); - } - else if(!isError($result) && hasData($result)) - { - $lvgesamtnote = getData($result)[0]; - - $orig = getData($result)[0]; - $origLvNote = $lvgesamtnote->note; - $origLvPunkte = $lvgesamtnote->punkte; - $origBenotungsdatum = $lvgesamtnote->benotungsdatum; - + $this->logLib->logInfoDB(array('pruefung: lvnote angelegt', $student_uid, $lva_id, $stsem, + $note, $punkte, getAuthUID(), getAuthPersonId())); + } else { $id = $this->LvgesamtnoteModel->update( - [$lvgesamtnote->student_uid, $lvgesamtnote->studiensemester_kurzbz, $lvgesamtnote->lehrveranstaltung_id], + [$bestehendeLvNote->student_uid, $bestehendeLvNote->studiensemester_kurzbz, $bestehendeLvNote->lehrveranstaltung_id], array( 'note' => $note, 'punkte' => $punkte, @@ -810,42 +821,43 @@ class Noten extends FHCAPI_Controller $res = $this->LvgesamtnoteModel->load($id->retval); if(hasData($res)) $lvgesamtnote = getData($res)[0]; } - - $this->logLib->logInfoDB(array('saveStudentPruefung update lvnote', $res, array( - 'updatevon' => getAuthUID(), - 'updateamum' => date('Y-m-d H:i:s') - ), getAuthUID(), getAuthPersonId(), $student_uid, $lva_id, $stsem, $note,$punkte, $jetzt)); + + $this->logLib->logInfoDB(array('pruefung: lvnote aktualisiert', $student_uid, $lva_id, $stsem, + $note, $punkte, getAuthUID(), getAuthPersonId())); } - - // save pruefung after updating lvnote, since pruefungspunkte get loaded by lv punkte - $pruefungenChanged = $this->savePruefungstermin($typ, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum, $origLvNote, $origLvPunkte, $origBenotungsdatum); - $savedPruefung = $pruefungenChanged['savedPruefung'] ?? null; - $extraPruefung = $pruefungenChanged['extraPruefung'] ?? null; + // save pruefung after updating lvnote, since pruefungspunkte get loaded by lv punkte + $pruefungenChanged = $this->savePruefungstermin($pruefung_id, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum, $origLvNote); + if(is_string($pruefungenChanged)) return $pruefungenChanged; - $savedPruefungData = count($savedPruefung) > 0 ? $savedPruefung[0] : null; - $extraPruefungData = count($extraPruefung) > 0 ? $extraPruefung[0] : null; - - $this->terminateWithSuccess(array($savedPruefungData, $lvgesamtnote, $extraPruefungData)); + $pruefungenChanged['lvgesamtnote'] = $lvgesamtnote; + + return $pruefungenChanged; } /** - * private helper method to update/insert pruefungstermine + * Legt einen Prüfungstermin an oder aktualisiert einen bestehenden. + * + * Termintyp-agnostisch: die Position eines Antritts ergibt sich aus dem Verlauf (Datum bzw. + * Reihenfolge), nicht aus pruefungstyp_kurzbz. Der Alt-Typ wird beim Schreiben nur noch als + * abgeleitete Rückwärtskompatibilitäts-Projektion mitgeschrieben (siehe PruefungsverlaufLib), + * damit bestehende Auswertungen weiterlaufen; zurückgelesen wird er nie. + * + * @param int $pruefung_id gesetzt = genau diesen Datensatz bearbeiten, null = neuer Antritt + * @return array|string geänderte Prüfungen oder eine lokalisierte Fehlermeldung */ - private function savePruefungstermin($typ, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum, $origLvNote = null, $origLvPunkte = null, $origBenotungsdatum = null) + private function savePruefungstermin($pruefung_id, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum, $origLvNote = null) { + // allocating pruefungen before lv note is forbidden (ungefiltert: auch eine noch nicht + // freigegebene LV-Note zählt als vorhanden, siehe getLvGesamtnoteRow) + if($this->getLvGesamtnoteRow($lva_id, $student_uid, $stsem) === null) { + return $this->p->t('benotungstool', 'c4keineLvNoteEingetragen'); + } - // extra check if the student has lvnote and a zeugnisnote in the relevant lva - $resultLV = $this->LvgesamtnoteModel->getLvGesamtNoten($lva_id, $student_uid, $stsem); - $lvgesamtnoteData = getData($resultLV); -// $this->addMeta('lvgesamtnoteData', $lvgesamtnoteData); - - // allocating pruefungen before lv note is forbidden - if($lvgesamtnoteData == null) return $this->p->t('benotungstool', 'c4keineLvNoteEingetragen'); + $pruefungen = $this->VerlaufLib->getPruefungen($student_uid, $lva_id, $stsem); $status = []; - - // TODO: config here? + // send $grades reference to moodle addon Events::trigger( 'getEntschuldigungsStatusForStudentOnDate', @@ -858,261 +870,151 @@ class Noten extends FHCAPI_Controller 'datum' => $datum ] ); - + if(count($status) > 0 && $status[0] == true) { - $entschuldigtNote = $this->resolveSpecialNotes()['entschuldigt']; + $entschuldigtNote = $this->VerlaufLib->getSpecialNotes()['entschuldigt']; // the occurrence limit was validated on the PRE-override note, so re-check it here: only // apply the auto-excuse if it would not exceed the allowed number of excused Termine - if($entschuldigtNote !== null) { - $allRes = $this->LePruefungModel->getPruefungenByUidTypLvStudiensemester($student_uid, null, $lva_id, $stsem); - $existing = (!isError($allRes) && hasData($allRes)) ? getData($allRes) : []; - if(!$this->exceedsNoteOccuranceLimit($existing, $entschuldigtNote, null)) { - $note = $entschuldigtNote; - } + if($entschuldigtNote !== null + && !$this->VerlaufLib->ueberschreitetNotenLimit($pruefungen, $entschuldigtNote, $pruefung_id)) { + $note = $entschuldigtNote; } } - + $jetzt = date("Y-m-d H:i:s"); - + $pruefungenChanged = []; - - if($typ == "Termin2" && defined('CIS_GESAMTNOTE_PRUEFUNG_TERMIN2') && CIS_GESAMTNOTE_PRUEFUNG_TERMIN2) - { - - // Wenn eine Nachprüfung angelegt wird, wird zuerst eine Pruefung mit 1. Termin angelegt welche für die ursprüngliche Note - // vor den Prüfungsantritten zählt - - $result1 = $this->LePruefungModel->getPruefungenByUidTypLvStudiensemester($student_uid, "Termin1", $lva_id, $stsem); - - // if there is a termin 1 entry already do nothing - if(!isError($result1) && hasData($result1)) { - } else if(!isError($result1) && !hasData($result1)) { - // new entry termin1 - - $resultLV = $this->LvgesamtnoteModel->getLvGesamtNoteVorschlag($lva_id, $student_uid, $stsem); - - // update Termin1 note - if ($origLvNote !== null) { - $pr_note = $origLvNote; - $pr_punkte = $origLvPunkte; - $benotungsdatum = $origBenotungsdatum ?? $jetzt; - } - else if (hasData($resultLV)) - { - $lvgesamtnote = getData($resultLV)[0]; - $pr_note = $lvgesamtnote->note; - $pr_punkte = $lvgesamtnote->punkte; - $benotungsdatum = $lvgesamtnote->benotungsdatum; - } - else if(!hasData($resultLV))// set Termin1 note to "noch nicht eingetragen" - { - $this->load->model('education/Note_model', 'NoteModel'); - $result = $this->NoteModel->getNochNichtEingetragenNote(); - $pr_note = getData($result)[0]->note; - $pr_punkte = ''; - $benotungsdatum = $jetzt; - } - - $id = $this->LePruefungModel->insert( - array( - 'lehreinheit_id' => $lehreinheit_id, - 'student_uid' => $student_uid, - 'mitarbeiter_uid' => getAuthUID(), - 'note' => $pr_note, - 'punkte' => $pr_punkte, - 'pruefungstyp_kurzbz' => "Termin1", - 'datum' => $benotungsdatum, - 'anmerkung' => "", - 'insertamum' => $jetzt, - 'insertvon' => getAuthUID(), - 'updateamum' => null, - 'updatevon' => null, - 'ext_id' => null - ) - ); - if($id) { - $res = $this->LePruefungModel->load($id->retval); - if(hasData($res)) $pruefungenChanged['extraPruefung'] = getData($res); - } - - $this->logLib->logInfoDB(array('termin1 created',$res, getAuthUID(), getAuthPersonId())); - + // Bearbeitung: der adressierte Datensatz wird geändert, seine Position im Verlauf ergibt + // sich danach wieder aus dem Datum. Kein Typwechsel, keine Neuanlage. + if($pruefung_id !== null && $pruefung_id !== '') { + $id = $this->LePruefungModel->update( + $pruefung_id, + array( + 'updateamum' => $jetzt, + 'updatevon' => getAuthUID(), + 'note' => $note, + 'punkte' => $punkte, + 'datum' => $datum, + 'anmerkung' => "" + ) + ); + if($id) { + $res = $this->LePruefungModel->load($id->retval); + if(hasData($res)) $pruefungenChanged['savedPruefung'] = getData($res); } - + $this->logLib->logInfoDB(array('pruefung updated', $res, getAuthUID(), getAuthPersonId())); - - // Die Pruefung wird als Termin2 eingetragen. - // Ein bestehender "entschuldigt"-Termin2 bleibt erhalten: in diesem Fall wird die neue - // Pruefung als eigener Datensatz angelegt statt den entschuldigten zu ueberschreiben. - $result2 = $this->LePruefungModel->getPruefungenByUidTypLvStudiensemester($student_uid, "Termin2", $lva_id, $stsem); - - $termin2 = null; - if(!isError($result2) && hasData($result2)) { - foreach(getData($result2) as $t2) { - if(!$this->isEntschuldigtNote($t2->note)) { $termin2 = $t2; break; } - } + if(!isset($pruefungenChanged['savedPruefung'])) { + return $this->p->t('benotungstool', 'c4pruefungNichtGespeichert', [$student_uid]); } - if($termin2 !== null) { - // update existing (non-excused) Termin2 - $id = $this->LePruefungModel->update( - $termin2->pruefung_id, - array( - 'updateamum' => $jetzt, - 'updatevon' => getAuthUID(), - 'note' => $note, - 'punkte' => $punkte, - 'datum' => $datum, - 'anmerkung' => "" - ) - ); - if($id) { - $res = $this->LePruefungModel->load($id->retval); - if(hasData($res)) $pruefungenChanged['savedPruefung'] = getData($res); - } - - $this->logLib->logInfoDB(array('termin2 updated',$res, getAuthUID(), getAuthPersonId())); - - - } else if(!isError($result2)) { - // no editable Termin2 (none yet, or only an entschuldigt one) -> insert new, excused stays - - $id = $this->LePruefungModel->insert( - array( - 'lehreinheit_id' => $lehreinheit_id, - 'student_uid' => $student_uid, - 'mitarbeiter_uid' => getAuthUID(), - 'note' => $note, - 'punkte' => $punkte, - 'pruefungstyp_kurzbz' => $typ, - 'datum' => $datum, - 'anmerkung' => "", - 'insertamum' => $jetzt, - 'insertvon' => getAuthUID(), - 'updateamum' => null, - 'updatevon' => null, - 'ext_id' => null - ) - ); - if($id) { - $res = $this->LePruefungModel->load($id->retval); - if(hasData($res)) $pruefungenChanged['savedPruefung'] = getData($res); - } - - $this->logLib->logInfoDB(array('termin2 inserted',$res, getAuthUID(), getAuthPersonId())); - - } - - } - else if($typ == "Termin3" && defined('CIS_GESAMTNOTE_PRUEFUNG_TERMIN3') && CIS_GESAMTNOTE_PRUEFUNG_TERMIN3) - { - // same entschuldigt-preservation handling as Termin2 - $result3 = $this->LePruefungModel->getPruefungenByUidTypLvStudiensemester($student_uid, "Termin3", $lva_id, $stsem); - - $termin3 = null; - if(!isError($result3) && hasData($result3)) { - foreach(getData($result3) as $t3) { - if(!$this->isEntschuldigtNote($t3->note)) { $termin3 = $t3; break; } - } - } - - if($termin3 !== null) { - // update existing (non-excused) Termin3 - $id = $this->LePruefungModel->update( - $termin3->pruefung_id, - array( - 'updateamum' => $jetzt, - 'updatevon' => getAuthUID(), - 'note' => $note, - 'punkte' => $punkte, - 'datum' => $datum, - 'anmerkung' => "" - ) - ); - if($id) { - $res = $this->LePruefungModel->load($id->retval); - if(hasData($res)) $pruefungenChanged['savedPruefung'] = getData($res); - } - - $this->logLib->logInfoDB(array('termin3 updated',$res, getAuthUID(), getAuthPersonId())); - - } else if(!isError($result3)) { - // no editable Termin3 (none yet, or only an entschuldigt one) -> insert new, excused stays - - $id = $this->LePruefungModel->insert( - array( - 'lehreinheit_id' => $lehreinheit_id, - 'student_uid' => $student_uid, - 'mitarbeiter_uid' => getAuthUID(), - 'note' => $note, - 'punkte' => $punkte, - 'pruefungstyp_kurzbz' => $typ, - 'datum' => $datum, - 'anmerkung' => "", - 'insertamum' => $jetzt, - 'insertvon' => getAuthUID(), - 'updateamum' => null, - 'updatevon' => null, - 'ext_id' => null - ) - ); - if($id) { - $res = $this->LePruefungModel->load($id->retval); - if(hasData($res)) $pruefungenChanged['savedPruefung'] = getData($res); - } - - $this->logLib->logInfoDB(array('termin3 inserted',$res, getAuthUID(), getAuthPersonId())); - - } + $pruefungenChanged['verlauf'] = $this->buildVerlaufSummary($student_uid, $lva_id, $stsem); + return $pruefungenChanged; } - else { - $this->terminateWithError($this->p->t('benotungstool', 'wrongPruefungType', [$student_uid, $typ]), 'general'); + + $verlauf = $this->VerlaufLib->buildVerlauf($pruefungen, $origLvNote); + $rolle = $verlauf->naechsteRolle; + + // Eine Aktion legt genau EINEN Termin an. Der erste Antritt entsteht nicht beiläufig hier, + // sondern bei der Notenfreigabe (upsertErstantritt) - dort wird die Note verbindlich. + + $typ = ($rolle === PruefungsverlaufLib::ROLLE_ERSTANTRITT) + ? $this->VerlaufLib->legacyTypFuerAntritt(1) + : $this->VerlaufLib->legacyTypFuerWiederholung($verlauf); + + $id = $this->LePruefungModel->insert( + array( + 'lehreinheit_id' => $lehreinheit_id, + 'student_uid' => $student_uid, + 'mitarbeiter_uid' => getAuthUID(), + 'note' => $note, + 'punkte' => $punkte, + 'pruefungstyp_kurzbz' => $typ, + 'datum' => $datum, + 'anmerkung' => "", + 'insertamum' => $jetzt, + 'insertvon' => getAuthUID(), + 'updateamum' => null, + 'updatevon' => null, + 'ext_id' => null + ) + ); + if($id) { + $res = $this->LePruefungModel->load($id->retval); + if(hasData($res)) $pruefungenChanged['savedPruefung'] = getData($res); } - + + $this->logLib->logInfoDB(array('pruefung inserted ('.$rolle.')', $res, getAuthUID(), getAuthPersonId())); + + // Schlägt das Insert fehl (zB fehlende lehreinheit_id), darf hier KEIN Erfolg zurückkommen: + // der Client wertet die Antwort sonst als angelegt und zeigt einen leeren Verlauf an. + if(!isset($pruefungenChanged['savedPruefung'])) { + return $this->p->t('benotungstool', 'c4pruefungNichtGespeichert', [$student_uid]); + } + + $pruefungenChanged['verlauf'] = $this->buildVerlaufSummary($student_uid, $lva_id, $stsem); return $pruefungenChanged; } /** - * ranking of the pruefung attempt types, used to detect whether a "höhere" - * (later attempt) pruefung exists for a student + * Verlaufskennzahlen und die abgeleiteten Termine eines Studenten für den Client. Wird nach + * jeder Änderung mitgeliefert, damit die Oberfläche die Regeln nicht selbst nachrechnet und + * ihre Zeilen einfach aus dem Serverstand neu aufbauen kann. */ - private function pruefungAttemptRank($typ) + private function buildVerlaufSummary($student_uid, $lva_id, $stsem) { - switch($typ) { - case 'Termin1': return 1; - case 'Termin2': return 2; - case 'Termin3': return 3; - case 'kommPruef': return 4; - default: return 0; - } + $lvNote = $this->getLvGesamtnoteRow($lva_id, $student_uid, $stsem); + $verlauf = $this->VerlaufLib->getVerlauf( + $student_uid, $lva_id, $stsem, + $lvNote ? $lvNote->note : null, + $this->getZeugnisnote($lva_id, $student_uid, $stsem) + ); + + return $this->verlaufSummary($verlauf, true, $lvNote !== null); } /** - * whether a note is the configured 'entschuldigt' note. Such Termine are preserved as their - * own dated entry instead of being overwritten when a new pruefung of the same type is created. + * @param bool $withPruefungen Termine mitliefern (Schreibpfade) oder nur die Kennzahlen + * @return array */ - private function isEntschuldigtNote($note) + private function verlaufSummary($verlauf, $withPruefungen = false, $hatLvNote = null) { - $entschuldigt = $this->resolveSpecialNotes()['entschuldigt']; - return $entschuldigt !== null && $note == $entschuldigt; + $summary = array( + 'antrittCount' => $verlauf->antrittCount, + 'maxAntritte' => $verlauf->maxAntritte, + 'canAdd' => $verlauf->canAdd, + 'terminal' => $verlauf->terminal, + 'erstantrittMoeglich' => $verlauf->erstantrittMoeglich, + 'naechsteRolle' => $verlauf->naechsteRolle, + // Zeugnisnote ist eine Anrechnung -> Zeile wird angezeigt, ist aber nicht auswählbar und + // bekommt keine Prüfungen + 'angerechnet' => $verlauf->angerechnet, + // ob überhaupt eine LV-Note existiert - ungefiltert, also inklusive noch nicht + // freigegebener. Die Oberfläche kennt über lv_note nur die freigegebene. + 'hatLvNote' => $hatLvNote + ); + + if($withPruefungen) $summary['pruefungen'] = $verlauf->pruefungen; + + return $summary; } /** - * Validates an edit to an existing Termin2/Termin3 pruefung. Returns a localized error - * string if the edit is not allowed, or null if it is. Mirrors the frontend guards so a - * disallowed edit cannot be forced through the API. + * Validates an edit to an existing pruefung. Returns a localized error string if the edit is + * not allowed, or null if it is. Mirrors the frontend guards so a disallowed edit cannot be + * forced through the API. * * Rules: * - The new $datum must stay strictly between the dates of the chronologically adjacent * pruefungen so the attempt order is preserved. - * - Once a later-dated or higher-attempt pruefung already exists the grade may no longer be - * changed (only the datum may be corrected within the bounds above). + * - Once a later attempt exists the grade may no longer be changed (only the datum may be + * corrected within the bounds above). * - * Only guards EDITS: $pruefung_id identifies the record being edited; when it is null (a new - * attempt is being added) nothing is restricted here (adds are validated client-side). + * Only guards EDITS: $pruefung_id identifies the record being edited; when it is null a new + * attempt is being added and validatePruefungAdd applies instead. * * @param string $student_uid * @param int $lva_id @@ -1126,43 +1028,44 @@ class Noten extends FHCAPI_Controller { if($pruefung_id === null || $pruefung_id === '') return null; // add, not an edit - $result = $this->LePruefungModel->getPruefungenByUidTypLvStudiensemester($student_uid, null, $lva_id, $stsem); - if(isError($result) || !hasData($result)) return null; + // Anrechnung: die Leistung wurde vorab anerkannt, an bestehenden Terminen wird nichts geändert + $zeugnisNote = $this->getZeugnisnote($lva_id, $student_uid, $stsem); + if($this->VerlaufLib->istAnrechnungsnote($zeugnisNote)) { + return $this->p->t('benotungstool', 'c4angerechnetKeinePruefung', [$student_uid]); + } - $pruefungen = getData($result); + $pruefungen = $this->VerlaufLib->getPruefungen($student_uid, $lva_id, $stsem); + if(count($pruefungen) === 0) return null; + + $verlauf = $this->VerlaufLib->buildVerlauf($pruefungen, null, $zeugnisNote); // the record being edited $current = null; - foreach($pruefungen as $p) { + foreach($verlauf->pruefungen as $p) { if($p->pruefung_id == $pruefung_id) { $current = $p; break; } } if($current === null) return null; - // only the changeable resit grades are guarded - if($current->pruefungstyp_kurzbz !== 'Termin2' && $current->pruefungstyp_kurzbz !== 'Termin3') return null; + // abschliessende Termine (kommPruef) werden in einem anderen Tool gepflegt + if($current->terminal) return null; - $currentRank = $this->pruefungAttemptRank($current->pruefungstyp_kurzbz); $currentDate = substr((string)$current->datum, 0, 10); $new = substr((string)$newDatum, 0, 10); - // chronological bounds from the immediate date-neighbours + detect later/higher pruefung - $lower = null; $upper = null; $hasLaterOrHigher = false; - foreach($pruefungen as $p) { + // chronologische Grenzen aus den unmittelbaren Datums-Nachbarn + $lower = null; $upper = null; + foreach($verlauf->pruefungen as $p) { if($p->pruefung_id == $current->pruefung_id) continue; $d = substr((string)$p->datum, 0, 10); - if($d !== '') { - if($d < $currentDate) { if($lower === null || $d > $lower) $lower = $d; } - elseif($d > $currentDate) { if($upper === null || $d < $upper) $upper = $d; } - } + if($d === '') continue; - if($d > $currentDate || $this->pruefungAttemptRank($p->pruefungstyp_kurzbz) > $currentRank) { - $hasLaterOrHigher = true; - } + if($d < $currentDate) { if($lower === null || $d > $lower) $lower = $d; } + elseif($d > $currentDate) { if($upper === null || $d < $upper) $upper = $d; } } - // grade is locked once a later/higher pruefung exists - if($hasLaterOrHigher && $newNote != $current->note) { + // grade is locked once a later attempt exists + if($this->VerlaufLib->hatSpaeterenTermin($verlauf, $current->pruefung_id) && $newNote != $current->note) { return $this->p->t('benotungstool', 'pruefungNoteLocked', [$student_uid]); } @@ -1172,7 +1075,7 @@ class Noten extends FHCAPI_Controller } // an edit may also change the note (e.g. to 'entschuldigt') -> keep the occurrence limit - if($this->exceedsNoteOccuranceLimit($pruefungen, $newNote, $current->pruefung_id)) { + if($this->VerlaufLib->ueberschreitetNotenLimit($verlauf->pruefungen, $newNote, $current->pruefung_id)) { return $this->p->t('benotungstool', 'noteOccuranceLimitReached', [$student_uid]); } @@ -1181,10 +1084,11 @@ class Noten extends FHCAPI_Controller /** * Validates ADDING a new pruefung (attempt). Returns an error string if the add is not - * allowed, or null if it is.. + * allowed, or null if it is. * * Rules (Prüfungsordnung §1): - * - Rule A: the number of counting Prüfungsantritte may not exceed the configured maximum + * - Rule A: die Zahl der zählenden Prüfungsantritte darf das Maximum nicht überschreiten, und + * nach einem abschliessenden Termin ist kein weiterer Antritt mehr möglich * - Rule B: attempts are taken in chronological order * - Rule C: a note carrying an occurrence limit (entschuldigt) may not exceed it. * @@ -1197,17 +1101,24 @@ class Noten extends FHCAPI_Controller */ private function validatePruefungAdd($student_uid, $lva_id, $stsem, $note, $datum) { - $result = $this->LePruefungModel->getPruefungenByUidTypLvStudiensemester($student_uid, null, $lva_id, $stsem); - $pruefungen = (!isError($result) && hasData($result)) ? getData($result) : []; + // Anrechnung: wer die Lehrveranstaltung angerechnet bekommen hat, tritt zu keiner Prüfung an + $zeugnisNote = $this->getZeugnisnote($lva_id, $student_uid, $stsem); + if($this->VerlaufLib->istAnrechnungsnote($zeugnisNote)) { + return $this->p->t('benotungstool', 'c4angerechnetKeinePruefung', [$student_uid]); + } + + $pruefungen = $this->VerlaufLib->getPruefungen($student_uid, $lva_id, $stsem); // current LV note (the implicit first Antritt), read before it gets mutated by the caller - $lvNote = null; - $resLv = $this->LvgesamtnoteModel->getLvGesamtNoten($lva_id, $student_uid, $stsem); - if(!isError($resLv) && hasData($resLv)) $lvNote = getData($resLv)[0]->note; + $lvRow = $this->getLvGesamtnoteRow($lva_id, $student_uid, $stsem); + $lvNote = $lvRow ? $lvRow->note : null; - // Rule A: max Antritte - if($this->computeAntrittCount($pruefungen, $lvNote) >= $this->computeMaxAntritte()) { - return $this->p->t('benotungstool', 'maxAntritteReached', [$student_uid, $this->computeMaxAntritte()]); + $verlauf = $this->VerlaufLib->buildVerlauf($pruefungen, $lvNote, $zeugnisNote); + + // Rule A: max Antritte. Der erste Antritt materialisiert nur die ohnehin als Antritt gezählte + // LV-Note und erhöht die Zahl der Antritte daher nicht. + if($verlauf->naechsteRolle !== PruefungsverlaufLib::ROLLE_ERSTANTRITT && !$verlauf->canAdd) { + return $this->p->t('benotungstool', 'maxAntritteReached', [$student_uid, $verlauf->maxAntritte]); } // Rule B: no new attempt on/before an existing dated attempt @@ -1219,131 +1130,14 @@ class Noten extends FHCAPI_Controller } } - // Rule C: occurrence limit (e.g. only one 'entschuldigt' across the fixed Antritte) - if($this->exceedsNoteOccuranceLimit($pruefungen, $note, null)) { + // Rule C: occurrence limit (e.g. only one 'entschuldigt' across the Antritte) + if($this->VerlaufLib->ueberschreitetNotenLimit($pruefungen, $note, null)) { return $this->p->t('benotungstool', 'noteOccuranceLimitReached', [$student_uid]); } return null; } - /** - * Number of counting Prüfungsantritte for a student, mirroring the frontend getAntrittCountStudent: - * a kommPruef caps the count at 4, notes in NOTEN_OHNE_ANTRITT (entschuldigt / noch nicht - * eingetragen) do not count, and when no pruefung counts yet the original LV note counts as the - * first Antritt (unless it is a non-lehre note such as 'angerechnet'). - */ - private function computeAntrittCount($pruefungen, $lvNote) - { - $notenOhneAntritt = $this->resolveSpecialNotes()['ohneAntritt']; - - foreach($pruefungen as $p) { - if($p->pruefungstyp_kurzbz == 'kommPruef') return 4; - } - - $count = 0; - foreach($pruefungen as $p) { - if(!in_array($p->note, $notenOhneAntritt)) $count++; - } - - if($count === 0 && $lvNote !== null && $this->isLehreNote($lvNote)) return 1; - - return $count; - } - - /** - * Maximum number of counting Prüfungsantritte enterable IN THIS TOOL, mirroring the frontend - * maxAntrittCount: the original note (always 1) plus each enabled in-tool retake type (Termin2 and - * the legacy Termin3). kommPruef is the terminal attempt, entered elsewhere, and is intentionally - * not part of this cap. For our installation (Termin2 on, Termin3 off) this is 2; excused Termine do - * not count (see computeAntrittCount), which is what lets Termin2 occur twice within the cap. - */ - private function computeMaxAntritte() - { - $max = 1; - if(defined('CIS_GESAMTNOTE_PRUEFUNG_TERMIN2') && CIS_GESAMTNOTE_PRUEFUNG_TERMIN2) $max++; - if(defined('CIS_GESAMTNOTE_PRUEFUNG_TERMIN3') && CIS_GESAMTNOTE_PRUEFUNG_TERMIN3) $max++; - return $max; - } - - /** - * Whether the given note is flagged as a 'lehre' note in tbl_note. - */ - private function isLehreNote($note) - { - $res = $this->NoteModel->load($note); - if(isError($res) || !hasData($res)) return false; - return (bool) getData($res)[0]->lehre; - } - - /** - * Single source of truth for the "special" note PKs (entschuldigt & noch nicht eingetragen). - * tbl_note is resolved by Bezeichnung (install-independent) and takes precedence; the hardcoded - * config PKs (NOTE_ENTSCHULDIGT / NOTEN_OHNE_ANTRITT) are only a fallback. This removes the second - * source of truth so the entschuldigt-preservation and Antritt-counting logic can't disagree with - * the Bezeichnung lookups on installs whose PKs differ. Cached per request. - * - * @return array{entschuldigt: mixed, ohneAntritt: array, limitMap: array} - */ - private function resolveSpecialNotes() - { - static $cache = null; - if($cache !== null) return $cache; - - $cfgEntschuldigt = $this->config->item('NOTE_ENTSCHULDIGT'); - $cfgOhneAntritt = $this->config->item('NOTEN_OHNE_ANTRITT'); - $cfgLimitMap = $this->config->item('NOTEN_OCCURANCE_LIMIT_MAP'); - if(!is_array($cfgOhneAntritt)) $cfgOhneAntritt = []; - if(!is_array($cfgLimitMap)) $cfgLimitMap = []; - - // resolve by Bezeichnung, fall back to the config PKs - $resEnt = $this->NoteModel->getEntschuldigtNote(); - $entschuldigt = (!isError($resEnt) && hasData($resEnt)) ? getData($resEnt)[0]->note : $cfgEntschuldigt; - - $resNn = $this->NoteModel->getNochNichtEingetragenNote(); - $nochNicht = (!isError($resNn) && hasData($resNn)) ? getData($resNn)[0]->note : ($cfgOhneAntritt[0] ?? null); - - $ohneAntritt = array_values(array_filter([$nochNicht, $entschuldigt], function($v){ return $v !== null; })); - - // re-key the configured entschuldigt limit onto the resolved PK (keep any other entries as-is) - $limitMap = []; - foreach($cfgLimitMap as $k => $v) { - $limitMap[($k == $cfgEntschuldigt) ? $entschuldigt : $k] = $v; - } - - $cache = [ - 'entschuldigt' => $entschuldigt, - 'ohneAntritt' => $ohneAntritt, - 'limitMap' => $limitMap - ]; - return $cache; - } - - /** - * Check for configured occurrence limit (NOTEN_OCCURANCE_LIMIT_MAP) among the given pruefungen. - * $excludePruefungId skips the record currently being edited - * Noten without a configured limit never exceed. - */ - private function exceedsNoteOccuranceLimit($pruefungen, $note, $excludePruefungId = null) - { - $limitMap = $this->resolveSpecialNotes()['limitMap']; - if(!is_array($limitMap)) return false; - - $limit = null; - foreach($limitMap as $limitNote => $limitVal) { - if($limitNote == $note) { $limit = $limitVal; break; } - } - if($limit === null) return false; - - $count = 0; - foreach($pruefungen as $p) { - if($excludePruefungId !== null && $p->pruefung_id == $excludePruefungId) continue; - if($p->note == $note) $count++; - } - - return ($count + 1) > $limit; - } - /** * Enforces the Noteneintragungsfrist (Prüfungsordnung §1) for grade/pruefung entry. Terminates the * request with an error when the deadline for the given studiensemester has passed. Does nothing @@ -1568,22 +1362,22 @@ class Noten extends FHCAPI_Controller /** * POST METHOD - * expects 'uids', 'datum' - * Bulk variant of saveStudentPruefung, used when creating a new Prüfung for several students. Always sets note to - * "noch nicht eingetragen" for the created Prüfung. + * expects 'uids', 'datum', optional 'note'/'punkte' + * Bulk variant of saveStudentPruefung, used when creating a new Prüfung for several students. + * Ohne ausgewählte Note wird "noch nicht eingetragen" gesetzt. */ public function createPruefungen() { - $result = $this->getPostJSON(); + $payload = $this->getPostJSON(); - if(!property_exists($result, 'uids') || !property_exists($result, 'datum')) { + if(!property_exists($payload, 'uids') || !property_exists($payload, 'datum')) { $this->terminateWithError($this->p->t('global', 'missingParameters'), 'general'); } - $uids = $result->uids; - $datum = $result->datum; - $lva_id = $result->lva_id; + $uids = $payload->uids; + $datum = $payload->datum; + $lva_id = $payload->lva_id; - $stsem = $result->sem_kurzbz; + $stsem = $payload->sem_kurzbz; $this->assertLvAccess($lva_id, $stsem); @@ -1592,25 +1386,28 @@ class Noten extends FHCAPI_Controller $ret = []; - $this->load->model('education/Note_model', 'NoteModel'); - $result = $this->NoteModel->getNochNichtEingetragenNote(); - $note = getData($result)[0]->note; + $note = property_exists($payload, 'note') ? $payload->note : null; + $punkte = property_exists($payload, 'punkte') ? $payload->punkte : null; + // Punkteeingabe hat Vorrang: die Note kommt dann aus dem Notenschlüssel + if(CIS_GESAMTNOTE_PUNKTE && $punkte !== null && $punkte !== '' && $punkte >= 0) { + $resNote = $this->NotenschluesselaufteilungModel->getNote($punkte, $lva_id, $stsem); + $note = $this->getDataOrTerminateWithError($resNote); + } + + // ohne Auswahl bleibt der Termin unbenotet + if($note === null || $note === '') { + $resNote = $this->NoteModel->getNochNichtEingetragenNote(); + $note = getData($resNote)[0]->note; + $punkte = null; + } + + // identisch zum Dialog aus der Tabelle, nur für mehrere Studenten: derselbe Kern schreibt + // LV-Note und Termin. Fehler kommen je Zeile als lokalisierte Meldung zurück. foreach ($uids as $student) { - $student_uid = $student->uid; - $typ = $student->typ; - $punkte = null; // new pruefungen never have punkte, - - $lehreinheit_id = $student->lehreinheit_id; - - // server-side add guards (max Antritte, chronological order, occurrence limit) - $addError = $this->validatePruefungAdd($student_uid, $lva_id, $stsem, $note, $datum); - if($addError !== null) { - $ret[$student->uid] = $addError; // string result is surfaced per-student in the UI - continue; - } - - $ret[$student->uid] = $this->savePruefungstermin($typ, $student_uid, $lva_id, $stsem, $lehreinheit_id, $note, $punkte, $datum); + $ret[$student->uid] = $this->savePruefungFuerStudent( + null, $student->uid, $lva_id, $stsem, $student->lehreinheit_id, $note, $punkte, $datum + ); } $this->logLib->logInfoDB(array('createPruefungen',$ret, getAuthUID(), getAuthPersonId())); @@ -1651,22 +1448,11 @@ class Noten extends FHCAPI_Controller // $this->addMeta($pruefung->uid."note", $pruefung->note); } - $student_uid = $pruefung->uid; - $typ = $pruefung->typ; - $note = $pruefung->note; // TODO: parameterize for import maybe - $datum = $pruefung->datum; - $punkte = $pruefung->punkte; - - $lehreinheit_id = $pruefung->lehreinheit_id; - - // server-side add guards (max Antritte, chronological order, occurrence limit) - $addError = $this->validatePruefungAdd($student_uid, $lv_id, $sem_kurzbz, $note, $datum); - if($addError !== null) { - $ret[$student_uid] = $addError; // string result is surfaced per-student in the UI - continue; - } - - $ret[$student_uid] = $this->savePruefungstermin($typ, $student_uid, $lv_id, $sem_kurzbz, $lehreinheit_id, $note, $punkte, $datum); + // identisch zum Dialog aus der Tabelle, nur je Importzeile + $ret[$pruefung->uid] = $this->savePruefungFuerStudent( + null, $pruefung->uid, $lv_id, $sem_kurzbz, $pruefung->lehreinheit_id, + $pruefung->note, $pruefung->punkte, $pruefung->datum + ); } $this->logLib->logInfoDB(array('savePruefungenBulk',$ret, getAuthUID(), getAuthPersonId())); @@ -1674,6 +1460,138 @@ class Noten extends FHCAPI_Controller $this->terminateWithSuccess($ret); } + /** + * Aktuelle LV-Gesamtnote eines Studenten oder null. In den Bulk-Pfaden ist sie zugleich die + * ursprüngliche Note, weil dort - anders als in saveStudentPruefung - vorher nichts + * überschrieben wird. + */ + /** + * Legt den ersten Prüfungstermin an oder aktualisiert ihn (Upsert). + * + * Aufgerufen bei der Notenfreigabe: erst dort wird die Note verbindlich, deshalb entsteht genau + * dann der erste Antritt - und nicht beiläufig beim Anlegen einer Prüfung. Datiert auf das + * Freigabedatum. + * + * Greift nur, solange höchstens ein Termin existiert: sobald Wiederholungen erfasst sind, führt + * die LV-Note die zuletzt erreichte Note und dürfte den ersten Antritt nicht mehr überschreiben. + * Bei einer Anrechnung passiert nichts. + * + * @return void + */ + private function upsertErstantritt($lva_id, $student_uid, $stsem, $note, $punkte, $datum) + { + if($this->VerlaufLib->istAnrechnungsnote($this->getZeugnisnote($lva_id, $student_uid, $stsem))) return; + + $pruefungen = $this->VerlaufLib->getPruefungen($student_uid, $lva_id, $stsem); + if(count($pruefungen) > 1) return; + + $jetzt = date("Y-m-d H:i:s"); + + if(count($pruefungen) === 1) { + $this->LePruefungModel->update( + $pruefungen[0]->pruefung_id, + array( + 'note' => $note, + 'punkte' => $punkte, + 'datum' => $datum, + 'updateamum' => $jetzt, + 'updatevon' => getAuthUID() + ) + ); + + $this->logLib->logInfoDB(array('erstantritt aktualisiert (freigabe)', $student_uid, getAuthUID(), getAuthPersonId())); + return; + } + + // lehreinheit_id serverseitig auflösen statt sie vom Client zu übernehmen + $resLe = $this->LehrveranstaltungModel->getLeByStudent($student_uid, $stsem, $lva_id); + if(isError($resLe) || !hasData($resLe)) return; + $le = current(getData($resLe)); + + $this->LePruefungModel->insert( + array( + 'lehreinheit_id' => $le->lehreinheit_id, + 'student_uid' => $student_uid, + 'mitarbeiter_uid' => getAuthUID(), + 'note' => $note, + 'punkte' => $punkte, + 'pruefungstyp_kurzbz' => $this->VerlaufLib->legacyTypFuerAntritt(1), + 'datum' => $datum, + 'anmerkung' => "", + 'insertamum' => $jetzt, + 'insertvon' => getAuthUID(), + 'updateamum' => null, + 'updatevon' => null, + 'ext_id' => null + ) + ); + + $this->logLib->logInfoDB(array('erstantritt angelegt (freigabe)', $student_uid, getAuthUID(), getAuthPersonId())); + } + + /** + * Zeugnisnote eines Studenten in einer LV, oder null. Anrechnungen werden dort geführt (nicht in + * der LV-Note) und entscheiden darüber, ob überhaupt Prüfungen möglich sind. + * + * @return mixed|null + */ + private function getZeugnisnote($lva_id, $student_uid, $stsem) + { + $this->load->model('education/Zeugnisnote_model', 'ZeugnisnoteModel'); + + $res = $this->ZeugnisnoteModel->load([ + 'studiensemester_kurzbz' => $stsem, + 'student_uid' => $student_uid, + 'lehrveranstaltung_id' => $lva_id + ]); + + return (!isError($res) && hasData($res)) ? getData($res)[0]->note : null; + } + + private function getLvGesamtnoteRow($lva_id, $student_uid, $stsem) + { + // ACHTUNG: getLvGesamtNoten filtert auf 'freigabedatum < NOW()' und liefert daher NUR + // freigegebene Noten. Für die Frage "existiert eine LV-Note?" ist das falsch - eine gerade + // erst angelegte Note hat noch kein Freigabedatum und wäre unsichtbar (Endlos-Fehler + // "keine LV-Note eingetragen" bzw. doppelter Insert). Deshalb hier der ungefilterte Zugriff. + $res = $this->LvgesamtnoteModel->getLvGesamtNoteVorschlag($lva_id, $student_uid, $stsem); + return (!isError($res) && hasData($res)) ? getData($res)[0] : null; + } + + /** + * Legt eine LV-Gesamtnote an. Die Zeugnisnote bleibt unberührt - dieses Tool liest sie nur, + * die Übernahme in das Zeugnis passiert in der Studierendenverwaltung. + * + * @return stdClass|null + */ + private function createLvGesamtnote($lva_id, $student_uid, $stsem, $note, $punkte) + { + $jetzt = date("Y-m-d H:i:s"); + + $id = $this->LvgesamtnoteModel->insert( + array( + 'student_uid' => $student_uid, + 'lehrveranstaltung_id' => $lva_id, + 'studiensemester_kurzbz' => $stsem, + 'note' => $note, + 'punkte' => $punkte, + 'mitarbeiter_uid' => getAuthUID(), + 'benotungsdatum' => $jetzt, + 'freigabedatum' => null, + 'freigabevon_uid' => null, + 'bemerkung' => null, + 'updateamum' => null, + 'updatevon' => null, + 'insertamum' => $jetzt, + 'insertvon' => getAuthUID() + ) + ); + if(!$id) return null; + + $res = $this->LvgesamtnoteModel->load($id->retval); + return hasData($res) ? getData($res)[0] : null; + } + private function getAnwesenheiten($prestudent_ids, $lv_id, $sem_kurzbz) { $anwesenheiten = []; diff --git a/application/controllers/api/frontend/v1/stv/Grades.php b/application/controllers/api/frontend/v1/stv/Grades.php index 128316d2b..abc417bca 100644 --- a/application/controllers/api/frontend/v1/stv/Grades.php +++ b/application/controllers/api/frontend/v1/stv/Grades.php @@ -41,6 +41,9 @@ class Grades extends FHCAPI_Controller // Load Libraries $this->load->library('VariableLib', ['uid' => getAuthUID()]); + $this->load->library('PruefungsverlaufLib', null, 'VerlaufLib'); + + $this->load->config('noten'); // Load Phrases $this->loadPhrases([ @@ -355,23 +358,24 @@ class Grades extends FHCAPI_Controller $data['lehrveranstaltung_id'] = $lehrveranstaltung_id; $data['student_uid'] = $student_uid; $data['studiensemester_kurzbz'] = $studiensemester_kurzbz; - - $this->ZeugnisnoteModel->insert($data); - if (defined('FAS_PRUEFUNG_BEI_NOTENEINGABE_ANLEGEN') - && FAS_PRUEFUNG_BEI_NOTENEINGABE_ANLEGEN) { - $result = $this->addTestsForGrade( - $studiensemester_kurzbz, - $student_uid, - $lehrveranstaltung_id, - $teacherGrade->note, - $teacherGrade->punkte - ); - $this->getDataOrTerminateWithError($result); - } + $this->ZeugnisnoteModel->insert($data); + } + + // Der erste Antritt wird bereits bei der Übernahme als eigene Prüfung angelegt, damit der + // Prüfungsverlauf von Anfang an vollständig ist und das Benotungstool ihn nicht erst beim + // Anlegen einer Wiederholung nachtragen muss. Läuft bewusst in BEIDEN Zweigen (auch beim + // Überschreiben einer bestehenden Zeugnisnote) und ist idempotent. + if ($this->config->item('CIS_GESAMTNOTE_ERSTANTRITT_BEI_UEBERNAHME')) { + $result = $this->addErstantrittForGrade( + $studiensemester_kurzbz, + $student_uid, + $lehrveranstaltung_id, + $teacherGrade + ); + $this->getDataOrTerminateWithError($result); } - $this->terminateWithSuccess(true); } @@ -491,20 +495,32 @@ class Grades extends FHCAPI_Controller } /** - * Helper function that adds tests for a student - * (Entries in lehre.tbl_pruefung) + * Legt bei der Notenübernahme den ersten Prüfungsantritt als eigenen Datensatz an + * (Eintrag in lehre.tbl_pruefung). * - * @param string $studiensemester_kurzbz - * @param string $student_uid - * @param integer $lehrveranstaltung_id - * @param integer $note - * @param numeric $punkte + * Idempotent: existiert für den Studenten in dieser LV bereits ein Termin, passiert nichts - + * die Übernahme darf beliebig oft laufen, ohne den Verlauf zu verfälschen. + * + * Datiert auf das Freigabedatum der LV-Note: das ist der Zeitpunkt, zu dem die + * Leistungsfeststellung verbindlich wurde. Das Benotungsdatum ändert sich bei jeder Korrektur, + * der Übernahmezeitpunkt ist reine Verwaltungsarbeit - beide taugen nicht als Termindatum. + * + * @param string $studiensemester_kurzbz + * @param string $student_uid + * @param integer $lehrveranstaltung_id + * @param stdClass $teacherGrade die übernommene LV-Gesamtnote * * @return stdClass */ - protected function addTestsForGrade($studiensemester_kurzbz, $student_uid, $lehrveranstaltung_id, $note, $punkte) + protected function addErstantrittForGrade($studiensemester_kurzbz, $student_uid, $lehrveranstaltung_id, $teacherGrade) { $this->load->model('education/Lehrveranstaltung_model', 'LehrveranstaltungModel'); + $this->load->model('education/LePruefung_model', 'LePruefungModel'); + + // bereits ein Termin vorhanden -> der Verlauf beginnt schon, nichts nachzutragen + $vorhanden = $this->VerlaufLib->getPruefungen($student_uid, $lehrveranstaltung_id, $studiensemester_kurzbz); + if (count($vorhanden) > 0) + return success(null); // Get Lehreinheit $result = $this->LehrveranstaltungModel->getLeByStudent($student_uid, $studiensemester_kurzbz, $lehrveranstaltung_id); @@ -515,19 +531,24 @@ class Grades extends FHCAPI_Controller return error($this->p->t("stv", "grades_error_lehreinheit_id")); $le = current(getData($result)); + $datum = $teacherGrade->freigabedatum ?: ($teacherGrade->benotungsdatum ?: date('Y-m-d')); + $now = date('Y-m-d H:i:s'); + // Prepare - $this->load->model('education/LePruefung_model', 'LePruefungModel'); $data = [ "student_uid" => $student_uid, "lehreinheit_id" => $le->lehreinheit_id, - "datum" => date('Y-m-d'), - "pruefungstyp_kurzbz" => "Termin1", - "note" => $note + "mitarbeiter_uid" => $teacherGrade->mitarbeiter_uid, + "datum" => $datum, + "pruefungstyp_kurzbz" => $this->VerlaufLib->legacyTypFuerAntritt(1), + "note" => $teacherGrade->note, + "insertamum" => $now, + "insertvon" => getAuthUID() ]; if (defined('CIS_GESAMTNOTE_PUNKTE') && CIS_GESAMTNOTE_PUNKTE) - $data["punkte"] = $punkte; - + $data["punkte"] = $teacherGrade->punkte; + // Get Anwesenheit $this->load->model('education/Anwesenheit_model', 'AnwesenheitModel'); $result = $this->AnwesenheitModel->loadAnwesenheitStudiensemester($studiensemester_kurzbz, $student_uid, $lehrveranstaltung_id); @@ -535,7 +556,8 @@ class Grades extends FHCAPI_Controller return $result; $anwesenheit = getData($result); - if ($anwesenheit && (float)current($anwesenheit)->prozent < FAS_ANWESENHEIT_ROT) { + if (defined('FAS_PRUEFUNG_BEI_NOTENEINGABE_ANLEGEN') && FAS_PRUEFUNG_BEI_NOTENEINGABE_ANLEGEN + && $anwesenheit && (float)current($anwesenheit)->prozent < FAS_ANWESENHEIT_ROT) { // Get Anwesenheitsbefreiung $this->load->model('person/Benutzerfunktion_model', 'BenutzerfunktionModel'); $result = $this->BenutzerfunktionModel->getBenutzerFunktionByUidInStdsem($student_uid, $studiensemester_kurzbz, 'awbefreit'); @@ -545,7 +567,10 @@ class Grades extends FHCAPI_Controller $anwesenheitsbefreit = hasData($result); - // Wenn nicht Anwesenheitsbefreit und Anwesenheit unter einem bestimmten Prozentsatz fällt dann wird ein Pruefungsantritt abgezogen + // Bei zu geringer Anwesenheit ohne Befreiung wird ein zusätzlicher Termin + // 'Nicht beurteilt' vor dem eigentlichen Antritt festgehalten. ACHTUNG: diese Note + // zählt nicht mehr als Antritt (NOTEN_OHNE_ANTRITT_BEZEICHNUNGEN) - der frühere + // "Antritt abziehen"-Effekt entfällt damit, der Eintrag ist reine Dokumentation. if (!$anwesenheitsbefreit) { $data2 = $data; $data2["note"] = 7; @@ -556,8 +581,6 @@ class Grades extends FHCAPI_Controller if (isError($result)) return $result; - - $data["pruefungstyp_kurzbz"] = "Termin2"; } } diff --git a/application/models/education/Lvgesamtnote_model.php b/application/models/education/Lvgesamtnote_model.php index 44645e75b..5afb07e40 100644 --- a/application/models/education/Lvgesamtnote_model.php +++ b/application/models/education/Lvgesamtnote_model.php @@ -55,7 +55,7 @@ class Lvgesamtnote_model extends DB_Model $params = [$student_uid, $studiensemester_kurzbz]; if ($lehrveranstaltung_id) { - $qry .= "AND campus.tbl_lvgesamtnote.lehrveranstaltung_id = ?"; + $qry .= " AND campus.tbl_lvgesamtnote.lehrveranstaltung_id = ?"; $params[] = $lehrveranstaltung_id; } diff --git a/cis/private/lehre/benotungstool/lvgesamtnoteverwalten.php b/cis/private/lehre/benotungstool/lvgesamtnoteverwalten.php index 553e3f03c..3b3689144 100644 --- a/cis/private/lehre/benotungstool/lvgesamtnoteverwalten.php +++ b/cis/private/lehre/benotungstool/lvgesamtnoteverwalten.php @@ -1824,7 +1824,7 @@ $htmlstring .= " "; -if ($anzahlChanged > 0) +if (isset($anzahlChanged) && $anzahlChanged > 0) echo '
' . $p->t('benotungstool/geaenderteNotenVorhanden') . '
'; echo $htmlstring; diff --git a/public/css/Cis4/Benotungstool.css b/public/css/Cis4/Benotungstool.css index 83a026d0e..225cfadf8 100644 --- a/public/css/Cis4/Benotungstool.css +++ b/public/css/Cis4/Benotungstool.css @@ -62,9 +62,63 @@ transform: translateY(-50%) rotate(180deg); } -.pruefung-badge { +/* + * Prueufungszelle: feste Spuren fuer Note | Datum | Aktion. + * Vorher war das eine zentrierte Flex-Zeile, dadurch verschoben laengere Notenbezeichnungen + * ("Nicht Genuegend" vs "Genuegend") Datum und Button - die Zellen standen von Zeile zu Zeile + * nicht mehr untereinander. Mit festen Spurbreiten fluchten sie immer. + */ +.pruefung-cell { position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) 6.5rem; /* Note | Aktion */ + align-items: center; + column-gap: 0.5rem; + height: 100%; padding-left: 32px; /* badge width (20) + its left offset (6) + gap (6) */ + padding-right: 6px; + text-align: left; +} + +/* Spaltenmodus 'antritt': das Datum steht in der Zelle, nicht im Spaltenkopf */ +.pruefung-cell.mit-datum { + grid-template-columns: minmax(0, 1fr) 4.75rem 6.5rem; /* Note | Datum | Aktion */ +} + +/* + * Zellen ohne Aktion (abschliessender Termin, wird in einem anderen Tool gepflegt): die Spur fuer + * den Button entfaellt, der Platz gehoert der Notenbezeichnung. + */ +.pruefung-cell.ohne-aktion { + grid-template-columns: minmax(0, 1fr); /* Note */ +} + +.pruefung-cell.ohne-aktion.mit-datum { + grid-template-columns: minmax(0, 1fr) 4.75rem; /* Note | Datum */ +} + +.pruefung-cell .pruefung-note { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pruefung-cell .pruefung-datum { + font-size: 0.75rem; + white-space: nowrap; + text-align: right; + opacity: 0.7; +} + +/* immer in der letzten Spur, auch wenn Note und Datum fehlen (leere Zelle mit "Hinzufuegen") */ +.pruefung-cell .pruefung-action { + grid-column: -2 / -1; + display: flex; + justify-content: flex-end; +} + +.pruefung-cell .pruefung-action .btn { + white-space: nowrap; } .pruefung-badge::before { @@ -87,4 +141,6 @@ .pruefung-badge.attempt-t1::before { background: #0072B2; } .pruefung-badge.attempt-t2::before { background: #E69F00; } .pruefung-badge.attempt-t3::before { background: #CC79A7; } -.pruefung-badge.attempt-k::before { background: #D55E00; } \ No newline at end of file +.pruefung-badge.attempt-k::before { background: #D55E00; } +/* Termin ohne verbrauchten Antritt (entschuldigt, nicht beurteilt) - traegt keine Nummer */ +.pruefung-badge.attempt-none::before { background: #767676; } \ No newline at end of file diff --git a/public/js/api/factory/noten.js b/public/js/api/factory/noten.js index 964cc5e73..55278964d 100644 --- a/public/js/api/factory/noten.js +++ b/public/js/api/factory/noten.js @@ -49,18 +49,21 @@ export default { params: { lv_id, sem_kurzbz, student_uid, note, punkte } }; }, - saveStudentPruefung(student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, typ, pruefung_id = null){ + // kein Termintyp mehr: welchen Antritt die Prüfung darstellt, leitet der Server aus dem + // bestehenden Prüfungsverlauf des Studenten in dieser LV und diesem Semester ab + saveStudentPruefung(student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, pruefung_id = null){ return { method: 'post', url: '/api/frontend/v1/Noten/saveStudentPruefung', - params: { student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, typ, pruefung_id } + params: { student_uid, note, punkte, datum, lva_id, lehreinheit_id, sem_kurzbz, pruefung_id } }; }, - createPruefungen(uids, datum, lva_id, sem_kurzbz){ + // note/punkte optional: ohne Auswahl wird der Termin als "noch nicht eingetragen" angelegt + createPruefungen(uids, datum, lva_id, sem_kurzbz, note = null, punkte = null){ return { method: 'post', url: '/api/frontend/v1/Noten/createPruefungen', - params: { uids, datum, lva_id, sem_kurzbz } + params: { uids, datum, lva_id, sem_kurzbz, note, punkte } }; }, saveNotenvorschlagBulk(lv_id, sem_kurzbz, noten) { diff --git a/public/js/components/Cis/Benotungstool/Benotungstool.js b/public/js/components/Cis/Benotungstool/Benotungstool.js index 0b727ff88..e4f8a3b7c 100644 --- a/public/js/components/Cis/Benotungstool/Benotungstool.js +++ b/public/js/components/Cis/Benotungstool/Benotungstool.js @@ -64,10 +64,15 @@ export const Benotungstool = { selectedPruefungNote: null, selectedPruefungDate: new Date(), // v-model for pruefung edit datepicker selectedPruefungPunkte: null, - pruefungNoteLocked: false, // grade read-only when a later/higher pruefung exists (date stays editable) + pruefungNoteLocked: false, // grade read-only when a later pruefung exists (date stays editable) pruefungDateMin: null, pruefungDateMax: null, distinctPruefungsDates: null, + // Spaltenaufteilung der Termine: 'antritt' (je Antrittsnummer) oder 'datum' (je Prüfungsdatum). + // null = Vorgabe aus der Konfiguration; die Wahl des Benutzers wird lokal gemerkt. + pruefungsspalten: localStorage.getItem('notenToolPruefungsspalten'), + // Studenten der aktuellen Anlage, für die noch keine LV-Note existiert -> sie entsteht mit + // der Prüfung und wird vorher bestätigt pruefungStudent: null, pruefung: null, password: '', @@ -453,25 +458,15 @@ export const Benotungstool = { } }, validatePruefungBulk(pruefungen) { - // need to check pruefungen for validity in respect to the students nr of antritte - // pruefungsdatum will be validated aswell so we dont get a termin 3 chronologically before - // a termin 2 which is totally possible in the old tool + // Reihenfolge und Antrittsgrenze vorab prüfen, damit offensichtlich unmögliche Zeilen gar + // nicht erst gesendet werden. Eine fehlende LV-Note ist kein Hindernis: liegt für den + // Studenten noch keine Leistungsfeststellung vor, entsteht sie mit der importierten Note. const validatedPruefungen = [] pruefungen.forEach( p => { const student = this.studenten.find(s => s.uid === p.uid) - // if a student doesnt have a lv_note entered definitely dont allow to create pruefung - if(!student.lv_note) { - this.$fhcAlert.alertWarning('Student ' + student.uid + ' hat noch keine LV-Note eingetragen! Es wird keine Prüfung angelegt.') - return - } - - // note: a missing Zeugnisnote is intentionally allowed here so that a dated import can - // create the first attempt for not-yet-graded students (Termin1 falls back to - // "noch nicht eingetragen" server-side). Only the LV-note is mandatory. - // check if student antrittCount is too high already - if(student.hoechsterAntritt >= this.maxAntrittCount) { + if(!this.canAddPruefung(student)) { this.$fhcAlert.alertWarning('Student ' + student.uid + ' hat bereits ' + student.hoechsterAntritt + ' Prüfungsantritte abgelegt. Die Zeile wurde übersprungen.') return } @@ -571,9 +566,7 @@ export const Benotungstool = { } } - const typ = this.getPruefungstypForStudentByAntritt(student) - - pruefungbulk.push({uid: student.uid, datum: dateStr, note, punkte, typ, lehreinheit_id: student.lehreinheit_id, dateObj}) + pruefungbulk.push({uid: student.uid, datum: dateStr, note, punkte, lehreinheit_id: student.lehreinheit_id, dateObj}) }, saveNotenBulk(notenbulk) { this.loading = true @@ -622,7 +615,7 @@ export const Benotungstool = { let errorList = '' Object.keys(res.data ?? {}).forEach(uid => { const entry = res.data[uid] - if(!entry?.savedPruefung && !entry?.extraPruefung) { + if(!entry?.savedPruefung) { errorList += entry + '\n' } }) @@ -643,179 +636,290 @@ export const Benotungstool = { handleAddNewPruefungenResponse(res, uids) { // in case we reload when changing lva_id or stsem to always consider local storage layout this.colLayoutRestored = false; - + const pruefungen = res.data uids.forEach(entry => { const rowResult = pruefungen[entry.uid] - const saved = rowResult?.savedPruefung?.[0] - const extra = rowResult?.extraPruefung?.[0] const student = this.studenten.find(s => s.uid == entry.uid) if(!student) return // a per-row backend rejection returns a localized error string (or produced nothing) -> - // skip it here; callers surface those messages to the user separately - if(!saved) return + // skip it here; callers surface those messages to the user separately. + // savedPruefung ist der Erfolgsnachweis, nicht verlauf: letzterer beschreibt nur den + // Stand und wäre auch bei einem fehlgeschlagenen Insert vorhanden. + if(!rowResult?.savedPruefung || !rowResult?.verlauf) return - // check for extra pruefung (termin1) to add before - if(extra) { - student["Termin1"] = extra - student.pruefungen.push(extra) // push to pruefungen array for antritt evaluation - } - - this.correctOldTerminTypenForStudent(student, saved) - - if(!this.distinctPruefungsDates.includes(saved.datum)) { - this.insertSortedDate(this.distinctPruefungsDates, saved.datum) - } - - // add pruefung to pruefungen array - student.pruefungen.push(saved) - - // add pruefung to student via its datum as a field - student[saved.datum] = saved - - // usually should be in order naturally, just to be save - student.pruefungen.sort((p1, p2) => { - if(p1.datum > p2.datum) { - return 1 - } else if (p1.datum < p2.datum) { - return -1 - } else { - return 0 - } - }) - - // recalculate student antritte - student.hoechsterAntritt = this.getAntrittCountStudent(student) - this.recalculateSelectable(student) - this.reformatStudentRow(student) + // gleiche Nachbereitung wie im Dialog aus der Tabelle: erst LV-Note, dann Verlauf + this.applyLvGesamtnote(student, rowResult.lvgesamtnote) + this.applyVerlauf(student, rowResult.verlauf) }) - // add col to table - const cols = [...this.notenTableOptions.columns.slice(0, -1)]; - let kommCol = null - if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) kommCol = this.notenTableOptions.columns[this.notenTableOptions.columns.length - 1]; - - if(this.distinctPruefungsDates.length) { - cols.push({ - title: this.$capitalize(this.$p.t('benotungstool/c4originalZnote')), - field: "Termin1", - formatter: this.pruefungFormatter, - titleFormatter: this.pruefungTitleFormatter, - topCalc: this.terminCalcFunc, - topCalcFormatter: this.terminCalcFormatter, - hozAlign:"center", - widthGrow: 1, - minWidth: 200, - width: 250, - visible: true, - tooltip: false - }) - } - - this.distinctPruefungsDates.forEach((date)=>{ - const dateparts = date.split('-') - const titledate = `${dateparts[2]}.${dateparts[1]}.${dateparts[0]}` - - cols.push({ - title: titledate,//this.$p.t('benotungstool/pruefungNr', [index+1]), - field: date, - formatter: this.pruefungFormatter, - titleFormatter: this.pruefungTitleFormatter, - topCalc: this.terminCalcFunc, - topCalcFormatter: this.terminCalcFormatter, - hozAlign:"center", - widthGrow: 1, - minWidth: 200, - width: 250, - visible: true, - tooltip: false - }) - }) - - if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) cols.push(kommCol) // keep kommPruef Col as last - // redraw table - - - // preemptively do the persistence part of columns already here so we get the merged definition before - // tabulator internal go to war with vue reactives - let colsUsed = null - const saved = this.loadState(); - if (saved && saved.columns) { - // new columns map for lookup - const colMap = new Map(cols.map(c => [c.field, c])); - - const restoredCols = []; - - // add columns in the SAVED order, applying saved width/visibility - saved.columns.forEach(savedCol => { - const originalDef = colMap.get(savedCol.field); - if (originalDef) { - restoredCols.push({ - ...originalDef, // Keep formatters, calcs, etc. - width: savedCol.width, - visible: savedCol.visible - }); - colMap.delete(savedCol.field); // Remove so we don't double-add - } - }); - - // append any NEW columns aka pruefungs cols - colMap.forEach((def) => { - restoredCols.push(def); - }); - - colsUsed = restoredCols; - this.colLayoutRestored = true; - } - this.loading = false - - const colsFinal = colsUsed ?? cols - this.$refs.notenTable.tabulator.setColumns(colsFinal) - this.$refs.notenTable.tabulator.setData(this.studenten); - this.$refs.notenTable.tabulator.redraw(true); + + // Scrollstand halten: nach einer Bulk-Anlage soll der Benutzer seine Zeilen weiter sehen + this.preserveScroll(() => { + this.applyPruefungColumns() + const loaded = this.$refs.notenTable.tabulator.setData(this.studenten); + this.$refs.notenTable.tabulator.redraw(true); + return loaded + }) + }, + /** + * Zeile aus dem vom Server gelieferten Prüfungsverlauf neu aufbauen. Positionen, Antrittszahl + * und Grenzen kommen ausschliesslich von dort - der Client führt keine eigene Buchhaltung über + * Termine, damit die Regeln nicht in zwei Implementierungen auseinanderlaufen. + */ + /** + * LV-Note einer Zeile aus der Serverantwort übernehmen: Note, Punkte und Freigabestatus. + * Beide Eingabewege (Dialog aus der Tabelle und Sammelanlage) verwenden dasselbe, sonst + * zeigt die Tabelle nach einer Sammelanlage die alte Note. + */ + applyLvGesamtnote(student, lvgesamtnote) { + if(!student || !lvgesamtnote) return + + student.lv_note = lvgesamtnote.note + student.freigabedatum = this.parseDate(lvgesamtnote.freigabedatum) + student.benotungsdatum = this.parseDate(lvgesamtnote.benotungsdatum) + student.freigegeben = this.checkFreigabe(student.freigabedatum, student.benotungsdatum, student.uid) + + if(this.config?.CIS_GESAMTNOTE_PUNKTE) student.punkte = lvgesamtnote.punkte + + // teilnoten ist die Quelle für changedNoten (Zähler und Liste der Freigabe) + if(this.teilnoten && this.teilnoten[student.uid]) { + this.teilnoten[student.uid].note_lv = lvgesamtnote.note + this.teilnoten[student.uid].punkte_lv = lvgesamtnote.punkte + this.teilnoten[student.uid]['freigabedatum'] = lvgesamtnote.freigabedatum + this.teilnoten[student.uid]['benotungsdatum'] = lvgesamtnote.benotungsdatum + } + + this.changedNotenCounter++ // computed changedNoten neu auswerten + }, + applyVerlauf(student, verlauf) { + if(!verlauf) return + + // die Kennzahlen vollständig übernehmen. Nicht einzeln abschreiben: fehlende Felder + // (angerechnet, hatLvNote) sind danach undefined und die Oberfläche urteilt falsch, + // bis die Lehrveranstaltung neu geladen wird. + student.verlauf = {...verlauf} + delete student.verlauf.pruefungen + + student.pruefungen = [] + delete student['kommPruef'] + + ;(verlauf.pruefungen ?? []).forEach(p => { + p.dateObj = this.parseISODate(p.datum) + // der abschliessende Termin steht in einer eigenen, immer letzten Spalte + if(p.terminal) student['kommPruef'] = p + else student.pruefungen.push(p) + }) + + // die flache Liste über alle Studenten mitziehen: sie entscheidet, ob Notenvorschlag und + // Punkte noch bearbeitbar sind (siehe getColumnsDefinition), und war bisher bis zum + // nächsten Laden veraltet + this.pruefungen = (this.pruefungen ?? []).filter(p => p.student_uid !== student.uid) + ;(verlauf.pruefungen ?? []).forEach(p => this.pruefungen.push(p)) + + this.syncDistinctPruefungsDates() + this.indexPruefungen(student) + + student.hoechsterAntritt = this.getAntrittCountStudent(student) + this.recalculateSelectable(student) + this.reformatStudentRow(student) + }, + /** Datumsspalten aus allen Zeilen neu ableiten (nur im Spaltenmodus 'datum' sichtbar). */ + syncDistinctPruefungsDates() { + const dates = new Set() + this.studenten?.forEach(s => (s.pruefungen ?? []).forEach(p => dates.add(p.datum))) + this.distinctPruefungsDates = [...dates].sort() + }, + /** Feldname der Spalte, in der ein Termin dargestellt wird. */ + pruefungField(pruefung, index) { + if(pruefung.terminal) return 'kommPruef' + return this.pruefungsspaltenModus === 'antritt' ? ('antritt_' + (index + 1)) : pruefung.datum + }, + /** + * Termine als Spaltenwerte an der Zeile ablegen. Vorherige Zuordnungen werden zuvor entfernt, + * damit ein Moduswechsel oder ein korrigiertes Prüfungsdatum keine verwaiste Spalte hinterlässt. + */ + indexPruefungen(student) { + (student._pruefungFields ?? []).forEach(f => { delete student[f] }) + + const fields = [] + student.pruefungen.forEach((p, i) => { + const field = this.pruefungField(p, i) + student[field] = p + fields.push(field) + }) + + student._pruefungFields = fields + }, + /** einheitliche Definition einer Prüfungsspalte, unabhängig vom Spaltenmodus */ + pruefungColumnDef(field, title) { + // Die Zelle hat feste Spuren (siehe .pruefung-cell): Badge 32 + Note + Aktion 6.5rem, + // im Antrittsmodus zusätzlich das Datum 4.75rem. Die Mindestbreite muss die festen + // Spuren plus lesbaren Notentext fassen, sonst wird die Note sofort abgeschnitten. + const minWidth = this.pruefungsspaltenModus === 'antritt' ? 320 : 250 + + return { + title, + field, + formatter: this.pruefungFormatter, + titleFormatter: this.pruefungTitleFormatter, + sorter: this.pruefungSorter, + topCalc: this.terminCalcFunc, + topCalcFormatter: this.terminCalcFormatter, + hozAlign: "center", + widthGrow: 1, + minWidth, + width: minWidth, + visible: true, + tooltip: false + } + }, + /** + * Die Prüfungsspalten im aktiven Modus: + * 'antritt' - eine Spalte je Antrittsnummer, das Datum steht in der Zelle. Robust auch dann, + * wenn jeder Student sein eigenes Prüfungsdatum hat. + * 'datum' - eine Spalte je Prüfungsdatum. Kompakt, wenn ganze Jahrgänge am selben Tag + * antreten; der erste Antritt ist hier eine Datumsspalte wie jede andere. + */ + buildPruefungColumns() { + if(this.pruefungsspaltenModus === 'antritt') { + let count = 0 + this.studenten?.forEach(s => { + // eine Spalte mehr, solange bei dieser Zeile noch ein Antritt angelegt werden darf + const needed = (s.pruefungen?.length ?? 0) + (this.canAddPruefung(s) ? 1 : 0) + if(needed > count) count = needed + }) + + return Array.from({length: count}, (_, i) => + this.pruefungColumnDef('antritt_' + (i + 1), this.$capitalize(this.$p.t('benotungstool/c4antrittNr', [i + 1]))) + ) + } + + return (this.distinctPruefungsDates ?? []).map(date => + this.pruefungColumnDef(date, this.formatDatumDMY(date)) + ) + }, + /** + * Prüfungsspalten neu setzen: Basisspalten, dann die Termine im aktiven Modus, zuletzt der + * abschliessende Termin. Einzige Stelle, an der die Spalten gebaut werden. + * + * setColumns baut die gesamte Tabelle neu auf (und setzt dabei den Scrollstand zurück), + * deshalb nur aufrufen, wenn sich die Spalten wirklich geändert haben: beim Bearbeiten einer + * Prüfung oder beim Anlegen auf einem bereits vorhandenen Termin bleiben sie gleich. + */ + applyPruefungColumns(force = false) { + const table = this.$refs.notenTable?.tabulator + if(!table || !this.notenTableOptions) return + + const pruefungCols = this.buildPruefungColumns() + const key = pruefungCols.map(c => c.field).join('|') + + if(!force && key === this._pruefungColumnKey) return + this._pruefungColumnKey = key + + const cols = [...this.notenTableOptions.columns.slice(0, -1)] + const kommCol = this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF + ? this.notenTableOptions.columns[this.notenTableOptions.columns.length - 1] + : null + + pruefungCols.forEach(c => cols.push(c)) + if(kommCol) cols.push(kommCol) // abschliessender Termin bleibt die letzte Spalte + + table.setColumns(this.restoreColumnLayout(cols)) + }, + /** + * Führt eine Tabellenoperation aus, die neu rendert (setColumns / setData / redraw), und + * stellt den Scrollstand danach wieder her - sonst springt die Tabelle nach jedem Speichern + * nach ganz oben links und der Benutzer verliert seine Zeile aus dem Blick. + * + * Gelesen und gesetzt wird direkt am .tabulator-tableholder. Tabulators interne + * rowManager.scrollLeft/scrollTop werden nicht in allen Renderpfaden nachgeführt - vor allem + * horizontal nicht, daher sprang die Tabelle bisher trotz Wiederherstellung nach links. + * + * Wiederhergestellt wird zusätzlich in den nächsten beiden Frames: Tabulator rendert Spalten + * und Zeilen teilweise erst danach und setzt den Scrollstand dabei erneut zurück. Vue.nextTick + * läuft auf der Microtask-Queue und damit zu früh. + */ + preserveScroll(operation) { + const holder = this.$refs.notenTable?.tabulator?.element?.querySelector('.tabulator-tableholder') + + const left = holder?.scrollLeft ?? 0 + const top = holder?.scrollTop ?? 0 + + const result = operation() + + // nichts wiederherzustellen -> auch nicht gegen ein legitimes Scrollen nach oben arbeiten + if(!holder || (!left && !top)) return result + + const restore = () => { + holder.scrollLeft = left + holder.scrollTop = top + } + + restore() + requestAnimationFrame(() => { restore(); requestAnimationFrame(restore) }) + + // setData liefert in Tabulator ein Promise -> nach dem Laden noch einmal + if(result && typeof result.then === 'function') result.then(() => requestAnimationFrame(restore)) + + return result + }, + /** + * Gespeicherte Breite/Sichtbarkeit/Reihenfolge auf frisch gebaute Spalten anwenden, bevor + * Tabulator sie übernimmt - sonst geraten die internen Definitionen mit den Vue-Reactives + * aneinander. + */ + restoreColumnLayout(cols) { + const saved = this.loadState() + if(!saved?.columns) return cols + + const colMap = new Map(cols.map(c => [c.field, c])) + const restored = [] + + // Spalten in der GESPEICHERTEN Reihenfolge, mit gespeicherter Breite/Sichtbarkeit + saved.columns.forEach(savedCol => { + const originalDef = colMap.get(savedCol.field) + if(originalDef) { + restored.push({...originalDef, width: savedCol.width, visible: savedCol.visible}) + colMap.delete(savedCol.field) + } + }) + + colMap.forEach(def => restored.push(def)) // neue Spalten anhängen + + this.colLayoutRestored = true + return restored + }, + /** Spaltenmodus umschalten; die Wahl bleibt lokal gespeichert. */ + setPruefungsspalten(modus) { + if(modus !== 'antritt' && modus !== 'datum') return + + this.pruefungsspalten = modus + localStorage.setItem('notenToolPruefungsspalten', modus) + + this.studenten?.forEach(s => this.indexPruefungen(s)) + + // vertikale Position halten - die Spalten wechseln komplett, die Zeile bleibt dieselbe + this.preserveScroll(() => { + this.applyPruefungColumns(true) + const loaded = this.$refs.notenTable?.tabulator?.setData(this.studenten) + this.$refs.notenTable?.tabulator?.redraw(true) + return loaded + }) }, reformatStudentRow(student) { const table = this.$refs.notenTable.tabulator if(!table) return - + const row = table.rowManager.getRowFromDataObject(student) + if(!row) return // Zeile noch nicht gerendert (zB direkt nach einem Datenwechsel) const rowComponent = row.getComponent() rowComponent.reformat() }, - correctOldTerminTypenForStudent(student, saved) { - // check if student has a preceding pruefung from same type and remove it since in this case - // the new pruefung will have overwritten the old one. - // EXCEPTION: an "entschuldigt" Termin must remain as its own dated entry (it was not an - // attempt, so the new pruefung is an additional dated record, not an overwrite of it). - const oldP = student.pruefungen.find(p => - p.pruefungstyp_kurzbz == saved.pruefungstyp_kurzbz - && p.pruefung_id != saved.pruefung_id - && !(this.config?.NOTE_ENTSCHULDIGT != null && p.note == this.config.NOTE_ENTSCHULDIGT) - ) - if(oldP) { - delete student[oldP.datum] // delete the variable col value - - const pIndex = student.pruefungen.indexOf(oldP) - if (pIndex > -1) { - student.pruefungen.splice(pIndex, 1) - } - // student.pruefungen.splice(student.pruefungen.indexOf(oldP), 1) // delete from pruefungen array - - // check if on that date any other student has a pruefung, if not delete it from distinctPruefungsDates - const other = this.studenten.find(s => s[oldP.datum] !== undefined) - if(!other) { - const dateIndex = this.distinctPruefungsDates.indexOf(oldP.datum); - if (dateIndex > -1) { - this.distinctPruefungsDates.splice(dateIndex, 1); - } - // this.distinctPruefungsDates.splice(this.distinctPruefungsDates.findIndex(oldP.datum), 1) - } - } - }, importPruefungen() { // Prüfungsimport: every row must carry a date: "UID/Matrikelnr Datum Note". // Each row creates a dated exam attempt. @@ -902,13 +1006,8 @@ export const Benotungstool = { }, selectableCheck(row, e) { - const data = row.getData(); - - if(data['kommPruef']) return false - else if(data.hoechsterAntritt >= this.maxAntrittCount) return false // 2 or 3 pruefungen counted - - return true; // student can be selected to add pruefung - + // auswählbar ist, wer überhaupt noch einen Antritt bekommen darf + return this.canAddPruefung(row.getData()); }, getColumnsDefinition() { const columns = [] @@ -967,22 +1066,26 @@ export const Benotungstool = { handleClick: this.selectAllHandler }, width: 50, + minWidth: 50, cssClass: this.stickyClass('selectCol'), field: 'selectCol', title: '' }) - columns.push({title: 'UID', field: 'uid', tooltip: false, topCalc: this.sumCalcFunc, formatter: centeredTextFormatter, cssClass: this.stickyClass('uid')}) - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4mail'))), field: 'email', formatter: this.mailFormatter, tooltip: false, visible: false, variableHeight: true}) - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4antrittCountv2'))), field: 'hoechsterAntritt', formatter: centeredTextFormatter, tooltip: false}) - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4vorname'))), field: 'vorname', formatter: centeredTextFormatter, headerFilter: true, tooltip: false, cssClass: this.stickyClass('vorname')}) - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4nachname'))), field: 'nachname', formatter: centeredTextFormatter, headerFilter: true, cssClass: this.stickyClass('nachname')}) - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4anwesenheitsquote'))), field: 'anwquote', formatter: this.percentFormatter}) - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4mobility'))), field: 'mobility_zusatz', formatter: centeredTextFormatter, headerFilter: true, visible: false}) + // Mindestbreiten: Spalten mit Kopffilter oder Editor brauchen mehr Platz als ihr Titel, + // sonst starten sie so schmal, dass Filterfeld und Werte abgeschnitten sind. + columns.push({title: 'UID', field: 'uid', tooltip: false, topCalc: this.sumCalcFunc, formatter: centeredTextFormatter, minWidth: 110, cssClass: this.stickyClass('uid')}) + columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4mail'))), field: 'email', formatter: this.mailFormatter, tooltip: false, visible: false, minWidth: 170, variableHeight: true}) + columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4antrittCountv2'))), field: 'hoechsterAntritt', formatter: centeredTextFormatter, tooltip: false, minWidth: 100}) + columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4vorname'))), field: 'vorname', formatter: centeredTextFormatter, headerFilter: true, tooltip: false, minWidth: 140, cssClass: this.stickyClass('vorname')}) + columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4nachname'))), field: 'nachname', formatter: centeredTextFormatter, headerFilter: true, minWidth: 140, cssClass: this.stickyClass('nachname')}) + columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4anwesenheitsquote'))), field: 'anwquote', formatter: this.percentFormatter, minWidth: 120}) + columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4mobility'))), field: 'mobility_zusatz', formatter: centeredTextFormatter, headerFilter: true, visible: false, minWidth: 140}) if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_MOODLE_LE_NOTE) { - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4teilnoten'))), field: 'teilnote', formatter: this.teilnotenFormatter, variableHeight: true}) + columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4teilnoten'))), field: 'teilnote', formatter: this.teilnotenFormatter, minWidth: 160, variableHeight: true}) } if(this.config?.CIS_GESAMTNOTE_PUNKTE) { - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4punkte'))), field: 'punkte', + columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4punkte'))), field: 'punkte', + minWidth: 110, editor: this.liveNumberEditor, editable: (cell) => { const rowData = cell.getRow().getData(); @@ -994,6 +1097,7 @@ export const Benotungstool = { }) } columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4notenvorschlag'))), field: 'note_vorschlag', + minWidth: 160, editor: 'list', editorParams: (cell) => { // write original cell value into row to it can be retrieved if edit is cancelled without selection @@ -1032,10 +1136,11 @@ export const Benotungstool = { return '
' + val + '
' } }) - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4notenvorschlagUebernehmen'))), field: 'übernehmen', width: 150, hozAlign: 'center', formatter: this.arrowFormatter, - cellClick: this.saveNote, + columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4notenvorschlagUebernehmen'))), field: 'übernehmen', width: 150, minWidth: 100, hozAlign: 'center', formatter: this.arrowFormatter, + cellClick: this.saveNote, variableHeight: true}) columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4lvnote'))), field: 'lv_note', + minWidth: 160, formatter: this.notenFormatter, headerFilter: 'list', headerFilterParams: () => { @@ -1043,9 +1148,10 @@ export const Benotungstool = { }, headerFilterFunc: this.notenFilterFunc }) - columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4freigabe'))), field: 'freigegeben', formatter: this.freigabeFormatter, variableHeight: true}) + columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4freigabe'))), field: 'freigegeben', formatter: this.freigabeFormatter, minWidth: 130, variableHeight: true}) columns.push({title: Vue.computed(() => this.$capitalize(this.$p.t('benotungstool/c4zeugnisnote'))), field: 'note', + minWidth: 160, formatter: this.notenFormatter, topCalc: this.negativeNotenCalc, topCalcFormatter: this.negativeNotenCalcFormatter, @@ -1061,7 +1167,7 @@ export const Benotungstool = { sorter: this.pruefungSorter, topCalc: this.terminCalcFunc, topCalcFormatter: this.terminCalcFormatter, - hozAlign:"center", minWidth: 150, visible: false, + hozAlign:"center", minWidth: 200, visible: false, tooltip: false }) @@ -1110,8 +1216,7 @@ export const Benotungstool = { const data = row.getData() - const notSelectable = data.pruefungen?.find(p => p.pruefungstyp_kurzbz == 'kommPruef') || data.hoechsterAntritt >= this.maxAntrittCount - if(notSelectable) { + if(!this.canAddPruefung(data)) { const el = row.getElement() el.children[0]?.children[0]?.remove() @@ -1282,182 +1387,127 @@ export const Benotungstool = { return '
'+val+'
' }, - pruefungAttemptRank(typ) { - // ranking of the attempt types so we can compare "höhere" pruefungen - switch(typ) { - case 'Termin1': return 1 - case 'Termin2': return 2 - case 'Termin3': return 3 - case 'kommPruef': return 4 - default: return 0 - } + /** Ob für diese Zeile überhaupt noch ein Antritt angelegt werden darf (Serverstand). */ + canAddPruefung(student) { + return NotenRules.canAddPruefung(student, this.config) }, - hasLaterOrHigherPruefung(student, pruefung) { - // a Termin2/3 grade may no longer be changed once the student already has a - // later-dated exam OR an exam of a higher attempt (e.g. a Termin3/kommPruef - // exists while looking at the Termin2). Mirrors the add-button's youngerPruefung guard. + hasLaterPruefung(student, pruefung) { + // Die Note eines Termins ist gesperrt, sobald im Verlauf ein späterer Termin steht (das + // Datum darf weiterhin korrigiert werden). Die Position kommt vom Server, es wird also + // nicht mehr über den Termintyp verglichen. if(!pruefung) return false - const currentRank = this.pruefungAttemptRank(pruefung.pruefungstyp_kurzbz) - const currentDate = (pruefung.datum ?? '').slice(0, 10) - return (student.pruefungen ?? []).some(p => { - if(p === pruefung) return false - if(p.pruefung_id != null && pruefung.pruefung_id != null && p.pruefung_id === pruefung.pruefung_id) return false - const laterDate = (p.datum ?? '').slice(0, 10) > currentDate - const higherRank = this.pruefungAttemptRank(p.pruefungstyp_kurzbz) > currentRank - return laterDate || higherRank - }) + const alle = [...(student.pruefungen ?? [])] + if(student.kommPruef) alle.push(student.kommPruef) + + return alle.some(p => p.pruefung_id != pruefung.pruefung_id && p.position > pruefung.position) }, + /** + * Zelle einer Prüfungsspalte. Termintyp-agnostisch: welcher Antritt in der Zelle steht, ergibt + * sich aus der Spaltenzuordnung (siehe indexPruefungen); Antrittsnummer und ob der Termin + * zählt, kommen aus dem Verlauf vom Server. + */ pruefungFormatter(cell) { const data = cell.getData() + // Angerechnet: die Zeile bleibt sichtbar (die Person gehört zur Lehrveranstaltung), es gibt + // aber keine Prüfungen dazu - weder anlegen noch ändern. + if(data.verlauf?.angerechnet) return '' + + // Eine bereits im Zeugnis stehende, nicht überschreibbare Note sperrt die Terminspalten. + // Ohne Zeugnisnote bleiben sie offen: dort wird der erste Antritt überhaupt erst erfasst. const noteDef = data.note ? this.notenOptions.find(n => n.note == data.note) : null - if(!data.note || !noteDef?.lkt_ueberschreibbar) { - return '' - } - - const colDef = cell.getColumn().getDefinition() - - // column is just a date, student can have any of his antritte on this date, so we need to get - // student.pruefungen and look for a pruefung with this cols title as date + if(noteDef && !noteDef.lkt_ueberschreibbar) return '' const field = cell.getColumn().getField() - let studentPruefung = null - if(field === 'kommPruef') { studentPruefung = data['kommPruef'] } - else if(field === "Termin1") { studentPruefung = data['Termin1'] } - else { studentPruefung = data.pruefungen.find(p => p.datum === field) } + const studentPruefung = data[field] + + // im Antrittsmodus trägt die Spalte kein Datum -> es steht in der Zelle + const antrittModus = this.pruefungsspaltenModus === 'antritt' + + // feste Aufteilung Note | Datum | Aktion (siehe .pruefung-cell), damit Datum und Button + // über alle Zeilen hinweg fluchten, egal wie lang die Notenbezeichnung ist + const rowDiv = document.createElement('div') + rowDiv.className = antrittModus ? 'pruefung-cell mit-datum' : 'pruefung-cell' + + const addSlot = (className, content) => { + const div = document.createElement('div') + div.className = className + + if(typeof content === 'string') div.textContent = content + else if(content instanceof HTMLElement) div.appendChild(content) + + rowDiv.appendChild(div) + return div + } + + const addButton = (label, title, onClick) => { + const button = document.createElement('button') + button.className = 'btn btn-outline-secondary' + button.textContent = label + if(title) button.title = title + button.addEventListener('click', onClick) + + addSlot('pruefung-action', button) + } - // is this column/cell allowed to have an add pruefung action - const canAdd = field !== 'kommPruef' && field !== "Termin1" && data.hoechsterAntritt < this.maxAntrittCount - - // TODO: check for some time limit maybe? old pruefungen can be changed/created - - // Create root row div - const rowDiv = document.createElement('div'); - rowDiv.className = 'row flex-nowrap'; - rowDiv.style.display = 'flex'; - rowDiv.style.justifyContent = 'center'; - rowDiv.style.alignItems = 'center'; - rowDiv.style.height = '100%'; - - let attemptLabel = '' - let attemptClass = '' if(studentPruefung) { - let color = '' - - - switch(studentPruefung.pruefungstyp_kurzbz) { - case 'Termin1': - color = 'green' - break - case 'Termin2': - color = 'yellow' - break - case 'Termin3': - color = 'orange' - break - case 'kommPruef': - color = 'red' - break - } + // Antrittsnummer als Badge; nicht zählende Termine (entschuldigt, nicht beurteilt) + // tragen keine Nummer und werden neutral dargestellt + const attemptLabel = studentPruefung.terminal + ? 'K' + : (studentPruefung.antritt_nr ? String(studentPruefung.antritt_nr) : '–') + const attemptClass = studentPruefung.terminal + ? 'attempt-k' + : (studentPruefung.zaehlt ? ('attempt-t' + Math.min(studentPruefung.antritt_nr, 3)) : 'attempt-none') - // rowDiv.style.borderLeft = `4px solid ${color}`; - rowDiv.style.marginLeft = "6px"; // small indent so text doesn't overlap - rowDiv.style.boxSizing = "border-box"; + rowDiv.classList.add('pruefung-badge', attemptClass) + rowDiv.setAttribute('data-attempt', attemptLabel) - switch(studentPruefung.pruefungstyp_kurzbz) { - case 'Termin1': attemptLabel = '1'; attemptClass = 'attempt-t1'; break - case 'Termin2': attemptLabel = '2'; attemptClass = 'attempt-t2'; break - case 'Termin3': attemptLabel = '3'; attemptClass = 'attempt-t3'; break - case 'kommPruef': attemptLabel = 'K'; attemptClass = 'attempt-k'; break - } + // der abschliessende Termin wird in einem anderen Tool gepflegt -> keine Aktion, und + // der dafür reservierte Platz gehört der Notenbezeichnung + if(studentPruefung.terminal) rowDiv.classList.add('ohne-aktion') + + const noteDefEntry = this.notenOptions.find(n => n.note == studentPruefung.note) + const bezeichnung = noteDefEntry?.bezeichnung || '' + addSlot('pruefung-note', bezeichnung).title = bezeichnung + + if(antrittModus) addSlot('pruefung-datum', this.formatDatumDMY(studentPruefung.datum)) + + if(studentPruefung.terminal) return rowDiv + + // sobald ein späterer Termin existiert ist die NOTE gesperrt, das Datum darf aber noch + // korrigiert werden -> Button bleibt aktiv, die Note wird im Modal gesperrt + const noteLocked = this.hasLaterPruefung(data, studentPruefung) + + addButton( + this.$capitalize(this.$p.t('benotungstool/changePruefungButtonText')), + noteLocked ? this.$capitalize(this.$p.t('benotungstool/pruefungNoteLockedHint')) : null, + () => this.openPruefungModal(data, studentPruefung, field) + ) + + return rowDiv } - - function createCol(content, classParam) { - const colDiv = document.createElement('div'); - colDiv.className = classParam ?? 'col-4'; - colDiv.style.justifyContent = 'center'; - colDiv.style.alignItems = 'center'; - colDiv.style.alignContent = 'center'; - colDiv.style.height = '100%'; - if (typeof content === 'string') { - colDiv.textContent = content; - } else if (content instanceof HTMLElement) { - colDiv.appendChild(content); - } + // leere Zelle: Anlegen nur, wenn überhaupt noch ein Antritt möglich ist und die Spalte + // chronologisch nach allen bereits eingetragenen Terminen liegt + if(field === 'kommPruef' || !this.canAddPruefung(data)) return '' - return colDiv; - } - - if(data[field]) { - - const noteDefEntry = data.note ? this.notenOptions.find(n => n.note == data[field].note) : null - - if(attemptClass) rowDiv.classList.add('pruefung-badge', attemptClass); - if(attemptLabel) rowDiv.setAttribute('data-attempt', attemptLabel); - - // Second column (note_bezeichnung) - rowDiv.appendChild(createCol(noteDefEntry.bezeichnung || '', 'col-auto d-flex justify-content-center align-items-center')); - - // no actions on kommPruef allowed - // no actions on termin1 aka pruefung 0 aka ursprüngliche note erlaubt - if(field === 'kommPruef' || field === "Termin1") { - // Prüfungsordnung §2: for the original Zeugnisnote (Termin1 = first Antritt) optionally - // show its Benotungsdatum inline next to the grade - if(field === "Termin1" - && this.config?.SHOW_BENOTUNGSDATUM_ON_NOTENVORSCHLAG_UEBERNAHME - && studentPruefung?.datum) { - const dateDiv = createCol(this.formatDatumDMY(studentPruefung.datum), 'col-auto d-flex justify-content-center align-items-center') - dateDiv.classList.add('text-muted') - dateDiv.style.fontSize = '0.75rem' - dateDiv.style.whiteSpace = 'nowrap' - dateDiv.style.marginLeft = '6px' - rowDiv.appendChild(dateDiv) - } - return rowDiv - } - - if(data[field]?.pruefungstyp_kurzbz !== 'Termin1') { - // once a later-dated or higher-attempt pruefung exists (e.g. a Termin3/kommPruef has - // already been entered) the GRADE is locked, but the exam DATE may still be corrected - // (within the neighbouring exam dates) -> keep the button enabled, lock the note in the modal - const noteLocked = this.hasLaterOrHigherPruefung(data, data[field]) - - // Third column (button) - const button = document.createElement('button'); - button.className = 'btn btn-outline-secondary'; - button.textContent = this.$capitalize(this.$p.t('benotungstool/changePruefungButtonText')); - if(noteLocked) { - button.title = this.$capitalize(this.$p.t('benotungstool/pruefungNoteLockedHint')) - } - button.addEventListener('click', () => { - this.openPruefungModal(data, data[field], field); - }); - - rowDiv.appendChild(createCol(button, 'col-4 d-flex justify-content-center align-items-center')); - } - - return rowDiv; - - } else if (canAdd) { // return new btn action - // dont render the add button in cells where a younger pruefung exists for the students - const youngerPruefung = data.pruefungen.find(p => p.datum > field) - if(youngerPruefung) return rowDiv - - const button = document.createElement('button'); - button.className = 'btn btn-outline-secondary'; - button.textContent = this.$capitalize(this.$p.t('benotungstool/addPruefungButtonText')); - button.addEventListener('click', () => { - this.openPruefungModal(data, null, field) - }); - - rowDiv.appendChild(createCol(button), 'col-4 d-flex justify-content-center align-content-center'); - - return rowDiv; + if(antrittModus) { + // nur die unmittelbar nächste freie Antrittsspalte darf einen Neuanlage-Button zeigen + if(field !== ('antritt_' + ((data.pruefungen?.length ?? 0) + 1))) return '' } else { - return '' + // keine Neuanlage vor einem bereits eingetragenen Termin + if((data.pruefungen ?? []).some(p => p.datum >= field)) return '' } + + addButton( + this.$capitalize(this.$p.t('benotungstool/addPruefungButtonText')), + null, + () => this.openPruefungModal(data, null, field) + ) + + return rowDiv }, parseISODate(iso) { const parts = (iso ?? '').slice(0, 10).split('-') @@ -1495,15 +1545,13 @@ export const Benotungstool = { openPruefungModal(student, pruefung = null, field) { this.pruefungStudent = student this.pruefung = pruefung - const dateStr = this.pruefung?.datum ?? field - const pruefungDateParts = dateStr.split('-') + // Im Antrittsmodus trägt die Spalte kein Datum -> beim Anlegen mit heute vorbelegen. + const dateStr = this.pruefung?.datum ?? (/^\d{4}-\d{2}-\d{2}$/.test(field) ? field : null) + this.selectedPruefungDate = dateStr ? this.parseISODate(dateStr) : new Date() - const newDate = new Date(Number(pruefungDateParts[0]), Number(pruefungDateParts[1]) - 1, Number(pruefungDateParts[2])) - this.selectedPruefungDate = newDate - - // grade is locked once a later/higher pruefung exists; only the date may be corrected - this.pruefungNoteLocked = !!(pruefung && this.hasLaterOrHigherPruefung(student, pruefung)) + // grade is locked once a later pruefung exists; only the date may be corrected + this.pruefungNoteLocked = !!(pruefung && this.hasLaterPruefung(student, pruefung)) // constrain the date to stay between the neighbouring exam dates const bounds = this.getPruefungDateBounds(student, pruefung, field) @@ -1565,17 +1613,6 @@ export const Benotungstool = { buildMailToLink(student){ return 'mailto:' + student.uid +'@'+ this.domain }, - insertSortedDate(arr, dateStr) { - // Binary search to find insertion index - let left = 0, right = arr.length; - while (left < right) { - const mid = (left + right) >> 1; - if (arr[mid] < dateStr) left = mid + 1; - else right = mid; - } - arr.splice(left, 0, dateStr); // insert at index - return arr; - }, tableResolve(resolve) { this.tableBuiltResolve = resolve }, @@ -1585,7 +1622,7 @@ export const Benotungstool = { async setupData(data){ // in case we reload when changing lva_id or stsem to always consider local storage layout this.colLayoutRestored = false; - + this.studenten = data[0] ?? [] this.studenten.forEach(s => { s.pruefungen = [] @@ -1594,58 +1631,36 @@ export const Benotungstool = { }) this.pruefungen = data[1] ?? [] this.domain = data[2] - - // contains notenvorschläge from moodle, lv_note + + // contains notenvorschläge from moodle, lv_note und den Prüfungsverlauf je Student this.teilnoten = data[3] ?? [] - - // let pruefungenRegularColCount = 0; + this.distinctPruefungsDates = [] - const cols = [...this.notenTableOptions.columns.slice(0, -1)]; - let kommCol = null - if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) kommCol = this.notenTableOptions.columns[this.notenTableOptions.columns.length - 1]; - + this.pruefungen?.forEach(p => { - const dateParts = p.datum.split('-') - p.dateObj = new Date(dateParts[0], +(dateParts[1]) - 1, dateParts[2]) - const student = this.studenten.find(s => s.uid === p.student_uid) if(!student) return - - if(p.pruefungstyp_kurzbz !== 'kommPruef' - && p.pruefungstyp_kurzbz !== 'Termin1' - && !this.distinctPruefungsDates.includes(p.datum)) { - this.distinctPruefungsDates.push(p.datum) - } - - // seperate kommPruefungen from previous pruefungen counts since the column count variability always ends with this - if(p.pruefungstyp_kurzbz == 'kommPruef') { + + p.dateObj = this.parseISODate(p.datum) + + // der abschliessende Termin steht in einer eigenen, immer letzten Spalte + if(p.terminal) { student['kommPruef'] = p } else { student.pruefungen.push(p) + if(!this.distinctPruefungsDates.includes(p.datum)) this.distinctPruefungsDates.push(p.datum) } - }) + this.distinctPruefungsDates.sort() + this.studenten?.forEach(s => { - // sort students regular pruefungen by datum - s.pruefungen.sort((p1, p2) => { - if(p1.datum > p2.datum) { - return 1 - } else if (p1.datum < p2.datum) { - return -1 - } else { - return 0 - } - }) - // set the sorted pruefungen to their respective column fields - s.pruefungen.forEach((p, i) => { - // Termin1 aka originale Zeugnisnote should note create variable columns - if(p.pruefungstyp_kurzbz == "Termin1") { - s["Termin1"] = p - } else { - s[p.datum] = p - } - }) + // der Server liefert die Termine bereits in Verlaufsreihenfolge; hier nur absichern + s.pruefungen.sort((p1, p2) => (p1.position ?? 0) - (p2.position ?? 0)) + + // Antrittszahl, Grenzen und die nächste mögliche Rolle kommen vom Server + s.verlauf = this.teilnoten[s.uid]?.verlauf ?? null + this.indexPruefungen(s) s.hoechsterAntritt = this.getAntrittCountStudent(s) // multiselect label, uid first: "uid – Nachname Vorname – Antritte: n" @@ -1656,9 +1671,9 @@ export const Benotungstool = { s.freigabedatum = this.parseDate(this.teilnoten[s.uid]['freigabedatum']) s.benotungsdatum = this.parseDate(this.teilnoten[s.uid]['benotungsdatum']) s.freigegeben = this.checkFreigabe(s.freigabedatum, s.benotungsdatum, s.uid); - + s.punkte = this.teilnoten[s.uid].punkte_lv - + const grades = this.teilnoten[s.uid].grades s.teilnote = '' s.mobility_zusatz = this.teilnoten[s.uid].mobility_zusatz @@ -1668,111 +1683,13 @@ export const Benotungstool = { if(notenOption.positiv) s.teilnote += (''+g.text +''+ '
') else s.teilnote += (''+g.text +''+ '
') }) - - const vueThis = this - Object.defineProperty(s, 'selectable', { - get() { - const kP = s.pruefungen?.find(p => p.pruefungstyp_kurzbz == 'kommPruef') - const maxAntrittReached = s.hoechsterAntritt >= vueThis.maxAntrittCount - return !(kP || maxAntrittReached) - - }, - set() { - // empty setter so tabulator doesnt scream - }, - enumerable: true, - configurable: true - }) - - }) - - // if we have any pruefungen at all someone must have original note/termin1 pruefung - if(this.distinctPruefungsDates.length) { - cols.push({ - title: this.$capitalize(this.$p.t('benotungstool/c4originalZnote')), - field: "Termin1", - formatter: this.pruefungFormatter, - titleFormatter: this.pruefungTitleFormatter, - sorter: this.pruefungSorter, - topCalc: this.terminCalcFunc, - topCalcFormatter: this.terminCalcFormatter, - hozAlign:"center", - widthGrow: 1, - minWidth: 200, - width: 250, - visible: true, - tooltip: false - }) - } - this.distinctPruefungsDates.sort((d1, d2) => { - if(d1 > d2) { - return 1 - } else if (d1 < d2) { - return -1 - } else { - return 0 - } - }) - this.distinctPruefungsDates.forEach((date)=>{ - const dateparts = date.split('-') - const titledate = `${dateparts[2]}.${dateparts[1]}.${dateparts[0]}` - - cols.push({ - title: titledate, - field: date, - formatter: this.pruefungFormatter, - titleFormatter: this.pruefungTitleFormatter, - sorter: this.pruefungSorter, - topCalc: this.terminCalcFunc, - topCalcFormatter: this.terminCalcFormatter, - hozAlign:"center", - widthGrow: 1, - minWidth: 200, - width: 250, - visible: true, - tooltip: false - }) + this.recalculateSelectable(s) }) - if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) cols.push(kommCol) // keep kommPruef Col as last + // frisch geladene LV -> Spalten in jedem Fall neu aufbauen, Scrollstand bewusst zurücksetzen + this.applyPruefungColumns(true) - - // preemptively do the persistence part of columns already here so we get the merged definition before - // tabulator internal go to war with vue reactives - let colsUsed = null - const saved = this.loadState(); - if (saved && saved.columns) { - // new columns map for lookup - const colMap = new Map(cols.map(c => [c.field, c])); - - const restoredCols = []; - - // add columns in the SAVED order, applying saved width/visibility - saved.columns.forEach(savedCol => { - const originalDef = colMap.get(savedCol.field); - if (originalDef) { - restoredCols.push({ - ...originalDef, // Keep formatters, calcs, etc. - width: savedCol.width, - visible: savedCol.visible - }); - colMap.delete(savedCol.field); // Remove so we don't double-add - } - }); - - // append any NEW columns aka pruefungs cols - colMap.forEach((def) => { - restoredCols.push(def); - }); - - colsUsed = restoredCols; - this.colLayoutRestored = true; - } - - const colsFinal = colsUsed ?? cols - - this.$refs.notenTable.tabulator.setColumns(colsFinal) this.$refs.notenTable.tabulator.setData(this.studenten); this.$refs.notenTable.tabulator.redraw(true); @@ -2002,9 +1919,6 @@ export const Benotungstool = { getOptionLabelLe(option) { return option.infoString }, - getPruefungstypForStudentByAntritt(student) { - return NotenRules.pruefungstypForAntritt(student, this.config) - }, savePruefungEingabe() { // keep the date within the neighbouring exam dates (a typed value can bypass the picker bounds) if((this.pruefungDateMin && this.selectedPruefungDate < this.pruefungDateMin) || @@ -2018,11 +1932,7 @@ export const Benotungstool = { const day = String(this.selectedPruefungDate.getDate()).padStart(2, '0'); const dateStr = `${year}-${month}-${day}`; - // first pruefung is always "Termin2" since normal note counts as Termin1 - // const pOffset = this.pruefung === null && this.pruefungStudent.pruefungen.length === 0 ? 2 : 1 - - const typ = this.pruefung ? this.pruefung.pruefungstyp_kurzbz : this.getPruefungstypForStudentByAntritt(this.pruefungStudent) - // when the grade is locked (later/higher pruefung exists) keep the existing note untouched + // when the grade is locked (later pruefung exists) keep the existing note untouched const note = this.pruefungNoteLocked && this.pruefung ? this.pruefung.note : (this.selectedPruefungNote?.note ?? 9) // noch nicht eingetragen @@ -2039,7 +1949,6 @@ export const Benotungstool = { this.lv_id, this.pruefungStudent.lehreinheit_id, this.sem_kurzbz, - typ, this.pruefung?.pruefung_id ?? null // null = adding a new pruefung, otherwise edit of this record )).then(res => { if(res.meta.status === 'success') { //'Prüfung für Student ' + this.pruefungStudent.uid + ' bearbeitet oder angelegt' @@ -2050,182 +1959,32 @@ export const Benotungstool = { true ) const s = this.studenten.find(s => s.uid === res.data[1]?.student_uid) - - s.freigabedatum = this.parseDate(res.data[1]?.['freigabedatum']) - s.benotungsdatum = this.parseDate(res.data[1]?.['benotungsdatum']) - - s.freigegeben = this.checkFreigabe(s.freigabedatum, s.benotungsdatum, s.uid); - - s.lv_note = res.data[1]?.note - const oldScrollLeft = this.$refs.notenTable.tabulator.rowManager.scrollLeft - const oldScrollTop = this.$refs.notenTable.tabulator.rowManager.scrollTop - - // add new pruefung to row - if(!this.pruefung) { - this.handleAddNewTermin(res.data, s) - } else { // update existing - const oldIndex = s.pruefungen.findIndex(p => p.pruefung_id == this.pruefung.pruefung_id) - if(oldIndex !== -1) { - s.pruefungen.splice(oldIndex, 1, res.data[0]) - s[res.data[0].datum] = res.data[0] - } - // antritte might have changed due to different benotung - s.hoechsterAntritt = this.getAntrittCountStudent(s) - this.recalculateSelectable(s) - this.reformatStudentRow(s) - } - - this.$refs.notenTable.tabulator.redraw(true) - Vue.nextTick(()=> { - const table = this.$refs.notenTable.tabulator.element.querySelector('.tabulator-tableholder') - if(table) { - table.scrollLeft = oldScrollLeft; - table.scrollTop = oldScrollTop; - } + this.applyLvGesamtnote(s, res.data[1]) + + // Zeile komplett aus dem Serververlauf neu aufbauen: das deckt Anlegen und + // Bearbeiten gleichermassen ab. Scrollstand halten, damit die bearbeitete Zeile + // im Blick bleibt. + this.colLayoutRestored = false + this.preserveScroll(() => { + this.applyVerlauf(s, res.data[2]) + this.applyPruefungColumns() + this.$refs.notenTable.tabulator.redraw(true) }) - } }).finally(()=> { this.pruefungStudent = null this.pruefung = null this.loading = false }) - + this.$refs.modalContainerPruefung.hide() }, - handleAddNewTermin(data, student){ - this.colLayoutRestored = false; // always keep persistence options in mind when modifying table cols dynamically - - const savedPruefung = data[0] - const extra = data[2] - - // check for extra pruefung (termin1) to add before - if(extra) { - student["Termin1"] = extra - student.pruefungen.push(extra) // push to pruefungen array for antritt evaluation - } - - this.correctOldTerminTypenForStudent(student, savedPruefung) - - if(!this.distinctPruefungsDates.includes(savedPruefung.datum)) { - this.insertSortedDate(this.distinctPruefungsDates, savedPruefung.datum) - } - - // add pruefung to pruefungen array - student.pruefungen.push(savedPruefung) - - // add pruefung to student via its datum as a field - student[savedPruefung.datum] = savedPruefung - - // usually should be in order naturally, just to be save - student.pruefungen.sort((p1, p2) => { - if(p1.datum > p2.datum) { - return 1 - } else if (p1.datum < p2.datum) { - return -1 - } else { - return 0 - } - }) - - // recalculate student antritte - - student.hoechsterAntritt = this.getAntrittCountStudent(student) - this.recalculateSelectable(student) - this.reformatStudentRow(student) - - // add col to table - const cols = [...this.notenTableOptions.columns.slice(0, -1)]; - let kommCol = null - if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) kommCol = this.notenTableOptions.columns[this.notenTableOptions.columns.length - 1]; - - if(this.distinctPruefungsDates.length) { - cols.push({ - title: this.$capitalize(this.$p.t('benotungstool/c4originalZnote')), - field: "Termin1", - formatter: this.pruefungFormatter, - titleFormatter: this.pruefungTitleFormatter, - sorter: this.pruefungSorter, - topCalc: this.terminCalcFunc, - topCalcFormatter: this.terminCalcFormatter, - hozAlign:"center", - widthGrow: 1, - minWidth: 200, - width: 250, - visible: true, - tooltip: false - }) - } - - this.distinctPruefungsDates.forEach((date)=>{ - const dateparts = date.split('-') - const titledate = `${dateparts[2]}.${dateparts[1]}.${dateparts[0]}` - - cols.push({ - title: titledate,//this.$p.t('benotungstool/pruefungNr', [index+1]), - field: date, - formatter: this.pruefungFormatter, - titleFormatter: this.pruefungTitleFormatter, - sorter: this.pruefungSorter, - topCalc: this.terminCalcFunc, - topCalcFormatter: this.terminCalcFormatter, - hozAlign:"center", - widthGrow: 1, - minWidth: 200, - width: 250, - visible: true, - tooltip: false - }) - }) - - if(this.config?.CIS_GESAMTNOTE_PRUEFUNG_KOMMPRUEF) cols.push(kommCol) // keep kommPruef Col as last - - - // preemptively do the persistence part of columns already here so we get the merged definition before - // tabulator internal go to war with vue reactives - let colsUsed = null - const saved = this.loadState(); - if (saved && saved.columns) { - // new columns map for lookup - const colMap = new Map(cols.map(c => [c.field, c])); - - const restoredCols = []; - - // add columns in the SAVED order, applying saved width/visibility - saved.columns.forEach(savedCol => { - const originalDef = colMap.get(savedCol.field); - if (originalDef) { - restoredCols.push({ - ...originalDef, // Keep formatters, calcs, etc. - width: savedCol.width, - visible: savedCol.visible - }); - colMap.delete(savedCol.field); // Remove so we don't double-add - } - }); - - // append any NEW columns aka pruefungs cols - colMap.forEach((def) => { - restoredCols.push(def); - }); - - colsUsed = restoredCols; - this.colLayoutRestored = true; - } - - - const colsFinal = colsUsed ?? cols - this.$refs.notenTable.tabulator.setColumns(colsFinal) - // this.$refs.notenTable.tabulator.redraw(true) - // redraw table outside this function - }, recalculateSelectable(student) { const vueThis = this Object.defineProperty(student, 'selectable', { get() { - const kP = student.pruefungen?.find(p => p.pruefungstyp_kurzbz == 'kommPruef') - return !(kP || student.hoechsterAntritt >= vueThis.maxAntrittCount) + return vueThis.canAddPruefung(student) }, set() { // empty setter so tabulator doesnt scream @@ -2268,6 +2027,10 @@ export const Benotungstool = { this.$refs.modalContainerNotenSpeichern.show() }, openNewPruefungsdatumModal() { + // gleiche Eingaben wie im Dialog aus der Tabelle -> beide starten leer, ohne Note + this.selectedPruefungNote = null + this.selectedPruefungPunkte = null + this.selectedPruefungDate = new Date() this.$refs.modalContainerNeuesPruefungsdatum.show() }, openPruefungImportModal() { @@ -2280,34 +2043,18 @@ export const Benotungstool = { return option.bezeichnung }, addPruefung(){ - - this.$refs.modalContainerNeuesPruefungsdatum.hide() - - // filter students that already have a pruefung on datum - const year = this.selectedPruefungDate.getFullYear(); const month = String(this.selectedPruefungDate.getMonth() + 1).padStart(2, '0'); // Months are 0-based const day = String(this.selectedPruefungDate.getDate()).padStart(2, '0'); const dateStrDb = `${year}-${month}-${day}`; const dateStrFront = `${day}.${month}.${year}`; - - const uids = [] - // const uids = this.selectedUids.map(student => { - this.selectedUids.forEach(student => { - - // if a student doesnt have a lv_note entered definitely dont allow to create pruefung - if(!student.lv_note) { - this.$fhcAlert.alertWarning('Student ' + student.uid + ' hat noch keine LV-Note eingetragen! Es wird keine Prüfung angelegt.') - return - } - if(!student.note) { - this.$fhcAlert.alertWarning('Student ' + student.uid + ' hat noch keine Zeugnis Note eingetragen! Es wird keine Prüfung angelegt.') - return - } + const uids = [] + + this.selectedUids.forEach(student => { // check if student antrittCount is too high already - if(student.hoechsterAntritt >= this.maxAntrittCount) { + if(!this.canAddPruefung(student)) { this.$fhcAlert.alertWarning('Student ' + student.uid + ' hat bereits ' + student.hoechsterAntritt + ' Prüfungsantritte abgelegt. Es wird keine Prüfung angelegt.') return } @@ -2323,46 +2070,53 @@ export const Benotungstool = { uids.push({ uid: student.uid, - lehreinheit_id: student.lehreinheit_id, - typ: this.getPruefungstypForStudentByAntritt(student)//student.hoechsterAntritt + lehreinheit_id: student.lehreinheit_id }) }) - + + this.$refs.modalContainerNeuesPruefungsdatum.hide() + + if(!uids.length) return + + // eine fehlende LV-Note blockiert nicht: sie entsteht serverseitig mit der Note dieser + // Prüfung. Der Hinweis darauf steht im Dialog, siehe pruefungOhneLvNote. this.loading = true; this.$api.call(ApiNoten.createPruefungen( uids, - dateStrDb, + dateStrDb, this.lv_id, this.sem_kurzbz, + this.selectedPruefungNote?.note ?? null, + this.selectedPruefungPunkte ?? null )).then(res => { if(res.meta.status === "success") { - - // iterate over response data + + // iterate over response data // -> alert successful pruefungen // -> alert denied pruefungen + reason - + let uidListSuccess = '' let uidListError = '' const successData = [] Object.keys(res.data).forEach(student_uid => { const student = res.data[student_uid] // actual pruefung has been allocated - if(student.savedPruefung || student.extraPruefung) { + if(student.savedPruefung) { uidListSuccess += ' ' + student_uid - + // keep res.data format intact for handleResponse method successData[student_uid] = student } else { // there should be an error message why no pruefungen where allocated for this person, many reasons possible uidListError += student_uid + ' - ' + student +'\n'// student variable is the error message here } }) - + if(uidListError != '') { this.$fhcAlert.alertError( this.$capitalize(this.$p.t('benotungstool/c4pruefungAnlageError', [dateStrFront])) + ': ' + uidListError + ' ' ) } - + if(uidListSuccess != '') { this.$fhcAlert.alertDefault( 'success', @@ -2373,7 +2127,7 @@ export const Benotungstool = { this.handleAddNewPruefungenResponse({data: successData}, uids) } - + } }).finally(()=> this.loading = false) }, @@ -2443,7 +2197,7 @@ export const Benotungstool = { const limit = limitMap[note] if (limit == null) return - // all fixed antritte: Termin1/2/3 sit in pruefungen, kommPruef is separate + // alle Termine des Verlaufs; der abschliessende steht separat an der Zeile const allPruefungen = [...this.pruefungStudent.pruefungen] if (this.pruefungStudent.kommPruef) allPruefungen.push(this.pruefungStudent.kommPruef) @@ -2477,8 +2231,18 @@ export const Benotungstool = { + this.$p.t('global/gesamt') + ': ' + (this.studenten?.length || 0) + ''; }, - maxAntrittCount() { - return NotenRules.maxAntrittCount(this.config) + // ausgewählte Studenten, für die noch keine LV-Note existiert: sie entsteht mit dieser + // Prüfung. Nur ein Hinweis im Dialog - blockiert nichts und braucht keine Bestätigung. + pruefungOhneLvNote() { + return (this.selectedUids ?? []).filter(s => NotenRules.brauchtNeueLvNote(s)) + }, + // derselbe Hinweis im Dialog aus der Tabelle: beide Dialoge zeigen dasselbe an + pruefungStudentOhneLvNote() { + return !this.pruefung && !!this.pruefungStudent && NotenRules.brauchtNeueLvNote(this.pruefungStudent) + }, + // aktive Spaltenaufteilung: Wahl des Benutzers, sonst die Vorgabe aus der Konfiguration + pruefungsspaltenModus() { + return this.pruefungsspalten ?? this.config?.CIS_GESAMTNOTE_PRUEFUNGSSPALTEN ?? 'antritt' }, getFreigabeCounter() { return this.studenten ? this.studenten.reduce((acc, cur) => { @@ -2656,7 +2420,29 @@ export const Benotungstool = {
- + +
+
{{$capitalize($p.t('benotungstool/c4punkte'))}}:
+
+ + +
+
+ +
+
{{$capitalize($p.t('lehre/note'))}}:
+
+ + +
+
+
{{$capitalize($p.t('benotungstool/prueflingSelectionv2'))}}:
@@ -2670,6 +2456,19 @@ export const Benotungstool = { class="w-100" />
+ +
+
+
+
{{ $p.t('benotungstool/c4lvNoteWirdAngelegtHinweis') }}
+
+ + , {{ s.vorname }} {{ s.nachname }} ({{ s.uid }}) + +
+
+
+
diff --git a/public/js/components/Cis/LvPlan/MyLvPlan.js b/public/js/components/Cis/LvPlan/MyLvPlan.js index cb43a06d9..3fab090a3 100644 --- a/public/js/components/Cis/LvPlan/MyLvPlan.js +++ b/public/js/components/Cis/LvPlan/MyLvPlan.js @@ -2,6 +2,7 @@ import FhcCalendar from "../../Calendar/LvPlan.js"; import ApiLvPlan from '../../../api/factory/lvPlan.js'; import ApiAuthinfo from '../../../api/factory/authinfo.js'; +import ApiRoomPlan from '../../../api/factory/calendar/roomPlan.js'; export const DEFAULT_MODE_LVPLAN_DESKTOP = "Week"; export const DEFAULT_MODE_LVPLAN_MOBILE = "List"; @@ -134,6 +135,24 @@ export default { this.isMitarbeiter = authInfo.isMitarbeiter; this.isStudent = authInfo.isStudent; }, + deleteEvent(event) { + if (event.type === "reservierung") { + this.deleteReservation(event); + } + }, + async deleteReservation(event) { + if ( + luxon.DateTime.fromISO(`${event.datum}T${event.beginn}`) < + luxon.DateTime.now() + ) + return; + + await this.$api.call( + ApiRoomPlan.deleteRoomReservation(event.reservierung_id), + ); + + this.$refs.calendar.reset(); + }, }, async created() { await this.fetchAuthInfo(); @@ -157,6 +176,7 @@ export default { @update:date="handleChangeDate" @update:mode="handleChangeMode" @update:range="updateRange" + @delete-event="(event) => deleteEvent(event)" class="responsive-calendar" >
Date: Mon, 10 Aug 2026 13:56:13 +0200 Subject: [PATCH 26/60] not closing reservation creation modal if creation request fails --- public/js/components/Cis/Mylv/RoomInformation.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/public/js/components/Cis/Mylv/RoomInformation.js b/public/js/components/Cis/Mylv/RoomInformation.js index d71ad32fd..300a40c9e 100644 --- a/public/js/components/Cis/Mylv/RoomInformation.js +++ b/public/js/components/Cis/Mylv/RoomInformation.js @@ -72,12 +72,15 @@ export default { } }); }, - async handleCreateEvent(event) - { + async handleCreateEvent(event) { event.ort_kurzbz = this.propsViewData.ort_kurzbz; - await this.$api.call(ApiRoomPlan.addRoomReservation(event)); - this.$refs.calendar.resetEventLoader(); - this.$refs.calendar.closeModal(); + const reservationCreationResponse = await this.$api.call( + ApiRoomPlan.addRoomReservation(event), + ); + if (reservationCreationResponse.meta.status === "success") { + this.$refs.calendar.resetEventLoader(); + this.$refs.calendar.closeModal(); + } }, async handleDeleteEvent(event) { From 9a951c499be6c536b14a2cc30d6e5493b2cd3f22 Mon Sep 17 00:00:00 2001 From: adisposkofh Date: Mon, 10 Aug 2026 13:56:57 +0200 Subject: [PATCH 27/60] maxlength attr on reservation creation modal title and description inputs --- public/js/components/Cis/Renderer/Slot/roomModalContent.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/public/js/components/Cis/Renderer/Slot/roomModalContent.js b/public/js/components/Cis/Renderer/Slot/roomModalContent.js index e9f518c75..6cc0c9805 100644 --- a/public/js/components/Cis/Renderer/Slot/roomModalContent.js +++ b/public/js/components/Cis/Renderer/Slot/roomModalContent.js @@ -130,6 +130,7 @@ export default { :label="capitalize($p.t('global', 'titel'))" type="text" container-class="col-3" + maxlength="10" v-model="title" name="title" /> @@ -138,6 +139,7 @@ export default { :label="capitalize($p.t('global', 'beschreibung'))" type="text" container-class="col-4" + maxlength="32" v-model="beschreibung" name="Beschreibung" /> From 42fc89a944018a38daf68d243384e448b60f6551 Mon Sep 17 00:00:00 2001 From: adisposkofh Date: Mon, 10 Aug 2026 14:00:33 +0200 Subject: [PATCH 28/60] removed redundant response meta used for testing --- application/libraries/StundenplanLib.php | 1 - 1 file changed, 1 deletion(-) diff --git a/application/libraries/StundenplanLib.php b/application/libraries/StundenplanLib.php index d518bf807..a2f8bbfe6 100644 --- a/application/libraries/StundenplanLib.php +++ b/application/libraries/StundenplanLib.php @@ -236,7 +236,6 @@ class StundenplanLib if ($is_mitarbeiter && empty($ort_kurzbz)) { // request for personal lvplan show only reservations of logged in user - $this->_ci->addMeta("meta", "adis"); $reservierungen = $this->_ci->ReservierungModel->getReservierungenMitarbeiter($start_date, $end_date, $uid); } else { // querying the reservierungen From 83b06b6c504d4e19c150694cd36ec94a2ac2bcb7 Mon Sep 17 00:00:00 2001 From: ma0068 Date: Mon, 10 Aug 2026 14:04:45 +0200 Subject: [PATCH 29/60] - Redesign Tags - use Slot in Filter Component - use Phrase for relaoding Tag in Detail Header --- public/css/components/Detailheader.css | 13 +++++ public/css/tags.css | 5 ++ .../components/DetailHeader/DetailHeader.js | 43 +++++++------- .../Stv/Studentenverwaltung/List.js | 56 ++++++++++--------- public/js/components/Tag/Tag.js | 7 ++- public/js/components/filter/Filter.js | 3 +- 6 files changed, 80 insertions(+), 47 deletions(-) diff --git a/public/css/components/Detailheader.css b/public/css/components/Detailheader.css index dcfcf6806..e9ebddfc1 100644 --- a/public/css/components/Detailheader.css +++ b/public/css/components/Detailheader.css @@ -62,3 +62,16 @@ top:13%;" z-index: 104; font-size: 1rem; width: 2.5rem; height: 2.5rem; opacity:0; transition: opacity 0.2s; top:13%;" }*/ + +.tag-hover:hover { + background-color: #e9ecef; +} + +.green { + background-color: #e9ecef; +} + +.btn-hover { + --bs-btn-hover-bg: #e9ecef; /* Neue Hintergrundfarbe beim Hover */ + --bs-btn-hover-border-color: #e9ecef; /* Neue Rahmenfarbe beim Hover */ +} diff --git a/public/css/tags.css b/public/css/tags.css index 0634c5ce3..0230cfbda 100644 --- a/public/css/tags.css +++ b/public/css/tags.css @@ -145,3 +145,8 @@ float: right; margin-top: 3px; } + +.btn-hover { + --bs-btn-hover-bg: #e9ecef; + --bs-btn-hover-border-color: #e9ecef; +} diff --git a/public/js/components/DetailHeader/DetailHeader.js b/public/js/components/DetailHeader/DetailHeader.js index 396e1245a..d4d9d4cc1 100644 --- a/public/js/components/DetailHeader/DetailHeader.js +++ b/public/js/components/DetailHeader/DetailHeader.js @@ -397,32 +397,37 @@ export default {
-

+

{{headerData[0].titelpre}} {{headerData[0].vorname}} {{headerData[0].nachname}} , {{headerData[0].titelpost}}

- -
- - {{currentSemester}} + +
+ +
+ + {{currentSemester}} +
+
unruly
diff --git a/public/js/components/Stv/Studentenverwaltung/List.js b/public/js/components/Stv/Studentenverwaltung/List.js index 997b1f499..b100f3e62 100644 --- a/public/js/components/Stv/Studentenverwaltung/List.js +++ b/public/js/components/Stv/Studentenverwaltung/List.js @@ -841,33 +841,37 @@ export default {
-