From 285a2921e4513eb921299541f47d00119d5587f2 Mon Sep 17 00:00:00 2001 From: Johann Hoffmann Date: Fri, 3 Jul 2026 14:23:14 +0200 Subject: [PATCH 01/11] code cleanup & refactoring lektor frontend/backend; transactions on important concurrent operations like deleteAnwesenheitskontrolle & getNewQRCode; show all flag saved/fetch from local storage; hardcoded strings -> phrasen; --- system/phrasesupdate.php | 202 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) diff --git a/system/phrasesupdate.php b/system/phrasesupdate.php index e01cb1130..7e450cd9a 100644 --- a/system/phrasesupdate.php +++ b/system/phrasesupdate.php @@ -59954,6 +59954,208 @@ I have been informed that I am under no obligation to consent to the transmissio ) ), // ### End Notenstatistik + // ### Start Anwesenheiten Sprint 25.2 + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwUnsavedChangesConfirm', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Es gibt ungespeicherte Änderungen. Möchten Sie diese wirklich verwerfen?', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'There are unsaved changes. Are you sure you want to discard them?', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwDiscardAndContinue', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Änderungen verwerfen und fortfahren', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Discard changes and continue', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'zurueck', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Zurück', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Back', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'entschuldigungenAnzahl', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Anzahl der Entschuldigungen', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Number of excuse notes', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'statusLabel', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Status', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Status', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'keineEntschuldigungenVorhanden', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Keine Entschuldigungen vorhanden', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'No excuse notes available', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'entschuldigungStatusOffen', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Offen', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Open', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'entschuldigungStatusAkzeptiert', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Akzeptiert', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Accepted', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'entschuldigungStatusAbgelehnt', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Abgelehnt', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Declined', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'keineTermineGefunden', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Keine Termine gefunden', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'No dates found', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ) + // ### End Anwesenheiten Sprint 25.2 ); From 7fdfa1481e32b6d0dfe72533b5476cd0f66a1c56 Mon Sep 17 00:00:00 2001 From: Johann Hoffmann Date: Thu, 9 Jul 2026 16:00:42 +0200 Subject: [PATCH 02/11] new phrases --- system/phrasesupdate.php | 100 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/system/phrasesupdate.php b/system/phrasesupdate.php index 7e450cd9a..d70e539c7 100644 --- a/system/phrasesupdate.php +++ b/system/phrasesupdate.php @@ -35558,6 +35558,106 @@ array( ) ) ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwMeineLvTeile', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Meine LV-Teile', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'My teaching units', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwLvTeileKollegen', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Andere LV-Teile', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Others teaching units', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwKontrolleFremdeLeConfirm', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Achtung! Sie starten eine Anwesenheitskontrolle für den LV-Teil von {0}. Fortfahren?', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Attention! You are starting an attendance check for the teaching unit of {0}. Continue?', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwEditFremdeLeConfirm', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Achtung! Sie bearbeiten Anwesenheitsdaten für den LV-Teil von {0}. Fortfahren?', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Attention! You are starting an attendance check for the teaching unit of {0}. Continue?', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwFortfahren', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Fortfahren', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Continue', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), // // DIGITALE ANWESENHEITEN PHRASEN END // From 04d90f53d383083a0b6583251659ce57c1dd6241 Mon Sep 17 00:00:00 2001 From: Johann Hoffmann Date: Mon, 20 Jul 2026 16:40:57 +0200 Subject: [PATCH 03/11] adapt stundeplanlib expandObjectInformation when called by getEventsEmployee with an event called extendStundenplanData, which allows extensions like digi anw to plant their own data object inside the stundenplan events in order to conditionally render something like currently in the lehreinheiten calendarEvent.js renderer -> parameterized link to anw extension in order to avoid duplicating functionality across widgets when there is already one doing it very well; --- application/libraries/StundenplanLib.php | 19 +++++++++-- .../Cis/Renderer/Lehreinheit/calendarEvent.js | 33 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/application/libraries/StundenplanLib.php b/application/libraries/StundenplanLib.php index a7bec61ec..2c21ac72b 100644 --- a/application/libraries/StundenplanLib.php +++ b/application/libraries/StundenplanLib.php @@ -7,6 +7,7 @@ use \DateTime as DateTime; use \DateTimeZone as DateTimeZone; use \DateInterval as DateInterval; use \DatePeriod as DatePeriod; +use CI3_Events as Events; class StundenplanLib { @@ -141,7 +142,7 @@ class StundenplanLib return $stundenplan_data; $stundenplan_data = getData($stundenplan_data) ?? []; - $function_error = $this->expandObjectInformation($stundenplan_data); + $function_error = $this->expandObjectInformation($stundenplan_data, true); if ($function_error) return $function_error; @@ -286,7 +287,7 @@ class StundenplanLib return success($lektoren); } - public function expandObjectInformation($data) + public function expandObjectInformation($data, $callExtensionsEvent = false) { $this->_ci =& get_instance(); @@ -375,6 +376,20 @@ class StundenplanLib $item->lektor = $lektor_obj_array; } + + // info retrieved from extensions might not be necessary for some stundenplan use cases so the event + // will be gated behind this flag + + if($callExtensionsEvent) { + Events::trigger( + 'extendStundenplanData', + function & () use (&$data) + { + return $data; + } + ); + } + } public function fetchFerienTageEvents($start_date, $end_date, $studiengang_kz) diff --git a/public/js/components/Cis/Renderer/Lehreinheit/calendarEvent.js b/public/js/components/Cis/Renderer/Lehreinheit/calendarEvent.js index 652b29645..8bbaa5d0f 100644 --- a/public/js/components/Cis/Renderer/Lehreinheit/calendarEvent.js +++ b/public/js/components/Cis/Renderer/Lehreinheit/calendarEvent.js @@ -66,6 +66,24 @@ export default { return "d-none d-xl-grid"; } }, + // link into the FHC-Core-Anwesenheiten tool + // digi_anw_data is attached per event by the extension's `extendStundenplanData` listener + anwLink() { + const d = this.event && this.event.digi_anw_data; + if (!d || !d.stg_kz || !d.lv_id || !d.sem_kurzbz) + return null; + const base = FHC_JS_DATA_STORAGE_OBJECT.app_root + + FHC_JS_DATA_STORAGE_OBJECT.ci_router + + '/extensions/FHC-Core-Anwesenheiten/'; + return base + + `?stg_kz=${encodeURIComponent(d.stg_kz)}` + + `&sem=${encodeURIComponent(d.sem ?? '')}` + + `&lvid=${encodeURIComponent(d.lv_id)}` + + `&sem_kurzbz=${encodeURIComponent(d.sem_kurzbz)}`; + }, + anwLinkTitle() { + return this.$p.t('global/digitalesAnwManagement'); + }, }, template: /*html*/`
+ + + `, } From 7e0114201673d70bbdb040abe2dab39963d02906 Mon Sep 17 00:00:00 2001 From: Johann Hoffmann Date: Tue, 21 Jul 2026 18:30:24 +0200 Subject: [PATCH 04/11] anw widget sql scripts for standalone linklist and once utilizing fhc-calendar list view; also added anw link in lehreinheiten renderer modalContent as new row --- .../Cis/Renderer/Lehreinheit/calendarEvent.js | 2 +- .../Cis/Renderer/Lehreinheit/modalContent.js | 24 ++++++ system/phrasesupdate.php | 80 +++++++++++++++++++ 3 files changed, 105 insertions(+), 1 deletion(-) diff --git a/public/js/components/Cis/Renderer/Lehreinheit/calendarEvent.js b/public/js/components/Cis/Renderer/Lehreinheit/calendarEvent.js index 8bbaa5d0f..28cf54fc9 100644 --- a/public/js/components/Cis/Renderer/Lehreinheit/calendarEvent.js +++ b/public/js/components/Cis/Renderer/Lehreinheit/calendarEvent.js @@ -82,7 +82,7 @@ export default { + `&sem_kurzbz=${encodeURIComponent(d.sem_kurzbz)}`; }, anwLinkTitle() { - return this.$p.t('global/digitalesAnwManagement'); + return this.$p.t('global/anwKontrolleOeffnen'); }, }, template: /*html*/` diff --git a/public/js/components/Cis/Renderer/Lehreinheit/modalContent.js b/public/js/components/Cis/Renderer/Lehreinheit/modalContent.js index 738c0c75c..80c32522c 100644 --- a/public/js/components/Cis/Renderer/Lehreinheit/modalContent.js +++ b/public/js/components/Cis/Renderer/Lehreinheit/modalContent.js @@ -49,6 +49,20 @@ export default { return this.event.ende; } return numberPadding(this.event.ende.getHours()) + ":" + numberPadding(this.event.ende.getMinutes()); + }, + // Deep link into the attendance tool; only present when the anw extension wrote into digi_anw_data + anwLink: function () { + const d = this.event && this.event.digi_anw_data; + if (!d || !d.stg_kz || !d.lv_id || !d.sem_kurzbz) + return null; + const base = FHC_JS_DATA_STORAGE_OBJECT.app_root + + FHC_JS_DATA_STORAGE_OBJECT.ci_router + + '/extensions/FHC-Core-Anwesenheiten/'; + return base + + `?stg_kz=${encodeURIComponent(d.stg_kz)}` + + `&sem=${encodeURIComponent(d.sem ?? '')}` + + `&lvid=${encodeURIComponent(d.lv_id)}` + + `&sem_kurzbz=${encodeURIComponent(d.sem_kurzbz)}`; } }, methods: { @@ -140,6 +154,16 @@ export default { }} {{event.organisationseinheit}} + + {{ + $p.t('global','anwKontrolle')? + $p.t('global','anwKontrolle')+':' + :'' + }} + + + + diff --git a/system/phrasesupdate.php b/system/phrasesupdate.php index d70e539c7..0a97cd2f7 100644 --- a/system/phrasesupdate.php +++ b/system/phrasesupdate.php @@ -32774,6 +32774,86 @@ array( ) ) ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwKontrolle', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Anwesenheitskontrolle', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Attendance check', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwKontrolleOeffnen', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Anwesenheitskontrolle öffnen', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Open attendance check', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwKeinUnterricht', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Kein Unterricht in diesem Zeitraum', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'No lessons in this period', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwJetzt', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Jetzt', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Now', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), array( 'app' => 'anwesenheiten', 'category' => 'global', From 943999cef9aa446c94deefab4c6b7aa976655107 Mon Sep 17 00:00:00 2001 From: Johann Hoffmann Date: Wed, 26 Aug 2026 16:45:06 +0200 Subject: [PATCH 05/11] adapt filter/tablePresets/tablePresetsModule to account for dynamic tables with a new presetColumns property. by default the preset holds all columns, if a scope is given it only holds those columns, their header filters and their sort; on apply the scope is merged into the current table columns so the dynamic ones keep their place; --- public/js/components/filter/Filter.js | 26 ++++-- .../components/filter/Filter/TablePresets.js | 41 ++++++--- .../customModules/TablePresetsModule.js | 89 ++++++++++++++----- 3 files changed, 117 insertions(+), 39 deletions(-) diff --git a/public/js/components/filter/Filter.js b/public/js/components/filter/Filter.js index 70b11401e..7f3c803ff 100644 --- a/public/js/components/filter/Filter.js +++ b/public/js/components/filter/Filter.js @@ -106,6 +106,13 @@ export const CoreFilterCmpt = { type: Array, default: [], }, + // fields of the columns a preset is allowed to handle. Give the constant + // columns here if the table builds dynamic columns at runtime. An empty + // array gives the preset every column of the table. + presetColumns: { + type: Array, + default: [], + }, }, data: function () { return { @@ -942,6 +949,10 @@ export const CoreFilterCmpt = { return res; }, {}); }, + isColumnInPresetScope(columnField) { + if (!this.$props.presetColumns.length) return true; + return this.$props.presetColumns.includes(columnField); + }, afterTablePresetApplied(preset) { this.selectedFields = [...preset.displayedColumns]; this.appliedPreset = { ...preset }; @@ -973,6 +984,7 @@ export const CoreFilterCmpt = { const displayedColumns = this.tabulator.columnManager.columns .filter((column) => column.field !== "collapse") + .filter((column) => this.isColumnInPresetScope(column.field)) .filter((column) => column.visible) .map((column) => column.field); @@ -1029,12 +1041,13 @@ export const CoreFilterCmpt = { }); const activeSort = this.tabulator.getSorters()[0]; - const sort = activeSort - ? { - column: activeSort.field, - direction: activeSort.dir, - } - : null; + const sort = + activeSort && this.isColumnInPresetScope(activeSort.field) + ? { + column: activeSort.field, + direction: activeSort.dir, + } + : null; if ( sort?.column !== this.appliedPreset.sort?.column || sort?.direction !== this.appliedPreset.sort?.direction @@ -1148,6 +1161,7 @@ export const CoreFilterCmpt = { :presetsId="$props.presetsId" :tabulator="tabulator" :generalPresets="$props.generalPresets" + :presetColumns="$props.presetColumns" ref="tablePresets" class="card-body collapse" > diff --git a/public/js/components/filter/Filter/TablePresets.js b/public/js/components/filter/Filter/TablePresets.js index a8a0c8e4c..e95b76f4b 100644 --- a/public/js/components/filter/Filter/TablePresets.js +++ b/public/js/components/filter/Filter/TablePresets.js @@ -26,6 +26,9 @@ export default { presetsId: { type: String }, tabulator: { type: Object }, generalPresets: { type: Array, default: [] }, + // fields of the columns the preset is allowed to handle. An empty array + // gives the preset every column of the table. + presetColumns: { type: Array, default: [] }, }, emits: ["tablePresetApplied"], data: function () { @@ -162,6 +165,10 @@ export default { }, }, methods: { + isInPresetScope(columnField) { + if (!this.$props.presetColumns.length) return true; + return this.$props.presetColumns.includes(columnField); + }, getPresetIndex() { return this.presetIndexBeingApplied; }, @@ -227,9 +234,9 @@ export default { } }, generateNewPreset() { - const columns = this.tabulator.columnManager.columns.filter( - (column) => column.field !== "collapse", - ); + const columns = this.tabulator.columnManager.columns + .filter((column) => column.field !== "collapse") + .filter((column) => this.isInPresetScope(column.field)); const displayedColumns = columns .filter((column) => column.visible) @@ -251,12 +258,13 @@ export default { ); const activeSort = this.tabulator.getSorters()[0]; - const sort = activeSort - ? { - column: activeSort.field, - direction: activeSort.dir, - } - : null; + const sort = + activeSort && this.isInPresetScope(activeSort.field) + ? { + column: activeSort.field, + direction: activeSort.dir, + } + : null; this.newPreset = { displayedColumns, @@ -325,8 +333,11 @@ export default { preset.displayedColumns.length; preset.displayedColumns = preset.displayedColumns.filter( (columnField) => { - return validColumns.some( - (column) => column.field === columnField, + return ( + this.isInPresetScope(columnField) && + validColumns.some( + (column) => column.field === columnField, + ) ); }, ); @@ -365,10 +376,11 @@ export default { if (preset.sort) { if ( + !this.isInPresetScope(preset.sort.column) || !validColumns.some( (column) => column.field === preset.sort.column && - column.headerSort, + column.headerSort !== false, ) ) { preset.sort = null; @@ -407,7 +419,10 @@ export default { window.localStorage["tabulatorPreset-" + this.$props.presetsId] = preset.id ? "custom:" + preset.id : "general:" + preset.name; - this.$props.tabulator.modules.tablePresets.applyPreset(preset); + this.$props.tabulator.modules.tablePresets.applyPreset( + preset, + this.$props.presetColumns, + ); this.$emit("tablePresetApplied", { preset }); diff --git a/public/js/tabulator/customModules/TablePresetsModule.js b/public/js/tabulator/customModules/TablePresetsModule.js index 644514dc5..8b05bd1ae 100644 --- a/public/js/tabulator/customModules/TablePresetsModule.js +++ b/public/js/tabulator/customModules/TablePresetsModule.js @@ -8,27 +8,36 @@ export default class TablePresetsModule extends Module { super(table); } - applyPreset(preset) { - let columnsConfig = this.table.options.columns; - let reorderedColumnsConfig = []; - preset.displayedColumns.forEach((columnField) => { - let columnConfig = columnsConfig.find( - (column) => column.field === columnField, - ); - if (!columnConfig) return; - columnConfig.visible = true; - reorderedColumnsConfig.push(columnConfig); - }); - - let hiddenColumnsConfig = columnsConfig - .filter((column) => !preset.displayedColumns.includes(column.field)) - .map((column) => { - column.visible = false; - return column; + // presetColumns limits the preset to the given column fields. A table with dynamic + // columns gives the fields of its constant columns here. + applyPreset(preset, presetColumns = []) { + if (presetColumns.length) { + this.applyColumnsInScope(preset, presetColumns); + } else { + let columnsConfig = this.table.options.columns; + let reorderedColumnsConfig = []; + preset.displayedColumns.forEach((columnField) => { + let columnConfig = columnsConfig.find( + (column) => column.field === columnField, + ); + if (!columnConfig) return; + columnConfig.visible = true; + reorderedColumnsConfig.push(columnConfig); }); - this.table.setColumns( - reorderedColumnsConfig.concat(hiddenColumnsConfig), - ); + + let hiddenColumnsConfig = columnsConfig + .filter( + (column) => + !preset.displayedColumns.includes(column.field), + ) + .map((column) => { + column.visible = false; + return column; + }); + this.table.setColumns( + reorderedColumnsConfig.concat(hiddenColumnsConfig), + ); + } this.table.clearHeaderFilter(); if (preset.headerFilters) { @@ -61,4 +70,44 @@ export default class TablePresetsModule extends Module { ); } } + + // The preset orders and hides the columns of the scope only. A column outside the + // scope keeps its position, because the table does not show it in every state. + applyColumnsInScope(preset, presetColumns) { + const displayedColumns = preset.displayedColumns ?? []; + + // a displayed column takes the rank of the preset. A column that the preset + // hides has no rank and keeps the current order at the end of its block. + const rank = (columnConfig) => { + const index = displayedColumns.indexOf(columnConfig.field); + return index === -1 ? displayedColumns.length : index; + }; + + let columnsConfig = []; + let scopeBlock = []; + + // the sort runs for each block of neighbouring scope columns. This way a scope + // column never moves over a column outside the scope. + const addScopeBlock = () => { + scopeBlock.sort((a, b) => rank(a) - rank(b)); + columnsConfig = columnsConfig.concat(scopeBlock); + scopeBlock = []; + }; + + this.table.getColumnDefinitions().forEach((columnConfig) => { + if (!presetColumns.includes(columnConfig.field)) { + addScopeBlock(); + columnsConfig.push(columnConfig); + return; + } + + columnConfig.visible = displayedColumns.includes( + columnConfig.field, + ); + scopeBlock.push(columnConfig); + }); + addScopeBlock(); + + this.table.setColumns(columnsConfig); + } } From 86a8350bdccebd778bce18424c83c54d1b74e92d Mon Sep 17 00:00:00 2001 From: Johann Hoffmann Date: Thu, 27 Aug 2026 09:32:59 +0200 Subject: [PATCH 06/11] fix phrasesupdate.php missing colon --- system/phrasesupdate.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/system/phrasesupdate.php b/system/phrasesupdate.php index ce3c5f432..d9cb3b0c6 100644 --- a/system/phrasesupdate.php +++ b/system/phrasesupdate.php @@ -61285,7 +61285,7 @@ I have been informed that I am under no obligation to consent to the transmissio 'insertvon' => 'system' ) ) - ) + ), // ### End Anwesenheiten Sprint 25.2 // generic tabulator phrases START array( @@ -62620,7 +62620,7 @@ I have been informed that I am under no obligation to consent to the transmissio 'insertvon' => 'system' ) ) - ), + ) ); From 98ceeb862df12cc47f4023c19ee78bb4dd0a33d7 Mon Sep 17 00:00:00 2001 From: Johann Hoffmann Date: Wed, 23 Sep 2026 15:49:03 +0200 Subject: [PATCH 07/11] not only log sophisticated upload errors into log table, also tell the user what went wrong with the upload as per codeigniter 3 upload class; --- system/phrasesupdate.php | 220 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) diff --git a/system/phrasesupdate.php b/system/phrasesupdate.php index 0d0535324..d967d1ff9 100644 --- a/system/phrasesupdate.php +++ b/system/phrasesupdate.php @@ -35700,6 +35700,226 @@ array( ) ) ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'errorEntUploadFileTooLarge', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Die Datei ist {size} MB groß. Die maximale Dateigröße ist {max} MB. Verkleinern Sie die Datei und laden Sie sie erneut hoch. Fotografieren Sie das Dokument zum Beispiel mit geringerer Auflösung oder komprimieren Sie das PDF.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'The file is {size} MB. The maximum file size is {max} MB. Make the file smaller and upload it again. For example, take the photo of the document with a lower resolution or compress the PDF.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'errorEntUploadFiletype', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Der Dateityp von »{file}« ist nicht erlaubt. Erlaubte Dateitypen: {filetypes}. Speichern Sie die Datei in einem erlaubten Dateityp und laden Sie sie erneut hoch.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'The file type of "{file}" is not allowed. Allowed file types: {filetypes}. Save the file in an allowed file type and upload it again.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'errorEntUploadContent', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Der Server kann »{file}« nicht als {type}-Datei lesen. Die Datei ist beschädigt oder hat eine falsche Dateiendung. Öffnen Sie die Datei und speichern Sie sie erneut in einem dieser Dateitypen: {filetypes}.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'The server cannot read "{file}" as a {type} file. The file is damaged or has an incorrect file extension. Open the file and save it again in one of these file types: {filetypes}.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'errorEntUploadEmptyFile', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Die Datei »{file}« ist leer. Wählen Sie eine andere Datei aus.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'The file "{file}" is empty. Select a different file.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'errorEntUploadPartial', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Der Server hat die Datei nicht vollständig erhalten. Prüfen Sie Ihre Internetverbindung und laden Sie die Datei erneut hoch.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'The server did not receive the complete file. Check your internet connection and upload the file again.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'errorEntUploadNoFile', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Der Server hat keine Datei erhalten. Wählen Sie die Datei noch einmal aus und laden Sie sie erneut hoch.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'The server did not receive a file. Select the file again and upload it.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'errorEntUploadTechnical', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Der Server kann die Datei wegen eines technischen Problems nicht speichern. Versuchen Sie es später erneut. Wenn der Fehler bleibt, wenden Sie sich an den Support und nennen Sie die Fehlernummer {reference}.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'The server cannot save the file because of a technical problem. Try again later. If the error continues, contact the support team and give them the error number {reference}.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'errorEntAlreadyAccepted', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Diese Entschuldigung hat bereits den Status »akzeptiert«. Sie müssen kein Dokument mehr nachreichen.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'This excuse note already has the status "accepted". You do not have to upload a document.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'errorEntAlreadyDeclined', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Diese Entschuldigung hat bereits den Status »abgelehnt«. Sie können kein Dokument mehr nachreichen. Wenn Sie Fragen haben, wenden Sie sich an Ihre Studiengangsassistenz.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'This excuse note already has the status "declined". You cannot upload a document for it. If you have questions, contact your study program assistant.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'entUploadAllowedFiletypes', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Erlaubte Dateitypen: {filetypes}.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Allowed file types: {filetypes}.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'entUploadMaxFilesize', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Maximale Dateigröße: {max} MB.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Maximum file size: {max} MB.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), // // DIGITALE ANWESENHEITEN PHRASEN END // From 2bb59c9f66a36659940e4d5ed736887107690547 Mon Sep 17 00:00:00 2001 From: Johann Hoffmann Date: Fri, 25 Sep 2026 13:47:51 +0200 Subject: [PATCH 08/11] fix a tabulator issue with dynamic columns and presets; legacy le selection now also uses the legacy loading procedure instead of the new one fetching all le of the lva, with the flag on only the assigned le to the looged in lektor; added phrasen; --- system/phrasesupdate.php | 280 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) diff --git a/system/phrasesupdate.php b/system/phrasesupdate.php index d9cb3b0c6..ef5c33263 100644 --- a/system/phrasesupdate.php +++ b/system/phrasesupdate.php @@ -61286,6 +61286,286 @@ I have been informed that I am under no obligation to consent to the transmissio ) ) ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwMeineLvTeile', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Meine LV-Teile', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'My teaching units', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwLvTeileKollegen', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Weitere LV-Teile dieser LV', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Other teaching units of this course', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwKontrolleFremdeLeConfirm', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Dieser LV-Teil gehört zu {0}. Möchten Sie hier trotzdem eine Anwesenheitskontrolle starten?', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'This teaching unit belongs to {0}. Do you want to start an attendance check here anyway?', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwEditFremdeLeConfirm', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Dieser LV-Teil gehört zu {0}. Möchten Sie die Änderung trotzdem durchführen?', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'This teaching unit belongs to {0}. Do you want to apply the change anyway?', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwFortfahren', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Fortfahren', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Continue', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwJetzt', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Jetzt', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Now', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwKeinUnterricht', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Kein Unterricht in diesem Zeitraum', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'No lessons in this period', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwKontrolle', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Anwesenheitskontrolle', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Attendance check', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwKontrolleOeffnen', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Anwesenheitskontrolle öffnen', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Open attendance check', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'errorCheckingKontrollenOnDate', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Fehler beim Prüfen der bestehenden Anwesenheitskontrollen an diesem Tag.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Error while checking the existing attendance checks on this day.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'errorInsertUserAnwEntries', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Fehler beim Anlegen der Anwesenheitseinträge der Studierenden.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Error while creating the attendance entries of the students.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'errorKontrolleDoesNotExist', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Die Anwesenheitskontrolle existiert nicht.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'The attendance check does not exist.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'errorKontrolleTimesCollide', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Die Zeiten überschneiden sich mit einer anderen Anwesenheitskontrolle dieses LV-Teils am selben Tag.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'The times overlap with another attendance check of this teaching unit on the same day.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'errorUpdateAnwKontrolle', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Fehler beim Speichern der Kontrollzeiten.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Error while saving the attendance check times.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), // ### End Anwesenheiten Sprint 25.2 // generic tabulator phrases START array( From c82a6e4c447077d83bafc8c8b10d5306cd58ae3d Mon Sep 17 00:00:00 2001 From: Johann Hoffmann Date: Mon, 28 Sep 2026 13:31:13 +0200 Subject: [PATCH 09/11] phrasens --- system/phrasesupdate.php | 452 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 452 insertions(+) diff --git a/system/phrasesupdate.php b/system/phrasesupdate.php index 10a2c6c26..b90a4c42e 100644 --- a/system/phrasesupdate.php +++ b/system/phrasesupdate.php @@ -61886,6 +61886,458 @@ I have been informed that I am under no obligation to consent to the transmissio ) ) ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwLegende', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Legende', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Legend', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'tooltipLegendeV2', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Legende der Symbole, Farben und Namenszusätze anzeigen', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Show the legend of the symbols, colors and name suffixes', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwLegendeEntschuldigtBestaetigt', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Bestätigte Entschuldigung am gewählten Datum', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Confirmed excuse on the selected date', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwLegendeEntschuldigtOffen', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Offene Entschuldigung am gewählten Datum', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Open excuse on the selected date', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwStudierendeImLvTeil', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Studierende im LV-Teil', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Students in the teaching unit', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwNamenszusaetze', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Namenszusätze', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Name suffixes', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwZusatzIncoming', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Incoming', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Incoming student', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwZusatzOutgoing', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Outgoing ab dem angegebenen Datum', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Outgoing from the given date', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwZusatzOutgoingAb', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'ab {0}', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'from {0}', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwZusatzAngerechnet', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Angerechnet', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Credited', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwZusatzInternAngerechnet', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Intern angerechnet', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Credited internally', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwZusatzNichtZugelassen', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Nicht zugelassen', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Not admitted', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwZusatzMitarbeiter', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Mitarbeiter*in', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Employee', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwZusatzAusserordentlich', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Außerordentliche*r Hörer*in', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Non-degree student', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwZusatzDoubleDegreeIntern', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Double Degree, interne*r Studierende*r', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Double degree, internal student', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwZusatzDoubleDegreeExtern', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Double Degree, externe*r Studierende*r', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Double degree, external student', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwLvTeileAuswaehlen', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'LV-Teile auswählen', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Select teaching units', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwKontrolleId', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Kontrolle-ID', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Check ID', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwLvTeilId', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'LV-Teil-ID', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Teaching unit ID', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'noStudentsFoundV3', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Keine zugeteilten Studierenden für Mitarbeiter*in {0} im gewählten LV-Teil {1} gefunden.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'No assigned students found for employee {0} in the selected teaching unit {1}.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'tooltipLektorDeleteKontrolleV2', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Wenn Sie eine Anwesenheitskontrolle irrtümlich gestartet haben, können Sie diese löschen. Sie können eine Kontrolle bis {0} Tage nach dem Anlegen löschen. Das System löscht dabei auch alle Anwesenheitseinträge der Studierenden zu dieser Kontrolle. Danach berechnet das System die Anwesenheitsquoten neu. + + Wenn die Kontrolle älter als {0} Tage ist, wenden Sie sich an eine*n Administrator*in.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'If you started an attendance check by mistake, you can delete it. You can delete a check for {0} days after you created it. The system then also deletes all attendance entries of the students for this check. Then the system calculates the attendance rates again. + + If the check is older than {0} days, contact an administrator.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'tooltipStudentByLvaV2', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'In dieser Detailansicht bearbeiten Sie einzelne Anwesenheiten von Studierenden. Das ist zum Beispiel nötig, wenn anwesende Studierende den Zugangscode wegen eines technischen Problems nicht eingeben können. Wenn abwesende Studierende den Zugangscode von anwesenden Studierenden erhalten haben, tragen Sie diese als abwesend ein. + + Wenn eine akzeptierte Entschuldigung den Status bestimmt, können Sie den Status nicht ändern. + + Sie können zu jedem Anwesenheitseintrag eine Notiz erfassen. Die Studierenden sehen diese Notiz nicht.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'In this detail view, you edit single attendance entries of students. For example, do this when a student who is present cannot enter the access code because of a technical problem. When absent students got the access code from students who are present, set these students to absent. + + If an accepted excuse sets the status, you cannot change the status. + + You can add a note to each attendance entry. The students do not see this note.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), // ### End Anwesenheiten Sprint 25.2 // generic tabulator phrases START array( From 614274f3e1ae169a22a9983a108f233e3aa49707 Mon Sep 17 00:00:00 2001 From: Johann Hoffmann Date: Wed, 30 Sep 2026 15:07:45 +0200 Subject: [PATCH 10/11] darkmode stylecheck & adjustments across anw extension; ent management UX changes: datepicker labels, counts for filtered/total/offen/akzeptiert/abgelehnt + a button to just load all open excuse note applications no matter the timespan; added more clear visualization for multi stg students inside student uid col, studiengang col, orgform col and edit entschuldigung modal; fixed a bug in edit entschuldigung modal never showing to upload datum oopsie; --- public/css/Cis4/Cis.css | 3 + public/css/Tabulator5.css | 23 ++- public/css/theme/default.css | 112 ++++++++++++- public/js/components/Bootstrap/Modal.js | 4 +- system/phrasesupdate.php | 200 ++++++++++++++++++++++++ 5 files changed, 336 insertions(+), 6 deletions(-) diff --git a/public/css/Cis4/Cis.css b/public/css/Cis4/Cis.css index fd417ed22..667e5cd66 100644 --- a/public/css/Cis4/Cis.css +++ b/public/css/Cis4/Cis.css @@ -840,8 +840,11 @@ html { color: var(--fhc-light); } +/* the bootstrap opacity 0.5 reaches 2.9:1 on the blue header, 0.75 reaches 4.5:1 (WCAG 1.4.11) */ #cis-main .modal-header .btn-close{ filter: invert(1); + --bs-btn-close-opacity: 0.75; + --bs-btn-close-hover-opacity: 1; } #cis-main .modal-footer { diff --git a/public/css/Tabulator5.css b/public/css/Tabulator5.css index c03ec61bb..e9b894a50 100644 --- a/public/css/Tabulator5.css +++ b/public/css/Tabulator5.css @@ -121,8 +121,9 @@ background-color: #000; } +/* contrast: #777 reaches 4.48:1 on the odd and 4:1 on the even rows (#f2f2f2), #6c6c6c 5.3:1 and 4.7:1 */ .tabulator-row.tabulator-unselectable:not(.tabulator-calcs) { - color: #777 !important; + color: #6c6c6c !important; } .tabulator-cell .btn { @@ -156,3 +157,23 @@ color: #fff; background: #1d68cd; } + +/* contrast (WCAG 1.4.11): tabulator_simple draws the inactive sort arrow in #bbb (1.9:1) and the + * placeholder in #ccc (1.6:1). #8f8f8f (3.2:1) is close to the grey of the active arrow, so the + * active arrow gets the primary color (6.8:1) */ +.tabulator .tabulator-header .tabulator-col .tabulator-col-content .tabulator-col-sorter .tabulator-arrow, +.tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="none"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow { + border-bottom-color: #8f8f8f; +} + +.tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="ascending"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow { + border-bottom-color: var(--fhc-primary, #006095); +} + +.tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="descending"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow { + border-top-color: var(--fhc-primary, #006095); +} + +.tabulator .tabulator-tableholder .tabulator-placeholder .tabulator-placeholder-contents { + color: #6c757d; +} diff --git a/public/css/theme/default.css b/public/css/theme/default.css index a4d197f07..c2a584c12 100644 --- a/public/css/theme/default.css +++ b/public/css/theme/default.css @@ -188,6 +188,9 @@ --fhc-light: var(--fhc-white-50); --fhc-cis-menu-lvl-1-color: var(--fhc-white-50); + + /* input borders, 3:1 against the background and the tinted card headers (#f8f8f9) */ + --fhc-input-border: #868e96; } :root.dark { @@ -233,6 +236,8 @@ --fhc-dark: var(--fhc-black-50); --fhc-light: var(--fhc-white-70); + /* input borders, 3:1 against the background */ + --fhc-input-border: #7c8791; } :root.purple { @@ -313,12 +318,13 @@ html.dark .border-primary { border-color: var(--fhc-primary) !important; } -/* fhc danger*/ +/* fhc danger: --fhc-danger is a light red for text and borders on the dark background. White text + * on it reaches 1.2:1, the backgrounds use the dark red (6.5:1) */ html.dark .bg-danger, html.dark .btn-danger, html.dark .btn-outline-danger { - background-color: var(--fhc-danger) !important; - border-color: var(--fhc-danger) !important; + background-color: var(--fhc-red-60) !important; + border-color: var(--fhc-red-60) !important; color: white; } @@ -335,6 +341,29 @@ html.dark .border-danger { border-color: var(--fhc-danger) !important; } +/* bootstrap draws btn-outline-secondary in #6c757d: 4.3:1 on the light dialog footer (#f6f6f6). + * --fhc-gray-30 reaches 5.4:1 there, the white hover text 5.9:1 */ +html.light .btn-outline-secondary { + --bs-btn-color: var(--fhc-gray-30); + --bs-btn-border-color: var(--fhc-gray-30); + --bs-btn-hover-bg: var(--fhc-gray-30); + --bs-btn-hover-border-color: var(--fhc-gray-30); + --bs-btn-active-bg: var(--fhc-gray-30); + --bs-btn-active-border-color: var(--fhc-gray-30); +} + +/* bootstrap keeps #6c757d for btn-outline-secondary in the dark theme: 2.4:1 on the dark tables and dialogs */ +html.dark .btn-outline-secondary { + --bs-btn-color: var(--fhc-white-90); + --bs-btn-border-color: var(--fhc-white-90); + --bs-btn-hover-color: var(--fhc-black-30); + --bs-btn-hover-bg: var(--fhc-white-90); + --bs-btn-hover-border-color: var(--fhc-white-90); + --bs-btn-active-color: var(--fhc-black-30); + --bs-btn-active-bg: var(--fhc-white-90); + --bs-btn-active-border-color: var(--fhc-white-90); +} + html.dark .tabulator, html.dark .tabulator .tabulator-header, @@ -355,6 +384,52 @@ html.dark .tabulator .tabulator-header .tabulator-col:hover { background-color: var(--fhc-tertiary) !important; } +/* the active sort arrow of tabulator_simple (#666) reaches 2:1 on the dark header */ +html.dark .tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="ascending"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow { + border-bottom-color: var(--fhc-text); +} + +html.dark .tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="descending"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow { + border-top-color: var(--fhc-text); +} + +@media (hover: hover) and (pointer: fine) { + html.dark .tabulator .tabulator-header .tabulator-col.tabulator-sortable .tabulator-col-content .tabulator-col-sorter.tabulator-col-sorter-element .tabulator-arrow:hover { + border-bottom-color: var(--fhc-text); + border-top-color: var(--fhc-text); + } +} + +/* the group toggle arrow of tabulator_simple (#666) reaches 2:1 on the dark rows */ +html.dark .tabulator .tabulator-row.tabulator-group:not(.tabulator-group-visible) .tabulator-arrow { + border-left-color: var(--fhc-text); +} + +html.dark .tabulator .tabulator-row.tabulator-group.tabulator-group-visible .tabulator-arrow { + border-top-color: var(--fhc-text); +} + +/* the footer stays white otherwise: the page buttons get the white button text of the dark color scheme */ +html.dark .tabulator .tabulator-footer { + background-color: var(--fhc-tertiary); + border-top-color: var(--fhc-border); + color: var(--fhc-text); +} + +html.dark .tabulator .tabulator-footer .tabulator-page { + background: transparent; + border-color: var(--fhc-white-90); + color: var(--fhc-text); +} + +html.dark .tabulator .tabulator-footer .tabulator-page.active { + color: var(--fhc-red-30); +} + +html.dark .tabulator .tabulator-tableholder .tabulator-placeholder .tabulator-placeholder-contents { + color: var(--fhc-white-90); +} + .p-paginator{ background-color: var(--fhc-background) !important; } @@ -376,4 +451,35 @@ html.dark .tabulator .tabulator-header .tabulator-col:hover { .p-link:focus { box-shadow: 0 0 0 0.2rem rgba(var(--fhc-primary), 0.5) !important; +} + +/* input borders: 3:1 against the background (WCAG 1.4.11), the bootstrap, primevue and datepicker defaults + * reach 1.3 - 1.9:1. Focus and checked states of the libraries stay */ +.form-control, +.form-select, +.form-check-input, +.p-inputtext, +.p-inputtext:enabled:hover:not(:focus), +.p-dropdown, +.p-dropdown:not(.p-disabled):hover:not(.p-focus), +.p-multiselect, +.p-multiselect:not(.p-disabled):hover:not(.p-focus), +.p-checkbox .p-checkbox-box, +.p-radiobutton .p-radiobutton-box { + border-color: var(--fhc-input-border, #868e96); +} + +/* the datepicker css loads after the theme, html raises the specificity */ +html .dp__theme_light, +html .dp__theme_dark { + --dp-border-color: var(--fhc-input-border, #868e96); + --dp-border-color-hover: var(--fhc-border-highlight, #495057); +} + +/* the light datepicker (also in the dark cis4 theme) draws the days of other months in #c0c4cc (1.8:1), + * the icons in #959595 (3:1) and the selected day in #f8f5f5 on #1976d2 (4.2:1) */ +html .dp__theme_light { + --dp-secondary-color: #6c6c6c; + --dp-icon-color: #6c757d; + --dp-primary-text-color: #fff; } \ No newline at end of file diff --git a/public/js/components/Bootstrap/Modal.js b/public/js/components/Bootstrap/Modal.js index 0f2504da6..1a91060f0 100644 --- a/public/js/components/Bootstrap/Modal.js +++ b/public/js/components/Bootstrap/Modal.js @@ -73,6 +73,7 @@ export default { } }, computed: { + // opacity 0.5 reaches 2.4:1 on the blue cis4 modal header, 0.75 reaches 3.6:1 (WCAG 1.4.11) getExpandButtonStyles() { const hovered = this.expandBtnHovered; const focused = this.expandBtnFocused; @@ -85,12 +86,11 @@ export default { border: 0; background: transparent; font-size: 1em; - opacity: 0.5; color: inherit; cursor: pointer; line-height: 1; transition: opacity 0.15s ease; - opacity: ${focused ? '1' : hovered ? '0.75' : '0.5'}; + opacity: ${focused || hovered ? '1' : '0.75'}; outline: ${focused ? '1px solid currentColor' : 'none'}; outline-offset: 2px;` } diff --git a/system/phrasesupdate.php b/system/phrasesupdate.php index b90a4c42e..4ab35138b 100644 --- a/system/phrasesupdate.php +++ b/system/phrasesupdate.php @@ -62338,6 +62338,206 @@ I have been informed that I am under no obligation to consent to the transmissio ) ) ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'entOffenAusserhalbZeitraum', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Offen außerhalb des Zeitraums', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Open outside the date range', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'entMehrereAccounts', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Zu dieser Person gibt es mehrere Studierenden-Accounts. Die Studiengangsassistenz eines anderen Studiengangs kann zuständig sein. Bevor Sie den Status ändern, prüfen Sie den Studiengang.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'This person has more than one student account. The assistant of a different degree program can be responsible. Before you change the status, check the degree program.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'entAlleOffenenLaden', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Zeitraum erweitern und alle offenen Entschuldigungen laden', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Extend the date range and load all open excuse notes', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'entAlleOffenenGeladen', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Alle offenen geladen', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'All open loaded', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'entZeitraumZuruecksetzen', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Vorherigen Zeitraum wiederherstellen', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Restore the previous date range', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'entAntragsdatumVon', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Antragsdatum von', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Application date from', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'entAntragsdatumBis', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Antragsdatum bis', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Application date to', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'entNurStatusAnzeigen', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Nur diesen Status anzeigen', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Show only this status', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'entStatusFilterAufheben', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Statusfilter aufheben', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Remove the status filter', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'entMehrereAccountsKurz', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Mehrere Studierenden-Accounts', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'More than one student account', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), // ### End Anwesenheiten Sprint 25.2 // generic tabulator phrases START array( From b4fc8dfd45e8578a28bf6f81bf2c6e549bfae7a3 Mon Sep 17 00:00:00 2001 From: Johann Hoffmann Date: Thu, 1 Oct 2026 12:03:34 +0200 Subject: [PATCH 11/11] status entry in legende for missing entry aka "-" --- system/phrasesupdate.php | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/system/phrasesupdate.php b/system/phrasesupdate.php index 4ab35138b..88f0adebb 100644 --- a/system/phrasesupdate.php +++ b/system/phrasesupdate.php @@ -35960,6 +35960,26 @@ array( ) ) ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'errorEntUploadDeadlinePassed', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Die Frist zum Nachreichen ist am {deadline} abgelaufen. Sie können ein Dokument bis zu {workdays} Werktage nach dem Erstellen der Entschuldigung hochladen. Wenn Sie Fragen haben, wenden Sie sich an Ihre Studiengangsassistenz.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'The deadline to upload a document ended on {deadline}. You can upload a document up to {workdays} workdays after you create the excuse note. If you have questions, contact your study program assistant.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), array( 'app' => 'anwesenheiten', 'category' => 'global', @@ -62538,6 +62558,26 @@ I have been informed that I am under no obligation to consent to the transmissio ) ) ), + array( + 'app' => 'anwesenheiten', + 'category' => 'global', + 'phrase' => 'anwLegendeKeinEintrag', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Kein Eintrag: Die Person war bei dieser Kontrolle nicht der Gruppe zugeteilt', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'No entry: the student was not in the group at the time of this check', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), // ### End Anwesenheiten Sprint 25.2 // generic tabulator phrases START array(